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:
@@ -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.
|
||||||
@@ -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.
|
|
||||||
Vendored
+66
-17
@@ -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),
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user