fix: tính năng tìm đường ngắn nhất và chỉ đường cho người dùng

This commit is contained in:
2026-06-24 22:05:41 +07:00
parent cfdcb573de
commit a135196cb5
12 changed files with 210 additions and 204 deletions
+104
View File
@@ -0,0 +1,104 @@
# To AI Agent: Fix Infinite Re-render Loop and Flickering "ĐANG TÌM ĐƯỜNG TỐI ƯU..." Label
## 1. Context & Layout Bug Analysis
We are resolving a critical performance and UI bug inside the Map routing engine interface (`image.png`):
- **The Issue:** The floating loading indicator badge reading `"ĐANG TÌM ĐƯỜNG TỐI ƯU..."` keeps flashing, flickering, or appearing and disappearing in an infinite execution loop.
- **Root Cause:** This is caused by a broken React lifecycle loop. Every time the map triggers a directions route query, it toggles a loading state variable (`isSearching: true`). Once the route loads, the state updates (`isSearching: false`), forcing a component re-render. If the coordinates object (`origin`, `destination`) or the map instance ref inside the `useEffect` dependency array changes its reference pointer on every render, the hook fires *again*, creating an endless loop of API fetching and component flashing.
---
## 2. Technical Execution Strategy
To terminate this flickering cycle, we must enforce a strict guard rail on the network/calculation trigger pipeline:
1. **Coordinate Reference Stability:** Deconstruct the input latitude/longitude objects into raw primitive string values (e.g., `origin.lat`, `origin.lng`) inside the hook dependency array to avoid object reference mutations triggering re-renders.
2. **Locking Ref Mechanism:** Implement a mutable React tracking reference (`const queryInProgress = useRef(false)`) to lock the execution window. If a fetch operation is active, block subsequent duplicate queries from firing.
3. **Clean Loading State Termination:** Turn off the search state flag explicitly only *after* the route geometry polyline has completely finished rendering onto the map viewport layout.
---
## 3. Code Refactoring Blueprint
Locate your map navigation layer or modal component (e.g., `LocationNavigationModal.tsx`) and refactor the execution lifecycle loop as defined below:
```typescript
import React, { useEffect, useState, useRef } from 'react';
// Inside your Map Navigation Modal / Layer component wrapper:
export const LocationNavigationModal = ({ isOpen, routeData }) => {
const [isSearchingRoute, setIsSearchingRoute] = useState(false);
const mapInstanceRef = useRef<any>(null);
// CRITICAL FIX 2: Guard mechanism to block concurrent duplicated queries
const fetchLockRef = useRef(false);
// Deconstruct coordinate primitives to secure a stable dependency array
const originLat = routeData?.origin?.lat;
const originLng = routeData?.origin?.lng;
const destLat = routeData?.destination?.lat;
const destLng = routeData?.destination?.lng;
useEffect(() => {
if (!isOpen || !originLat || !originLng || !destLat || !destLng) return;
// If an operation is already locked and active, bail out immediately to prevent loops
if (fetchLockRef.current) return;
const calculateOptimalRoute = async () => {
try {
// 1. Activate loading feedback banner
setIsSearchingRoute(true);
fetchLockRef.current = true; // Engage execution lock
console.log("Fetching route coordinates exactly once...");
// --- YOUR MAP COMPONENT ROUTING LOGIC START ---
// Example: const response = await directionsService.route({...});
// await mapInstanceRef.current.drawPolyline(response);
// --- YOUR MAP COMPONENT ROUTING LOGIC END ---
} catch (error) {
console.error("Failed to compile route optimization maps:", error);
} finally {
// 2. Safe, definitive termination of the tracking states
setIsSearchingRoute(false);
fetchLockRef.current = false; // Disengage execution lock
}
};
calculateOptimalRoute();
// Cleanup phase: Reset execution parameters when inputs dismantle or modal closes
return () => {
fetchLockRef.current = false;
setIsSearchingRoute(false);
};
/* CRITICAL FIX 1: Explicitly tracking primitives only.
Do NOT pass full 'routeData', 'mapInstanceRef' or object literals here!
*/
}, [isOpen, originLat, originLng, destLat, destLng]);
return (
<div className="relative w-full h-full">
{/* Map Content Target Canvas */}
<div id="navigation-viewport-map-canvas" className="w-full h-full" />
{/* RENDER CONTROLLER: Only mount the label if routing calculations are actively processing */}
{isSearchingRoute && (
<div className="absolute top-16 left-4 z-30 bg-white/95 dark:bg-slate-900/95 border border-slate-200 dark:border-slate-800 px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 animate-pulse">
{/* Circular Loading Spinner Element */}
<div className="w-3.5 h-3.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-[11px] font-bold text-slate-700 dark:text-slate-200 uppercase tracking-wider">
ĐANG TÌM ĐƯỜNG TỐI ƯU...
</span>
</div>
)}
</div>
);
};
## 4. Verification & Quality Acceptance Criteria
[ ] Single Instance Trigger: Check console telemetry outputs. When the map modal mounts, the route compilation routine must print its trace log exactly once.
[ ] Flicker Nullification: The "ĐANG TÌM ĐƯỜNG TỐI ƯU..." badge must display smoothly with an animation pulse. It must not shake, blink, flash, or rapid-cycle on and off.
[ ] Deterministic Hiding: As soon as the blue route line draws completely across the map terrain grid layout, the loading badge must cleanly unmount and disappear from view without reappearing unless a new destination node button is clicked.
-151
View File
@@ -1,151 +0,0 @@
# To AI Agent: Implement Real-Time Point-to-Point Navigation Map from Timeline Nodes
## 1. Context & Feature Objective
We are introducing a real-time routing feature inside `ItineraryTimeline.tsx` as indicated by pointers [1] and [2] in `image.png`.
### Target Requirements:
1. **Interactive Node Trigger:** Convert the static white circular timeline node (Pointer [1]) next to each location card into an active, clickable interactive button.
2. **Real-Time Geolocation Acquisition:** Clicking the button must invoke the HTML5 Geolocation API to fetch the user's exact current device coordinates (`userLat`, `userLng`).
3. **Point-to-Point Shortest Route:** Open a dedicated Map Modal (`LocationNavigationModal.tsx`). This map must calculate and draw the shortest transit route connecting **ONLY TWO POINTS**: the user's current location (Origin) and the selected timeline node's coordinates (Destination). No other intermediate tour locations should be displayed on this map layer.
---
## 2. Technical Architecture Blueprint
We will implement this using a localized React component state wrapper combined with the device's native GPS API, loading the route into a clean Map Modal interface:
[Timeline Circular Button] ➔ [Request Browser GPS] ➔ [Capture Current Coordinates] ➔ [Open Modal with 2-Point Route Engine]
---
## 3. Detailed Code Refactoring Specifications
### Step 1: Upgrade the Timeline Node Checkpoint into a Button
Locate the white circular element layout inside the `leg.locations.map` rendering loop. Convert it into a semantic `<button>` equipped with micro-interactions:
```jsx
{/* ❌ OLD STATIC NODE */}
<div className="absolute left-[13px] top-[26px] w-6 h-6 bg-white ... " />
{/* ✅ NEW INTERACTIVE NAVIGATION BUTTON */}
<button
onClick={() => handleTriggerNavigation(location)}
className="absolute left-[13px] top-[26px] w-6 h-6 bg-white rounded-full border-2 border-gray-300 z-20 flex items-center justify-center shadow-sm hover:scale-110 hover:border-blue-500 hover:shadow-md transition-all group"
title="Bấm để xem chỉ đường từ vị trí của bạn"
>
{/* Inner center dot changes color on hover to signify link action */}
<div className="w-2 h-2 bg-blue-500 rounded-full group-hover:bg-red-500 transition-colors" />
</button>
### Step 2: Implement Geolocation Extraction Handler
Inside ItineraryTimeline.tsx, initialize state controllers to manage the modal visibility and add the coordinate-acquisition engine:
// 1. Initialize State Vectors for the Navigation Pipeline
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);
// 2. Core Handler Engine
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;
}
// Request high-accuracy real-time user positioning data
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 }
);
};
### Step 3: Create the Dedicated 2-Point Route Modal Component
Create a new file components/LocationNavigationModal.tsx. Configure your mapping stack (e.g., Google Maps JavaScript API, Leaflet Routing Machine, or Mapbox Directions) ensuring zero waypoints are injected, displaying strictly the point-to-point path:
import React from 'react';
// Note: Adapt the map layer imports below to match your active mapping stack (e.g., Mapbox, Google, or Leaflet)
interface NavModalProps {
isOpen: boolean;
onClose: () => void;
routeData: {
origin: { lat: number; lng: number } | null;
destination: { lat: number; lng: number; name: string } | null;
};
}
export const LocationNavigationModal: React.FC<NavModalProps> = ({ isOpen, onClose, routeData }) => {
if (!isOpen || !routeData.origin || !routeData.destination) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-yotripDark-surface border border-yotripDark-border w-full max-w-4xl h-[80vh] rounded-2xl overflow-hidden flex flex-col shadow-2xl animate-fade-in">
{/* Modal Header */}
<div className="p-4 bg-slate-900 border-b border-yotripDark-border 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ối ưu từ vị trí hiện tại của bạn</p>
</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">
Đóng [X]
</button>
</div>
{/* Map Container Viewport */}
<div className="flex-1 relative bg-slate-950">
{/* AI AGENT IMPLEMENTATION TASK: Load Map View Container here.
- Inject Marker A at [routeData.origin.lat, routeData.origin.lng] (Label: "Vị trí của bạn")
- Inject Marker B at [routeData.destination.lat, routeData.destination.lng] (Label: Destination Name)
- Invoke Directions Service with alternative route options disabled to enforce rendering ONLY the absolute shortest route line connecting these two coordinates.
*/}
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-500">
[Bản đồ Mapbox/Google/Leaflet định tuyến A ➔ B hiển thị tại đây]
</div>
</div>
</div>
</div>
);
};
Mount this modal at the bottom of your main ItineraryTimeline.tsx file return layer:
<LocationNavigationModal
isOpen={isNavModalOpen}
onClose={() => setIsNavModalOpen(false)}
routeData={navRouteData}
/>
## 4. Verification & Acceptance Criteria for AI Agent
[ ] Interaction Verification: Hovering over the circular white node checkpoints must convert the mouse cursor into a pointer hand and slightly scale up the element (scale-110).
[ ] Security Validation: Triggering the handler must properly request the standard native browser/OS location authorization dialog popup.
[ ] Clean Routing Strictness: The generated modal map must display precisely 2 custom markers (Current Device Spot & Selected Location Point). Ensure no other intermediate destination waypoints from other stages crawl onto the map workspace canvas.
+66 -17
View File
@@ -824,27 +824,13 @@ let TourController = class TourController {
} }
} }
}); });
const noteContent = `<h2>${filteredTitle} - Initial Planning</h2> const noteContent = `<h1>${filteredTitle}</h1><h2>Ghi chú chung</h2><p><em>Nội dung ghi chú tổng quan của chuyến đi...</em></p>`;
<p><strong>Start Date:</strong> ${startDate ? new Date(startDate).toLocaleDateString() : 'TBD'}</p>
<p><strong>End Date:</strong> ${endDate ? new Date(endDate).toLocaleDateString() : 'TBD'}</p>
<p><strong>Adult Participants:</strong> ${adultCount || 1}</p>
<p><strong>Child Participants:</strong> ${childCount || 0}</p>
<h3>Key Items to Plan:</h3>
<ul>
<li>Accommodations</li>
<li>Transportation</li>
<li>Activities & Attractions</li>
<li>Budget & Expenses</li>
<li>Important Contact Numbers</li>
<li>Special Requirements & Notes</li>
</ul>
<p><em>Add your planning notes here...</em></p>`;
try { try {
await this.prisma.tourNote.create({ await this.prisma.tourNote.create({
data: { data: {
tourId: tour.id, tourId: tour.id,
userId: req.user.id, userId: req.user.id,
title: `[${filteredTitle}] - Initial Planning`, title: `Ghi chú: ${filteredTitle}`,
content: noteContent content: noteContent
} }
}); });
@@ -2260,6 +2246,19 @@ let PhotoController = class PhotoController {
console.log(`[EXIF GPS] Sử dụng tọa độ dự phòng từ Frontend: lat=${lat}, lng=${lng}`); console.log(`[EXIF GPS] Sử dụng tọa độ dự phòng từ Frontend: lat=${lat}, lng=${lng}`);
} }
} }
let tags = [];
if (req.body.tags) {
try {
tags = JSON.parse(req.body.tags);
if (!Array.isArray(tags)) {
tags = [];
}
}
catch (e) {
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
console.warn('[TAGS] Failed to parse tags from request:', errorMsg);
}
}
if (lat === undefined || lng === undefined) { if (lat === undefined || lng === undefined) {
lat = 10.7769; lat = 10.7769;
lng = 106.7009; lng = 106.7009;
@@ -2293,7 +2292,8 @@ let PhotoController = class PhotoController {
privacy: 'PUBLIC', privacy: 'PUBLIC',
metadata: { metadata: {
lat: lat, lat: lat,
lng: lng lng: lng,
tags: tags
} }
}, },
}); });
@@ -4210,6 +4210,45 @@ let TourNoteController = class TourNoteController {
}); });
return { success: true }; return { success: true };
} }
async insertSection(tourId, body, req) {
const tour = await this.prisma.tour.findUnique({ where: { id: tourId } });
if (!tour)
throw new common_1.NotFoundException('Không tìm thấy tour');
const masterNote = await this.prisma.tourNote.findFirst({
where: { tourId, title: `Ghi chú: ${tour.title}`, isDeleted: false, userId: req.user.id }
});
let note = masterNote;
if (!note) {
note = await this.prisma.tourNote.create({
data: {
tourId,
userId: req.user.id,
title: `Ghi chú: ${tour.title}`,
content: `# ${tour.title}\n\n## Ghi chú chung\n\n*(Nội dung ghi chú tổng quan của chuyến đi...)*\n\n`
}
});
}
const leg = await this.prisma.leg.findUnique({ where: { id: body.legId } });
const stageHeader = leg ? `<h2>Ghi chú: ${leg.note || `Chặng ${leg.sequence}`}</h2>` : '<h2>Ghi chú:</h2>';
let content = note.content;
const stageIndex = content.indexOf(stageHeader);
if (stageIndex === -1) {
content += `<br><br>${stageHeader}${body.noteSnippet}`;
}
else {
const nextHeaderIndex = content.indexOf('<h2>', stageIndex + stageHeader.length);
if (nextHeaderIndex !== -1) {
content = content.slice(0, nextHeaderIndex) + body.noteSnippet + content.slice(nextHeaderIndex);
}
else {
content += body.noteSnippet;
}
}
return this.prisma.tourNote.update({
where: { id: note.id },
data: { content }
});
}
}; };
__decorate([ __decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE),
@@ -4250,6 +4289,16 @@ __decorate([
__metadata("design:paramtypes", [String, String, Object]), __metadata("design:paramtypes", [String, String, Object]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourNoteController.prototype, "deleteNote", null); ], TourNoteController.prototype, "deleteNote", null);
__decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE),
(0, common_1.Post)('insert'),
__param(0, (0, common_1.Param)('tourId')),
__param(1, (0, common_1.Body)()),
__param(2, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise)
], TourNoteController.prototype, "insertSection", null);
TourNoteController = __decorate([ TourNoteController = __decorate([
(0, common_1.Controller)('tours/:tourId/notes'), (0, common_1.Controller)('tours/:tourId/notes'),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
+1 -1
View File
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: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: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" /> <meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
<script type="module" crossorigin src="/assets/index-ULMuD9f4.js"></script> <script type="module" crossorigin src="/assets/index-D6jMbgMg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BU0L_D9H.css"> <link rel="stylesheet" crossorigin href="/assets/index-nLng8wU9.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useRef, useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
@@ -34,26 +34,33 @@ const FitBounds = ({ coords }: { coords: [number, number][] }) => {
export const LocationNavigationModal: React.FC<NavModalProps> = ({ isOpen, onClose, routeData }) => { export const LocationNavigationModal: React.FC<NavModalProps> = ({ isOpen, onClose, routeData }) => {
const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null); const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null);
const [loading, setLoading] = useState(false); const [isSearchingRoute, setIsSearchingRoute] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [routeInfo, setRouteInfo] = useState<{ distance: string; duration: string } | null>(null); const [routeInfo, setRouteInfo] = useState<{ distance: string; duration: string } | null>(null);
const fetchLockRef = useRef(false);
const originLat = routeData?.origin?.lat;
const originLng = routeData?.origin?.lng;
const destLat = routeData?.destination?.lat;
const destLng = routeData?.destination?.lng;
useEffect(() => { useEffect(() => {
if (!isOpen || !routeData.origin || !routeData.destination) { if (!isOpen || !originLat || !originLng || !destLat || !destLng) {
setRouteGeometry(null); setRouteGeometry(null);
setError(null); setError(null);
setRouteInfo(null); setRouteInfo(null);
return; return;
} }
const origin = routeData.origin; if (fetchLockRef.current) return;
const destination = routeData.destination;
const fetchRoute = async () => { const calculateOptimalRoute = async () => {
setLoading(true);
setError(null);
try { try {
const url = `https://router.project-osrm.org/route/v1/driving/${origin.lng},${origin.lat};${destination.lng},${destination.lat}?overview=full&geometries=geojson`; setIsSearchingRoute(true);
fetchLockRef.current = true;
const url = `https://router.project-osrm.org/route/v1/driving/${originLng},${originLat};${destLng},${destLat}?overview=full&geometries=geojson`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) throw new Error(`OSRM error: ${res.status}`); if (!res.ok) throw new Error(`OSRM error: ${res.status}`);
const data: { code: string; routes: OSRMRoute[] } = await res.json(); const data: { code: string; routes: OSRMRoute[] } = await res.json();
@@ -73,11 +80,18 @@ export const LocationNavigationModal: React.FC<NavModalProps> = ({ isOpen, onClo
} catch (err: any) { } catch (err: any) {
setError(err.message); setError(err.message);
} finally { } finally {
setLoading(false); setIsSearchingRoute(false);
fetchLockRef.current = false;
} }
}; };
fetchRoute();
}, [isOpen, routeData.origin, routeData.destination]); calculateOptimalRoute();
return () => {
fetchLockRef.current = false;
setIsSearchingRoute(false);
};
}, [isOpen, originLat, originLng, destLat, destLng]);
const userIcon = useMemo(() => L.divIcon({ const userIcon = useMemo(() => L.divIcon({
className: '!bg-transparent !border-none', className: '!bg-transparent !border-none',
@@ -126,7 +140,7 @@ export const LocationNavigationModal: React.FC<NavModalProps> = ({ isOpen, onClo
</div> </div>
{/* Map Container */} {/* Map Container */}
<div className="flex-1 relative bg-slate-950"> <div className="relative flex-1 bg-slate-950">
<MapContainer <MapContainer
center={center} center={center}
zoom={14} zoom={14}
@@ -155,11 +169,8 @@ export const LocationNavigationModal: React.FC<NavModalProps> = ({ isOpen, onClo
)} )}
</MapContainer> </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 && ( {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]"> <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} {error}
@@ -170,4 +181,3 @@ export const LocationNavigationModal: React.FC<NavModalProps> = ({ isOpen, onClo
</div> </div>
); );
}; };
+1 -7
View File
@@ -2585,13 +2585,7 @@ export const TourDetailPage = ({
</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> </div>