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

3358 lines
166 KiB
TypeScript

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 { useTranslation } from '../hooks/useTranslation';
import { AddLocationModal } from '@/components/AddLocationModal';
import { AddMemberModal } from '../components/AddMemberModal';
import { MembersTab } from '../components/MembersTab';
import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
import { CommentModal } from '@/components/CommentModal';
import { AddPhotoModal } from '@/components/AddPhotoModal';
import { TourChat } from '../components/TourChat';
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<L.LatLng | null>(null);
const [visible, setVisible] = useState(false);
const timerRef = React.useRef<any>(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 (
<Marker position={tipPos} icon={L.divIcon({ className: 'opacity-0' })}>
<Tooltip direction="top" offset={[0, -10]} opacity={0.9} permanent>
<span className="text-[10px] font-bold text-blue-600 whitespace-nowrap">Mẹo: nhấn giữ chuột phải để ghim</span>
</Tooltip>
</Marker>
);
};
// Menu ngữ cảnh cho bản đồ
const MapContextMenu = ({ onAction, onOpen }: { onAction: (action: string, latlng: L.LatLng) => void; onOpen?: () => void }) => {
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
const menuRef = React.useRef<HTMLDivElement>(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 });
if (onOpen) onOpen();
},
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 (
<div
ref={menuRef}
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200"
style={{ top: menuPos.y, left: menuPos.x }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<button onClick={() => { onAction('START', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-600" /> Bắt đầu từ đây
</button>
<button onClick={() => { onAction('END', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-green-50 text-sm font-bold text-gray-700 flex items-center gap-2 border-b border-gray-50">
<div className="w-2 h-2 rounded-full bg-green-600" /> Kết thúc đây
</button>
<div className="px-4 py-2 text-[10px] font-black text-gray-400 uppercase tracking-widest">Thêm vào chặng</div>
{legs.map(leg => (
<button
key={leg.id}
onClick={() => { onAction(`ADD_TO_LEG_${leg.id}`, menuPos.latlng); setMenuPos(null); }}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-medium text-gray-600 truncate"
>
Chặng {leg.sequence}: {leg.note || 'Không có ghi chú'}
</button>
))}
</div>
);
};
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 { t } = useTranslation();
const notify = useNotification();
const confirm = useConfirm();
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<number | null>(null); // Hướng di chuyển từ GPS
const [userSpeed, setUserSpeed] = useState<number | null>(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' | 'members' | 'chat'>(() => {
const defaultTab = localStorage.getItem('tour_detail_default_tab');
localStorage.removeItem('tour_detail_default_tab');
if (defaultTab === 'chat') return 'chat';
return 'plan';
});
const [mergingId, setMergingId] = useState<string | null>(null);
const [unreadChatCount, setUnreadChatCount] = useState(0);
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);
const [isRatingModalOpen, setIsRatingModalOpen] = useState(false);
const [ratingTargetUser, setRatingTargetUser] = useState<any>(null);
const [ratingScores, setRatingScores] = useState({
honesty: 5,
transparency: 5,
enthusiasm: 5,
cheerfulness: 5,
seriousness: 5,
planning: 5,
survival: 5
});
const [ratingComment, setRatingComment] = useState('');
const [isSubmittingRating, setIsSubmittingRating] = useState(false);
const handleSubmitRating = async () => {
if (!ratingTargetUser) return;
setIsSubmittingRating(true);
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/v1/tours/${tourId}/ratings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
targetUserId: ratingTargetUser.userId || ratingTargetUser.user?.id,
...ratingScores,
comment: ratingComment
})
});
if (res.ok) {
notify({ title: 'Thành công', message: 'Cảm ơn bạn đã gửi đánh giá!', type: 'success' });
setIsRatingModalOpen(false);
setRatingComment('');
setRatingScores({
honesty: 5,
transparency: 5,
enthusiasm: 5,
cheerfulness: 5,
seriousness: 5,
planning: 5,
survival: 5
});
} else {
const err = await res.json();
notify({ title: 'Lỗi', message: err.message || 'Lỗi khi gửi đánh giá.', type: 'error' });
}
} catch (error) {
console.error(error);
notify({ title: 'Lỗi', message: 'Lỗi mạng khi gửi đánh giá.', type: 'error' });
} finally {
setIsSubmittingRating(false);
}
};
const [shareStatus, setShareStatus] = useState<{ isEnabled: boolean; token: string } | null>(null);
const fetchShareStatus = async () => {
if (isPublicView) return;
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/v1/tours/${tourId}/share`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setShareStatus(data);
}
} catch (e) {
console.error('Error fetching share status:', e);
}
};
const handleToggleShare = async (isEnabled: boolean) => {
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/v1/tours/${tourId}/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ isEnabled })
});
if (res.ok) {
const data = await res.json();
setShareStatus(data);
notify({
title: 'Thành công',
message: isEnabled ? 'Đã bật chia sẻ hành trình cứu hộ.' : 'Đã tắt chia sẻ.',
type: 'success'
});
}
} catch (e) {
console.error(e);
}
};
const handleExportPDF = async () => {
if (!(window as any).html2pdf) {
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js';
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load html2pdf'));
document.head.appendChild(script);
});
}
const style = document.createElement('style');
style.innerHTML = `
.pdf-exclude { display: none !important; }
.pdf-container { padding: 40px !important; color: #000 !important; background: #fff !important; }
.pdf-title { font-size: 24px !important; font-weight: bold !important; margin-bottom: 20px !important; text-align: center !important; }
.pdf-timeline { margin-top: 20px; }
.pdf-location-card { border: 1px solid #e5e7eb; padding: 15px; border-radius: 12px; margin-bottom: 15px; background: #fafafa; }
.pdf-leg-header { font-size: 16px; font-weight: bold; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #3b82f6; padding-bottom: 5px; }
`;
document.head.appendChild(style);
const element = document.createElement('div');
element.className = 'pdf-container font-sans text-black bg-white';
const titleEl = document.createElement('h1');
titleEl.className = 'pdf-title';
titleEl.innerText = `Hành Trình: ${currentTour?.title || 'Tour Itinerary'}`;
element.appendChild(titleEl);
const subEl = document.createElement('div');
subEl.style.textAlign = 'center';
subEl.style.marginBottom = '30px';
subEl.style.fontSize = '12px';
subEl.style.color = '#555';
subEl.innerText = `Thời gian: ${currentTour?.startDate ? new Date(currentTour.startDate).toLocaleDateString('vi-VN') : ''} - ${currentTour?.endDate ? new Date(currentTour.endDate).toLocaleDateString('vi-VN') : ''}`;
element.appendChild(subEl);
const printDom = document.getElementById('itinerary-timeline-print-zone');
if (printDom) {
const clone = printDom.cloneNode(true) as HTMLElement;
// Clean clone layout by removing action buttons and interactive inputs, expenses
clone.querySelectorAll('button, input, textarea, .pdf-exclude, .expense-badge, .paid-by-badge, .comment-section, .location-actions, .mt-2.text-indigo-650, .flex.gap-2.mt-2').forEach(el => {
el.remove();
});
// Clear styles or apply simple standard styles so PDF generation is clean
clone.style.background = 'white';
clone.style.color = 'black';
element.appendChild(clone);
} else {
notify({ title: 'Lỗi', message: 'Không tìm thấy vùng hiển thị lịch trình để xuất PDF.', type: 'error' });
document.head.removeChild(style);
return;
}
const opt = {
margin: 10,
filename: `Lich_trinh_${currentTour?.title || 'tour'}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
};
try {
notify({ title: 'Đang tạo PDF...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
await (window as any).html2pdf().from(element).set(opt).save();
notify({ title: 'Thành công', message: 'Lịch trình đã được xuất ra tập tin PDF thành công.', type: 'success' });
} catch (err) {
console.error(err);
notify({ title: 'Lỗi', message: 'Không thể xuất PDF lịch trình.', type: 'error' });
} finally {
document.head.removeChild(style);
}
};
useEffect(() => {
fetchShareStatus();
}, [tourId]);
// State cho vị trí và hướng của người dùng
const [deviceOrientationHeading, setDeviceOrientationHeading] = useState<number | null>(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: `<div class="w-7 h-7 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-[11px] font-black text-white animate-in zoom-in duration-300">S</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14]
}),
end: L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-7 h-7 bg-green-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-[11px] font-black text-white animate-in zoom-in duration-300">E</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14]
}),
visit: L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-5 h-5 bg-indigo-500 rounded-full border-2 border-white shadow-lg hover:scale-125 transition-transform flex items-center justify-center"><div class="w-1.5 h-1.5 bg-white rounded-full opacity-50"></div></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10]
}),
user: L.divIcon({
className: '!bg-transparent !border-none',
html: `
<div class="relative flex items-center justify-center">
<!-- Hiệu ứng ping tỏa lan -->
<div class="absolute w-8 h-8 bg-blue-400 rounded-full opacity-30 animate-ping"></div>
<!-- Marker chính màu xanh -->
<div class="w-5 h-5 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center z-10">
<!-- Mũi tên chỉ hướng nhìn (xoay theo heading) -->
<div style="transform: rotate(${currentHeading}deg); transition: transform 0.2s ease-out;" class="absolute inset-0 flex flex-col items-center">
<div class="w-0 h-0 border-l-[5px] border-l-transparent border-r-[5px] border-r-transparent border-b-[8px] border-b-white mt-[1px]"></div>
</div>
</div>
</div>
`,
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<string | null>(null);
const [editingLocation, setEditingLocation] = useState<any>(null);
const [selectedMember, setSelectedMember] = useState<any>(null);
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(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<number | ''>('');
const [editPhotoLng, setEditPhotoLng] = useState<number | ''>('');
const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false);
const [isMapOpen, setIsMapOpen] = useState(false);
const [resolvedAddress, setResolvedAddress] = useState<string>('');
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<any[]>([]);
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<string[]>(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<string | 'all'>('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<string | null>(null);
// State cho Modal ghi chú nhanh
const [quickNoteLocName, setQuickNoteLocName] = useState<string | null>(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<any[]>([]);
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<any[]>([]);
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<number[]>([]);
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);
// 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 || '');
const canManage = isOwner || (!isPublicView && userRole === 'MANAGER');
const duplicateMatches = useMemo(() => {
if (!canManage || !currentTour?.participants) return [];
const manualMembers = currentTour.participants.filter((p: any) => !p.userId && p.displayName);
const systemMembers = currentTour.participants.filter((p: any) => p.userId && p.user?.name);
const matches: Array<{ manual: any; system: any }> = [];
manualMembers.forEach((m: any) => {
const match = systemMembers.find((s: any) => {
return s.user.name.trim().toLowerCase() === m.displayName.trim().toLowerCase();
});
if (match) {
matches.push({ manual: m, system: match });
}
});
return matches;
}, [canManage, currentTour?.participants]);
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<OSRMRoute[] | null>[] = [];
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]);
setViewMode('map');
setLocateTrigger(prev => prev + 1);
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);
});
socket.on('tourMessageReceived', (data: any) => {
if (data.tourId === currentTour.id && activeTab !== 'chat') {
setUnreadChatCount(prev => prev + 1);
}
});
return () => { socket.disconnect(); };
}, [currentTour?.id, activeTab]);
// 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(`<strong>${oldTitle}</strong>`).join(`<strong>${newTitle}</strong>`);
}
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 = `
<div class="quick-note-box" style="border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; background: #fffbeb; border-radius: 8px; border: 1px solid #fef3c7; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: #b45309; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;">🕒 ${now} • 👤 ${userName}</span>
</div>
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${quickNoteLocName}:</strong> ${content}</p>
</div>
<p></p>
`;
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: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${currentTour.title}</strong> của bạn tại đây...</p>` + 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<HTMLDivElement>(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: 'chat', label: 'Trò chuyện', icon: MessageSquare, visible: !!userRole, hasBadge: unreadChatCount > 0, badgeCount: unreadChatCount },
{ id: 'members', label: 'Thành viên', icon: Users, visible: canManage },
{ 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 (
<div className="min-h-screen bg-gray-50 pb-20">
{/* Top Navigation Bar */}
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between">
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<ChevronLeft className="w-6 h-6 text-gray-600" />
</button>
<h1 className="text-lg font-bold text-gray-800 truncate px-4 flex-1 text-center">
{tourInfo.title}
</h1>
<div className="flex items-center gap-1">
{/* 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 && (
<button
onClick={onOpenNotes}
className="p-2 hover:bg-amber-50 text-amber-600 rounded-full transition-colors"
title="Ghi chú của tôi"
>
<FileText className="w-5 h-5" />
</button>
)}
{/* Nút chia sẻ: Chỉ dành cho OWNER, MANAGER, MEMBER */}
{canShare && (
<button
onClick={handleShare}
className="p-2 hover:bg-blue-50 text-blue-600 rounded-full transition-colors"
title="Chia sẻ tour"
>
<Share2 className="w-5 h-5" />
</button>
)}
</div>
</div>
{/* Tour Header Info */}
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
<img
src={tourInfo.coverImage}
className="absolute inset-0 w-full h-full object-cover opacity-60"
alt="Tour Cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
<div className="relative z-10 p-6 text-white pt-28 pb-20">
<div className="max-w-2xl mx-auto space-y-4">
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
{/* Nhãn hiển thị ngay dưới Tiêu đề */}
{currentTour?.tags && currentTour.tags.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{currentTour.tags.map((tag: string) => (
<span key={tag} className="px-2.5 py-1 bg-white/20 backdrop-blur-md border border-white/30 rounded-lg text-[10px] font-black uppercase tracking-wider">
{tag}
</span>
))}
</div>
)}
{/* Mô tả hiển thị dưới Nhãn */}
{currentTour?.description && (
<p className="text-sm md:text-base text-white/90 max-w-xl line-clamp-3 md:line-clamp-none bg-black/20 backdrop-blur-sm p-4 rounded-2xl border border-white/10 italic leading-relaxed">
<Quote className="w-4 h-4 inline-block mr-2 opacity-50" />
{currentTour.description}
</p>
)}
{/* Dòng tóm tắt Lộ trình */}
<div className="mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full">
<span className="text-white/60 mr-1">Lộ trình:</span>
<span className="text-blue-300">Điểm xuất phát:</span>
<span className="ml-1 text-white banner-location-text" title={startPoint?.name}>{startPoint?.name || '...'}</span>
<span className="mx-2 text-white/30">-</span>
<span className="text-green-300">Điểm kết thúc:</span>
<span className="ml-1 text-white banner-location-text" title={endPoint?.name}>{endPoint?.name || '...'}</span>
</div>
<div className="flex flex-wrap gap-4 text-sm font-medium opacity-90">
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
<Calendar className="w-4 h-4 mr-1.5" />
{tourInfo.date}
</div>
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
<Users className="w-4 h-4 mr-1.5" />
{tourInfo.membersCount} thành viên
</div>
</div>
{/* Member Avatars Stack */}
<div className="flex items-center gap-2 mt-4">
<div className="flex flex-wrap gap-2"> {/* 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 (
<button
key={p.id}
onClick={() => {
setSelectedMember(p);
setIsMemberDetailOpen(true);
}}
className="w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg hover:scale-110 transition-transform"
title={memberName}
>
{p.user ? (
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
) : (
<span>{memberName.charAt(0)}</span>
)}
</button>
);
})}
{isOwner && !isPublicView && joinRequests.slice(0, 3).map((req: any) => ( // Hide join requests in public view
<div key={req.id} className="relative group">
<div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg">
{req.user?.name?.charAt(0) || '?'}
</div>
<div className="absolute -top-1 -right-1 flex">
<button
type="button"
disabled={joinRequestActionId === req.id}
onClick={async (e) => {
e.stopPropagation();
if (!currentTour) return;
const isConfirmed = await confirm({
title: 'Chấp nhận yêu cầu',
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
});
if (isConfirmed) {
setJoinRequestActionId(req.id);
try {
await acceptJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notify({ title: 'Thông báo', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
} finally {
setJoinRequestActionId(null);
}
}
}}
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
aria-label="Accept"
>
+
</button>
<button
type="button"
disabled={joinRequestActionId === req.id}
onClick={async (e) => {
e.stopPropagation();
if (!currentTour) return;
const isConfirmed = await confirm({
title: 'Từ chối yêu cầu',
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
});
if (isConfirmed) {
setJoinRequestActionId(req.id);
try {
await rejectJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notify({ title: 'Thông báo', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
} finally {
setJoinRequestActionId(null);
}
}
}}
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
aria-label="Reject"
>
x
</button>
</div>
</div>
))}
{tourInfo.membersCount > 5 && (
<div className="w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg">
+{tourInfo.membersCount - 5}
</div>
)}
</div>
{!isPublicView && canInvite && (
<button
onClick={() => {
if (!currentTour) return;
setIsAddMemberOpen(true);
}}
className="flex items-center gap-1.5 px-3 py-2 rounded-full border border-white/20 bg-white/10 hover:bg-white/20 text-white font-bold text-xs transition-all ml-2 shadow-md hover:scale-105 active:scale-95"
>
<Plus className="w-3.5 h-3.5" />
Thêm thành viên
</button>
)}
</div>
{duplicateMatches.map((match) => (
<div key={match.manual.id} className="mt-3 p-3 bg-amber-500/20 backdrop-blur-md border border-amber-500/30 rounded-2xl flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-xs text-amber-100 animate-in fade-in slide-in-from-top-1 shadow-lg">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-amber-400 animate-pulse shrink-0"></span>
<span>
Phát hiện thành viên ngoài hệ thống <strong>"{match.manual.displayName}"</strong> trùng tên với tài khoản <strong>"{match.system.user.name}"</strong> vừa tham gia.
</span>
</div>
<button
disabled={mergingId === match.manual.id}
onClick={async () => {
const isConfirmed = await confirm({
title: 'Hợp nhất thành viên',
message: `Gán tài khoản "${match.system.user.name}" thay thế cho thành viên thủ công "${match.manual.displayName}"? Thao tác này không thể hoàn tác.`
});
if (!isConfirmed) return;
setMergingId(match.manual.id);
try {
const res = await fetch(`/api/v1/tours/${currentTour.id}/members/merge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
manualParticipantId: match.manual.id,
systemUserId: match.system.userId
})
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'Hợp nhất thất bại.');
}
notify({ title: 'Thành công', message: 'Đã gán thành viên ngoài hệ thống thành công!', type: 'success' });
fetchTour(currentTour.id);
} catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể hợp nhất', type: 'error' });
} finally {
setMergingId(null);
}
}}
className={`px-3.5 py-2 bg-amber-500 hover:bg-amber-600 active:scale-95 text-white font-black rounded-xl transition-all shrink-0 shadow-md text-[11px] ${mergingId === match.manual.id ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{mergingId === match.manual.id ? 'Đang xử lý...' : 'Gán & Hợp nhất'}
</button>
</div>
))}
</div>
</div>
</div>
{/* Financial Quick-View Widget or Quote */}
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
<div // Always show quote if public view, otherwise show financial widget if has access
onClick={() => 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'}`}
>
<div className="flex justify-between items-center">
{hasFinanceAccess ? (
<>
<div>
<p className="text-indigo-100 text-[10px] font-black uppercase tracking-widest mb-1">Tổng chi tiêu hiện tại (Nhấn để xem chi tiết)</p>
<h3 className="text-3xl font-black">{tourInfo.budget}</h3>
</div>
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
</>
) : ( // If no finance access or is public view, show quote
<div className="flex items-start gap-4 py-2">
<Quote className="w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" />
<p className="italic text-lg font-medium leading-relaxed">"{randomQuote}"</p>
</div>
)}
</div>
</div>
</div>
{/* Khối hiển thị Điểm đầu & Điểm cuối (Dưới Financial Quick-View) */}
<div className="max-w-2xl mx-auto mt-4 px-4 grid grid-cols-1 sm:grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-500">
{startPoint && (
<div className="bg-white p-4 rounded-2xl border border-blue-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 shadow-inner">
<MapPin className="w-5 h-5" />
</div>
<div className="overflow-hidden">
<p className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-0.5">Điểm xuất phát</p>
<p className="text-sm font-bold text-gray-800 truncate">{startPoint.name}</p>
</div>
</div>
)}
{endPoint && (
<div className="bg-white p-4 rounded-2xl border border-green-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center text-green-600 shadow-inner">
<Flag className="w-5 h-5" />
</div>
<div className="overflow-hidden">
<p className="text-[10px] font-black text-green-400 uppercase tracking-widest mb-0.5">Điểm kết thúc</p>
<p className="text-sm font-bold text-gray-800 truncate">{endPoint.name}</p>
</div>
</div>
)}
</div>
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`}>
{/* Tab Switcher */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id as any);
if (tab.id === 'chat') setUnreadChatCount(0);
}}
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all relative ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
activeTab === tab.id
? 'bg-blue-50 text-blue-600 shadow-sm'
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
}`}
>
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}`} />
{tab.label}
{tab.hasBadge && tab.badgeCount !== undefined && tab.badgeCount > 0 && (
<span className="absolute -top-1 -right-1.5 bg-red-500 text-white text-[9px] font-black rounded-full px-1.5 py-0.5 animate-bounce shadow-md">
{tab.badgeCount}
</span>
)}
</button>
))}
</div>
{/* Tab Panels */}
<div className="transition-opacity duration-300">
{activeTab === 'plan' && (
<div className="animate-in fade-in slide-in-from-bottom-2">
{/* View Mode Toggle */}
<div className="flex justify-between items-center mb-6">
<div className="flex-1" />
<div className="bg-gray-100 dark:bg-slate-800 p-1 rounded-2xl flex gap-1">
<button
onClick={() => setViewMode('timeline')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
>
<List className="w-3.5 h-3.5" /> Danh sách
</button>
<button
onClick={() => setViewMode('map')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
>
<MapIconLucide className="w-3.5 h-3.5" /> Bản đồ
</button>
</div>
<div className="flex-1 flex justify-end">
<button
onClick={handleExportPDF}
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs font-bold shadow-md transition-all active:scale-95"
>
📥 {t('exportPDF') || 'Xuất PDF'}
</button>
</div>
</div>
{viewMode === 'timeline' ? (
<ItineraryTimeline onAddLocation={(legId, isStart, isEnd) => {
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}
/>
) : (
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative animate-in fade-in duration-500">
{/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */}
{!isPublicView && (
<div className="absolute top-3 right-3 z-[1001] w-48 md:w-64">
<div className="relative group">
<input
type="text"
placeholder="Tìm địa điểm..."
className="w-full pl-8 pr-8 py-2 bg-white/95 backdrop-blur-md border border-white/20 rounded-xl shadow-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all text-xs font-bold"
value={searchQuery}
onChange={e => handleSearchLocation(e.target.value)}
/>
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-blue-500" />
{isSearching ? (
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 animate-spin text-blue-500" />
) : searchQuery && (
<button onClick={() => { setSearchQuery(''); setSearchResults([]); }} className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-red-500 transition-colors">
<X className="w-4 h-4" />
</button>
)}
{/* Kết quả tìm kiếm */}
{searchResults.length > 0 && (
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-48 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
{searchResults.map((result, idx) => (
<button
key={idx}
onClick={() => {
setMapCenter([parseFloat(result.lat), parseFloat(result.lon)]);
setSearchResults([]);
setSearchQuery('');
notify({
title: 'Tìm thấy địa điểm',
message: `Đã di chuyển bản đồ tới: ${result.namedetails?.name || result.display_name.split(',')[0]}`,
type: 'success'
});
}}
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-colors"
>
<div className="font-bold text-xs text-gray-900 truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
<div className="text-[10px] text-gray-500 truncate leading-tight">{result.display_name}</div>
</button>
))}
</div>
)}
</div>
</div>
)}
{/* Overlay thông tin lộ trình đề xuất (Lộ trình thông dụng nhất) */}
{selectedRouteInfo && drivingRoute.length > 0 && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1001] bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl shadow-xl border border-white flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-500">
<div className="flex items-center gap-2">
<Navigation className="w-3.5 h-3.5 text-blue-600 rotate-45" />
<span className="text-[10px] font-black text-gray-400 uppercase tracking-tighter">{selectedRouteInfo.label}</span>
</div>
<div className="flex items-center gap-2 text-xs font-bold text-blue-700 whitespace-nowrap">
<span>{selectedRouteInfo.distance} km</span>
<span className="text-gray-300"></span>
<span>{selectedRouteInfo.duration}</span>
</div>
</div>
)}
<MapContainer
center={initialViewState?.center || mapCenter}
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
attributionControl={false}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
{canEdit && !isPublicView && (
<>
<MapHoverTip canEdit={canEdit} />
<MapContextMenu onAction={handleMapAction} onOpen={() => setRouteMenu(null)} />
</>
)}
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
<MapTourBounds locations={allLocations} />
{/* Xử lý xoay bản đồ theo Heading */}
<MapRotationHandler rotation={mapRotation} />
{/* Xử lý di chuyển tâm bản đồ về phía người dùng */}
<RecenterUser position={userLocation} trigger={locateTrigger} />
{/* Hiển thị vị trí hiện tại của người dùng */}
{userLocation && (
<Marker position={userLocation} icon={mapIcons.user} zIndexOffset={1000}>
<Popup>
<div className="text-xs font-bold text-blue-600">Bạn đang đây</div>
</Popup>
</Marker>
)}
{/* 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 }) => (
<Polyline
key={`polyline-${index}-${index === selectedRouteIndex ? 'active' : 'alt'}-${routeKey}`}
positions={data.geometry.coordinates.map((c: any) => [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ế */
<Polyline
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
color="#3b82f6"
weight={3}
dashArray="5, 10"
smoothFactor={1.5}
/>
)}
<MarkerClusterGroup key={`cluster-group-${allLocations.length}`} chunkedLoading>
{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 (
<Marker key={`marker-${loc.id}-${isStart ? 'start' : isEnd ? 'end' : 'visit'}`} position={[loc.latitude, loc.longitude]} icon={icon}>
<Popup>
<div className="p-1">
<div className="font-bold text-gray-900 leading-tight mb-0.5">{loc.name}</div>
<div className="text-[10px] text-gray-400 mb-2 uppercase tracking-widest">{loc.type}</div>
<div className="flex flex-col gap-1">
<button
onClick={() => handleQuickNote(loc.name)}
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-black transition-all border border-amber-100"
>
<FileText className="w-3 h-3" />
GHI CHÚ NHANH
</button>
<button
onClick={() => {
setCommentLocationId(loc.id);
setCommentLocationName(loc.name);
setIsCommentModalOpen(true);
}}
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg text-[10px] font-black transition-all border border-blue-100"
>
<MessageSquare className="w-3 h-3" />
BÌNH LUẬN {loc._count?.comments > 0 && `(${loc._count.comments})`}
</button>
</div>
{(drivingDist || (distanceToPrev && distanceToPrev !== "0.0")) && (
<div className="mt-3 pt-3 border-t border-gray-100 flex items-center gap-2 animate-in fade-in slide-in-from-bottom-1">
<div className="p-1.5 bg-blue-50 rounded-lg">
<Navigation className="w-3 h-3 text-blue-600 rotate-45" />
</div>
<div className="flex flex-col">
<span className="text-[9px] font-black text-blue-400 uppercase leading-none tracking-tighter mb-0.5">
{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'}
</span>
<span className="text-xs font-black text-blue-700">{drivingDist ? drivingDist.toFixed(1) : distanceToPrev} km</span>
</div>
</div>
)}
</div>
</Popup>
</Marker>
);
})}
</MarkerClusterGroup>
</MapContainer>
{/* Menu ngữ cảnh khi click chuột phải vào con đường */}
{routeMenu && (
<div
ref={routeMenuRef}
className="absolute z-[2001] bg-white rounded-2xl shadow-2xl border border-gray-100 py-1 w-44 animate-in zoom-in-95 duration-200"
style={{ top: routeMenu.y, left: routeMenu.x }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => {
setSelectedRouteIndex(routeMenu.index);
setRouteMenu(null);
notify({
title: 'Đã chọn đường đi',
message: `Hệ thống đã chuyển sang Lựa chọn ${routeMenu.index + 1}`,
type: 'success'
});
}}
className="w-full text-left px-4 py-3 hover:bg-blue-50 text-sm font-bold text-blue-600 flex items-center gap-2 transition-colors rounded-xl"
>
<Navigation className="w-4 h-4 rotate-45" /> Đi đường này
</button>
</div>
)}
{/* Overlay điều khiển trên bản đồ */}
<div className="absolute top-3 left-3 z-[1001] flex flex-col gap-1.5">
<button
onClick={() => setIsMapControlsOpen(!isMapControlsOpen)}
className="w-9 h-9 bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-white text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center shrink-0"
>
{isMapControlsOpen ? (
<X className="w-4 h-4 text-gray-400" />
) : (
travelMode === 'driving' ? <Car className="w-4 h-4" /> :
travelMode === 'bike' ? <Bike className="w-4 h-4" /> :
<Footprints className="w-4 h-4" />
)}
</button>
{isMapControlsOpen && (
<div className="bg-white/90 backdrop-blur-md p-1 rounded-xl shadow-xl border border-white flex flex-col gap-1 animate-in slide-in-from-top-2 duration-300 items-center">
<button
onClick={() => { setTravelMode('driving'); setIsMapControlsOpen(false); }}
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${travelMode === 'driving' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
title="Ô tô"
>
<Car className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { setTravelMode('bike'); setIsMapControlsOpen(false); }}
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${travelMode === 'bike' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
title="Xe máy / Xe đạp"
>
<Bike className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { setTravelMode('foot'); setIsMapControlsOpen(false); }}
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${travelMode === 'foot' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
title="Đi bộ"
>
<Footprints className="w-3.5 h-3.5" />
</button>
{/* Nút La bàn / Xoay bản đồ */}
<button
onClick={() => {
const newMode = !isHeadingMode;
setIsHeadingMode(newMode);
if (!newMode) setMapRotation(0);
setIsMapControlsOpen(false);
}}
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border-t border-gray-100 mt-0.5 ${isHeadingMode ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`}
title={isHeadingMode ? "Khóa hướng Bắc" : "Xoay theo hướng nhìn"}
>
<Compass className="w-3.5 h-3.5 transition-transform duration-300" style={{ transform: `rotate(${mapRotation}deg)` }} />
</button>
{/* Nút Tìm tôi */}
<button
onClick={() => { setLocateTrigger(prev => prev + 1); setIsMapControlsOpen(false); }}
disabled={!userLocation}
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border-t border-gray-100 mt-0.5 ${!userLocation ? 'opacity-30 cursor-not-allowed' : 'text-blue-600 hover:bg-blue-50'}`}
title="Vị trí của tôi"
>
<LocateFixed className="w-3.5 h-3.5" />
</button>
{/* Danh sách lộ trình rút gọn */}
{routes.length > 0 && (
<div className="pt-1 border-t border-gray-100 flex flex-col gap-1">
<div className="flex flex-col gap-1 max-h-[160px] overflow-y-auto pr-1 custom-scrollbar">
{routes.map((route, idx) => (
<button
key={idx}
onClick={() => { setSelectedRouteIndex(idx); setIsMapControlsOpen(false); }}
title={`${(route.distance / 1000).toFixed(1)} km - ${Math.round(route.duration / 60)}p`}
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all border ${
selectedRouteIndex === idx
? 'bg-blue-600 text-white border-blue-600 shadow-sm'
: 'bg-gray-50 text-gray-600 border-gray-100'
}`}
>
<span className="text-[10px] font-bold">{idx + 1}</span>
</button>
))}
</div>
</div>
)}
</div>
)}
{/* Chỉ báo đang tìm đường */}
{isRoutingLoading && (
<div className="bg-white/90 backdrop-blur-md px-3 py-2 rounded-xl shadow-lg border border-white flex items-center gap-2 animate-pulse animate-in slide-in-from-left-2">
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-600" />
<span className="text-[10px] font-black text-gray-500 uppercase tracking-tighter">Đang tìm đường tối ưu...</span>
</div>
)}
</div>
</div>
)}
</div>
)}
{activeTab === 'expense' && (
<div className="animate-in fade-in slide-in-from-bottom-4">
<ExpenseManager />
</div>
)}
{activeTab === 'photo' && (
<div className="animate-in fade-in">
{/* Main container for the new layout */}
<div className="flex flex-col md:flex-row gap-4">
{/* Left Column: Leg List */}
<div className="md:w-1/4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex-shrink-0">
<h3 className="text-sm font-bold text-gray-800 mb-3">Chặng của Tour</h3>
<div className="space-y-2">
<button
onClick={() => {
setSelectedLegIdForPhoto('all');
setSelectedPhotoForDisplay(null); // Reset selected photo when changing leg
}}
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
selectedLegIdForPhoto === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Tất cả nh
</button>
{legs.map(leg => (
<button
key={leg.id}
onClick={() => {
setSelectedLegIdForPhoto(leg.id);
setSelectedPhotoForDisplay(null); // Reset selected photo when changing leg
}}
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
selectedLegIdForPhoto === leg.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Chặng {leg.sequence}: {leg.note || 'Không ghi chú'}
</button>
))}
</div>
</div>
{/* Right Column: Large Photo Display */}
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
{selectedPhotoForDisplay ? (
<div className="relative w-full h-full flex flex-col items-center justify-center">
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
<img
src={selectedPhotoForDisplay.imageUrl}
alt="Selected Tour Photo"
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
onClick={() => setIsFullscreen(true)}
/>
{/* Overlays (Only show when not editing) */}
{!isEditingPhoto && (
<>
{/* Like (Heart) button overlay */}
<button
onClick={handleToggleLikePhoto}
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-black/60 hover:bg-black/75 border border-white/10 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
title={isPhotoLiked ? "Bỏ thích" : "Thích"}
>
<Heart className={`w-4 h-4 transition-colors ${
isPhotoLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-gray-300 hover:text-rose-450'
}`} />
<span>{photoLikeCount}</span>
</button>
{/* Delete button overlay (if owner) */}
{currentUserId === selectedPhotoForDisplay.uploaderId && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeletePhoto(selectedPhotoForDisplay.id);
}}
className="absolute top-4 right-4 z-10 p-2 bg-black/60 hover:bg-red-650 border border-white/10 text-white rounded-full transition-all active:scale-95 backdrop-blur-md"
title="Xóa ảnh này"
>
<Trash2 className="w-4 h-4" />
</button>
)}
{/* Bottom metadata details gradient panel overlay */}
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
{selectedPhotoForDisplay.metadata?.title ? (
<h3 className="text-base font-extrabold text-white break-words drop-shadow-md">
{selectedPhotoForDisplay.metadata.title}
</h3>
) : (
<span className="text-xs text-gray-350 italic block mb-1 drop-shadow-md">Chưa tiêu đề</span>
)}
{selectedPhotoForDisplay.metadata?.description ? (
<p className="text-xs text-gray-250 leading-relaxed mt-1 break-words drop-shadow-sm max-h-16 overflow-y-auto no-scrollbar">
{selectedPhotoForDisplay.metadata.description}
</p>
) : (
<span className="text-[11px] text-gray-350 italic block mt-1 drop-shadow-sm">Chưa tả</span>
)}
</div>
{/* Edit Button Overlay */}
{((!isPublicView && currentUser?.isAdmin) || (currentUserId && currentUserId === selectedPhotoForDisplay.uploaderId)) && (
<button
onClick={() => setIsEditingPhoto(true)}
className="p-2 bg-white/10 hover:bg-white/20 border border-white/15 rounded-xl text-white hover:text-gray-200 transition-all shrink-0 backdrop-blur-sm"
title="Chỉnh sửa thông tin"
>
<Edit className="w-4 h-4" />
</button>
)}
</div>
{/* Additional metadata info inside bottom overlay */}
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-3 text-xs text-gray-200">
<div className="space-y-1">
<div className="flex items-center gap-2 text-gray-300 text-[10px] font-bold uppercase tracking-wider">
<Calendar className="w-3.5 h-3.5" />
Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</div>
<div className="flex items-center gap-2 font-bold" title={selectedPhotoForDisplay.metadata?.lat && selectedPhotoForDisplay.metadata?.lng ? `${selectedPhotoForDisplay.metadata.lat.toFixed(6)}, ${selectedPhotoForDisplay.metadata.lng.toFixed(6)}` : ''}>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Địa điểm: {resolvedAddress}
</div>
<div className="text-[10px] text-gray-400">
Tải lên bởi: {selectedPhotoForDisplay.uploader?.name || 'Thành viên'}
</div>
</div>
{(currentUser?.isAdmin || currentUserId === selectedPhotoForDisplay.uploaderId) && selectedPhotoForDisplay.originalUrl && (
<a
href={selectedPhotoForDisplay.originalUrl}
download
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold uppercase tracking-widest text-[9px] transition-all shadow-md active:scale-95 shrink-0"
>
<Download className="w-3.5 h-3.5 animate-pulse" /> Tải nh gốc
</a>
)}
</div>
</div>
</>
)}
</div>
{isEditingPhoto && (
<div className="mt-6 w-full flex flex-col gap-4 px-2">
<div className="space-y-3 bg-gray-50 border border-gray-150 p-4 rounded-2xl w-full text-left">
<h4 className="text-xs font-black uppercase tracking-wider text-blue-600">Chỉnh sửa thông tin nh</h4>
<div className="space-y-2">
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Tiêu đề</label>
<input
type="text"
value={editPhotoTitle}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1"> tả</label>
<textarea
value={editPhotoDescription}
onChange={(e) => setEditPhotoDescription(e.target.value)}
placeholder="Nhập mô tả..."
rows={2}
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 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1"> độ</label>
<input
type="number"
step="any"
value={editPhotoLat}
onChange={(e) => setEditPhotoLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Vĩ độ..."
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"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Kinh độ</label>
<input
type="number"
step="any"
value={editPhotoLng}
onChange={(e) => setEditPhotoLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
placeholder="Kinh độ..."
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"
/>
</div>
</div>
</div>
<div className="flex justify-start">
<button
type="button"
onClick={() => setIsMapOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 hover:text-gray-900 rounded-xl text-[10px] font-bold transition-all"
>
<MapPin className="w-3.5 h-3.5 text-rose-500" />
Chọn trên bản đồ
</button>
</div>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setIsEditingPhoto(false)}
disabled={isSavingPhotoEdit}
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-xs font-bold transition-all"
>
Hủy
</button>
<button
onClick={handleSavePhotoEdit}
disabled={isSavingPhotoEdit}
className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
>
{isSavingPhotoEdit ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Đang lưu...
</>
) : (
'Lưu lại'
)}
</button>
</div>
</div>
</div>
)}
</div>
) : (
<div className="text-center text-gray-400">
<ImageIcon className="w-16 h-16 mx-auto mb-4" />
<p className="text-lg font-medium">Chọn một nh để xem chi tiết</p>
</div>
)}
</div>
</div>
{/* Bottom Row: Thumbnails */}
<div className="mt-4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4">
<h3 className="text-sm font-bold text-gray-800 mb-3">
{selectedLegIdForPhoto === 'all' ? 'Tất cả ảnh' : `Ảnh của Chặng ${legs.find(l => l.id === selectedLegIdForPhoto)?.sequence || ''}`}
</h3>
{filteredPhotos.length > 0 ? (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 overflow-x-auto pb-2">
{filteredPhotos.map((photo: any) => (
<div
key={photo.id}
onClick={() => 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`}
>
<img
src={photo.imageUrl}
alt="Thumbnail"
className="w-full h-full object-cover"
/>
</div>
))}
</div>
) : (
<div className="py-10 text-center text-gray-400">
<ImageIcon className="w-12 h-12 mx-auto mb-3" />
<p className="text-md font-medium">Chưa nh nào cho chặng này.</p>
</div>
)}
</div>
</div>
)}
{activeTab === 'settings' && !isPublicView && ( // Hide settings tab in public view
<div className="space-y-4">
<div className="p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95">
<div className="flex items-center gap-3 mb-4">
<Clock className="w-6 h-6 text-blue-500" />
<h3 className="text-lg font-bold text-gray-900">Yêu cầu tham gia</h3>
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2 py-1 rounded-full">{joinRequests.length} đang chờ</span>
</div>
<div className="space-y-2">
{joinRequests.map((req: any) => (
<div key={req.id} className="flex items-center justify-between gap-3 p-3 rounded-2xl border border-gray-100 bg-white">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-700 font-bold text-sm">
{req.user?.name?.charAt(0) || '?'}
</div>
<div>
<div className="text-sm font-bold text-gray-800">{req.user?.name || req.userId}</div>
<div className="text-[11px] text-gray-500">
Được mời bởi {req.requestedBy?.name} {new Date(req.createdAt).toLocaleString('vi-VN')}
</div>
</div>
</div>
{isOwner && <div className="flex gap-2">
<button
disabled={joinRequestActionId === req.id}
onClick={async () => {
if (!currentTour) return;
const isConfirmed = await confirm({
title: 'Chấp nhận yêu cầu',
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`
});
if (isConfirmed) {
setJoinRequestActionId(req.id);
try {
await acceptJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notify({ title: 'Thông báo', message: e.message || 'Không thể chấp nhận yêu cầu', type: 'error' });
} finally {
setJoinRequestActionId(null);
}
}
}}
className="p-2 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 disabled:opacity-50"
aria-label="Accept"
>
<Check className="w-4 h-4" />
</button>
<button
disabled={joinRequestActionId === req.id}
onClick={async () => {
if (!currentTour) return;
const isConfirmed = await confirm({
title: 'Từ chối yêu cầu',
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`
});
if (isConfirmed) {
setJoinRequestActionId(req.id);
try {
await rejectJoinRequest(currentTour.id, req.id);
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
} catch (e: any) {
notify({ title: 'Thông báo', message: e.message || 'Không thể từ chối yêu cầu', type: 'error' });
} finally {
setJoinRequestActionId(null);
}
}
}}
className="p-2 rounded-xl bg-red-50 text-red-700 hover:bg-red-100 disabled:opacity-50"
aria-label="Reject"
>
<X className="w-4 h-4" />
</button>
</div>}
</div>
))}
{joinRequests.length === 0 && (
<div className="text-center py-8 text-sm text-gray-500">Không yêu cầu tham gia nào đang chờ phê duyệt.</div>
)}
</div>
</div>
{/* Emergency Sharing Settings */}
<div className="p-6 bg-white dark:bg-slate-900 rounded-3xl border border-dashed border-rose-200 dark:border-rose-950/40 shadow-sm animate-in zoom-in-95">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<span className="text-xl">🚨</span>
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{t('emergencyShare')}</h3>
<p className="text-xs text-gray-500 dark:text-slate-400">{t('emergencyShareTooltip')}</p>
</div>
</div>
{shareStatus && (
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={shareStatus.isEnabled}
onChange={(e) => handleToggleShare(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-rose-500"></div>
</label>
)}
</div>
{shareStatus?.isEnabled && (
<div className="mt-4 bg-rose-50 dark:bg-rose-950/20 p-4 rounded-2xl border border-rose-100 dark:border-rose-950/30 flex flex-col gap-2">
<div className="text-xs font-bold text-rose-700 dark:text-rose-400 uppercase tracking-widest">Đường dẫn chia sẻ khẩn cấp:</div>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={`${window.location.origin}/journey/${shareStatus.token}`}
className="flex-1 bg-white dark:bg-slate-800 border dark:border-slate-700 rounded-xl px-3 py-2 text-xs text-slate-800 dark:text-slate-100 select-all"
/>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/journey/${shareStatus.token}`);
notify({ title: 'Thành công', message: 'Đã sao chép liên kết!', type: 'success' });
}}
className="bg-rose-600 hover:bg-rose-700 text-white font-bold px-3 py-2 rounded-xl text-xs transition-all active:scale-95 shrink-0"
>
{t('copyShareLink')}
</button>
</div>
</div>
)}
</div>
{canEdit && (
<div className="p-6 bg-white rounded-3xl border border-gray-100 shadow-sm animate-in zoom-in-95">
<div className="flex items-center gap-3 mb-6">
<Settings className="w-6 h-6 text-blue-500" />
<h3 className="text-lg font-bold text-gray-900">Thông tin bản</h3>
</div>
<div className="space-y-4 mb-8">
{isOwner && (
<>
<div>
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Tiêu đề Tour</label>
<input
type="text"
value={titleInput}
onChange={(e) => 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..."
/>
</div>
<div>
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1"> tả chuyến đi</label>
<textarea
value={descriptionInput}
onChange={(e) => setDescriptionInput(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 min-h-[100px] resize-none"
placeholder="Viết vài dòng giới thiệu về hành trình này..."
/>
</div>
</>
)}
<div className="flex items-center gap-3 mb-4">
<Users className="w-6 h-6 text-purple-500" />
<h3 className="text-md font-bold text-gray-800">Số lượng người tham gia</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label htmlFor="adultCount" className="block text-sm font-medium text-gray-700 mb-1">Số lượng người lớn</label>
<input
type="number"
id="adultCount"
value={adultCountInput}
onChange={(e) => setAdultCountInput(Number(e.target.value))}
min="0"
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="childCount" className="block text-sm font-medium text-gray-700 mb-1">Số lượng trẻ em</label>
<input
type="number"
id="childCount"
value={childCountInput}
onChange={(e) => setChildCountInput(Number(e.target.value))}
min="0"
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="childDiscount" className="block text-sm font-medium text-gray-700 mb-1">Giảm giá trẻ em (%)</label>
<input
type="number"
id="childDiscount"
value={childDiscountInput}
onChange={(e) => setChildDiscountInput(Number(e.target.value))}
min="0"
max="100"
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1 flex items-center gap-2">
<TagIcon className="w-3 h-3" /> Phân loại Tour
</label>
<div className="flex flex-wrap gap-2">
{availableTags.map(tag => (
<button
key={tag}
type="button"
onClick={() => setTagsInput(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all border ${
tagsInput.includes(tag)
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-500 border-gray-200 hover:border-blue-300'
}`}
>
{tag}
</button>
))}
</div>
<div className="flex gap-2 mt-3">
<input
type="text"
value={customTag}
onChange={(e) => setCustomTag(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), setTagsInput(prev => customTag.trim() && !prev.includes(customTag.trim()) ? [...prev, customTag.trim()] : prev), setCustomTag(''))}
placeholder="Thêm nhãn tùy chỉnh..."
className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={() => { if (customTag.trim() && !tagsInput.includes(customTag.trim())) { setTagsInput([...tagsInput, customTag.trim()]); setCustomTag(''); } }}
className="px-4 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all border border-blue-100"
>
Thêm
</button>
</div>
</div>
<button
onClick={handleUpdateTourInfo}
className="w-full mt-6 px-4 py-4 bg-blue-600 hover:bg-blue-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-blue-100 active:scale-95"
>
Lưu thay đổi
</button>
</div>
</div>
)}
{/* Danger Zone - Khu vực dành cho các hành động quan trọng */}
<div className="p-6 bg-red-50 rounded-3xl border border-red-100 animate-in zoom-in-95">
<div className="flex items-center gap-3 mb-4 text-red-600">
<Trash2 className="w-6 h-6" />
<h3 className="text-lg font-bold">Vùng nguy hiểm</h3>
</div>
<p className="text-sm text-red-500 mb-6 font-medium">Một khi đã xóa, bạn sẽ không thể khôi phục lại dữ liệu của hành trình này.</p>
<button
onClick={handleDeleteTour}
className="w-full py-4 bg-red-600 hover:bg-red-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-red-100 active:scale-95"
>
Xóa Tour vĩnh viễn
</button>
</div>
</div>
)}
{activeTab === 'members' && canManage && currentTour && (
<MembersTab
tourId={currentTour.id}
participants={currentTour.participants || []}
joinRequests={joinRequests}
userRole={userRole}
canManage={canManage}
isOwner={isOwner}
onRemoveMember={(memberIdOrUserId: string) => removeMember(currentTour.id, memberIdOrUserId)}
onRefresh={() => fetchTour(currentTour.id)}
onOpenAddMember={() => setIsAddMemberOpen(true)}
/>
)}
{activeTab === 'chat' && currentTour && (
<div className="animate-in fade-in slide-in-from-bottom-2">
<TourChat tourId={currentTour.id} />
</div>
)}
</div>
</div>
{/* Floating Action Button (Mobile) */}
{((activeTab === 'plan' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
<button
onClick={() => {
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
setEditingLocation(null);
setIsStartPointAction(false);
setIsEndPointAction(false);
if (activeTab === 'plan') setIsAddLocationOpen(true);
if (activeTab === 'photo' && canUploadPhoto) setIsAddPhotoOpen(true);
}}
className="bg-blue-600 text-white px-4 py-2.5 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold text-sm">
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
</button>
</div>
)}
{/* Fullscreen Map Overlay - Hiển thị khi click vào nút trạng thái/chỉ đường */}
{isMapFullscreen && (
<div className="fixed inset-0 z-[5000] bg-white animate-in fade-in slide-in-from-bottom-16 duration-500 overflow-hidden">
{/* Nút X để quay lại */}
<button
onClick={() => setIsMapFullscreen(false)}
className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-4 z-[1002] w-11 h-11 bg-white/90 backdrop-blur-md rounded-full shadow-2xl flex items-center justify-center border border-white/20 hover:bg-white transition-all active:scale-95 group"
title="Đóng bản đồ"
>
<X className="w-6 h-6 text-gray-800 group-hover:rotate-90 transition-transform duration-300" />
</button>
{/* Transparent Top Bar Label - Glassmorphism style */}
{selectedRouteInfo && drivingRoute.length > 0 && (
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-1/2 -translate-x-1/2 z-[1001] bg-white/20 backdrop-blur-lg px-6 py-2.5 rounded-full border border-white/30 flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500 shadow-xl">
<div className="flex items-center gap-2">
<Navigation className="w-4 h-4 text-blue-600 rotate-45 fill-blue-600" />
<span className="text-[11px] font-black text-gray-700 uppercase tracking-widest">{selectedRouteInfo.label}</span>
</div>
<div className="flex items-center gap-3 text-sm font-black text-blue-700 whitespace-nowrap">
<span>{selectedRouteInfo.distance} km</span>
<span className="text-gray-400 opacity-40"></span>
<span>{selectedRouteInfo.duration}</span>
</div>
</div>
)}
<MapContainer
center={mapCenter}
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
attributionControl={false}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
<MapTourBounds locations={allLocations} />
<MapRotationHandler rotation={mapRotation} />
<RecenterUser position={userLocation} trigger={locateTrigger} />
{userLocation && (
<Marker position={userLocation} icon={mapIcons.user} />
)}
{routes.length > 0 ? (
routes.map((r, i) => (
<Polyline
key={`fs-poly-${i}`}
positions={r.geometry.coordinates.map((c: any) => [c[1], c[0]])}
color={i === selectedRouteIndex ? "#2563eb" : "#94a3b8"}
weight={i === selectedRouteIndex ? 6 : 4}
opacity={i === selectedRouteIndex ? 1 : 0.4}
/>
))
) : allLocations.length > 1 && (
<Polyline positions={allLocations.map(l => [l.latitude, l.longitude]) as any} color="#3b82f6" weight={3} dashArray="5, 10" />
)}
{allLocations.map((loc: any, index: number) => {
const isStart = startPoint && startPoint.id === loc.id;
const isEnd = endPoint && endPoint.id === loc.id;
return (
<Marker key={`fs-marker-${loc.id}`} position={[loc.latitude, loc.longitude]} icon={isStart ? mapIcons.start : isEnd ? mapIcons.end : mapIcons.visit}>
<Popup>
<div className="font-bold">{loc.name}</div>
</Popup>
</Marker>
);
})}
</MapContainer>
{/* Overlay điều khiển trên bản đồ toàn màn hình */}
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 z-[1001] flex flex-col gap-2">
<button
onClick={() => setIsMapControlsOpen(!isMapControlsOpen)}
className="w-11 h-11 bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white text-blue-600 flex items-center justify-center transition-all active:scale-95"
>
{travelMode === 'driving' ? <Car className="w-5 h-5" /> : travelMode === 'bike' ? <Bike className="w-5 h-5" /> : <Footprints className="w-5 h-5" />}
</button>
{isMapControlsOpen && (
<div className="bg-white/90 backdrop-blur-md p-1.5 rounded-2xl shadow-xl border border-white flex flex-col gap-1.5 animate-in slide-in-from-top-2">
<button onClick={() => { setTravelMode('driving'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'driving' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Car className="w-4 h-4" /></button>
<button onClick={() => { setTravelMode('bike'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'bike' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Bike className="w-4 h-4" /></button>
<button onClick={() => { setTravelMode('foot'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'foot' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Footprints className="w-4 h-4" /></button>
<button
onClick={() => {
const newMode = !isHeadingMode;
setIsHeadingMode(newMode);
if (!newMode) setMapRotation(0);
setIsMapControlsOpen(false);
}}
className={`w-9 h-9 flex items-center justify-center rounded-xl border-t border-gray-100 transition-all ${isHeadingMode ? 'bg-blue-600 text-white' : 'text-gray-500'}`}
>
<Compass className="w-4 h-4" style={{ transform: `rotate(${mapRotation}deg)` }} />
</button>
</div>
)}
</div>
</div>
)}
{/* Add Member Modal */}
{currentTour && (
<AddMemberModal
isOpen={isAddMemberOpen}
onClose={() => setIsAddMemberOpen(false)}
tourId={currentTour.id}
participants={currentTour.participants || []}
joinRequests={joinRequests}
onRemoveMember={(userId) => removeMember(currentTour.id, userId)}
onMemberAdded={() => fetchTour(currentTour.id)}
userRole={userRole || undefined}
isPublicView={isPublicView} // Pass isPublicView
/>
)}
{/* Add Location Modal */}
{currentTour && (
<AddLocationModal
isOpen={isAddLocationOpen}
onClose={() => setIsAddLocationOpen(false)}
initialLegId={targetLegId || undefined}
isStartPoint={isStartPointAction}
isEndPoint={isEndPointAction}
editingLocation={editingLocation}
tourId={currentTour.id}
isPublicView={isPublicView} // Pass isPublicView
onSuccess={() => {
console.log("TourDetailPage: AddLocationModal onSuccess -> Re-fetching tour data.");
fetchTour(tourId); // Re-fetch tour data after adding/editing location
}}
/>
)}
{/* Add Photo Modal */}
{currentTour && (
<AddPhotoModal
isOpen={isAddPhotoOpen}
onClose={() => setIsAddPhotoOpen(false)}
tourId={currentTour.id}
onSuccess={() => fetchTour(currentTour.id)}
/>
)}
{/* Quick Note Modal */}
{quickNoteLocName && (
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={() => setQuickNoteLocName(null)} />
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-gray-900">Ghi chú nhanh</h3>
<button onClick={() => setQuickNoteLocName(null)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
<div className="space-y-4">
<p className="text-sm font-bold text-gray-700">
📍 Địa điểm: <span className="text-blue-600">{quickNoteLocName}</span>
</p>
<div>
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Nội dung ghi chú</label>
<textarea
value={quickNoteInput}
onChange={(e) => setQuickNoteInput(e.target.value)}
placeholder="Nhập nội dung ghi chú nhanh..."
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[120px] resize-none"
autoFocus
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-8">
<button
onClick={() => setQuickNoteLocName(null)}
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
>
Hủy
</button>
<button
onClick={submitQuickNote}
disabled={!quickNoteInput.trim()}
className="py-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
>
Lưu
</button>
</div>
</div>
</div>
)}
{/* Member Detail Popover */}
{isMemberDetailOpen && selectedMember && (
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={() => setIsMemberDetailOpen(false)} />
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold">
{selectedMember.user?.name?.charAt(0) || selectedMember.displayName?.charAt(0) || '?'}
</div>
<div>
<div className="text-base font-bold text-gray-900">{selectedMember.user?.name || selectedMember.displayName || 'Chưa đặt tên'}</div>
<div className="text-xs text-gray-500">{selectedMember.user?.email || 'Thành viên ngoài hệ thống'}</div>
<div className="text-[10px] font-semibold text-gray-500">{selectedMember.role}</div>
</div>
</div>
{(selectedMember.user?.phone || selectedMember.user?.address) && (
<div className="mt-3 text-xs text-gray-600 space-y-1">
{selectedMember.user?.phone && <div>📞 {selectedMember.user.phone}</div>}
{selectedMember.user?.address && <div>📍 {selectedMember.user.address}</div>}
</div>
)}
<div className="mt-4 flex justify-end gap-2">
<button onClick={() => setIsMemberDetailOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100">Đóng</button>
{!isPublicView && currentUser && selectedMember && (selectedMember.userId || selectedMember.user?.id) && currentUser.id !== (selectedMember.userId || selectedMember.user?.id) && (selectedMember.role === 'OWNER' || selectedMember.role === 'MANAGER') && (
<button
onClick={() => {
setIsMemberDetailOpen(false);
setRatingTargetUser(selectedMember);
setIsRatingModalOpen(true);
}}
className="px-3 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-xl text-sm font-bold flex items-center gap-1 shadow-md transition-all active:scale-95"
>
{t('rateOrganizer') || 'Đánh giá'}
</button>
)}
{canEdit && selectedMember.role !== 'OWNER' && (
<button
onClick={async () => {
if (!currentTour || !selectedMember) return;
try {
await removeMember(currentTour.id, selectedMember.userId || selectedMember.id);
setIsMemberDetailOpen(false);
} catch (e) {
notify({ title: 'Thông báo', message: 'Không thể xóa thành viên', type: 'error' });
}
}}
className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold"
>
Xóa
</button>
)}
{canEdit && selectedMember.role === 'OWNER' && (
<button
onClick={() => {
setIsMemberDetailOpen(false);
setIsAddMemberOpen(true);
}}
className="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold"
>
Mời thêm người
</button>
)}
</div>
</div>
</div>
)}
{/* Organizer Rating Modal */}
{isRatingModalOpen && ratingTargetUser && (
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in" onClick={() => setIsRatingModalOpen(false)} />
<div className="relative w-full max-w-lg bg-white dark:bg-slate-900 rounded-[32px] shadow-2xl p-8 max-h-[90vh] overflow-y-auto flex flex-col animate-in zoom-in-95 duration-250 text-slate-800 dark:text-slate-100 border dark:border-slate-800">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-slate-950 dark:text-white flex items-center gap-2">
{t('rateTitle') || 'Đánh giá Người tạo Tour'}
</h3>
<button onClick={() => setIsRatingModalOpen(false)} className="p-2 hover:bg-gray-150 dark:hover:bg-slate-850 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
<div className="flex items-center gap-3 mb-6 bg-slate-50 dark:bg-slate-850 p-4 rounded-2xl border border-slate-100 dark:border-slate-800">
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold overflow-hidden">
{ratingTargetUser.avatar ? (
<img src={ratingTargetUser.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span>{ratingTargetUser.user?.name?.charAt(0) || ratingTargetUser.displayName?.charAt(0) || '?'}</span>
)}
</div>
<div>
<div className="text-sm font-black dark:text-white">{ratingTargetUser.user?.name || ratingTargetUser.displayName}</div>
<div className="text-xs text-gray-450 uppercase tracking-widest font-bold">{ratingTargetUser.role}</div>
</div>
</div>
<div className="space-y-4 flex-1">
{[
{ key: 'honesty', label: t('honesty') || 'Trung thực' },
{ key: 'transparency', label: t('transparency') || 'Minh bạch' },
{ key: 'enthusiasm', label: t('enthusiasm') || 'Nhiệt tình' },
{ key: 'cheerfulness', label: t('cheerfulness') || 'Vui vẻ' },
{ key: 'seriousness', label: t('seriousness') || 'Nghiêm túc' },
{ key: 'planning', label: t('planning') || 'Có kế hoạch' },
{ key: 'survival', label: t('survival') || 'Kỹ năng sinh tồn' }
].map((c) => (
<div key={c.key} className="flex items-center justify-between border-b border-slate-100 dark:border-slate-850 pb-2">
<span className="text-xs font-bold text-slate-700 dark:text-slate-350">{c.label}</span>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => {
const currentVal = (ratingScores as any)[c.key];
return (
<button
key={star}
type="button"
onClick={() => setRatingScores(prev => ({ ...prev, [c.key]: star }))}
className={`w-6 h-6 text-xl transition-all active:scale-95 ${
star <= currentVal ? 'text-amber-450' : 'text-gray-300 dark:text-gray-700 hover:text-amber-300'
}`}
>
</button>
);
})}
</div>
</div>
))}
<div className="mt-4">
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Nhận xét khác</label>
<textarea
value={ratingComment}
onChange={(e) => setRatingComment(e.target.value)}
placeholder={t('rateCommentPlaceholder') || 'Nhập ý kiến đánh giá khác...'}
rows={3}
className="w-full px-4 py-3 bg-gray-50 dark:bg-slate-850 border border-gray-200 dark:border-slate-800 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-xs resize-none text-slate-900 dark:text-white"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-6">
<button
type="button"
onClick={() => setIsRatingModalOpen(false)}
className="py-3.5 bg-gray-100 dark:bg-slate-800 hover:bg-gray-250 dark:hover:bg-slate-700 text-gray-600 dark:text-slate-300 font-bold rounded-2xl transition-all active:scale-95 text-xs"
>
{t('cancel') || 'Hủy'}
</button>
<button
type="button"
onClick={handleSubmitRating}
disabled={isSubmittingRating}
className="py-3.5 bg-amber-500 hover:bg-amber-600 disabled:opacity-50 text-white font-bold rounded-2xl shadow-lg transition-all active:scale-95 text-xs flex items-center justify-center gap-1.5"
>
{isSubmittingRating ? (
<Loader2 className="w-4.5 h-4.5 animate-spin" />
) : (
<>
<span>{t('save') || 'Gửi đánh giá'}</span>
</>
)}
</button>
</div>
</div>
</div>
)}
<CommentModal
isOpen={isCommentModalOpen}
onClose={() => setIsCommentModalOpen(false)}
locationId={commentLocationId}
locationName={commentLocationName}
isPublicView={isPublicView} // Pass isPublicView
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
/>
<CoordinateSelectModal
isOpen={isMapOpen}
onClose={() => setIsMapOpen(false)}
initialLat={typeof editPhotoLat === 'number' ? editPhotoLat : undefined}
initialLng={typeof editPhotoLng === 'number' ? editPhotoLng : undefined}
onSelect={(lat, lng) => {
setEditPhotoLat(lat);
setEditPhotoLng(lng);
}}
/>
{isFullscreen && selectedPhotoForDisplay && (
<div
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
onClick={() => setIsFullscreen(false)}
>
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]"
>
<X className="w-6 h-6" />
</button>
<img
src={selectedPhotoForDisplay.imageUrl}
alt="Fullscreen photo"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
/>
</div>
)}
</div>
);
};