10 Commits

3 changed files with 487 additions and 86 deletions
@@ -42,12 +42,14 @@ export const ItineraryTimeline = ({
onAddLocation, onAddLocation,
onEditLocation, onEditLocation,
onQuickNote, onQuickNote,
onNavigate,
onSuccess, onSuccess,
isPublicView = false isPublicView = false
}: { }: {
onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void, onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void,
onEditLocation?: (location: any) => void, onEditLocation?: (location: any) => void,
onQuickNote?: (name: string) => void, onQuickNote?: (name: string) => void,
onNavigate?: (location: any) => void,
onSuccess?: () => void, onSuccess?: () => void,
isPublicView?: boolean isPublicView?: boolean
}) => { }) => {
@@ -113,9 +115,10 @@ export const ItineraryTimeline = ({
endDate: '' endDate: ''
}); });
const toggleComplete = async (locationId: string) => { const handleStatusClick = (location: any) => {
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái if (onNavigate) {
console.log("Toggle status for location:", locationId); onNavigate(location);
}
}; };
const handleAddLeg = async () => { const handleAddLeg = async () => {
@@ -371,7 +374,7 @@ export const ItineraryTimeline = ({
{/* Timeline Node */} {/* Timeline Node */}
<div className="z-10 mt-1.5 mr-4"> <div className="z-10 mt-1.5 mr-4">
<button <button
onClick={() => toggleComplete(location.id)} onClick={() => handleStatusClick(location)}
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`} className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
> >
{location.status === 'COMPLETED' ? ( {location.status === 'COMPLETED' ? (
+6 -6
View File
@@ -223,7 +223,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
<div className="flex items-center gap-3 pointer-events-auto"> <div className="flex items-center gap-3 pointer-events-auto">
<button <button
onClick={onBack} onClick={onBack}
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100" className="w-11 h-11 flex items-center justify-center bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 shrink-0"
title="Quay lại" title="Quay lại"
> >
<X className="w-6 h-6 text-gray-800" /> <X className="w-6 h-6 text-gray-800" />
@@ -233,7 +233,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
<div className="relative"> <div className="relative">
<button <button
onClick={() => setIsFilterDropdownOpen(prev => !prev)} onClick={() => setIsFilterDropdownOpen(prev => !prev)}
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center" className="w-11 h-11 bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center shrink-0"
title="Lọc theo loại" title="Lọc theo loại"
> >
<Filter className="w-6 h-6 text-gray-800" /> <Filter className="w-6 h-6 text-gray-800" />
@@ -318,7 +318,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
console.log("Đang mở Ảnh của tôi..."); console.log("Đang mở Ảnh của tôi...");
onOpenMyPhotos(); onOpenMyPhotos();
}} }}
className="bg-white p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-50 text-blue-600 transition-all flex items-center gap-2 font-bold border border-blue-100" className="w-11 h-11 md:w-auto bg-white p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-50 text-blue-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold border border-blue-100 shrink-0"
title="Ảnh của tôi" title="Ảnh của tôi"
> >
<ImageIcon className="w-5 h-5" /> <ImageIcon className="w-5 h-5" />
@@ -330,7 +330,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
{user && ( {user && (
<button <button
onClick={() => setIsCreateModalOpen(true)} onClick={() => setIsCreateModalOpen(true)}
className="bg-green-600 p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold" className="w-11 h-11 md:w-auto bg-green-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
title="Tạo Tour mới" title="Tạo Tour mới"
> >
<Navigation className="w-5 h-5" /> <Navigation className="w-5 h-5" />
@@ -342,7 +342,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
{user?.isAdmin && ( {user?.isAdmin && (
<button <button
onClick={() => setIsAdminModalOpen(true)} onClick={() => setIsAdminModalOpen(true)}
className="bg-blue-600 p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold" className="w-11 h-11 md:w-auto bg-blue-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
title="Quản lý hệ thống" title="Quản lý hệ thống"
> >
<Settings className="w-5 h-5" /> <Settings className="w-5 h-5" />
@@ -354,7 +354,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
{onLogout && ( {onLogout && (
<button <button
onClick={onLogout} onClick={onLogout}
className="bg-white p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700 border border-gray-100" className="w-11 h-11 md:w-auto bg-white p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold text-gray-700 border border-gray-100 shrink-0"
title="Đăng xuất" title="Đăng xuất"
> >
<LogOut className="w-5 h-5" /> <LogOut className="w-5 h-5" />
+474 -76
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { ItineraryTimeline } from '../components/ItineraryTimeline'; import { ItineraryTimeline } from '../components/ItineraryTimeline';
import { ExpenseManager } from '../components/ExpenseManager'; import { ExpenseManager } from '../components/ExpenseManager';
@@ -49,6 +49,26 @@ import L from 'leaflet';
// Định nghĩa kiểu dữ liệu cho Địa điểm để khớp với Schema Prisma // Định nghĩa kiểu dữ liệu cho Địa điểm để khớp với Schema Prisma
type LocationType = 'MOVE' | 'VISIT' | 'REST' | 'EAT'; 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 // Fix lỗi icon mặc định của Leaflet cho môi trường Vite
delete (L.Icon.Default.prototype as any)._getIconUrl; delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({ L.Icon.Default.mergeOptions({
@@ -69,6 +89,53 @@ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: numbe
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km 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 // 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 MapTourBounds = ({ locations }: { locations: any[] }) => {
const map = useMap(); const map = useMap();
@@ -99,14 +166,36 @@ const RecenterUser = ({ position, trigger }: { position: [number, number] | null
}, [trigger, position, map]); }, [trigger, position, map]);
return null; return null;
}; };
// Component Helper để xử lý xoay bản đồ theo hướng di chuyển hoặc hướng Bắc
// Component Helper để xử lý xoay bản đồ theo hướng di chuyển
const MapRotationHandler = ({ rotation }: { rotation: number }) => { const MapRotationHandler = ({ rotation }: { rotation: number }) => {
const map = useMap(); 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(() => { useEffect(() => {
const container = map.getContainer(); const container = map.getContainer();
// Xoay container bản đồ và scale nhẹ để tránh lộ khoảng trắng ở các góc khi xoay
container.style.transform = `rotate(${rotation}deg) scale(${rotation === 0 ? 1 : 1.2})`; if (rotation === 0) {
container.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'; 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
container.style.transform = `rotate(${cumulativeRotationRef.current}deg) scale(2.5)`;
container.style.transition = 'transform 0.1s linear';
}, [rotation, map]); }, [rotation, map]);
return null; return null;
}; };
@@ -247,7 +336,20 @@ export const TourDetailPage = ({
const publicTours = useTourStore(state => state.publicTours); const publicTours = useTourStore(state => state.publicTours);
const userRole = useTourStore(state => state.userRole); const userRole = useTourStore(state => state.userRole);
const mapCenter = useTourStore(state => state.mapCenter); const mapCenter = useTourStore(state => state.mapCenter);
const [userLocation, setUserLocation] = useState<[number, number] | null>(null); 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'>('plan');
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isHeadingMode, setIsHeadingMode] = useState(false);
const [mapRotation, setMapRotation] = useState(0);
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
const [isMapFullscreen, setIsMapFullscreen] = useState(false);
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
// Định nghĩa Icons bên trong Component bằng useMemo để đảm bảo tính ổn định và tránh lỗi render // Đị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(() => ({ const mapIcons = useMemo(() => ({
@@ -283,21 +385,84 @@ export const TourDetailPage = ({
}), []); }), []);
useEffect(() => { 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) { if (!isPublicView && navigator.geolocation) {
const watchId = navigator.geolocation.watchPosition( const watchId = navigator.geolocation.watchPosition(
(pos) => setUserLocation([pos.coords.latitude, pos.coords.longitude]), (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), (err) => console.warn("Lỗi định vị người dùng:", err),
{ enableHighAccuracy: true } { enableHighAccuracy: true }
); );
return () => navigator.geolocation.clearWatch(watchId); return () => navigator.geolocation.clearWatch(watchId);
} else {
setGpsHeading(null);
setUserSpeed(null);
} }
}, [isPublicView]); }, [isPublicView]); // Chỉ phụ thuộc vào isPublicView
// State mới cho hướng thiết bị (la bàn)
const [deviceOrientationHeading, setDeviceOrientationHeading] = useState<number | null>(null);
// 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 [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
const [isStartPointAction, setIsStartPointAction] = useState(false); const [isStartPointAction, setIsStartPointAction] = useState(false);
const [isEndPointAction, setIsEndPointAction] = useState(false); const [isEndPointAction, setIsEndPointAction] = useState(false);
const [targetLegId, setTargetLegId] = useState<string | null>(null); const [targetLegId, setTargetLegId] = useState<string | null>(null);
@@ -363,8 +528,6 @@ export const TourDetailPage = ({
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [locateTrigger, setLocateTrigger] = useState(0); const [locateTrigger, setLocateTrigger] = useState(0);
const [isMapControlsOpen, setIsMapControlsOpen] = useState(false); const [isMapControlsOpen, setIsMapControlsOpen] = useState(false);
const [isHeadingMode, setIsHeadingMode] = useState(false);
const [mapRotation, setMapRotation] = useState(0);
const [isRoutingLoading, setIsRoutingLoading] = useState(false); const [isRoutingLoading, setIsRoutingLoading] = useState(false);
const [travelMode, setTravelMode] = useState<'driving' | 'bike' | 'foot'>('driving'); const [travelMode, setTravelMode] = useState<'driving' | 'bike' | 'foot'>('driving');
const [routes, setRoutes] = useState<any[]>([]); const [routes, setRoutes] = useState<any[]>([]);
@@ -524,12 +687,30 @@ export const TourDetailPage = ({
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]); 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 // 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(() => allLocations.map(l => `${l.id}-${l.latitude}-${l.longitude}`).join('|'), [allLocations]); 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 đồ // 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(() => { useEffect(() => {
const fetchRoutes = async () => { const fetchRoutes = async () => {
if (allLocations.length < 2) { // 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([]); setRoutes([]);
setSelectedRouteIndex(0); setSelectedRouteIndex(0);
setDrivingRoute([]); setDrivingRoute([]);
@@ -537,29 +718,92 @@ export const TourDetailPage = ({
return; return;
} }
const coordsString = allLocations // Chèn vị trí người dùng vào đầu danh sách tọa độ nếu có
.map(loc => `${loc.longitude},${loc.latitude}`) const coordsArray = allLocations.map(loc => `${loc.longitude},${loc.latitude}`);
.join(';');
if (userLocation) {
coordsArray.unshift(`${userLocation[1]},${userLocation[0]}`);
}
const coordsString = coordsArray.join(';');
setIsRoutingLoading(true); setIsRoutingLoading(true);
try { try {
// Thêm alternatives=true để yêu cầu các phương án lộ trình khác từ OSRM (Lưu ý: OSRM thường chỉ trả về lộ trình thay thế cho 2 điểm tọa độ) const segmentPromises: Promise<OSRMRoute[] | null>[] = [];
const response = await fetch(`https://router.project-osrm.org/route/v1/${travelMode}/${coordsString}?overview=full&geometries=geojson&alternatives=true`); for (let i = 0; i < currentPoints.length - 1; i++) {
const data = await response.json(); const p1 = currentPoints[i];
if (data.code === 'Ok' && data.routes.length > 0) { const p2 = currentPoints[i + 1];
setRoutes(data.routes); const coordsString = `${p1.longitude},${p1.latitude};${p2.longitude},${p2.latitude}`;
// Reset về lộ trình đầu tiên khi danh sách điểm đến thay đổi hoàn toàn
setSelectedRouteIndex(0); 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) { } catch (error) {
console.error(`Lỗi lấy lộ trình ${travelMode}:`, error); console.error(`Lỗi lấy lộ trình ${travelMode}:`, error);
setRoutes([]);
} finally { } finally {
setIsRoutingLoading(false); setIsRoutingLoading(false);
} }
}; };
fetchRoutes(); fetchRoutes();
}, [allLocations, travelMode]); }, [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 // 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(() => { useEffect(() => {
@@ -573,6 +817,45 @@ export const TourDetailPage = ({
} }
}, [selectedRouteIndex, routes]); }, [selectedRouteIndex, routes]);
// Tính toán thông tin hiển thị cho lộ trình đang chọn để đề xuất cho người dùng
const selectedRouteInfo = useMemo(() => {
if (!routes || routes.length === 0 || !routes[selectedRouteIndex]) return null;
const r = routes[selectedRouteIndex];
// Định dạng thời gian di chuyển
const duration = r.duration;
const hours = Math.floor(duration / 3600);
const minutes = Math.round((duration % 3600) / 60);
const durationStr = hours > 0 ? `${hours}h${minutes}p` : `${minutes}p`;
// Xác định nhãn đề xuất: Index 0 thường là lộ trình tối ưu nhất của OSRM (thông dụng nhất)
// Kiểm tra thêm nếu đây là lộ trình ngắn nhất trong các phương án
const isShortest = routes.length > 1 && r.distance === Math.min(...routes.map(rt => rt.distance));
let label = "Lộ trình";
if (selectedRouteIndex === 0) label = "Đề xuất";
else if (isShortest) label = "Ngắn nhất";
else label = `Lựa chọn ${selectedRouteIndex + 1}`;
return {
distance: (r.distance / 1000).toFixed(1),
duration: durationStr,
label
};
}, [routes, selectedRouteIndex]);
const handleNavigateToLocation = (location: any) => {
setMapCenter([location.latitude, location.longitude]);
setLocateTrigger(prev => prev + 1);
setIsMapFullscreen(true);
notify({
title: 'Bắt đầu chỉ đường',
message: `Đang hiển thị lộ trình từ vị trí của bạn đến ${location.name}`,
type: 'info'
});
};
useEffect(() => { useEffect(() => {
if (initialViewState) { if (initialViewState) {
setMapCenter(initialViewState.center); setMapCenter(initialViewState.center);
@@ -1203,23 +1486,24 @@ export const TourDetailPage = ({
onQuickNote={(locName: string) => handleQuickNote(locName)} onQuickNote={(locName: string) => handleQuickNote(locName)}
onSuccess={() => fetchTour(tourId)} onSuccess={() => fetchTour(tourId)}
isPublicView={isPublicView} isPublicView={isPublicView}
onNavigate={handleNavigateToLocation}
/> />
) : ( ) : (
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative"> <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 */} {/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */}
{!isPublicView && ( {!isPublicView && (
<div className="absolute top-4 right-4 z-[1001] w-64 md:w-80"> <div className="absolute top-3 right-3 z-[1001] w-48 md:w-64">
<div className="relative group"> <div className="relative group">
<input <input
type="text" type="text"
placeholder="Tìm địa điểm để ghim..." placeholder="Tìm địa điểm..."
className="w-full pl-10 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold" 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} value={searchQuery}
onChange={e => handleSearchLocation(e.target.value)} onChange={e => handleSearchLocation(e.target.value)}
/> />
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-blue-500" />
{isSearching ? ( {isSearching ? (
<Loader2 className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" /> <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 && ( ) : 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"> <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" /> <X className="w-4 h-4" />
@@ -1253,6 +1537,22 @@ export const TourDetailPage = ({
</div> </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 <MapContainer
center={initialViewState?.center || mapCenter} center={initialViewState?.center || mapCenter}
zoom={mapZoom} zoom={mapZoom}
@@ -1270,12 +1570,12 @@ export const TourDetailPage = ({
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */} {/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
<MapTourBounds locations={allLocations} /> <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 */} {/* Xử lý di chuyển tâm bản đồ về phía người dùng */}
<RecenterUser position={userLocation} trigger={locateTrigger} /> <RecenterUser position={userLocation} trigger={locateTrigger} />
{/* Xử lý xoay bản đồ */}
<MapRotationHandler rotation={mapRotation} />
{/* Hiển thị vị trí hiện tại của người dùng */} {/* Hiển thị vị trí hiện tại của người dùng */}
{userLocation && ( {userLocation && (
<Marker position={userLocation} icon={mapIcons.user} zIndexOffset={1000}> <Marker position={userLocation} icon={mapIcons.user} zIndexOffset={1000}>
@@ -1296,11 +1596,11 @@ export const TourDetailPage = ({
}) })
.map(({ data, index }) => ( .map(({ data, index }) => (
<Polyline <Polyline
key={`route-${index}-${index === selectedRouteIndex ? 'active' : 'alt'}-${routeKey}-${routes.length}`} key={`polyline-${index}-${index === selectedRouteIndex ? 'active' : 'alt'}-${routeKey}`}
positions={data.geometry.coordinates.map((c: any) => [c[1], c[0]])} positions={data.geometry.coordinates.map((c: any) => [c[1], c[0]])}
color={index === selectedRouteIndex ? "#2563eb" : "#94a3b8"} color={index === selectedRouteIndex ? "#2563eb" : "#94a3b8"}
weight={index === selectedRouteIndex ? 6 : 14} weight={index === selectedRouteIndex ? 6 : 12}
opacity={index === selectedRouteIndex ? 1 : 0.4} opacity={index === selectedRouteIndex ? 1 : 0.35}
dashArray={index === selectedRouteIndex ? undefined : "15, 15"} dashArray={index === selectedRouteIndex ? undefined : "15, 15"}
smoothFactor={1} smoothFactor={1}
eventHandlers={{ eventHandlers={{
@@ -1323,12 +1623,12 @@ export const TourDetailPage = ({
}, },
mouseover: (e) => { mouseover: (e) => {
if (index !== selectedRouteIndex) { if (index !== selectedRouteIndex) {
(e.target as L.Polyline).setStyle({ opacity: 0.8, weight: 16, color: '#64748b' }); (e.target as L.Polyline).setStyle({ opacity: 0.7, weight: 14, color: '#64748b' });
} }
}, },
mouseout: (e) => { mouseout: (e) => {
if (index !== selectedRouteIndex) { if (index !== selectedRouteIndex) {
(e.target as L.Polyline).setStyle({ opacity: 0.4, weight: 14, color: '#94a3b8' }); (e.target as L.Polyline).setStyle({ opacity: 0.35, weight: 12, color: '#94a3b8' });
} }
} }
}} }}
@@ -1437,86 +1737,84 @@ export const TourDetailPage = ({
)} )}
{/* Overlay điều khiển trên bản đồ */} {/* Overlay điều khiển trên bản đồ */}
<div className="absolute top-4 left-4 z-[1001] flex flex-col gap-2"> <div className="absolute top-3 left-3 z-[1001] flex flex-col gap-1.5">
<button <button
onClick={() => setIsMapControlsOpen(!isMapControlsOpen)} onClick={() => setIsMapControlsOpen(!isMapControlsOpen)}
className="bg-white/90 backdrop-blur-md p-3 rounded-2xl shadow-xl border border-white text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center" 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 ? ( {isMapControlsOpen ? (
<X className="w-5 h-5 text-gray-400" /> <X className="w-4 h-4 text-gray-400" />
) : ( ) : (
travelMode === 'driving' ? <Car className="w-5 h-5" /> : travelMode === 'driving' ? <Car className="w-4 h-4" /> :
travelMode === 'bike' ? <Bike className="w-5 h-5" /> : travelMode === 'bike' ? <Bike className="w-4 h-4" /> :
<Footprints className="w-5 h-5" /> <Footprints className="w-4 h-4" />
)} )}
</button> </button>
{isMapControlsOpen && ( {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 duration-300"> <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 <button
onClick={() => setTravelMode('driving')} onClick={() => { setTravelMode('driving'); setIsMapControlsOpen(false); }}
className={`p-2.5 rounded-xl transition-all ${travelMode === 'driving' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`} 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ô" title="Ô tô"
> >
<Car className="w-4 h-4" /> <Car className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={() => setTravelMode('bike')} onClick={() => { setTravelMode('bike'); setIsMapControlsOpen(false); }}
className={`p-2.5 rounded-xl transition-all ${travelMode === 'bike' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`} 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" title="Xe máy / Xe đạp"
> >
<Bike className="w-4 h-4" /> <Bike className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={() => setTravelMode('foot')} onClick={() => { setTravelMode('foot'); setIsMapControlsOpen(false); }}
className={`p-2.5 rounded-xl transition-all ${travelMode === 'foot' ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`} 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ộ" title="Đi bộ"
> >
<Footprints className="w-4 h-4" /> <Footprints className="w-3.5 h-3.5" />
</button> </button>
{/* Nút La bàn / Xoay bản đồ */} {/* Nút La bàn / Xoay bản đồ */}
<button <button
onClick={() => { onClick={() => {
if (mapRotation !== 0) { const newMode = !isHeadingMode;
setMapRotation(0); setIsHeadingMode(newMode);
setIsHeadingMode(false); if (!newMode) setMapRotation(0);
} else { setIsMapControlsOpen(false);
setIsHeadingMode(!isHeadingMode);
}
}} }}
className={`p-2.5 rounded-xl transition-all border-t border-gray-100 mt-1 ${isHeadingMode ? 'bg-blue-600 text-white shadow-md' : 'text-gray-500 hover:bg-gray-100'}`} 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 ? "Dừng xoay (Khóa hướng Bắc)" : "Tự động xoay theo hướng di chuyển"} title={isHeadingMode ? "Khóa hướng Bắc" : "Xoay theo hướng nhìn"}
> >
<Compass className="w-4 h-4 transition-transform duration-500" style={{ transform: `rotate(${mapRotation}deg)` }} /> <Compass className="w-3.5 h-3.5 transition-transform duration-300" style={{ transform: `rotate(${mapRotation}deg)` }} />
</button> </button>
{/* Nút Tìm tôi */} {/* Nút Tìm tôi */}
<button <button
onClick={() => setLocateTrigger(prev => prev + 1)} onClick={() => { setLocateTrigger(prev => prev + 1); setIsMapControlsOpen(false); }}
disabled={!userLocation} disabled={!userLocation}
className={`p-2.5 rounded-xl transition-all border-t border-gray-100 mt-1 ${!userLocation ? 'opacity-30 cursor-not-allowed' : 'text-blue-600 hover:bg-blue-50'}`} 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" title="Vị trí của tôi"
> >
<LocateFixed className="w-4 h-4" /> <LocateFixed className="w-3.5 h-3.5" />
</button> </button>
{/* Danh sách lộ trình rút gọn */} {/* Danh sách lộ trình rút gọn */}
{routes.length > 0 && ( {routes.length > 0 && (
<div className="pt-2 border-t border-gray-100 flex flex-col gap-1 min-w-[120px]"> <div className="pt-1 border-t border-gray-100 flex flex-col gap-1">
<div className="text-[9px] font-black text-gray-400 uppercase tracking-tighter px-1 mb-1">Lộ trình</div>
<div className="flex flex-col gap-1 max-h-[160px] overflow-y-auto pr-1 custom-scrollbar"> <div className="flex flex-col gap-1 max-h-[160px] overflow-y-auto pr-1 custom-scrollbar">
{routes.map((route, idx) => ( {routes.map((route, idx) => (
<button <button
key={idx} key={idx}
onClick={() => setSelectedRouteIndex(idx)} onClick={() => { setSelectedRouteIndex(idx); setIsMapControlsOpen(false); }}
className={`p-2 rounded-xl text-left transition-all border ${ 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 selectedRouteIndex === idx
? 'bg-blue-600 text-white border-blue-600 shadow-sm' ? 'bg-blue-600 text-white border-blue-600 shadow-sm'
: 'bg-gray-50 text-gray-600 border-gray-100' : 'bg-gray-50 text-gray-600 border-gray-100'
}`} }`}
> >
<div className="text-[10px] font-bold">#{idx + 1} - {(route.distance / 1000).toFixed(1)} km</div> <span className="text-[10px] font-bold">{idx + 1}</span>
</button> </button>
))} ))}
</div> </div>
@@ -1881,12 +2179,112 @@ export const TourDetailPage = ({
if (activeTab === 'plan') setIsAddLocationOpen(true); if (activeTab === 'plan') setIsAddLocationOpen(true);
if (activeTab === 'photo' && canUploadPhoto) setIsAddPhotoOpen(true); if (activeTab === 'photo' && canUploadPhoto) setIsAddPhotoOpen(true);
}} }}
className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold"> 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'} {activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
</button> </button>
</div> </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-4 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-4 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}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<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-4 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 */} {/* Add Member Modal */}
{currentTour && ( {currentTour && (
<AddMemberModal <AddMemberModal