10 Commits

Author SHA1 Message Date
3dtours f74587376e feat: Tạo trang bản đồ chỉ đường độc lập TourNavigationPage
- Thêm trang TourNavigationPage.tsx hiển thị full màn hình với
  top-bar, bản đồ fullscreen và nút la bàn nổi ở góc dưới bên phải
- Cập nhật ItineraryTimeline.tsx: thay thế LocationNavigationModal
  bằng callback onOpenNavigationPage để chuyển đến trang mới
- Đăng ký trang tourNavigation trong App.tsx với navigationPayload
- Cập nhật TourDetailPage.tsx truyền onOpenNavigationPage xuống
- Thêm INSTALL_MAP.md hướng dẫn thực hiện tính năng
2026-06-25 10:17:23 +07:00
3dtours a135196cb5 fix: tính năng tìm đường ngắn nhất và chỉ đường cho người dùng 2026-06-24 22:05:41 +07:00
3dtours cfdcb573de feat: khi người dùng click vào nút đầu mỗi điểm để tìm đường đi trên bản đồ 2026-06-24 21:53:52 +07:00
3dtours f17215f236 feat: khi người dùng click vào nút Chi tiết hành trình thì sẽ tự cuộn đến mốc thời gian hiện tại 2026-06-24 21:25:04 +07:00
3dtours 5e60614588 feat: khi người dùng click vào nút Chi tiết hành trình thì sẽ dựa vào ngày của hệ thống mà chuyển đến Chặng đó 2026-06-24 21:17:26 +07:00
3dtours e385049652 fix: in đậm cho tiêu đề của cột khi xuất pdf 2026-06-24 20:52:17 +07:00
3dtours ef295e0994 fix: hiển thị ngày ở các chặng 2026-06-24 20:43:49 +07:00
3dtours c3382a422d fix: hiển thị ngày ở các chặng 2026-06-24 20:41:15 +07:00
3dtours 5d47e6d291 fix: lỗi nút xuất PDF và chèn link ở Địa điểm khi xuất PDF 2026-06-24 19:54:14 +07:00
3dtours 957ba6c72c fix: lỗi font Bold của xuất pdf 2026-06-24 18:17:42 +07:00
26 changed files with 1636 additions and 757 deletions
+2
View File
@@ -1,2 +1,4 @@
.env .env
node_modules node_modules
server/dist
dist
+126
View File
@@ -0,0 +1,126 @@
# To AI Agent: Implement Smart Date-Based Accordion Auto-Expansion and Smooth Viewport Auto-Scroll
## 1. Context & UI/UX Requirements
We are enhancing the routing UX between `MemberDashboard.tsx` and `ItineraryTimeline.tsx` based on `image_cc31a4.png`.
### Functional Specifications:
1. **Date-Matching Evaluation:** When a user clicks "Chi tiết hành trình" on a tour card, calculate if the current user system local date falls inside any Stage/Leg timeline block.
2. **Auto-Expansion State:** On timeline page load, the matched Leg accordion must be expanded by default (`expandedStageId === leg.id`).
3. **Smart Auto-Scroll Layout:** The viewport must smoothly auto-scroll so that the expanded Leg's title bar lands **exactly below the sticky navigation tab bar** ("Lộ trình", "Chi phí", etc.), making it fully visible at the top of the viewport without layout clipping.
---
## 2. Technical Architecture & Layout Constraints
- **The Sticky Header Challenge:** Since the top navigation tab bar uses sticky/fixed positioning, a naive `scrollIntoView({ block: 'start' })` will cause the Leg's title to crawl *underneath* the tabs, hiding it.
- **The Solution:** We will inject a dynamic CSS scroll-margin-top parameter (`scroll-mt-[70px]` or matching header height) on each Leg container, and trigger a minor delayed layout effect pool using a React `setTimeout` to wait for the DOM accordion expansion reflow before pulling the scroll trigger.
---
## 3. Code Refactoring Blueprint
### Step 1: Update Navigation Payload Handler in Dashboard Component
Ensure the `MemberDashboard.tsx` accurately packages the matched target identifier into the client route state bucket:
```typescript
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
const handleNavigateToItinerary = (tour: any) => {
let targetLegId = null;
if (tour?.legs && tour.legs.length > 0) {
const today = new Date();
today.setHours(0, 0, 0, 0);
for (const leg of tour.legs) {
const rawStart = leg.startDate || leg.plannedStart || leg.date;
const rawEnd = leg.endDate || leg.plannedEnd || leg.date;
if (rawStart) {
const startBound = new Date(rawStart);
startBound.setHours(0, 0, 0, 0);
const endBound = rawEnd ? new Date(rawEnd) : new Date(rawStart);
endBound.setHours(23, 59, 59, 999);
if (today >= startBound && today <= endBound) {
targetLegId = leg.id;
break;
}
}
}
if (!targetLegId) {
targetLegId = tour.legs[0].id; // Fallback to Chặng 1 if out of tour bounds
}
}
navigate(`/tour/${tour.id}`, {
state: { defaultExpandedLegId: targetLegId, shouldScrollToTarget: true }
});
};
### Step 2: Inject Safe Identifiers and Scroll Margin inside ItineraryTimeline
Locate the top-level outer container div of each Leg item inside the timeline loop (currentTour.legs.map). Assign a unique id and a scroll margin class utility:
{currentTour.legs.map((leg: any, legIdx: number) => (
<div
key={leg.id}
id={`leg-anchor-node-${leg.id}`}
/* CRITICAL: scroll-mt-[70px] leaves a 70px buffer space at the top.
Adjust '70px' to match the exact height of your white Tour Navigation Tabs bar!
*/
className="scroll-mt-[70px] transition-all w-full mb-4"
>
{/* Accordion Header Title ("Chặng 2: Dạo chơi ở xứ hoa vàng...") */}
<div className="flex items-center justify-between ...">
...
</div>
</div>
))}
### Step 3: Implement Lifecycle Interaction Trigger Engine
Inside ItineraryTimeline.tsx, listen to the passed history route parameters. Set the state, then handle the async smooth scrolling action:
import { useLocation } from 'react-router-dom';
const location = useLocation();
const [expandedStageId, setExpandedStageId] = useState<string | null>(null);
useEffect(() => {
if (location.state?.defaultExpandedLegId) {
const targetId = location.state.defaultExpandedLegId;
// 1. Instantly trigger the accordion state layout expansion
setExpandedStageId(targetId);
// 2. Schedule a deferred macro-task callback to allow DOM re-renders to finish
if (location.state?.shouldScrollToTarget) {
const scrollTimer = setTimeout(() => {
const targetElement = document.getElementById(`leg-anchor-node-${targetId}`);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
// Clear history router state token flags to prevent repeating scroll on subsequent reload shifts
window.history.replaceState({}, document.title);
}, 150); // 150ms ensures smooth accordion height deployment transition finishes
return () => clearTimeout(scrollTimer);
}
} else if (currentTour?.legs && currentTour.legs.length > 0 && !expandedStageId) {
setExpandedStageId(currentTour.legs[0].id);
}
}, [location.state, currentTour]);
## 4. Quality Control & Acceptance Verification
[ ] Date Matching Accuracy: Set today's date context matching a target Leg configuration profile. Tap the transition interface button from dashboard. The page must route directly and expand the target container block node.
[ ] Flush Viewport Ceiling Test: The animated target header segment node must slide upwards smoothly. It must lock positions cleanly right below the lowest baseline layer shadow boundary of the Tab Controller without passing behind it.
[ ] State Cleanliness Check: Refreshing or navigating back and forth within the active Timeline panel after the initial landing should not lock or force layout views to keep jumping scroll heights automatically.
+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.
+196
View File
@@ -0,0 +1,196 @@
# To AI Agent: Create Standalone Navigation Route and Page for Mobile Map Viewport
## 1. Context & Architectural Goal
We are replacing the modal-based map system. On mobile viewports, when a user clicks the circular timeline node next to a location card, the application must transition entirely to a **new, dedicated standalone page** rather than opening a popup.
This architectural shift prevents the map engine from being trapped inside parent stacking contexts (accordions/scroll wrappers) or restricted app layout frames, allowing the map to take up 100% of the mobile viewport safely.
---
## 2. Structural Requirements for the New Page Layout
The new page (`TourNavigationPage.tsx`) must strictly render only two visual zones:
1. **Top-bar (Zone 1):** A sticky/fixed header containing a back button (`<`) to return to the previous tour timeline, and the text title of the active Tour.
2. **Fullscreen Map (Zone 2):** Extending from the absolute bottom edge of the Top-bar all the way to the bottom edge of the browser viewport (`100vw` by `100vh minus header height`).
3. **Compass Floating Action Button:** A separate, high-priority circular button placed explicitly at the **bottom-right corner** (`bottom-6 right-6`) floating directly on top of the map grid tiles.
---
## 3. Step-by-Step Implementation Refactoring Blueprint
### Step 1: Register the Standalone Route
Locate your central application routing file (e.g., `frontend/src/App.tsx`, `backend/src/main.ts`, or `routes.tsx`) and register the isolated navigation path:
```typescript
// Insert this route path inside your React Router / routing array configuration
<Route path="/tour/:id/navigation" element={<TourNavigationPage />} />
### Step 2: Update Trigger Behavior in ItineraryTimeline.tsx
Locate the white circular node button component. Convert the click handler from opening a modal state to a standard React Router redirection hook, passing all geospatial metadata safely within the history state container:
import { useNavigate, useParams } from 'react-router-dom';
const navigate = useNavigate();
const { id: tourId } = useParams();
const handleTriggerNavigation = (location: any, tourTitle: string) => {
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("Thiết bị không hỗ trợ định vị GPS toàn cầu.");
return;
}
// Request device coordinates before firing routing transitions
navigator.geolocation.getCurrentPosition(
(position) => {
// Transition out of the timeline and push spatial coordinates via state pack
navigate(`/tour/${tourId}/navigation`, {
state: {
origin: {
lat: position.coords.latitude,
lng: position.coords.longitude
},
destination: {
lat: parseFloat(location.latitude),
lng: parseFloat(location.longitude),
name: location.name || "Điểm đến"
},
tourTitle: tourTitle || "Chi tiết hành trình"
}
});
},
(error) => {
alert("Vui lòng bật quyền truy cập vị trí (GPS) trên trình duyệt để tìm đường.");
},
{ enableHighAccuracy: true, timeout: 7000 }
);
};
### Step 3: Create the New Page File (TourNavigationPage.tsx)
Create a brand new separate file at pages/TourNavigationPage.tsx. Enforce a flat layout architecture free from layout decorators or panel decorators:
import React, { useEffect, useState, useRef } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { ChevronLeft, Compass } from 'lucide-react';
export const TourNavigationPage: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const { id: tourId } = useParams();
const [isCompassActive, setIsCompassActive] = useState(false);
const mapRef = useRef<any>(null); // Anchor pointer to hold your initialized Map instance
const watchIdRef = useRef<number | null>(null);
const routeData = location.state?.origin && location.state?.destination ? location.state : null;
// Security Rail Guard: Bounce user back to timeline overview if accessed via raw URL parameters
useEffect(() => {
if (!routeData) {
console.warn("Direct route access missing state payload variables. Backtracking.");
navigate(`/tour/${tourId}`);
}
}, [routeData, navigate, tourId]);
// Compass Toggle Control Loops (Auto-rotation and Gesture cancellation)
useEffect(() => {
if (!mapRef.current || !routeData) return;
const map = mapRef.current;
const disableCompassOnGesture = () => {
if (isCompassActive) {
console.log("User manual gesture detected. Detaching compass auto-centering.");
setIsCompassActive(false);
}
};
// Listen to manual map manipulations to automatically drop tracking state
map.on('movestart', disableCompassOnGesture);
map.on('zoomstart', disableCompassOnGesture);
map.on('dragstart', disableCompassOnGesture);
return () => {
map.off('movestart', disableCompassOnGesture);
map.off('zoomstart', disableCompassOnGesture);
map.off('dragstart', disableCompassOnGesture);
};
}, [isCompassActive, routeData]);
useEffect(() => {
if (isCompassActive && navigator.geolocation) {
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => {
if (mapRef.current) {
// Dynamically center and turn map bearing heading to follow direction of travel
mapRef.current.easeTo({
center: [pos.coords.longitude, pos.coords.latitude],
bearing: pos.coords.heading || 0,
duration: 800
});
}
},
(err) => console.error(err),
{ enableHighAccuracy: true }
);
} else {
if (watchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current);
watchIdRef.current = null;
}
if (mapRef.current) mapRef.current.easeTo({ bearing: 0, duration: 400 });
}
return () => { if (watchIdRef.current !== null) navigator.geolocation.clearWatch(watchIdRef.current); };
}, [isCompassActive]);
if (!routeData) return null;
return (
<div className="w-screen h-screen min-h-screen bg-slate-950 flex flex-col overflow-hidden select-none text-white antialiased">
{/* PART 1: TOP-BAR HEADER ZONE (Isolated from Sub-Tabs layout skins) */}
<div className="w-full bg-[#1e293b]/95 backdrop-blur-md border-b border-slate-800 px-4 py-3.5 flex items-center gap-3 z-50">
<button
onClick={() => navigate(`/tour/${tourId}`)}
className="text-slate-300 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors"
title="Quay lại danh sách lộ trình"
>
<ChevronLeft className="w-6 h-6" />
</button>
<h2 className="text-sm font-bold truncate tracking-wide text-slate-100">
{routeData.tourTitle}
</h2>
</div>
{/* PART 2: FULLSCREEN MAP EDGE-TO-EDGE CONTAINER */}
<div className="w-full h-full relative flex-1">
{/* Map injection Canvas - Absolutely no rounded boundaries or wrapper cushions */}
<div id="dedicated-page-map-canvas" className="w-full h-full absolute inset-0 rounded-none border-none" />
{/* PART 3: FLOATING INTERACTIVE COMPASS ACTION BUTTON */}
<button
onClick={() => setIsCompassActive(!isCompassActive)}
className={`absolute bottom-8 right-6 z-40 w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
isCompassActive
? 'bg-blue-600 border-blue-400 text-white animate-pulse'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-blue-400'
}`}
title="Chuyển đổi chế độ xoay bản đồ tự động theo hướng di chuyển"
>
<Compass className="w-7 h-7" />
</button>
</div>
</div>
);
};
## 4. Verification & Quality Acceptance Criteria for AI Agent
[ ] Eradication of Inset Borders: The map canvas must draw edge-to-edge on mobile browser views without exposing structural margins or inner framing borders.
[ ] Tab Bar Exclusion: The secondary menu header rows ("Lộ trình, Chi phí, Ảnh...") must be 100% hidden on this path layout.
[ ] Lifecycle Integrity Test: Verify that performing a quick finger-drag pan gesture on the map surface immediately turns off the pulse mode state of the bottom-right Compass button.
+132
View File
@@ -0,0 +1,132 @@
# To AI Agent: Implement Clickable Google Maps Hyperlinks in PDF Export Table
## 1. Context & Feature Objective
We are upgrading the PDF export functionality within the Yotrip Travel Planner application. Currently, the PDF generates a static table containing location names, addresses, and coordinates.
**Objective:** Automatically turn every location row inside the PDF table into an interactive, clickable hyperlink. When a user clicks on the location cell in the generated PDF document, it must immediately open a browser tab navigating directly to that exact location on Google Maps.
---
## 2. Technical Architecture & Data Strategy
Because `jspdf-autotable` draws text onto a canvas layout, raw HTML tags like `<a>` will fail. We must implement a two-step rendering lifecycle:
1. **Extraction & Styling State:** During the data loop, verify coordinates or address data. Generate a standard universal Google Maps search URL, save it into a coordinate-tracking index object, and transform the raw text cell into a styled cell object (Yale Blue text `#28536b` to mimic an online link).
2. **Link Injection Layer:** Utilize the `didDrawCell` hook callback inside the `doc.autoTable` configuration to position a native invisible link window (`doc.link()`) precisely over the drawn dimensions of that specific location cell.
---
## 3. Code Refactoring Specification
### Step 1: Update Data Preparation Loop
Locate the loop processing `currentTour.legs` inside your PDF generation code block. Initialize a temporary lookup map array named `pdfMapLinks` and refactor the item distribution logic as follows:
```typescript
// Initialize an index mapping registry for hyperlinks before the loop execution
const pdfMapLinks: { [key: number]: string } = {};
if (currentTour?.legs && currentTour.legs.length > 0) {
currentTour.legs.forEach((leg: any, legIdx: number) => {
const legName = leg.note || `Chặng ${legIdx + 1}`;
const legLocations = (leg.locations || []).filter((loc: any) =>
loc && (loc.plannedStart || loc.plannedEnd || loc.name)
);
const startRow = globalRowIndex;
if (legLocations.length > 0) {
legRowRanges[leg.id] = { start: startRow, count: legLocations.length };
legLocations.forEach((loc: any, locIdx: number) => {
const isStartPoint = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
const timeSource = isStartPoint ? loc.plannedEnd : loc.plannedStart;
const timeStr = timeSource ? formatDateTime(timeSource) : '';
const locName = loc.name || '';
const addressStr = loc.address || '';
const coordStr = loc.latitude && loc.longitude
? `\n${loc.latitude}, ${loc.longitude}`
: '';
const locationText = [locName, addressStr, coordStr].filter(Boolean).join('\n');
const noteText = loc.note || '';
// 1. Generate standard universal Google Maps URL query pattern
let mapUrl = '';
if (loc.latitude && loc.longitude) {
// Absolute Precision using GPS coordinates
mapUrl = `https://www.google.com/maps/search/?api=1&query=${loc.latitude},${loc.longitude}`;
} else if (addressStr || locName) {
// Text-search query fallback if GPS coordinates are missing
mapUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(addressStr || locName)}`;
}
// 2. Register current row array position to map lookup index
const currentRowPosition = tableRows.length;
if (mapUrl) {
pdfMapLinks[currentRowPosition] = mapUrl;
}
// 3. Convert raw string into custom styled autoTable Cell configuration object
const locationCellObj = {
content: locationText,
// Apply custom link colors matching theme style #28536b (Yale Blue)
styles: mapUrl ? { textColor: [40, 83, 107], fontStyle: 'bold' as const } : {}
};
tableRows.push([
stt++,
timeStr,
locIdx === 0 ? legName : '',
locationCellObj, // Injected as cell structure object
noteText
]);
globalRowIndex++;
});
} else {
legRowRanges[leg.id] = { start: startRow, count: 1 };
tableRows.push([
stt++,
'',
legName,
'Chưa có địa điểm trong chặng này',
''
]);
globalRowIndex++;
}
});
}
### Step 2: Inject Coordinate-Based Overlay inside doc.autoTable
Locate the core configuration module block where doc.autoTable({}) is invoked. Inject the didDrawCell event engine to deploy the link bounds:
doc.autoTable({
head: [['STT', 'Thời gian', 'Chặng', 'Địa điểm', 'Ghi chú']],
body: tableRows,
theme: 'grid',
// HOOK HANDLER: Overlays active coordinate zones on top of native cells after writing
didDrawCell: (data: any) => {
// Target explicitly: body section only + column index 3 (Location Column)
if (data.section === 'body' && data.column.index === 3) {
const activeRowIndex = data.row.index;
const targetMapUrl = pdfMapLinks[activeRowIndex];
if (targetMapUrl) {
// Build active link container utilizing jsPDF coordinates framework
data.doc.link(
data.cell.x,
data.cell.y,
data.cell.width,
data.cell.height,
{ url: targetMapUrl }
);
}
}
},
styles: { font: 'Roboto' } // Retain existing styling structure
});
## 4. Quality Control & Acceptance Criteria
[ ] Visual Differentiation: Location text cells containing map URLs must render cleanly in bold dark-blue ([40, 83, 107]) while empty state texts stay muted.
[ ] Coordinate Precision: Clicking a location card possessing explicit coordinates (latitude, longitude) must directly map to those absolute markers instead of doing an inaccurate keyword address query.
[ ] Boundary Accuracy: The click targets must fit perfectly inside the grid cell borders. Clicking near the edges of column 3 must register properly, while clicking column 2 (Leg name) or column 4 (Notes) must remain non-reactive.
-105
View File
@@ -1,105 +0,0 @@
Markdown
# To AI Agent: Implement Automatic Tour Note Generation & Dynamic Timeline Quick Note Insertion
## 1. Context & Feature Overview
We are implementing an interconnected Note System for the Yotrip Travel Planner application. The feature spans across three core components: `TourDetailPage.tsx`, `MyNotePage.tsx`, and `ItineraryTimeline.tsx`.
### Core Requirements:
1. **Auto-Note Initialization:** When a new Tour is created in `TourDetailPage.tsx`, the system must automatically instantiate a matching master note record inside the `MyNotePage.tsx` system linked by `tourId`.
2. **Strict Document Schema Hierarchy:** The note content must format itself using structured headings reflecting the tour's legs (stages) and location points.
3. **Timeline Quick Note Insertion:** Inside `ItineraryTimeline.tsx`, each location node must feature a "Quick Note Button". Clicking it must immediately stringify location metadata (Timestamp + Reverse-Geocoded Address + User Text) and `INSERT` it dynamically under the correct Stage section inside the master note.
---
## 2. Note Structure & Markdown Payload Template
When initializing a tour or syncing content, the document data payload within the database/state framework must stringify exactly to this structural template:
```markdown
# [Tên Tour]
## Ghi chú chung
*(Nội dung ghi chú tổng quan của chuyến đi...)*
## Ghi chú: [Tên Chặng 1]
#### 📅 [Ngày giờ] | 📍 [Tên địa điểm - Phân giải từ tọa độ]
- **Nhật ký:** [Nội dung người dùng nhập vào ở Điểm này trên Timeline]
## Ghi chú: [Tên Chặng 2]
#### ...
## 3. Detailed Logic & Component Specifications
### Step 1: TourDetailPage.tsx - Creation Hook Handler
When the user submits the "Create Tour" form successfully, inject an asynchronous action handler to create the accompanying node container:
TypeScript
// Blueprint for Tour Creation Action Hook
const handleCreateTour = async (tourData: any) => {
const newTour = await api.tours.create(tourData);
if (newTour) {
// Generate the initial markdown note layout template
const initialNoteBody = `# ${newTour.title}\n\n## Ghi chú chung\n\n`;
await api.notes.create({
tourId: newTour.id,
title: `Ghi chú: ${newTour.title}`,
content: initialNoteBody,
createdAt: new Date().toISOString()
});
}
};
### Step 2: ItineraryTimeline.tsx - Quick Note Component Injection
Locate the location list renderer (leg.locations.map) adjacent to the white circular checkpoint nodes. Insert a small "Quick Note" button (utilizing Lucide icon FileText or similar).
JSX Target Insertion Blueprint
JavaScript
{/* Quick Note Button Interface sitting right near the action buttons of the card */}
<button
onClick={() => handleQuickNoteInsert(leg.id, location.id, location)}
className="p-1.5 text-yotripLight-steel hover:text-yotripLight-text dark:text-yotripDark-muted dark:hover:text-yotripDark-lime rounded-lg hover:bg-gray-100 dark:hover:bg-yotripDark-surface-muted transition-colors"
title="Ghi chú nhanh điểm này"
>
<FileText className="w-4 h-4" />
</button>
### Step 3: Coordinate Resolution & String Insertion Engine
Implement the core method to reverse-geocode latitudes/longitudes, construct the standard string layout, and push the patch to the existing text repository of the note.
TypeScript
const handleQuickNoteInsert = async (legId: string, locationId: string, locationData: any) => {
// 1. Resolve coordinates to a readable address string
const resolvedAddress = await reverseGeocode(locationData.lat, locationData.lng) || locationData.addressName;
// 2. Format localized Date/Time
const formattedTime = locationData.plannedStart
? new Date(locationData.plannedStart).toLocaleString('vi-VN')
: "Thời gian tùy hứng";
// 3. Extract user text inputted on the timeline card
const userInputText = locationData.timelineComment || "Không có ghi chú thêm.";
// 4. Synthesize the injection block
const noteSnippet = `#### 📅 ${formattedTime} | 📍 ${resolvedAddress}\n- **Nhật ký:** ${userInputText}\n\n`;
// 5. Trigger API/State update to find the master note by tourId,
// locate or append the '## Ghi chú: [Tên Chặng]' marker, and inject the snippet directly below it.
await api.notes.insertSectionByTourId(tourId, legId, noteSnippet);
};
## 4. UI Style Integration Constraints
Apply the application design system colors directly to the new buttons.
In Light mode, buttons must remain a clean #28536b (Yale Blue) or soft #7ea8be (Steel Blue).
In Dark mode, hover interactions must transition to #bce784 (Lime Cream) with a smooth transition-colors duration-200 modifier.
## 5. Acceptance Criteria for Verification
[ ] Instantiation Check: Create a test Tour titled "Hoa vàng cỏ xanh 2026". Navigate to MyNotePage and confirm a note document with the exact title has been instantly spawned.
[ ] Geocoding & String Format Check: Click the Quick Note button on a location card with coordinates (e.g., 15.56, 108.49). The string appended to the note must fully resolve the address (e.g., "Tam Kỳ, Quảng Nam") instead of dumping raw coordinate numbers.
[ ] Structural Targeting: Ensure that inserting a quick note from Chặng 2 appends the text directly beneath the ## Ghi chú: Chặng 2 markdown anchor block, without scrambling or overwriting the text blocks of Chặng 1.
-86
View File
@@ -1,86 +0,0 @@
# To AI Agent: Fix Overlapping Text and Layout Overflow in MyNotePage Note Cards
## 1. Context & Layout Bug Analysis
We are fixing a severe rendering and layout bug inside the Note Card component of `MyNotePage.tsx` as captured in `image.png`:
- **Bug 1 (Chữ đè chồng lên nhau):** The text lines inside the note body (headings, dates, journal contents) are collapsing vertically and rendering directly on top of each other. This is caused by rogue `absolute` positioning or a broken flex/grid system on inner content elements.
- **Bug 2 (Tràn ra khỏi Board chứa):** Long text strings (such as resolved address lines with "Đà Nẵng, Vietnam" or "Tuy Hòa, Phú Yên") are breaking out horizontally and vertically beyond the rounded border bounds of the card.
- **Goal:** Clean up the typography layout engine so that elements stack naturally in a vertical flow (`block` or `flex-col`), wrap text cleanly when boundaries are met, and respect the container's height/scroll rules.
---
## 2. Refactoring Instructions
### Step 1: Clean Up the Note Content Wrapper Class
Locate the container rendering the inner markdown text inside the note board card. Force it to follow a regular vertical block layout flow and ensure text wrapping is active.
```jsx
{/* ❌ OLD COLLAPSED CONTAINER (Dự đoán đang bị sai class) */}
<div className="absolute ..."> or <div className="h-full ...">
{/* ✅ NEW FIXED CONTAINER STRUCTURE */}
<div className="flex flex-col gap-3 w-full text-left overflow-y-auto max-h-[250px] pr-2 scrollbar-thin">
{/* Ensure markdown rendering outputs elements as block-level */}
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-pre-wrap text-yotripDark-text-primary">
{/* Inside here, headings (##, ####) and lists (-) must stack naturally */}
{note.content}
</div>
</div>
### Step 2: Fix Inner Line Items Styles (If parsing strings manually)
If you are split-parsing the lines manually (e.g., parsing 📅 and 📍 into individual rows), ensure NO element inside uses absolute. Every line item must be relative or static.
/* Inject these explicit fixes into index.css under @layer components if needed */
.note-content-line {
position: relative !important; /* Force breakout from any absolute parent trap */
display: block !important; /* Clear any inline overlap */
width: 100% !important;
word-break: break-word !important; /* Force text to wrap instead of bleeding out */
white-space: normal !important; /* Overwrite any nowrap rule */
margin-bottom: 8px;
}
## 3. Full Component JSX Layout Optimization Blueprint
Update the single Note Card render template inside MyNotePage.tsx to match this stable layout standard:
export const NoteCard = ({ note }) => {
return (
<div className="relative w-full bg-yotripDark-surface border border-yotripDark-border rounded-2xl p-5 shadow-md hover:shadow-lg transition-all flex flex-col gap-4">
{/* 1. Header Zone (Title & Date) */}
<div className="flex flex-col gap-1 border-b border-yotripDark-border pb-3">
<h3 className="text-lg font-bold text-white break-words pr-8">
{note.title}
</h3>
<span className="text-xs text-yotripDark-muted flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
{note.date}
</span>
</div>
{/* 2. Content Zone - FIXES THE BUG IN IMAGE */}
<div className="w-full flex flex-col gap-3 overflow-y-auto max-h-[280px] text-sm text-slate-200 pr-1">
{/* Dynamic content rendering with forced layout safety rails */}
<div className="space-y-3 break-words whitespace-pre-wrap text-left select-text">
{/* Ensure raw strings or processed lines flow down smoothly */}
{note.content.split('\n').map((line, index) => {
if (line.trim() === '') return null;
return (
<p key={index} className="leading-relaxed text-slate-200 block w-full m-0 p-0">
{line}
</p>
);
})}
</div>
</div>
</div>
);
};
## 4. Verification Checklist for AI Agent
[ ] No Overlap: Verify that the "## Ghi chú chung...", "Ghi chú: Xuất phát", and "📅 Thời gian tùy hứng" text elements sit on unique vertical lines. They must never occupy the same space.
[ ] Horizontal Bound Test: Long addresses (e.g., Apec mandala Victor condotel, Hùng Vương, Phường Tuy Hòa...) must trigger a soft wrap onto line 2 and line 3 instead of punching through the card's right border.
[ ] Scroll Containment: If the text length exceeds the card's designated size, it must elegantly clip inside and provide a clean vertical scrollbar, rather than dripping out underneath the board container.
-129
View File
@@ -1,129 +0,0 @@
# NOTE_EDIT.md
## MyNotePage.tsx
**Đường dẫn:** `frontend/src/pages/MyNotePage.tsx`
### Mục đích
Trang quản lý ghi chú cá nhân (My Notes) của người dùng trong một tour. Hỗ trợ tạo, sửa, xóa, tìm kiếm ghi chú và đồng bộ dữ liệu giữa client và server.
### Cấu trúc & Luồng hoạt động chính
- **State quản lý:**
- `notes`: danh sách ghi chú hiện tại
- `isCreating`: điều khiển hiển thị form tạo/sửa ghi chú
- `editingNoteId`: xác định đang sửa ghi chú nào
- `noteForm`: lưu trữ form data `{ title, content }`
- `searchQuery`: từ khóa tìm kiếm
- **Render có 3 nhánh:**
1. Không tạo ghi chú + không có ghi chú: hiển thị màn hình trống + nút "Tạo ghi chú mới"
2. `isCreating = true`: hiển thị form nhập tiêu đề + ReactQuill + nút Lưu/Hủy
3. `filteredNotes.length > 0`: hiển thị danh sách ghi chú dạng grid card
- **Lưu ghi chú (handleSaveNote):**
- Nếu đang `edit`: cập nhật note trong `notes` gọi API PUT
- Nếu `create` mới: tạo `temp_` id, thêm vào đầu mảng `notes`, gọi API POST để lấy id thật
- Sau khi API thành công: cập nhật `id` thật và đánh dấu `synced: true`
- **Xóa ghi chú:** hỏi confirm → đánh dấu `deletedLocally: true` → gọi API DELETE → xóa khỏi UI
- **Đồng bộ offline (syncLocalNotesToServer):**
- Xử lý tuần tự 3 nhóm: `deletedLocally``temp_` (chưa đồng bộ) → `edited` (synced: false)
- Mỗi nhóm có retry riêng, lỗi chỉ log console
- **Fetch & Cache (fetchServerNotes + useEffect):**
- Khi load trang: đọc `localStorage` key `my_journey_notes_{tourId}` làm cache ban đầu
- Gọi API GET `/api/v1/tours/${tourId}/notes` để lấy dữ liệu server
- Merge: giữ `unsyncedNotes` (temp_, synced:false, deletedLocally) + lọc khỏi server
- Sort theo `createdAt` giảm dần (mới nhất lên đầu)
- Lưu lại `localStorage` sau mỗi thay đổi
- **Tìm kiếm (filteredNotes):**
- Lọc theo title HOẶC content (strip HTML tag)
- Không lọc note đã `deletedLocally`
### ReactQuill configuration
- Module: table, header(1,2), bold/italic/underline/strike, align, indent, bullet/check list, link/image/table/clean
- Custom Icons: gán SVG từ `lucide-react` vào `Quill.import('ui/icons')`
- Table handler: dùng `prompt` nhập số hàng/cột → gọi `quill.getModule('table').insertTable()`
### Theme/Class cần lưu ý
- Wrapper ReactQuill: `bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm min-h-[500px] flex flex-col`
- CSS class ở ReactQuill đã fix: `.ql-container` lấy `h-full, min-h-0`; `.ql-editor` lấy `flex-1, overflow-y-auto` để nội dung dài cuộn trong khung
- Note card: `bg-[var(--surface)] dark:bg-[var(--surface-muted)] p-5 rounded-3xl`
- Màu chính: `text-amber-500` cho buttons, header
---
## TourDetailPage.tsx
**Đường dẫn:** `frontend/src/pages/TourDetailPage.tsx`
### Mục đích
Trang chi tiết tour, tổng hợp toàn bộ thông tin: bản đồ interactive, lộ trình, gallery ảnh, manage members, quản lý chi phí, chat, và chức năng share. Trang này rất nặng (~3400 dòng) nên cần chú ý các captured props và callback pattern.
### Cấu trúc chính
- **Header:** sticky top bar có các tab: Timeline | Spot / Location | Expenses | Chat
- **Tabs switch:** điều khiển hiển thị `ItineraryTimeline`, `ExpenseManager`, `TourChat`
- **Map section:** leaflet map kết hợp MarkerCluster, Polyline vẽ lộ trình OSRM, Recenter button
### Key logic/Data flow
- `useTourStore` là state chính: lưu `currentTour`, `legs`, `locations`, `userRole`, `mapCenter`
- **OSRM Routes:** kết quả từ backend đa segment, `combineSegmentRoutes()` gộp thành 1 polyline
- **Sync data:** `addLocation`, `editLocation`, `deleteLocation` gọi API và cập nhật store
- **PDF Export:** dùng `jsPDF` + `jspdf-autotable` xuất hóa đơn/nhật ký tour
- **ShareJourney button:** tạo public link hoặc mở share modal
- **Socket.IO:** `io()` kết nối realtime cho chat và comment
- **Offline-first:** nhiều actions gọi API nhưng không block UI; có try/catch và fallback
### Các modal/components con được gọi
- `AddLocationModal`, `MembersTab`, `ExpenseManager`, `CommentModal`, `AddPhotoModal`, `TourChat`
- `MapContextMenu`: menu right-click trên map để bắt đầu/kết thúc/add to leg
- `MapRotationHandler`: xoay map theo hướng di chuyển (cumulative rotation để tránh nhảy -360→0)
- `MapHoverTip`: tooltip xuất hiện sau 3s khi hover trên map (cho user biết tip right-click)
### Theme & Styling cần lưu ý
- Map marker icon: dùng `unpkg.com/leaflet@1.9.4` (fixed URL) do Vite bundler không nhận asset mặc định
- Màu primary tour: `text-blue-600`, accent: `text-amber-500`
- Sticky header: `bg-[var(--surface)]/80 backdrop-blur-md`
---
## ItineraryTimeline.tsx
**Đường dẫn:** `frontend/src/components/ItineraryTimeline.tsx`
### Mục đích
Component hiển thị lộ trình đa chặng (multi-leg itinerary) dạng timeline accordion. Mỗi `leg` là 1 section có thể expand/collapse độc lập (exclusive mode).
### Cấu trúc chính
- **props:**
- `onAddLocation(legId, isStart?, isEnd?)`: callback mở modal thêm địa điểm
- `onEditLocation(location)`: callback mở modal sửa địa điểm
- `onQuickNote(name)`: callback mở modal ghi chú nhanh
- `onNavigate(location)`: callback điều hướng trên map
- `onSuccess()`: callback sau khi xóa/edit thành công
- `isPublicView`: boolean, hide edit/delete buttons khi true
- **State:**
- `expandedStageId`: id của leg đang mở (chỉ 1 mở tại 1 thời điểm) → mặc định leg đầu tiên
- `isEditModalOpen`, `editingLegData`: modal sửa chặng
- `isCommentModalOpen`, `commentLocationId`, `commentLocationName`: modal comment
### Logic đặc biệt
- **Exclusive expansion:** `toggleStageExpanded` đảm bảo chỉ 1 leg mở tại 1 thời điểm (click lại để đóng)
- **Distance & Travel time:**
- Tính haversine giữa 2 điểm liên tiếp (`allLocations` flat)
- Convert giữa các chặng: lấy `prevLegLastLoc` → tính khoảng cách → hiển thị "Tiếp nối từ ..."
- `leg.totalDistance` + `formatTravelTime()` để đổi sang "X giờ Y phút"
- **Dwell time:** thời gian dừng tại mỗi điểm = `plannedEnd - plannedStart`
- **Marker milestones:**
- Start point: `plannedStart` timestamp = 0
- End point: `plannedEnd` timestamp = 0
- `TimeVariance`: so sánh actual vs planned, hiển thị "Trễ X phút" / "Đúng giờ" / "Sớm X phút"
- **Accordion CSS:** dùng `grid-template-rows: 0fr → 1fr` transition (`folder-child-content-box`) để animate mở/đóng mượt
- **Comment count:** `handleCommentIncrement/Decrement` trực tiếp mutate `useTourStore` để cập nhật UI gần như tức thì mà không cần re-fetch API
### Giao tiếp với cha (TourDetailPage)
- `onAddLocation?.(leg.id, isFirst, isLast)` ↔ mở `AddLocationModal`
- `onEditLocation(loc)` ↔ pre-fill modal edit
- `onNavigate(loc)` ↔ mở notification/guide "Đang điều hướng" rồi gọi map navigate
- `onSuccess?.()` gọi sau delete/edit thành công để refresh từ cha
---
## Lưu ý chung
- Cả 3 file đều dùng Tailwind CSS + theme variables từ `index.css`
- Offline/sync-first: ghi chú lưu localStorage trước, API sau
- Khi gọi API từ các hook, header gắn `Authorization: Bearer ${localStorage.getItem('token')}`
- `react-quill-new` dùng thay cho `react-quill` do tương thích React 18+
-116
View File
@@ -1,116 +0,0 @@
# To AI Agent: Implement Unified Dual-Theme System (Light & Dark) for Yotrip App
## 1. Context & Design System Overview
We are building a robust, high-contrast, yet eye-friendly dual-theme system (Light and Dark modes) for our mobile application (`yotrip.labz.io.vn`).
- **Dark Theme Constraint:** Must use the pre-configured smoky grape base (`Vintage Grape` & `Dusty Grape`) with vivid lime/aquatic accents. No pitch-black.
- **Light Theme Constraint:** Must use the soft parchment base (`Parchment`) to eliminate screen glare, using deep academic blue (`Yale Blue`) for high-contrast readability. No pure blazing white backgrounds.
---
## 2. Comprehensive Color Token Mapping
### A. LIGHT THEME PALETTE ("Classic Heritage & Soft Sand")
- **`Parchment` (`#f6f0ed`):** Map to **Global Page Backgrounds**. A warm, ancient-manuscript off-white that eliminates mobile screen glare.
- **`Yale Blue` (`#28536b`):** Map to **Primary Text, Main Titles, and Primary Action Buttons**. Provides dependable trust and sharp contrast.
- **`Steel Blue` (`#7ea8be`):** Map to **Active Navigation Tabs, Active Icons, and Secondary Actions**.
- **`Khaki Beige` (`#bbb193`):** Map to **Borders, Dividers, and Deactivated/Muted States**.
- **`Rosy Taupe` (`#c2948a`):** Map to **Special Highlight Badges, Notification Banners, or Warm Accent Elements**.
### B. DARK THEME PALETTE ("Vintage Velvet & Aquatic Zest")
- **`Vintage Grape` (`#513b56`):** Map to **Global Page Backgrounds**.
- **`Dusty Grape` (`#525174`):** Map to **Component Surfaces (Cards, Chat Bubbles, Accordion Rows)**.
- **`Lime Cream` (`#bce784`):** Map to **Brand Text Highlights, Active Icons**.
- **`Bondi Blue` (`#348aa7`):** Map to **Primary Action Buttons, Links**.
- **`Emerald` (`#5dd39e`):** Map to **Success Utilities & "Tối ưu" badges**.
---
## 3. Technical Global Configuration
### Option A: Clean CSS Variables (`global.css`)
Replace or update the root variable tokens inside your main stylesheet:
```css
/* --- THEME SÁNG (Mềm mại, Tương phản cao, Không chói) --- */
:root {
--background: #f6f0ed; /* Parchment */
--surface: #ffffff; /* Pure White for crisp card layers */
--surface-muted: #bbb193; /* Khaki Beige */
--border: #bbb193; /* Khaki Beige */
--text-primary: #28536b; /* Yale Blue (High contrast text) */
--text-secondary: #7ea8be; /* Steel Blue */
--text-accent: #c2948a; /* Rosy Taupe */
--primary: #28536b; /* Yale Blue for main buttons */
--primary-hover: #1f4154;
--secondary-active: #7ea8be; /* Steel Blue */
}
/* --- THEME TỐI (Dịu mắt, Sang trọng, Không đen kịt) --- */
:root.dark {
--background: #513b56; /* Vintage Grape */
--surface: #525174; /* Dusty Grape */
--surface-muted: #626186;
--border: #626186;
--text-primary: #f8fafc; /* Soft White */
--text-secondary: #94a3b8; /* Muted Slate */
--text-brand: #bce784; /* Lime Cream */
--primary: #348aa7; /* Bondi Blue */
--primary-hover: #296f86;
--success-accent: #5dd39e; /* Emerald */
}
### Option B: Tailwind Extension Configuration (tailwind.config.js)
Expose these custom design tokens cleanly into the utility library framework:
theme: {
extend: {
colors: {
yotripLight: {
bg: '#f6f0ed', // Parchment
text: '#28536b', // Yale Blue
steel: '#7ea8be', // Steel Blue
khaki: '#bbb193', // Khaki Beige
rosy: '#c2948a', // Rosy Taupe
},
yotripDark: {
bg: '#513b56', // Vintage Grape
surface: '#525174', // Dusty Grape
lime: '#bce784', // Lime Cream
bondi: '#348aa7', // Bondi Blue
emerald: '#5dd39e', // Emerald
}
}
}
}
## 4. UI Component Application Reference
Apply these unified color classes to ensure the interface flips seamlessly:
Chat Wrapper & Itinerary Timelines: - Light Mode: Background is yotripLight-bg, General Text is yotripLight-text.
Dark Mode: Background is yotripDark-bg, General Text is #f8fafc.
Navigation Tabs Menu:
Light Mode: Inactive text is yotripLight-steel. Active tab gets a yotripLight-text highlight indicator.
Dark Mode: Inactive text is yotripDark-surface. Active tab gets a yotripDark-lime highlight indicator.
Main Action Call-To-Actions (e.g., "Thêm địa điểm"):
Light Mode: Background is yotripLight-text (Yale Blue) with clean white text.
Dark Mode: Background is yotripDark-bondi (Bondi Blue) with clean white text.
## 5. Acceptance Criteria for Verification
[ ] Ensure that toggling the .dark class on the <html> root triggers a global transition without style flashes (transition-colors duration-200).
[ ] Text legibility inside Light Mode satisfies standard WCAG accessibility contrast limits on mobile displays out in the sun.
[ ] The background of the expanded folder nodes/chat bubbles maps cleanly to their respective surface tokens across both system states.
+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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+17 -2
View File
@@ -6,8 +6,23 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script src="https://accounts.google.com/gsi/client" async defer></script> <script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title> <title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-FtyTkPzF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DQ8dDKht.css"> <!-- Open Graph Meta Tags for Social Media Sharing -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://yotrip.labz.io.vn" />
<meta property="og:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
<meta property="og: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 property="og:image" content="https://yotrip.labz.io.vn/background.avif" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image" />
<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-D6jMbgMg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nLng8wU9.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+27 -7
View File
@@ -9,6 +9,7 @@ import { JoinTourPage } from './pages/JoinTourPage';
import { MemberDashboard } from './pages/MemberDashboard'; import { MemberDashboard } from './pages/MemberDashboard';
import { AdminDashboard } from './pages/AdminDashboard'; import { AdminDashboard } from './pages/AdminDashboard';
import { ShareJourneyPage } from './pages/ShareJourneyPage'; import { ShareJourneyPage } from './pages/ShareJourneyPage';
import { TourNavigationPage } from './pages/TourNavigationPage';
import { ConfirmProvider } from './hooks/useConfirm'; import { ConfirmProvider } from './hooks/useConfirm';
import { NotificationProvider } from './hooks/useNotification'; import { NotificationProvider } from './hooks/useNotification';
@@ -21,12 +22,13 @@ function App() {
const [user, setUser] = useState<any>(null); const [user, setUser] = useState<any>(null);
const [shareJourneyToken, setShareJourneyToken] = useState<string | null>(journeyTokenVal); const [shareJourneyToken, setShareJourneyToken] = useState<string | null>(journeyTokenVal);
const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'admin' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney'>( const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'admin' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney' | 'tourNavigation'>(
journeyTokenVal ? 'shareJourney' : (viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing')) journeyTokenVal ? 'shareJourney' : (viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing'))
); );
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId); const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId); const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore'); const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore');
const [navigationPayload, setNavigationPayload] = useState<{ tourId: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string } | null>(null);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -122,26 +124,30 @@ function App() {
}; };
const handleBackFromTourDetail = () => { const handleBackFromTourDetail = () => {
// Check if this was a public view BEFORE clearing the flag
const wasPublicView = isPublicTourView; const wasPublicView = isPublicTourView;
setCurrentTourId(null); setCurrentTourId(null);
setIsPublicTourView(false); setIsPublicTourView(false);
// If user was viewing a public tour, redirect to index/landing page
// Otherwise redirect based on authentication and previous page
if (wasPublicView) { if (wasPublicView) {
// Public tour view - always redirect to index/landing
setCurrentPage('landing'); setCurrentPage('landing');
} else if (user) { } else if (user) {
// Authenticated user viewing their own tour - go back to previous page
setCurrentPage(previousPage); setCurrentPage(previousPage);
} else { } else {
// Not authenticated and not public view - go to landing
setCurrentPage('landing'); setCurrentPage('landing');
} }
}; };
const handleOpenNavigationPage = (payload: { tourId: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string }) => {
setNavigationPayload(payload);
setCurrentPage('tourNavigation');
};
const handleBackFromNavigation = () => {
setNavigationPayload(null);
setCurrentPage('tourDetail');
};
const handleBackFromSignup = () => { const handleBackFromSignup = () => {
const pendingInviteToken = localStorage.getItem('pendingInviteToken'); const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) { if (pendingInviteToken) {
@@ -259,6 +265,7 @@ function App() {
onBack={handleBackFromTourDetail} onBack={handleBackFromTourDetail}
isPublicView={isPublicTourView} isPublicView={isPublicTourView}
onOpenNotes={() => setCurrentPage('notes')} onOpenNotes={() => setCurrentPage('notes')}
onOpenNavigationPage={handleOpenNavigationPage}
/> />
); );
} }
@@ -267,6 +274,19 @@ function App() {
return <MyNotePage tourId={currentTourId!} onBack={() => setCurrentPage('tourDetail')} />; return <MyNotePage tourId={currentTourId!} onBack={() => setCurrentPage('tourDetail')} />;
} }
if (currentPage === 'tourNavigation' && navigationPayload) {
return (
<TourNavigationPage
tourId={navigationPayload.tourId}
routeData={{
origin: navigationPayload.origin,
destination: navigationPayload.destination
}}
onBack={handleBackFromNavigation}
/>
);
}
if (currentPage === 'explore') { if (currentPage === 'explore') {
return ( return (
<ExploreMap <ExploreMap
+64 -6
View File
@@ -50,6 +50,7 @@ export const ItineraryTimeline = ({
onEditLocation?: (location: any) => void, onEditLocation?: (location: any) => void,
onQuickNote?: (data: { legId: string; location: any; leg: any }) => void, onQuickNote?: (data: { legId: string; location: any; leg: any }) => void,
onNavigate?: (location: any) => void, onNavigate?: (location: any) => void,
onOpenNavigationPage?: (routeData: { origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string }) => void,
onSuccess?: () => void, onSuccess?: () => void,
isPublicView?: boolean isPublicView?: boolean
}) => { }) => {
@@ -127,6 +128,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) => {
onOpenNavigationPage?.({
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"
},
tourTitle: currentTour?.title || "Chi tiết hành trình"
});
},
(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 handleAddLeg = async () => {
const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`); const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
if (note && currentTour) { if (note && currentTour) {
@@ -203,12 +238,25 @@ export const ItineraryTimeline = ({
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]); const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
useEffect(() => { useEffect(() => {
console.log("ItineraryTimeline: Legs updated", legs); const storedLegId = sessionStorage.getItem('defaultExpandedLegId');
// Initialize first leg as expanded when legs change if (storedLegId) {
if (legs.length > 0 && !expandedStageId) { setExpandedStageId(storedLegId);
sessionStorage.removeItem('defaultExpandedLegId');
setTimeout(() => {
const targetElement = document.getElementById(`leg-anchor-node-${storedLegId}`);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
window.history.replaceState({}, document.title);
}, 150);
} else if (legs.length > 0 && !expandedStageId) {
setExpandedStageId(legs[0].id); setExpandedStageId(legs[0].id);
} }
}, [legs]); }, [legs, expandedStageId]);
return ( return (
<div id="itinerary-timeline-print-zone" className="timeline-scroll-container itinerary-timeline-container"> <div id="itinerary-timeline-print-zone" className="timeline-scroll-container itinerary-timeline-container">
@@ -230,7 +278,7 @@ export const ItineraryTimeline = ({
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null; const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
return ( return (
<section key={leg.id} className={`folder-node-wrapper ${expandedStageId === leg.id ? 'expanded' : 'collapsed'}`}> <section key={leg.id} id={`leg-anchor-node-${leg.id}`} className={`folder-node-wrapper scroll-mt-[70px] ${expandedStageId === leg.id ? 'expanded' : 'collapsed'}`}>
{/* Folder header row - clickable to toggle exclusive expansion */} {/* Folder header row - clickable to toggle exclusive expansion */}
<div <div
onClick={() => toggleStageExpanded(leg.id)} onClick={() => toggleStageExpanded(leg.id)}
@@ -490,6 +538,16 @@ export const ItineraryTimeline = ({
<MessageSquare className="w-3 h-3" /> <MessageSquare className="w-3 h-3" />
{location._count?.comments > 0 && `(${location._count.comments})`} {location._count?.comments > 0 && `(${location._count.comments})`}
</button> </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>
<div className="flex items-center text-sm font-black text-blue-600"> <div className="flex items-center text-sm font-black text-blue-600">
<Clock className="w-3 h-3 mr-1" /> <Clock className="w-3 h-3 mr-1" />
@@ -699,7 +757,7 @@ export const ItineraryTimeline = ({
isPublicView={isPublicView} isPublicView={isPublicView}
onCommentAdded={() => handleCommentIncrement(commentLocationId)} onCommentAdded={() => handleCommentIncrement(commentLocationId)}
onCommentDeleted={() => handleCommentDecrement(commentLocationId)} onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
/> />
</div> </div>
); );
}; };
@@ -0,0 +1,183 @@
import React, { useState, useEffect, useRef, 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 [isSearchingRoute, setIsSearchingRoute] = useState(false);
const [error, setError] = useState<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(() => {
if (!isOpen || !originLat || !originLng || !destLat || !destLng) {
setRouteGeometry(null);
setError(null);
setRouteInfo(null);
return;
}
if (fetchLockRef.current) return;
const calculateOptimalRoute = async () => {
try {
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);
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 {
setIsSearchingRoute(false);
fetchLockRef.current = false;
}
};
calculateOptimalRoute();
return () => {
fetchLockRef.current = false;
setIsSearchingRoute(false);
};
}, [isOpen, originLat, originLng, destLat, destLng]);
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="relative flex-1 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>
{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>
);
};
+43 -7
View File
@@ -109,6 +109,42 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
const [shareStatus, setShareStatus] = useState<any | null>(null); const [shareStatus, setShareStatus] = useState<any | null>(null);
const [loadingShare, setLoadingShare] = useState(false); const [loadingShare, setLoadingShare] = useState(false);
const handleNavigateToItinerary = (tour: any) => {
let targetLegId = null;
if (tour?.legs && tour.legs.length > 0) {
const today = new Date();
today.setHours(0, 0, 0, 0);
for (const leg of tour.legs) {
const rawStart = leg.startDate || leg.plannedStart || leg.date;
const rawEnd = leg.endDate || leg.plannedEnd || leg.date;
if (rawStart) {
const startBound = new Date(rawStart);
startBound.setHours(0, 0, 0, 0);
const endBound = rawEnd ? new Date(rawEnd) : new Date(rawStart);
endBound.setHours(23, 59, 59, 999);
if (today >= startBound && today <= endBound) {
targetLegId = leg.id;
break;
}
}
}
if (!targetLegId) {
targetLegId = tour.legs[0].id;
}
}
if (targetLegId) {
sessionStorage.setItem('defaultExpandedLegId', targetLegId);
}
onViewTour(tour.id, 'dashboard');
};
const handleOpenShareModal = async (tour: any) => { const handleOpenShareModal = async (tour: any) => {
setSharingTour(tour); setSharingTour(tour);
setShareStatus(null); setShareStatus(null);
@@ -1459,13 +1495,13 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
<span>Chụp nh</span> <span>Chụp nh</span>
</button> </button>
<button <button
onClick={() => onViewTour(tour.id, 'dashboard')} onClick={() => handleNavigateToItinerary(tour)}
className="flex-1 py-2 px-2 bg-slate-900 hover:bg-slate-800 text-slate-850 hover:text-white rounded-xl text-xs font-bold transition-all border border-slate-800 hover:border-slate-700 flex items-center justify-center gap-1" className="flex-1 py-2 px-2 bg-slate-900 hover:bg-slate-800 text-slate-850 hover:text-white rounded-xl text-xs font-bold transition-all border border-slate-800 hover:border-slate-700 flex items-center justify-center gap-1"
> >
<span>Chi tiết hành trình</span> <span>Chi tiết hành trình</span>
<ChevronRight className="w-3.5 h-3.5" /> <ChevronRight className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
{/* Emergency Share Button - moved to bottom */} {/* Emergency Share Button - moved to bottom */}
+183 -52
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { jsPDF } from 'jspdf'; import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable'; import autoTable from 'jspdf-autotable';
import { robotoBase64 } from '../utils/pdfFont'; import { robotoBase64, robotoBoldBase64 } from '../utils/pdfFont';
import { ItineraryTimeline } from '../components/ItineraryTimeline'; import { ItineraryTimeline } from '../components/ItineraryTimeline';
import { ExpenseManager } from '../components/ExpenseManager'; import { ExpenseManager } from '../components/ExpenseManager';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
@@ -348,12 +348,14 @@ export const TourDetailPage = ({
onBack, onBack,
tourId, tourId,
isPublicView = false, isPublicView = false,
onOpenNotes onOpenNotes,
onOpenNavigationPage
}: { }: {
onBack: () => void, onBack: () => void,
tourId: string, tourId: string,
isPublicView?: boolean, isPublicView?: boolean,
onOpenNotes?: () => void onOpenNotes?: () => void,
onOpenNavigationPage?: (routeData: { origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string }) => void
}) => { }) => {
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization // Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
const { t } = useTranslation(); const { t } = useTranslation();
@@ -486,12 +488,12 @@ export const TourDetailPage = ({
const handleExportPDF = async () => { const handleExportPDF = async () => {
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }); const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
// 1. Thêm font vào Virtual File System của jsPDF để hỗ trợ tiếng Việt
doc.addFileToVFS("Roboto-Regular.ttf", robotoBase64); doc.addFileToVFS("Roboto-Regular.ttf", robotoBase64);
doc.addFont("Roboto-Regular.ttf", "Roboto", "normal"); doc.addFont("Roboto-Regular.ttf", "Roboto", "normal");
doc.addFileToVFS("Roboto-Bold.ttf", robotoBoldBase64);
doc.addFont("Roboto-Bold.ttf", "Roboto", "bold");
doc.setFont("Roboto"); doc.setFont("Roboto");
// 2. Vẽ Tiêu đề & Thông tin Tour ở đầu trang
const titleText = `LỊCH TRÌNH TOUR: ${currentTour?.title?.toUpperCase() || 'HÀNH TRÌNH TOUR'}`; const titleText = `LỊCH TRÌNH TOUR: ${currentTour?.title?.toUpperCase() || 'HÀNH TRÌNH TOUR'}`;
doc.setFontSize(16); doc.setFontSize(16);
doc.text(titleText, 148.5, 15, { align: 'center' }); doc.text(titleText, 148.5, 15, { align: 'center' });
@@ -508,48 +510,99 @@ export const TourDetailPage = ({
doc.text(dateRangeStr, 148.5, startY, { align: 'center' }); doc.text(dateRangeStr, 148.5, startY, { align: 'center' });
startY += 8; startY += 8;
// 3. Chuẩn bị dữ liệu bảng
const tableColumn = ["STT", "Ngày giờ", "Chặng", "Địa điểm", "Ghi chú"]; const tableColumn = ["STT", "Ngày giờ", "Chặng", "Địa điểm", "Ghi chú"];
const tableRows: any[] = []; const tableRows: any[] = [];
const legRowRanges: Record<string, { start: number; count: number }> = {};
const formatDateTime = (dateStr: string) => { const pdfMapLinks: { [key: number]: string } = {};
if (!dateStr) return '';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return '';
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${hours}:${minutes} ${day}/${month}/${year}`;
};
let stt = 1; let stt = 1;
let globalRowIndex = 0;
const extractAndFormatTimeInline = (primaryTime: any, fallbackLegObj: any) => {
const absoluteTimeSource = primaryTime ||
fallbackLegObj?.plannedStart ||
fallbackLegObj?.startDate ||
fallbackLegObj?.start_date ||
fallbackLegObj?.date ||
fallbackLegObj?.createdAt;
if (!absoluteTimeSource) return '';
const d = new Date(absoluteTimeSource);
if (isNaN(d.getTime())) return '';
const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const ddmmyyyy = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
return `${hhmm}|${ddmmyyyy}`;
};
if (currentTour?.legs && currentTour.legs.length > 0) { if (currentTour?.legs && currentTour.legs.length > 0) {
currentTour.legs.forEach((leg: any, legIdx: number) => { currentTour.legs.forEach((leg: any, legIdx: number) => {
const legName = leg.note || `Chặng ${legIdx + 1}`; const legName = leg.note || `Chặng ${legIdx + 1}`;
if (leg.locations && leg.locations.length > 0) { const legNameCellObj = {
leg.locations.forEach((loc: any) => { content: legName,
const currentStt = stt++; styles: { fontStyle: 'bold' as const }
};
const arrivalStr = loc.arrivalTime ? `Đến: ${formatDateTime(loc.arrivalTime)}` : ''; const legLocations = (leg.locations || []).filter((loc: any) =>
const departureStr = loc.departureTime ? `Đi: ${formatDateTime(loc.departureTime)}` : ''; loc && (loc.plannedStart || loc.plannedEnd || loc.name)
const timeText = [arrivalStr, departureStr].filter(Boolean).join('\n'); );
const locName = loc.name; const startRow = globalRowIndex;
const addressStr = loc.address ? `\n📍 Địa chỉ: ${loc.address}` : '';
const locationText = `${locName}${addressStr}`;
const noteText = loc.notes || ''; if (legLocations.length > 0) {
legRowRanges[leg.id] = { start: startRow, count: legLocations.length };
legLocations.forEach((loc: any, locIdx: number) => {
const isStartPoint = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
const initialTimeSource = isStartPoint ? loc.plannedEnd : loc.plannedStart;
const timeStr = extractAndFormatTimeInline(initialTimeSource, leg);
const locName = loc.name || '';
const addressStr = loc.address || '';
const coordStr = loc.latitude && loc.longitude ? `\n${loc.latitude}, ${loc.longitude}` : '';
const locationText = [locName, addressStr, coordStr].filter(Boolean).join('\n');
const noteText = loc.note || '';
let mapUrl = '';
if (loc.latitude && loc.longitude) {
mapUrl = `https://www.google.com/maps/search/?api=1&query=${loc.latitude},${loc.longitude}`;
} else if (addressStr || locName) {
mapUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(addressStr || locName)}`;
}
const currentRowPosition = tableRows.length;
if (mapUrl) {
pdfMapLinks[currentRowPosition] = mapUrl;
}
const locationCellObj = {
content: locationText,
styles: mapUrl ? { textColor: [40, 83, 107], fontStyle: 'bold' as const } : {}
};
tableRows.push([ tableRows.push([
currentStt, stt++,
timeText, timeStr,
legName, locIdx === 0 ? legNameCellObj : '',
locationText, locationCellObj,
noteText noteText
]); ]);
globalRowIndex++;
}); });
} else {
const legTimeStr = extractAndFormatTimeInline(null, leg);
legRowRanges[leg.id] = { start: startRow, count: 1 };
tableRows.push([
stt++,
legTimeStr,
legNameCellObj,
'Chưa có địa điểm trong chặng này',
''
]);
globalRowIndex++;
} }
}); });
} }
@@ -558,22 +611,108 @@ export const TourDetailPage = ({
tableRows.push(["-", "-", "-", "Chưa có chặng hoặc địa điểm nào trong hành trình.", "-"]); tableRows.push(["-", "-", "-", "Chưa có chặng hoặc địa điểm nào trong hành trình.", "-"]);
} }
// 4. Vẽ bảng dùng autoTable
autoTable(doc, { autoTable(doc, {
head: [tableColumn], head: [tableColumn],
headStyles: {
fillColor: [37, 99, 235],
textColor: [255, 255, 255],
font: 'Roboto',
fontStyle: 'bold',
halign: 'center',
valign: 'middle'
},
body: tableRows, body: tableRows,
startY: startY, startY: startY,
theme: 'grid', theme: 'grid',
headStyles: { fillColor: [37, 99, 235], textColor: [255, 255, 255], fontStyle: 'normal' }, styles: {
styles: { font: "Roboto", fontSize: 9, cellPadding: 3, overflow: 'linebreak' }, font: "Roboto",
columnStyles: { fontSize: 9,
0: { cellWidth: 12, halign: 'center' }, // STT cellPadding: 3,
1: { cellWidth: 50 }, // Ngày giờ overflow: 'linebreak',
2: { cellWidth: 45 }, // Chặng valign: 'top'
3: { cellWidth: 90 }, // Địa điểm
4: { cellWidth: 70 } // Ghi chú
}, },
margin: { left: 15, right: 15 } columnStyles: {
0: { cellWidth: 12, halign: 'center', valign: 'middle' },
1: { cellWidth: 50, halign: 'center', valign: 'middle' },
2: { cellWidth: 45, halign: 'center', valign: 'middle' },
3: { cellWidth: 90, valign: 'top' },
4: { cellWidth: 70, valign: 'top' }
},
margin: { left: 15, right: 15 },
didParseCell: (cell: any) => {
if (cell.section === 'body') {
if (cell.column.index === 2) {
let assigned = false;
for (const leg of currentTour?.legs || []) {
const range = legRowRanges[leg.id];
if (range && cell.row.index >= range.start && cell.row.index < range.start + range.count) {
if (cell.row.index === range.start) {
cell.rowSpan = range.count;
} else {
cell.rowSpan = 0;
}
assigned = true;
break;
}
}
if (!assigned) {
cell.rowSpan = 1;
}
}
}
},
willDrawCell: (data: any) => {
if (data.section === 'body' && data.column.index === 1) {
if (data.cell.text && data.cell.text.length > 0) {
data.cell.customInlineBuffer = data.cell.text.join('');
}
data.cell.text = [];
}
},
didDrawCell: (data: any) => {
if (data.section === 'body' && data.column.index === 1) {
const rawTextStr = data.cell.customInlineBuffer || data.cell.raw;
if (rawTextStr && typeof rawTextStr === 'string' && rawTextStr.includes('|')) {
const [timePart, datePart] = rawTextStr.split('|');
const separatorSpace = " ";
data.doc.setFont(data.cell.styles.font, 'bold');
const timeWidth = data.doc.getTextWidth(timePart);
data.doc.setFont(data.cell.styles.font, 'normal');
const spaceWidth = data.doc.getTextWidth(separatorSpace);
const dateWidth = data.doc.getTextWidth(datePart);
const totalBlockWidth = timeWidth + spaceWidth + dateWidth;
const targetX = data.cell.x + (data.cell.width - totalBlockWidth) / 2;
const targetY = data.cell.y + (data.cell.height / 2) + (data.cell.styles.fontSize / 2) - 1;
data.doc.setFont(data.cell.styles.font, 'bold');
data.doc.setTextColor(239, 68, 68);
data.doc.text(timePart, targetX, targetY);
data.doc.setFont(data.cell.styles.font, 'normal');
data.doc.setTextColor(37, 99, 235);
data.doc.text(datePart, targetX + timeWidth + spaceWidth, targetY);
}
}
if (data.section === 'body' && data.column.index === 3) {
const activeRowIndex = data.row.index;
const targetMapUrl = pdfMapLinks[activeRowIndex];
if (targetMapUrl) {
data.doc.link(
data.cell.x,
data.cell.y,
data.cell.width,
data.cell.height,
{ url: targetMapUrl }
);
}
}
}
}); });
try { try {
@@ -2095,19 +2234,17 @@ export const TourDetailPage = ({
setEditingLocation(loc); setEditingLocation(loc);
setTargetLegId(loc.legId); setTargetLegId(loc.legId);
setMapCenter([loc.latitude, loc.longitude]); setMapCenter([loc.latitude, loc.longitude]);
// Kiểm tra xem địa điểm đang sửa có phải là điểm mốc đặc biệt không (dựa trên timestamp 1970)
const isStart = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0; const isStart = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
const isEnd = loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0; const isEnd = loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0;
setIsStartPointAction(!!isStart); setIsStartPointAction(!!isStart);
setIsEndPointAction(!!isEnd); setIsEndPointAction(!!isEnd);
setIsAddLocationOpen(true); setIsAddLocationOpen(true);
}} }}
onQuickNote={(data) => handleQuickNote(data)} onQuickNote={(data) => handleQuickNote(data)}
onSuccess={() => fetchTour(tourId)} onSuccess={() => fetchTour(tourId)}
isPublicView={isPublicView} isPublicView={isPublicView}
onNavigate={handleNavigateToLocation} onNavigate={handleNavigateToLocation}
onOpenNavigationPage={onOpenNavigationPage}
/> />
) : ( ) : (
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative animate-in fade-in duration-500"> <div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative animate-in fade-in duration-500">
@@ -2448,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>
+268
View File
@@ -0,0 +1,268 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { ChevronLeft, Compass } from 'lucide-react';
interface NavigationRouteData {
origin: { lat: number; lng: number };
destination: { lat: number; lng: number; name: string };
tourTitle?: string;
}
interface TourNavigationPageProps {
tourId: string;
routeData: NavigationRouteData | null;
onBack: () => void;
}
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;
};
const MapRotationHandler = ({ rotation }: { rotation: number }) => {
const map = useMap();
const cumulativeRotationRef = useRef(0);
const prevRotationRef = useRef(0);
useEffect(() => {
const container = map.getContainer();
container.style.transformOrigin = 'center center';
container.style.willChange = 'transform';
if (rotation === 0) {
cumulativeRotationRef.current = 0;
prevRotationRef.current = 0;
container.style.transform = 'rotate(0deg) scale(1)';
return;
}
let delta = rotation - prevRotationRef.current;
if (delta > 180) delta -= 360;
else if (delta < -180) delta += 360;
cumulativeRotationRef.current += delta;
prevRotationRef.current = rotation;
container.style.transform = `rotate(${cumulativeRotationRef.current}deg) scale(2.2) translateZ(0)`;
container.style.transition = 'transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1)';
}, [rotation, map]);
return null;
};
const CompassInteractionDetector = ({
onUserInteraction
}: {
onUserInteraction: () => void;
}) => {
useMapEvents({
movestart: onUserInteraction,
zoomstart: onUserInteraction,
dragstart: onUserInteraction,
});
return null;
};
const MapRefSetter = ({ mapRef }: { mapRef: React.MutableRefObject<L.Map | null> }) => {
const map = useMap();
useEffect(() => {
mapRef.current = map;
}, [map, mapRef]);
return null;
};
export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId, routeData, onBack }) => {
const [isCompassActive, setIsCompassActive] = useState(false);
const [currentHeading, setCurrentHeading] = useState(0);
const [routeGeometry, setRouteGeometry] = useState<[number, number][] | null>(null);
const [error, setError] = useState<string | null>(null);
const mapRef = useRef<L.Map | null>(null);
const watchIdRef = useRef<number | 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(() => {
if (!routeData || !originLat || !originLng || !destLat || !destLng) {
setRouteGeometry(null);
setError(null);
return;
}
if (fetchLockRef.current) return;
const calculateOptimalRoute = async () => {
try {
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);
if (!res.ok) throw new Error(`OSRM error: ${res.status}`);
const data: { code: string; routes: Array<{ geometry: { coordinates: number[][] } }> } = 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 coords = data.routes[0].geometry.coordinates.map((c: number[]) => [c[1], c[0]] as [number, number]);
setRouteGeometry(coords);
} catch (err: any) {
setError(err.message);
} finally {
fetchLockRef.current = false;
}
};
calculateOptimalRoute();
return () => {
fetchLockRef.current = false;
};
}, [routeData, originLat, originLng, destLat, destLng]);
const handleUserInteraction = useCallback(() => {
if (isCompassActive) {
setIsCompassActive(false);
}
}, [isCompassActive]);
useEffect(() => {
if (!isCompassActive) {
setCurrentHeading(0);
return;
}
if (navigator.geolocation) {
watchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
if (mapRef.current) {
mapRef.current.setView([position.coords.latitude, position.coords.longitude], undefined, { animate: true });
}
if (position.coords.heading !== null) {
setCurrentHeading(position.coords.heading);
}
},
(err) => console.error("Compass tracking acquisition error:", err),
{ enableHighAccuracy: true }
);
}
const handleOrientation = (event: any) => {
let heading: number | null = null;
if (event.webkitCompassHeading !== undefined && event.webkitCompassHeading !== null) {
heading = event.webkitCompassHeading;
} else if (event.alpha !== null && event.alpha !== undefined) {
if (event.absolute === true || event.type === 'deviceorientationabsolute') {
heading = (360 - event.alpha) % 360;
}
}
if (heading !== null) {
setCurrentHeading(heading);
}
};
window.addEventListener('deviceorientation', handleOrientation, true);
window.addEventListener('deviceorientationabsolute', handleOrientation, true);
return () => {
if (watchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current);
}
window.removeEventListener('deviceorientation', handleOrientation, true);
window.removeEventListener('deviceorientationabsolute', handleOrientation, true);
};
}, [isCompassActive]);
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 (!routeData) 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 bg-slate-950 flex flex-col overflow-hidden select-none text-white antialiased">
<div className="w-full bg-[#1e293b]/95 backdrop-blur-md border-b border-slate-800 px-4 py-3.5 flex items-center gap-3 z-50">
<button
onClick={onBack}
className="text-slate-300 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors"
title="Quay lại danh sách lộ trình"
>
<ChevronLeft className="w-6 h-6" />
</button>
<h2 className="text-sm font-bold truncate tracking-wide text-slate-100">
{routeData.tourTitle || "Bản đồ chỉ đường"}
</h2>
</div>
<div className="flex-1 relative h-[calc(100vh-56px)]">
<MapContainer
center={center}
zoom={14}
className="absolute inset-0 h-full w-full"
zoomControl={true}
attributionControl={false}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
<MapRefSetter mapRef={mapRef} />
{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} />
)}
<MapRotationHandler rotation={currentHeading} />
<CompassInteractionDetector onUserInteraction={handleUserInteraction} />
</MapContainer>
<button
onClick={() => setIsCompassActive(!isCompassActive)}
className={`absolute bottom-8 right-6 z-[999] w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
isCompassActive
? 'bg-blue-600 border-blue-400 text-white animate-pulse'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-blue-400'
}`}
>
<Compass className="w-7 h-7" />
</button>
{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>
);
};
File diff suppressed because one or more lines are too long