feat: khi người dùng click vào nút đầu mỗi điểm để tìm đường đi trên bản đồ

This commit is contained in:
2026-06-24 21:53:52 +07:00
parent f17215f236
commit cfdcb573de
10 changed files with 598 additions and 219 deletions
@@ -5,6 +5,7 @@ import { useTourStore } from '@/store/useTourStore';
import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
import { CommentModal } from '@/components/CommentModal';
import { LocationNavigationModal } from '@/components/LocationNavigationModal';
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
if (!actual) return null;
@@ -77,6 +78,11 @@ export const ItineraryTimeline = ({
// State to track single expanded stage (exclusive single-expansion mode)
const [expandedStageId, setExpandedStageId] = useState<string | null>(legs.length > 0 ? legs[0]?.id : null);
const [navRouteData, setNavRouteData] = useState<{
origin: { lat: number; lng: number } | null;
destination: { lat: number; lng: number; name: string } | null;
}>({ origin: null, destination: null });
const [isNavModalOpen, setIsNavModalOpen] = useState(false);
const toggleStageExpanded = (legId: string) => {
// Exclusive mode: if clicking the same stage, close it. Otherwise, open only the clicked one.
@@ -127,6 +133,40 @@ export const ItineraryTimeline = ({
}
};
const handleTriggerNavigation = (location: any) => {
if (!location.latitude || !location.longitude) {
alert("Địa điểm này chưa được cấu hình tọa độ GPS chính xác.");
return;
}
if (!navigator.geolocation) {
alert("Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị toàn cầu GPS.");
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
setNavRouteData({
origin: {
lat: position.coords.latitude,
lng: position.coords.longitude
},
destination: {
lat: parseFloat(location.latitude),
lng: parseFloat(location.longitude),
name: location.name || "Điểm đến chọn sẵn"
}
});
setIsNavModalOpen(true);
},
(error) => {
console.error("Error fetching native geolocation metrics:", error);
alert("Không thể truy cập vị trí hiện tại của bạn. Vui lòng bật định vị GPS của thiết bị.");
},
{ enableHighAccuracy: true, timeout: 8000 }
);
};
const handleAddLeg = async () => {
const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
if (note && currentTour) {
@@ -503,6 +543,16 @@ export const ItineraryTimeline = ({
<MessageSquare className="w-3 h-3" />
{location._count?.comments > 0 && `(${location._count.comments})`}
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleTriggerNavigation(location);
}}
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100"
title="Chỉ đường từ vị trí của bạn"
>
<Navigation className="w-4 h-4" />
</button>
</div>
<div className="flex items-center text-sm font-black text-blue-600">
<Clock className="w-3 h-3 mr-1" />
@@ -712,6 +762,11 @@ export const ItineraryTimeline = ({
isPublicView={isPublicView}
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
/>
<LocationNavigationModal
isOpen={isNavModalOpen}
onClose={() => setIsNavModalOpen(false)}
routeData={navRouteData}
/>
</div>
);
@@ -0,0 +1,173 @@
import React, { useState, useEffect, useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
interface NavModalProps {
isOpen: boolean;
onClose: () => void;
routeData: {
origin: { lat: number; lng: number } | null;
destination: { lat: number; lng: number; name: string } | null;
};
}
interface OSRMRoute {
geometry: {
coordinates: number[][];
type: string;
};
legs: { distance: number; duration: number }[];
distance: number;
duration: number;
}
const FitBounds = ({ coords }: { coords: [number, number][] }) => {
const map = useMap();
useEffect(() => {
if (coords.length > 0) {
map.fitBounds(coords, { padding: [60, 60], maxZoom: 15 });
}
}, [map, coords]);
return null;
};
export const LocationNavigationModal: React.FC<NavModalProps> = ({ isOpen, onClose, routeData }) => {
const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [routeInfo, setRouteInfo] = useState<{ distance: string; duration: string } | null>(null);
useEffect(() => {
if (!isOpen || !routeData.origin || !routeData.destination) {
setRouteGeometry(null);
setError(null);
setRouteInfo(null);
return;
}
const origin = routeData.origin;
const destination = routeData.destination;
const fetchRoute = async () => {
setLoading(true);
setError(null);
try {
const url = `https://router.project-osrm.org/route/v1/driving/${origin.lng},${origin.lat};${destination.lng},${destination.lat}?overview=full&geometries=geojson`;
const res = await fetch(url);
if (!res.ok) throw new Error(`OSRM error: ${res.status}`);
const data: { code: string; routes: OSRMRoute[] } = await res.json();
if (data.code !== 'Ok' || !data.routes?.length) throw new Error('Không tìm thấy lộ trình phù hợp');
const route = data.routes[0];
const coords = route.geometry.coordinates.map((c: number[]) => [c[1], c[0]] as [number, number]);
setRouteGeometry(coords);
const hours = Math.floor(route.duration / 3600);
const minutes = Math.round((route.duration % 3600) / 60);
const durationStr = hours > 0 ? `${hours}h${minutes}p` : `${minutes}p`;
setRouteInfo({
distance: (route.distance / 1000).toFixed(1),
duration: durationStr
});
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchRoute();
}, [isOpen, routeData.origin, routeData.destination]);
const userIcon = useMemo(() => L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-8 h-8 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center"><div class="w-2 h-2 bg-white rounded-full"></div></div>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
}), []);
const destIcon = useMemo(() => L.divIcon({
className: '!bg-transparent !border-none',
html: `<div class="w-8 h-8 bg-red-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-white text-xs font-black">Đ</div>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
}), []);
if (!isOpen) return null;
const center: [number, number] = routeData.origin && routeData.destination
? [(routeData.origin.lat + routeData.destination.lat) / 2, (routeData.origin.lng + routeData.destination.lng) / 2]
: [0, 0];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-700 w-full max-w-4xl h-[80vh] rounded-2xl overflow-hidden flex flex-col shadow-2xl">
{/* Modal Header */}
<div className="p-4 bg-slate-800 border-b border-slate-700 flex items-center justify-between">
<div>
<h3 className="text-md font-bold text-white flex items-center gap-2">
📍 Chỉ đưng đến: <span className="text-blue-400">{routeData.destination?.name}</span>
</h3>
<p className="text-xs text-gray-400 mt-0.5">Tuyến đưng ngắn nhất từ vị trí hiện tại của bạn</p>
{routeInfo && (
<div className="flex items-center gap-3 mt-1.5">
<span className="text-xs font-bold text-blue-300">{routeInfo.distance} km</span>
<span className="text-xs text-gray-500">|</span>
<span className="text-xs font-bold text-green-300">~{routeInfo.duration}</span>
</div>
)}
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-white text-sm font-bold px-3 py-1.5 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
>
Đóng [X]
</button>
</div>
{/* Map Container */}
<div className="flex-1 relative bg-slate-950">
<MapContainer
center={center}
zoom={14}
className="h-full w-full"
zoomControl={true}
attributionControl={false}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
{routeData.origin && (
<Marker position={[routeData.origin.lat, routeData.origin.lng]} icon={userIcon}>
<Popup>
<div className="text-xs font-bold text-blue-600">Bạn đang đây</div>
</Popup>
</Marker>
)}
{routeData.destination && (
<Marker position={[routeData.destination.lat, routeData.destination.lng]} icon={destIcon}>
<Popup>
<div className="text-xs font-bold text-red-600">{routeData.destination.name}</div>
</Popup>
</Marker>
)}
{routeGeometry && <FitBounds coords={routeGeometry} />}
{routeGeometry && (
<Polyline positions={routeGeometry} color="#2563eb" weight={6} opacity={0.9} smoothFactor={1} />
)}
</MapContainer>
{loading && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-xs px-3 py-1.5 rounded-lg shadow-lg z-[1000]">
Đang tải lộ trình...
</div>
)}
{error && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-red-900 text-white text-xs px-3 py-1.5 rounded-lg shadow-lg z-[1000]">
{error}
</div>
)}
</div>
</div>
</div>
);
};