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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -21,8 +21,8 @@
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
<script type="module" crossorigin src="/assets/index-DTmIGIUE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DpJrHqwq.css">
<script type="module" crossorigin src="/assets/index-ULMuD9f4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BU0L_D9H.css">
</head>
<body>
<div id="root"></div>
@@ -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>
);
};