27 Commits

Author SHA1 Message Date
3dtours 03ee337e28 xóa các tập tin ảnh up public 2026-06-25 20:42:31 +07:00
3dtours 48ffd7e49b Thêm README.md cho dự án 2026-06-25 20:07:52 +07:00
3dtours 8df94bca26 fix: sửa lỗi hiển thị trên màn hình android 2026-06-25 17:11:19 +07:00
3dtours d2aced396f fix: bản đồ xoay mượt trên Iphone 2026-06-25 16:48:26 +07:00
3dtours 4e93722ba5 fix: bản đồ không zoom hay thu nhỏ được, không hiển thị nút vị trí của tôi trên bản đồ 2026-06-25 15:57:25 +07:00
3dtours 33a5996bee fix: Ngăn bản đồ tự động reset về trung tâm khi người dùng đã tương tác
- TourDetailPage: MapTourBounds chỉ fitBounds 1 lần cho mỗi lần dữ liệu lộ trình thay đổi thực sự (theo locKey). Nếu người dùng đã pan/zoom rồi thì không refit khi data reload.
- TourNavigationPage: FitBounds chỉ chạy khi người dùng CHƯA tương tác (hasInteractedRef). Sau khi người dùng pan/zoom/drag, bản đồ giữ nguyên vị trí.
- Cả hai bản đồ đều tôn trọng vị trí người dùng đã chọn, không reset về mặc định khi có thay đổi dữ liệu phụ.
2026-06-25 12:28:37 +07:00
3dtours 90475d4130 fix: Ngăn bản đồ ExploreMap tự động nhảy về vị trí người dùng khi thay đổi zoom trên mobile
- Tách userPos (dùng cho marker) và mapCenter (dùng cho center bản đồ) thành 2 state riêng biệt
- MapContainer dùng center={mapCenter} cố định thay vì center={userPos} bị GPS thay đổi liên tục
- Xóa component RecenterMap tự động re-center khi userPos thay đổi
- Thêm nút 'Vị trí của tôi' để người dùng chủ động nhấn mới nhảy bản đồ đến vị trí của mình
- requestGpsPosition cập nhật cả mapCenter lẫn store để map thực sự nhảy khi người dùng chủ động yêu cầu
- Các thao tác khác như click gợi ý tìm kiếm, click ảnh trên bản đồ cũng cập nhật mapCenter để map nhảy đúng vị trí
- Áp dụng cho ExploreMap.tsx - bản đồ chính khi người dùng mở app
2026-06-25 11:58:32 +07:00
3dtours 8e984e609b feat: Thêm nút định vị người dùng trên bản đồ điều hướng mobile
- Thêm nút 'Vị trí của tôi' ở góc dưới bên trái bản đồ
- Khi bật: map tự động center theo vị trí GPS của người dùng (dùng watchPosition)
- Cho phép thu phóng tự do ngay cả khi đang định vị
- Khi người dùng kéo thả bản đồ thủ công: tự động tắt chế độ định vị
- Nút hoạt động độc lập với nút la bàn (compass)
- Sửa import leaflet.css bị lỗi path
2026-06-25 11:34:09 +07:00
3dtours b48502c34a fix: Bản đồ không reset khi người dùng thay đổi zoom trên mobile
- Thêm MapInteractionWatcher lưu zoom/center khi người dùng tương tác
- MapSizeHandler dùng ResizeObserver thay vì setTimeout: chỉ gọi invalidateSize khi container thực sự thay đổi (>2px)
- Sau invalidateSize, tự động restore zoom mà người dùng đã chọn
- canFitRef đảm bảo fitBounds chỉ chạy 1 lần duy nhất khi route vừa load
- Áp dụng cho cả chế độ la bàn bật (xoay theo hướng) và tắt (hướng Bắc)
2026-06-25 11:22:29 +07:00
3dtours 978992057a fix: Luôn giữ kích thước bản đồ người dùng chọn khi tìm đường ngắn nhất
- Thay userInteractedRef bằng canFitRef - chỉ fitBounds đúng 1 lần khi route vừa load
- Sau lần fit đầu, canFitRef = false mãi mãi, không bao giờ reset khi route đổi
- FitBounds chỉ chạy khi canFitRef còn true VÀ compass đang tắt
- MapSizeHandler chỉ gọi invalidateSize() mà không setView để không kích hoạt zoomend/moveend
- Loại bỏ useEffect chết đăng ký zoomend/moveend ở page level (đã có trong MapRefSetter)
- Áp dụng cho cả 2 chế độ: compass bật (xoay theo hướng) và tắt (hướng Bắc)
2026-06-25 11:15:09 +07:00
3dtours ed0fb8dd64 fix: Giữ nguyên zoom/center của người dùng khi bản đồ resize trên mobile
- Thêm userInteractedRef để theo dõi người dùng đã zoom/drag chưa
- FitBounds chỉ chạy khi người dùng chưa tương tác
- MapSizeHandler lưu center/zoom trước khi invalidateSize, khôi phục sau nếu user đã tương tác
- MapRotationHandler xóa transform thay vì set rotate(0) khi compass tắt
- Ngăn map tự động reset về mức zoom ban đầu sau khi người dùng pinch-zoom
2026-06-25 11:08:13 +07:00
3dtours 828bc89890 fix: Sửa lỗi bản đồ mobile chỉ hiển thị kích thước mặc định và không zoom
- Xóa state viewportHeight phụ thuộc documentElement.clientHeight (không ổn định trên mobile)
- Xóa style dvh/-webkit-fill-available gây lỗi trên iOS Safari
- Đổi layout sang flex-1 thuần túy, không tính chiều cao bằng JS
- Xóa CSS transform khi rotation = 0 để tránh lỗi render trên mobile
- Tăng delay invalidateSize lên 100ms để Leaflet đo đúng kích thước container sau layout
2026-06-25 11:03:45 +07:00
3dtours b8bf4f88cc fix: Sửa lỗi bản đồ không full màn hình và không zoom được trên iOS
- Dùng viewportHeight từ JS thay vì CSS dvh để tránh vấn đề iOS Safari
- Thêm MapSizeHandler gọi invalidateSize() để Leaflet tính lại kích thước
- Bật scrollWheelZoom, touchZoom, doubleClickZoom, dragging, inertia
- Đặt maxZoom=20, minZoom=2 rõ ràng
- Loại bỏ max-width/overflow-x trong index.css để không ảnh hưởng trang khác
2026-06-25 10:33:01 +07:00
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
3dtours 4b551ccc31 fix: thêm múi giờ vào docker-compose.prod.yml để sửa lỗi hiển thị sai giờ 2026-06-24 17:45:02 +07:00
3dtours 9dc1faee6f fix: lưu ghi chú ở địa điểm vào ghi chú 2026-06-24 17:05:21 +07:00
3dtours b998833371 fix: thay đổi màu theme tổi của timeline hành trình 2026-06-24 10:40:59 +07:00
3dtours 13cd68707e fix: sửa lỗi hiển thị trong itineraryTimeline và MemberDashboard 2026-06-24 09:56:05 +07:00
33 changed files with 2452 additions and 933 deletions
+2
View File
@@ -1,2 +1,4 @@
.env
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.
-142
View File
@@ -1,142 +0,0 @@
# To AI Agent: Refactor Itinerary Timeline into an Exclusive File Tree Directory System (Single Expansion Mode)
## 1. Context & Architectural Analogy
We are refactoring the `ItineraryTimeline` component on the mobile view (`yotrip.labz.io.vn`) using a **File Tree Directory System** analogy:
- **Parent Folders ("Thư mục mẹ"):** Represented by the Stages ("Chặng 1", "Chặng 2"...).
- **Child Nodes ("Thư mục con"):** Represented by the Locations/Destinations inside that stage (`Trần Cao Vân`, `Đồng Khởi`...).
### CRITICAL LOGIC CONSTRAINT (Exclusive Accordion):
- When a Parent Folder (Stage) is **collapsed**, all of its Child Nodes (Locations) must immediately hide cleanly inside it.
- **Single Expansion Rule:** The system must enforce an **exclusive single-expansion mode**. On the entire timeline screen, **MAXIMUM ONE** Parent Folder can be expanded at any given time.
- Opening/Expanding a new Stage folder must **automatically collapse** whichever Stage folder was previously open.
---
## 2. State Management Specification (For Script/Logic Implementation)
To enforce the "Maximum 1 Expanded Folder" rule, do NOT use isolated individual boolean flags for each stage. Instead, implement a centralized single-active-state variable:
```javascript
// Example React State Hook Blueprint:
// Track the ID or index of the single active expanded stage folder.
// If null, all stages are collapsed.
const [expandedStageId, setExpandedStageId] = useState(initialStageId);
const handleStageToggle = (stageId) => {
// If clicking the already open folder, close it. Otherwise, open the new one and shut the rest.
setExpandedStageId(prevId => prevId === stageId ? null : stageId);
};
## 3. Component DOM Tree & CSS Blueprint
### A. Component Layout Structure
<div class="file-tree-itinerary-container">
<div class="fixed-top-header-block">
<header class="main-tour-header">...</header>
<nav class="sub-navigation-tabs">...</nav>
</div>
<main class="directory-scroll-viewport">
<section class="folder-node-wrapper collapsed">
<div class="folder-header-row" onclick="handleStageToggle('stage_1')">
<span class="folder-badge-index">1</span>
<h3 class="folder-title">Chặng khởi đầu</h3>
</div>
<div class="folder-child-content-box">
</div>
</section>
<section class="folder-node-wrapper expanded">
<div class="folder-header-row" onclick="handleStageToggle('stage_2')">
<span class="folder-badge-index">2</span>
<h3 class="folder-title">Chặng 2</h3>
</div>
<div class="folder-child-content-box">
<div class="child-nodes-list">
<div class="location-child-card">Trần Cao Vân</div>
<div class="location-child-card">Đồng Khởi</div>
</div>
</div>
</section>
</main>
</div>
### B. Core CSS Layout Styles
.file-tree-itinerary-container {
display: flex !important;
flex-direction: column !important;
height: 100dvh !important;
width: 100vw !important;
overflow: hidden !important;
}
.directory-scroll-viewport {
flex: 1 1 0% !important;
overflow-y: auto !important;
padding: 0 !important;
margin: 0 !important;
background-color: #f8fafc;
}
/* Base style for folder blocks stacked flush against each other */
.folder-node-wrapper {
width: 100% !important;
background-color: #ffffff;
margin-bottom: 1px !important; /* Micro hair-line divider between folders */
display: flex;
flex-direction: column;
}
.folder-header-row {
width: 100%;
padding: 12px 16px !important;
display: flex;
align-items: center;
background-color: #ffffff;
cursor: pointer;
/* Sticky behavior remains active when scrolling within an open directory */
position: sticky !important;
top: 0px;
z-index: 30;
}
/* CSS Grid Transition Engine for smooth directory expansions */
.folder-child-content-box {
display: grid !important;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
width: 100%;
}
.child-nodes-list {
overflow: hidden;
min-height: 0px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* --- MUTUALLY EXCLUSIVE STATE STYLES --- */
/* Collapsed Folder: Sub-files shrink immediately to 0 height */
.folder-node-wrapper.collapsed .folder-child-content-box {
grid-template-rows: 0fr !important;
}
/* Expanded Folder: Opens dynamically to accommodate dynamic content height */
.folder-node-wrapper.expanded .folder-child-content-box {
grid-template-rows: 1fr !important;
}
## 4. Acceptance Criteria for AI Agent Verification
[ ] Mutually Exclusive Test: Clicking an inactive Stage header while another stage is open must trigger a simultaneous transition: the old stage collapses back into a tight header row, and the clicked stage expands its location cards.
[ ] Zero-Gap Validation: All collapsed folder components must sit completely flush against the top tab block and one another, eliminating all gray margin bleeding.
[ ] Under-Clip Containment: When scrolling a long open folder, the list items must slide behind their sticky active parent folder row and disappear without visual overlapping artifacts.
+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.
+133
View File
@@ -0,0 +1,133 @@
# Travel Planning - Multi-User Travel Itinerary Management System
A comprehensive travel planning application that enables collaborative itinerary planning, expense tracking, and photo sharing for travel groups.
## Features
- **Tour Management**: Create and manage travel tours with multiple legs (stages) and locations
- **Multi-user Collaboration**: Invite friends to join tours with role-based access control
- **Interactive Maps**: Visual tour planning with Leaflet.js integration
- **Expense Tracking**: Automatic cost splitting with configurable adult/child discounts
- **Photo Sharing**: Secure photo album with privacy controls (PUBLIC, TOUR_ONLY, PRIVATE)
- **Real-time Navigation**: Live GPS tracking and route optimization using OSRM API
## Tech Stack
| Layer | Technology | Purpose |
|-------|-----------|---------|
| **Frontend** | React + Vite | Single Page Application with fast HMR |
| | TailwindCSS | Utility-first CSS framework |
| | Zustand | Lightweight state management |
| | Leaflet.js | Interactive map rendering |
| **Backend** | NestJS | Scalable Node.js framework |
| | JWT | Authentication & authorization |
| | Prisma ORM | Type-safe database access |
| | PostgreSQL + PostGIS | Spatial database for geographic data |
## Project Structure
```
travelplanning/
├── backend/ # Backend API server
│ ├── src/
│ │ ├── auth/ # Authentication modules
│ │ ├── main.ts # NestJS entry point
│ │ └── v1/ # API v1 endpoints
│ └── prisma/
│ └── schema.prisma # Database schema
├── frontend/ # React frontend
│ └── src/
│ ├── pages/ # Main pages
│ ├── components/ # Reusable UI components
│ ├── hooks/ # Custom React hooks
│ └── store/ # Zustand stores
├── docs/ # Documentation
│ ├── ARCHITECTURE.md # System architecture
│ └── UITourDesign.md # UI design specifications
└── .env # Environment variables
```
## Database Schema
The application uses PostgreSQL with Prisma ORM. Key models include:
- **User**: Registered users with admin capability
- **Tour**: Travel itineraries with date ranges and participant management
- **Leg**: Stages within a tour (ordered sequence)
- **Location**: Geographic points with timing and status tracking
- **Expense**: Cost tracking linked to legs/locations
- **Photo**: Media storage with privacy controls
- **TourParticipant**: Many-to-many relationship with role-based permissions
### User Roles
| Role | Permissions |
|------|-------------|
| OWNER | Full access to all features |
| MANAGER | Can edit tour content and manage members |
| MEMBER | View tour and participate, access financial data |
| MEMBER_NO_FINANCE | View tour only, no financial access |
| VIEWER_ONLY | Read-only access to itinerary and photos |
## API Endpoints
### Tours
- `GET /api/v1/tours` - Get all public tours
- `POST /api/v1/tours` - Create new tour
- `GET /api/v1/tours/:id` - Get tour details
- `PUT /api/v1/tours/:id` - Update tour
### Authentication
- `POST /api/v1/auth/login` - User login
- `POST /api/v1/auth/register` - User registration
- `POST /api/v1/auth/promote-admin` - Admin role promotion (with secret key)
### Photos
- `GET /api/v1/public-photos` - Get public photos
- `POST /api/v1/tours/:id/photos` - Upload tour photos
## Quick Start
### Prerequisites
- Node.js 18+
- PostgreSQL with PostGIS extension
### Installation
```bash
# Install dependencies
npm install
cd frontend && npm install
cd ../backend && npm install
# Set up database
npx prisma migrate dev
npx prisma generate
# Start development servers
npm run dev # Frontend (Vite)
npm run start:backend # Backend (NestJS)
```
### Environment Variables
Create `.env` in the root directory:
```env
DATABASE_URL="postgresql://user:password@localhost:5432/traveldb"
JWT_SECRET="your-secret-key"
```
## Mobile Optimization
The application is built mobile-first with support for:
- Safe area insets for notch displays (iOS/Android)
- Touch gestures for map interactions
- Responsive layouts for all screen sizes
- Device orientation and compass integration
## Documentation
See the `docs/` directory for detailed documentation:
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - System architecture and data models
- [UITourDesign.md](docs/UITourDesign.md) - UI design specifications
-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.
+134
View File
@@ -0,0 +1,134 @@
# To AI Agent: Audit Location Lock Bug and Refactor GPS Tracking into a Toggle Stateful Button
## 1. Context & Problem Statement
Currently, in our travel planner application map engine (on pages like `TourNavigationPage.tsx`, `LocationNavigationModal.tsx`, or map utilities), the viewport is continuously forced to lock onto the user's live GPS position. This architecture severely damages mobile UX because:
1. It prevents users from manually dragging, panning, or scouting other areas of the map terrain.
2. There is no control interface to temporarily mute or disable live GPS tracking.
**Objective:** - Run a global audit across the entire codebase to locate functions driving this forced-center trap (e.g., custom hooks, `requestGpsPosition`, native geolocation callbacks, or reactive map state updates).
- Refactor the logic so that live tracking is bound strictly to an independent toggle state button. The map must **ONLY** lock/re-center on the user's coordinates when this tracking toggle button is actively switched **ON**.
---
## 2. Phase 1: Codebase Audit Plan (Where to Search)
Scan the entire project repository (specifically `/frontend/src`) using code search patterns to intercept the lock mechanism. Target the following files and keywords:
### Key Target Files to Inspect:
- `frontend/src/pages/TourNavigationPage.tsx`
- `frontend/src/components/LocationNavigationModal.tsx`
- Any custom hooks or contexts handling geography, such as `useGeolocation.ts`, `useMap.ts`, or generic map setup wrappers.
### Regex & Keyword Global Search Queries:
- Search for native background watchers: `navigator.geolocation.watchPosition` or `navigator.geolocation.getCurrentPosition`
- Search for custom map-centering loops: `requestGpsPosition`, `followUser`, `centerToUser`
- Search for viewport mutation commands specific to our active map engine stack:
- **Leaflet:** `.setView(`, `.panTo(`, `center={`
- **Mapbox GL JS:** `.flyTo(`, `.easeTo(`, `.jumpTo(`
---
## 3. Phase 2: Technical Refactoring Blueprint
Once the tracking logic code blocks are isolated from Phase 1, implement the structural state safety rails below:
### Step 1: Initialize the Tracking State Guard
Introduce a state hook controller (`isTrackingLocation`) to manage whether the view should actively mirror device coordinates:
```typescript
// Add inside the map controller/page container component
const [isTrackingLocation, setIsTrackingLocation] = useState(false);
const watchIdRef = useRef<number | null>(null);
### Step 2: Encapsulate the Geolocation Watcher Handler
Wrap your positioning tracking engine loop inside a conditional check governed directly by the state guard. Ensure that if the tracking state is disabled, the background watcher cleanly unmounts:
useEffect(() => {
if (isTrackingLocation) {
if (navigator.geolocation) {
console.log("GPS Location Tracking engaged. Syncing viewport to center...");
watchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
const { latitude, longitude, heading } = position.coords;
if (mapRef.current) {
// ✅ CORRECTION: Viewport ONLY repositions center when tracking button is active
mapRef.current.easeTo({
center: [longitude, latitude],
zoom: 16, // Lock to comfortable navigation zoom level
duration: 600
});
}
},
(error) => console.error("GPS stream tracking lost:", error),
{ enableHighAccuracy: true }
);
}
} else {
// Clean up tracking process instantly when toggled OFF
if (watchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current);
watchIdRef.current = null;
console.log("GPS Location Tracking disabled. Map control released to user.");
}
}
return () => {
if (watchIdRef.current !== null) navigator.geolocation.clearWatch(watchIdRef.current);
};
}, [isTrackingLocation]);
### Step 3: Implement Gesture Detection (UX Safety Rail)
If the user manually drags the screen while tracking is active, the tracking state must automatically toggle OFF so the viewport doesn't fight against the user's finger movements:
useEffect(() => {
if (!mapRef.current) return;
const map = mapRef.current;
const breakTrackingOnGesture = () => {
if (isTrackingLocation) {
console.log("User touch map interaction detected. Disengaging auto-center lock.");
setIsTrackingLocation(false); // Automatically drop tracking flag on map pan/zoom
}
};
map.on('dragstart', breakTrackingOnGesture);
map.on('zoomstart', breakTrackingOnGesture);
map.on('movestart', breakTrackingOnGesture);
return () => {
map.off('dragstart', breakTrackingOnGesture);
map.off('zoomstart', breakTrackingOnGesture);
map.off('movestart', breakTrackingOnGesture);
};
}, [isTrackingLocation]);
### Step 4: Render the UI Toggle Button UI Component
Deploy a new independent floating button on top of the map canvas workspace (placed at bottom-24 right-6, just right above your custom Compass button layout):
{/* FLOATING LIVE GPS TRACKING CENTER LOCK BUTTON */}
<button
onClick={() => setIsTrackingLocation(!isTrackingLocation)}
className={`absolute bottom-24 right-6 z-40 w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
isTrackingLocation
? 'bg-green-600 border-green-400 text-white animate-pulse'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-green-500'
}`}
title={isTrackingLocation ? "Tắt tự động định tâm vị trí" : "Bật tự động định tâm theo vị trí của bạn"}
>
{/* Replace Crosshair icon element with your active layout icon package asset */}
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
## 4. Verification & Quality Acceptance Criteria
[ ] Code Erasure Verification: Confirm that old continuous loops or uncontrolled recursive .setView/.easeTo methods triggered instantly on map load are fully removed or properly contained inside the state block.
[ ] Default State Freedom: Upon opening the map page path, tracking must default to OFF. Users must be able to drag the map anywhere in the world without the screen snapped or yanked back to their physical house position.
[ ] Toggle Activation Centering: Pressing the new GPS tracking button must instantly engage the animation, center the map view directly on top of the user blue dot icon, and follow them smoothly if they move.
[ ] Manual Override Interception: Turn tracking ON. Drag the map manually with a finger gesture. Verify that the tracking button instantly changes style states back to deactivated and tracking shuts down cleanly.
+66 -17
View File
@@ -824,27 +824,13 @@ let TourController = class TourController {
}
}
});
const noteContent = `<h2>${filteredTitle} - Initial Planning</h2>
<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>`;
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>`;
try {
await this.prisma.tourNote.create({
data: {
tourId: tour.id,
userId: req.user.id,
title: `[${filteredTitle}] - Initial Planning`,
title: `Ghi chú: ${filteredTitle}`,
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}`);
}
}
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) {
lat = 10.7769;
lng = 106.7009;
@@ -2293,7 +2292,8 @@ let PhotoController = class PhotoController {
privacy: 'PUBLIC',
metadata: {
lat: lat,
lng: lng
lng: lng,
tags: tags
}
},
});
@@ -4210,6 +4210,45 @@ let TourNoteController = class TourNoteController {
});
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([
(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:returntype", Promise)
], 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([
(0, common_1.Controller)('tours/:tourId/notes'),
(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
+51 -17
View File
@@ -789,29 +789,14 @@ class TourController {
}
});
// Auto-create default note template for new tour
const noteContent = `<h2>${filteredTitle} - Initial Planning</h2>
<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>`;
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>`;
try {
await this.prisma.tourNote.create({
data: {
tourId: tour.id,
userId: req.user.id,
title: `[${filteredTitle}] - Initial Planning`,
title: `Ghi chú: ${filteredTitle}`,
content: noteContent
}
});
@@ -4110,6 +4095,55 @@ class TourNoteController {
});
return { success: true };
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE)
@Post('insert')
async insertSection(
@Param('tourId') tourId: string,
@Body() body: { legId: string; noteSnippet: string },
@Req() req: any
) {
const tour = await this.prisma.tour.findUnique({ where: { id: tourId } });
if (!tour) throw new 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 }
});
}
}
@Controller('admin/notes')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

+1
View File
@@ -45,6 +45,7 @@ services:
SMTP_SECURE: "${SMTP_SECURE}"
SMTP_USER: "${SMTP_USER}"
SMTP_PASS: "${SMTP_PASS}"
TZ: "Asia/Ho_Chi_Minh"
depends_on:
postgres:
condition: service_healthy
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" />
<script src="https://accounts.google.com/gsi/client" async defer></script>
<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>
<body>
<div id="root"></div>
+27 -7
View File
@@ -9,6 +9,7 @@ import { JoinTourPage } from './pages/JoinTourPage';
import { MemberDashboard } from './pages/MemberDashboard';
import { AdminDashboard } from './pages/AdminDashboard';
import { ShareJourneyPage } from './pages/ShareJourneyPage';
import { TourNavigationPage } from './pages/TourNavigationPage';
import { ConfirmProvider } from './hooks/useConfirm';
import { NotificationProvider } from './hooks/useNotification';
@@ -21,12 +22,13 @@ function App() {
const [user, setUser] = useState<any>(null);
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'))
);
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
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(() => {
const params = new URLSearchParams(window.location.search);
@@ -122,26 +124,30 @@ function App() {
};
const handleBackFromTourDetail = () => {
// Check if this was a public view BEFORE clearing the flag
const wasPublicView = isPublicTourView;
setCurrentTourId(null);
setIsPublicTourView(false);
// If user was viewing a public tour, redirect to index/landing page
// Otherwise redirect based on authentication and previous page
if (wasPublicView) {
// Public tour view - always redirect to index/landing
setCurrentPage('landing');
} else if (user) {
// Authenticated user viewing their own tour - go back to previous page
setCurrentPage(previousPage);
} else {
// Not authenticated and not public view - go to 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 pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
@@ -259,6 +265,7 @@ function App() {
onBack={handleBackFromTourDetail}
isPublicView={isPublicTourView}
onOpenNotes={() => setCurrentPage('notes')}
onOpenNavigationPage={handleOpenNavigationPage}
/>
);
}
@@ -267,6 +274,19 @@ function App() {
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') {
return (
<ExploreMap
+105 -47
View File
@@ -48,8 +48,9 @@ export const ItineraryTimeline = ({
}: {
onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void,
onEditLocation?: (location: any) => void,
onQuickNote?: (name: string) => void,
onQuickNote?: (data: { legId: string; location: any; leg: 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,
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 note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
if (note && currentTour) {
@@ -203,12 +238,25 @@ export const ItineraryTimeline = ({
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
useEffect(() => {
console.log("ItineraryTimeline: Legs updated", legs);
// Initialize first leg as expanded when legs change
if (legs.length > 0 && !expandedStageId) {
const storedLegId = sessionStorage.getItem('defaultExpandedLegId');
if (storedLegId) {
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);
}
}, [legs]);
}, [legs, expandedStageId]);
return (
<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;
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 */}
<div
onClick={() => toggleStageExpanded(leg.id)}
@@ -278,20 +326,20 @@ export const ItineraryTimeline = ({
<>
<button
onClick={(e) => { e.stopPropagation(); onAddLocation?.(leg.id, legIdx === 0 && leg.locations.length === 0); }}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
className="p-2 bg-gray-50 text-gray-600 hover:text-blue-600 hover:bg-blue-100 rounded-xl transition-all border border-gray-200"
title="Thêm địa điểm vào chặng này"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleEditLeg(leg); }}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
className="p-2 bg-gray-50 text-gray-600 hover:text-blue-600 hover:bg-blue-100 rounded-xl transition-all border border-gray-200"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteLeg(leg.id); }}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
className="p-2 bg-gray-50 text-red-600 hover:text-white-600 hover:bg-red-100 rounded-xl transition-all border border-gray-200"
>
<Trash2 className="w-4 h-4" />
</button>
@@ -335,11 +383,11 @@ export const ItineraryTimeline = ({
</div>
<button
onClick={() => onAddLocation?.(leg.id, true)}
className="flex-1 bg-blue-50/20 p-4 rounded-xl border border-dashed border-blue-100 hover:border-blue-400 hover:bg-blue-50 transition-all flex items-center justify-between group"
className="flex-1 bg-blue-50/20 dark:bg-slate-800/20 p-4 rounded-xl border border-dashed border-blue-100 dark:border-slate-700 hover:border-blue-400 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-slate-800/40 transition-all flex items-center justify-between group"
>
<div className="text-left">
<span className="inline-block px-2 py-0.5 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm xuất phát</span>
<h3 className="font-bold text-gray-400 text-sm italic">Nhấn đ ghim điểm bắt đu cho Tour...</h3>
<span className="inline-block px-2 py-0.5 bg-blue-50 dark:bg-slate-700 text-blue-600 dark:text-blue-300 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm xuất phát</span>
<h3 className="font-bold text-gray-400 dark:text-slate-400 text-sm italic">Nhấn đ ghim điểm bắt đu cho Tour...</h3>
</div>
<Plus className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
</button>
@@ -350,17 +398,17 @@ export const ItineraryTimeline = ({
{legIdx === legs.length - 1 && !legs.some((l: any) => l.locations.some((loc: any) => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0)) && (
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
<div className="z-10 mt-1.5 mr-4">
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-red-200 flex items-center justify-center text-red-400">
<div className="w-8 h-8 bg-white dark:bg-slate-800 rounded-full border-2 border-dashed border-red-200 dark:border-red-700 flex items-center justify-center text-red-400">
<Flag className="w-4 h-4" />
</div>
</div>
<button
onClick={() => onAddLocation?.(leg.id, false, true)}
className="flex-1 bg-red-50/20 p-4 rounded-xl border border-dashed border-red-100 hover:border-red-400 hover:bg-red-50 transition-all flex items-center justify-between group"
className="flex-1 bg-red-50/20 dark:bg-red-900/20 p-4 rounded-xl border border-dashed border-red-100 dark:border-red-700 hover:border-red-400 dark:hover:border-red-400 hover:bg-red-50 dark:hover:bg-red-900/40 transition-all flex items-center justify-between group"
>
<div className="text-left">
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
<h3 className="font-bold text-gray-400 text-sm italic">Nhấn đ ghim điểm kết thúc cho Tour...</h3>
<span className="inline-block px-2 py-0.5 bg-red-50 dark:bg-red-700 text-red-600 dark:text-red-300 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
<h3 className="font-bold text-gray-400 dark:text-slate-400 text-sm italic">Nhấn đ ghim điểm kết thúc cho Tour...</h3>
</div>
<Plus className="w-5 h-5 text-red-500 group-hover:scale-110 transition-transform" />
</button>
@@ -393,7 +441,7 @@ export const ItineraryTimeline = ({
<div key={location.id}>
<div className="relative flex group mb-6">
{/* Timeline Node */}
<div className="z-10 mt-1.5 mr-4">
<div className="z-10 -ml-3.5 mr-1.5 mt-1.5">
<button
onClick={() => handleStatusClick(location)}
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
@@ -470,12 +518,12 @@ export const ItineraryTimeline = ({
<button
onClick={(e) => {
e.stopPropagation();
onQuickNote(location.name);
onQuickNote({ legId: leg.id, location, leg });
}}
className="flex items-center gap-1 px-2 py-1 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-bold transition-all border border-amber-100"
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="Ghi chú nhanh"
>
<FileText className="w-3 h-3" />
<FileText className="w-4 h-4" />
</button>
)}
<button
@@ -490,6 +538,16 @@ export const ItineraryTimeline = ({
<MessageSquare className="w-3 h-3" />
{location._count?.comments > 0 && `(${location._count.comments})`}
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleTriggerNavigation(location);
}}
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100"
title="Chỉ đường từ vị trí của bạn"
>
<Navigation className="w-4 h-4" />
</button>
</div>
<div className="flex items-center text-sm font-black text-blue-600">
<Clock className="w-3 h-3 mr-1" />
@@ -558,14 +616,14 @@ export const ItineraryTimeline = ({
<div className="flex flex-col gap-3 pb-20 mt-8">
<button
onClick={handleDeclareLegs}
className="w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold"
className="w-full py-4 rounded-2xl bg-white dark:bg-slate-800 text-blue-600 dark:text-blue-300 border-2 border-dashed border-blue-200 dark:border-slate-700 hover:bg-blue-50 dark:hover:bg-slate-700 transition-all flex items-center justify-center gap-2 text-sm font-bold"
>
<List className="w-5 h-5" />
{legs.length > 0 ? "Khai báo lại số lượng chặng" : "Bắt đầu bằng việc khai báo số chặng"}
</button>
<button
onClick={handleAddLeg}
className="w-full py-4 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold"
className="w-full py-4 rounded-2xl bg-blue-600 dark:bg-blue-700 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold"
>
<Plus className="w-5 h-5" /> Thêm chặng lẻ vào cuối
</button>
@@ -575,30 +633,30 @@ export const ItineraryTimeline = ({
{/* Modal Khai báo số chặng (Popover) */}
{isLegCountModalOpen && (
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsLegCountModalOpen(false)} />
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<div className="absolute inset-0 bg-gray-900/60 dark:bg-slate-950/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsLegCountModalOpen(false)} />
<div className="relative w-full max-w-sm bg-white dark:bg-slate-900 rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-gray-900">Số chặng lộ trình</h3>
<button onClick={() => setIsLegCountModalOpen(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
<h3 className="text-xl font-black text-gray-900 dark:text-white">Số chặng lộ trình</h3>
<button onClick={() => setIsLegCountModalOpen(false)} className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400 dark:text-slate-400" />
</button>
</div>
<p className="text-sm text-gray-500 mb-6 leading-relaxed">
<p className="text-sm text-gray-500 dark:text-slate-400 mb-6 leading-relaxed">
Bạn muốn chia chuyến đi này thành bao nhiêu chặng nhỏ? (Tối đa 20 chặng)
</p>
<div className="flex items-center justify-center gap-6 mb-8">
<button
onClick={() => setTempLegCount(Math.max(1, tempLegCount - 1))}
className="w-12 h-12 rounded-2xl border-2 border-gray-100 flex items-center justify-center text-2xl font-bold text-gray-400 hover:border-blue-200 hover:text-blue-600 transition-all"
className="w-12 h-12 rounded-2xl border-2 border-gray-100 dark:border-slate-700 flex items-center justify-center text-2xl font-bold text-gray-400 dark:text-slate-400 hover:border-blue-200 dark:hover:border-blue-500 hover:text-blue-600 dark:hover:text-blue-400 transition-all"
>
-
</button>
<span className="text-4xl font-black text-blue-600 w-12 text-center">{tempLegCount}</span>
<span className="text-4xl font-black text-blue-600 dark:text-blue-400 w-12 text-center">{tempLegCount}</span>
<button
onClick={() => setTempLegCount(Math.min(20, tempLegCount + 1))}
className="w-12 h-12 rounded-2xl border-2 border-gray-100 flex items-center justify-center text-2xl font-bold text-gray-400 hover:border-blue-200 hover:text-blue-600 transition-all"
className="w-12 h-12 rounded-2xl border-2 border-gray-100 dark:border-slate-700 flex items-center justify-center text-2xl font-bold text-gray-400 dark:text-slate-400 hover:border-blue-200 dark:hover:border-blue-500 hover:text-blue-600 dark:hover:text-blue-400 transition-all"
>
+
</button>
@@ -606,7 +664,7 @@ export const ItineraryTimeline = ({
<button
onClick={confirmDeclareLegs}
className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
className="w-full py-4 bg-blue-600 dark:bg-blue-700 hover:bg-blue-700 dark:hover:bg-blue-800 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 dark:shadow-blue-950 transition-all active:scale-95"
>
Xác nhận
</button>
@@ -617,58 +675,58 @@ export const ItineraryTimeline = ({
{/* Modal Chỉnh sửa Chặng (Popover) */}
{isEditModalOpen && (
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsEditModalOpen(false)} />
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<div className="absolute inset-0 bg-gray-900/60 dark:bg-slate-950/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsEditModalOpen(false)} />
<div className="relative w-full max-w-md bg-white dark:bg-slate-900 rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-gray-900">Chỉnh sửa Chặng</h3>
<button onClick={() => setIsEditModalOpen(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
<h3 className="text-xl font-black text-gray-900 dark:text-white">Chỉnh sửa Chặng</h3>
<button onClick={() => setIsEditModalOpen(false)} className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400 dark:text-slate-400" />
</button>
</div>
<div className="space-y-5">
<div>
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Tên chặng</label>
<label className="block text-xs font-black text-gray-400 dark:text-slate-400 uppercase tracking-widest mb-2 ml-1">Tên chặng</label>
<input
type="text"
value={editingLegData.note}
onChange={(e) => setEditingLegData({ ...editingLegData, note: e.target.value })}
placeholder="VD: Ngày 1: Khởi hành"
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
className="w-full px-5 py-4 bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm text-gray-800 dark:text-white"
/>
</div>
<div>
<label className="flex items-center gap-2 text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">
<label className="flex items-center gap-2 text-xs font-black text-gray-400 dark:text-slate-400 uppercase tracking-widest mb-2 ml-1">
<AlignLeft className="w-3 h-3" /> tả chi tiết
</label>
<textarea
value={editingLegData.description}
onChange={(e) => setEditingLegData({ ...editingLegData, description: e.target.value })}
placeholder="Mô tả các hoạt động chính trong chặng này..."
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[120px] resize-none"
className="w-full px-5 py-4 bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[120px] resize-none text-gray-800 dark:text-white"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center gap-2 text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">
<label className="flex items-center gap-2 text-xs font-black text-gray-400 dark:text-slate-400 uppercase tracking-widest mb-2 ml-1">
<CalendarIcon className="w-3 h-3" /> Bắt đu
</label>
<input
type="date"
value={editingLegData.startDate}
onChange={(e) => setEditingLegData({ ...editingLegData, startDate: e.target.value })}
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
className="w-full px-5 py-4 bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm text-gray-800 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Kết thúc</label>
<label className="block text-xs font-black text-gray-400 dark:text-slate-400 uppercase tracking-widest mb-2 ml-1">Kết thúc</label>
<input
type="date"
value={editingLegData.endDate}
onChange={(e) => setEditingLegData({ ...editingLegData, endDate: e.target.value })}
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
className="w-full px-5 py-4 bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm text-gray-800 dark:text-white"
/>
</div>
</div>
@@ -677,13 +735,13 @@ export const ItineraryTimeline = ({
<div className="grid grid-cols-2 gap-3 mt-8">
<button
onClick={() => setIsEditModalOpen(false)}
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
className="py-4 bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-600 dark:text-slate-300 font-bold rounded-2xl transition-all active:scale-95"
>
Hủy
</button>
<button
onClick={saveLegEdit}
className="py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
className="py-4 bg-blue-600 dark:bg-blue-700 hover:bg-blue-700 dark:hover:bg-blue-800 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 dark:shadow-blue-950 transition-all active:scale-95"
>
Lưu thay đi
</button>
@@ -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>
);
};
+27 -15
View File
@@ -17,8 +17,8 @@
--border: #e5e7eb; /* Light Border */
--border-light: #f3f4f6; /* Light Border Alt */
--text-primary: #28536b; /* Yale Blue (High contrast text) */
--text-secondary: #7ea8be; /* Steel Blue */
--text-primary: #0596e4; /* Yale Blue (High contrast text) */
--text-secondary: #74a8c4; /* Steel Blue */
--text-accent: #c2948a; /* Rosy Taupe */
--text-muted: #9ca3af; /* Muted Text */
--text-disabled: #d1d5db; /* Disabled Text */
@@ -44,25 +44,22 @@
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
/* Smooth theme transition */
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
/* ============ DARK THEME ============ */
:root.dark {
/* Dark Theme Variables - Vintage Grape & Bondi Blue */
--background: #513b56; /* Vintage Grape */
--background-alt: #434354; /* Dark Alt */
--surface: #525174; /* Dusty Grape */
--surface-muted: #626186; /* Surface Muted */
--surface-hover: #5f6b7e; /* Surface Hover */
--background: #1e293b; /* Dark slate-800 */
--background-alt: #0f172a; /* Dark slate-900 */
--surface: #1e293b; /* Dark slate-800 */
--surface-muted: #334155; /* Dark slate-700 */
--surface-hover: #334155; /* Dark slate-700 */
--border: #626186; /* Border */
--border-light: #525174; /* Border Light */
--border: #334155; /* Dark slate-700 */
--border-light: #1e293b; /* Dark slate-800 */
--text-primary: #f8fafc; /* Soft White */
--text-secondary: #cbd5e1; /* Secondary Text */
--text-secondary: #cbd5e1; /* Light gray */
--text-accent: #bce784; /* Lime Cream */
--text-muted: #94a3b8; /* Muted Slate */
--text-disabled: #64748b; /* Disabled Text */
@@ -88,9 +85,19 @@
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
}
/* Smooth theme transition */
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
/* FIX LỖI 2: Khai báo ép toàn bộ Input / Textarea trên hệ thống ăn theo màu Theme */
input, textarea, select {
background-color: var(--surface) !important;
color: var(--text-primary) !important;
border-color: var(--border) !important;
}
/* Đảm bảo chữ gợi ý (placeholder) không bị trùng nền */
input::placeholder, textarea::placeholder {
color: var(--text-muted) !important;
opacity: 0.8;
}
html, body, #root, .app-container {
@@ -337,6 +344,11 @@
gap: 12px;
}
/* Remove padding from child nodes list when folder is collapsed */
.folder-child-content-box:not(.expanded) .child-nodes-list {
padding: 0 !important;
}
/* CSS Grid Accordion Engine - Smooth expand/collapse without layout shattering */
.stage-accordion-content {
display: grid !important;
+44 -15
View File
@@ -39,15 +39,6 @@ const PHOTO_TAG_LABELS: { [key: string]: string } = {
'thu-cung': '🐕 Thú cưng'
};
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
function RecenterMap({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(position, map.getZoom());
}, [position, map]);
return null;
}
// Component Helper để đóng menu khi tương tác với bản đồ
function MapEvents({ onMapAction }: { onMapAction: () => void }) {
useMapEvents({
@@ -202,7 +193,11 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
return null;
});
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
// Map dùng center cố định để không bị GPS tự nhảy vị trí người dùng
const defaultCenter = initialViewState?.center || [10.7769, 106.7009];
const [userPos, setUserPos] = useState<[number, number]>(defaultCenter);
const [mapCenter, setLocalMapCenter] = useState<[number, number]>(initialViewState?.center || defaultCenter);
const [mapZoom] = useState(initialViewState?.zoom || 13);
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
@@ -262,7 +257,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
const [blacklist, setBlacklist] = useState<any[]>([]);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false);
const mapCenter = useTourStore(state => state.mapCenter);
const storeMapCenter = useTourStore(state => state.mapCenter);
// Recommendations and GPS States
const [recommendedLocations, setRecommendedLocations] = useState<any[]>([]);
@@ -342,6 +337,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserGpsPos(posArray);
setUserPos(posArray);
setLocalMapCenter(posArray);
setMapCenter(posArray);
},
() => {
@@ -435,7 +431,28 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
fetchTrustedUsers();
fetchBlacklist();
fetchRecommendations();
// Luôn tải danh sách ảnh công khai để hiển thị trên bản đồ cho tất cả mọi người
fetchPublicPhotos();
// Chỉ tải danh sách tour khi người dùng đã đăng nhập và có token
if (user || localStorage.getItem('token')) {
fetchPublicTours();
}
}, []);
// Chỉ lấy vị trí GPS ban đầu để hiển thị marker, KHÔNG tự động nhảy bản đồ đến vị trí đó
// Người dùng phải chủ động nhấn nút định vị mới nhảy bản đồ đến vị trí của mình
useEffect(() => {
if (!initialViewState && navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserPos(posArray);
},
() => console.log("Không thể lấy vị trí người dùng")
);
}
}, [initialViewState]);
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
const [searchQuery, setSearchQuery] = useState(''); // State cho giá trị tìm kiếm
const [suggestions, setSuggestions] = useState<{ type: 'tour' | 'location', id: string, name: string, lat?: number, lon?: number }[]>([]);
@@ -576,6 +593,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
} else if (s.lat && s.lon) {
const pos: [number, number] = [s.lat, s.lon];
setUserPos(pos);
setLocalMapCenter(pos);
setMapCenter(pos);
notify({
title: 'Tìm thấy địa điểm',
@@ -783,6 +801,18 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
{/* Nhóm bên phải: Các thao tác người dùng */}
<div className="flex items-center gap-2 pointer-events-auto">
{/* Nút định vị người dùng */}
<button
onClick={requestGpsPosition}
className="w-11 h-11 bg-[var(--surface)] rounded-full shadow-xl hover:bg-blue-100 text-blue-600 transition-all flex items-center justify-center border border-[var(--border)] shrink-0"
title="Vị trí của tôi"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
</button>
{/* Nút Ảnh của tôi */}
{isLoggedInOrGuest && (
<button
@@ -891,7 +921,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</div>
<MapContainer
center={userPos}
center={mapCenter}
zoom={mapZoom}
className="h-full w-full"
preferCanvas={true}
@@ -906,9 +936,6 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
{/* Đóng menu và dropdown khi tương tác bản đồ */}
<MapEvents onMapAction={() => { setShareMenu(null); setSuggestions([]); setIsFilterDropdownOpen(false); }} />
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
<RecenterMap position={userPos} />
<MarkerClusterGroup key={`cluster-${filteredTours.length}`} chunkedLoading>
{filteredTours.map((tour) => {
let startLoc = null;
@@ -1403,6 +1430,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
<button
onClick={() => {
setUserPos([item.latitude, item.longitude]);
setLocalMapCenter([item.latitude, item.longitude]);
setMapCenter([item.latitude, item.longitude]);
}}
className="text-[9px] font-bold text-blue-600 dark:text-blue-400 text-left hover:underline flex items-center gap-1 mt-0.5 cursor-pointer bg-transparent border-none p-0"
@@ -1481,6 +1509,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
<button
onClick={() => {
setUserPos([item.latitude, item.longitude]);
setLocalMapCenter([item.latitude, item.longitude]);
setMapCenter([item.latitude, item.longitude]);
}}
className="text-[9px] font-bold text-blue-600 dark:text-blue-400 text-left hover:underline flex items-center gap-1 mt-0.5 cursor-pointer bg-transparent border-none p-0"
+45 -9
View File
@@ -109,6 +109,42 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
const [shareStatus, setShareStatus] = useState<any | null>(null);
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) => {
setSharingTour(tour);
setShareStatus(null);
@@ -962,7 +998,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${
activeTab === 'tours'
? 'bg-indigo-900/40 border-indigo-500/40'
: 'bg-slate-900/60 border-slate-800/60'
: 'bg-slate-850/80 border-slate-700/80'
}`}
>
<div className="flex items-center gap-3">
@@ -978,7 +1014,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
</div>
</div>
<div className="flex items-center gap-1.5">
<span className="bg-slate-800 px-2.5 py-0.5 text-xs rounded-full text-slate-350 font-bold border border-slate-700">
<span className="bg-slate-800 px-2.5 py-0.5 text-xs rounded-full text-white/90 font-bold border border-slate-700">
{myTours.length}
</span>
<ChevronRight className="w-4 h-4 text-slate-500" />
@@ -990,7 +1026,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${
activeTab === 'photos'
? 'bg-indigo-900/40 border-indigo-500/40'
: 'bg-slate-900/60 border-slate-800/60'
: 'bg-slate-850/80 border-slate-700/80'
}`}
>
<div className="flex items-center gap-3">
@@ -1432,7 +1468,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
setUnreadTourChats(prev => prev.filter(id => id !== tour.id));
onViewTour(tour.id, 'dashboard');
}}
className="w-full py-2 px-2 bg-indigo-600/20 hover:bg-indigo-600 text-indigo-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-indigo-500/30 hover:border-indigo-500 flex items-center justify-center gap-1 relative whitespace-nowrap"
className="w-full py-2 px-2 bg-indigo-600/20 hover:bg-indigo-600 text-black-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-indigo-500/30 hover:border-indigo-500 flex items-center justify-center gap-1 relative whitespace-nowrap"
>
<MessageSquare className="w-3.5 h-3.5" />
<span>Trò chuyện</span>
@@ -1449,7 +1485,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
<button
onClick={() => handleTakeCameraPhoto(tour)}
disabled={isUploading}
className="flex-1 py-2 px-2 bg-green-600/20 hover:bg-green-600 text-green-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-green-500/30 hover:border-green-500 flex items-center justify-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 py-2 px-2 bg-green-600/20 hover:bg-green-600 text-black-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-green-500/30 hover:border-green-500 flex items-center justify-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -1460,8 +1496,8 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
</button>
<button
onClick={() => onViewTour(tour.id, 'dashboard')}
className="flex-1 py-2 px-2 bg-slate-900 hover:bg-slate-800 text-slate-350 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"
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"
>
<span>Chi tiết hành trình</span>
<ChevronRight className="w-3.5 h-3.5" />
@@ -1472,7 +1508,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
<button
onClick={() => handleEmergencyShare(tour)}
disabled={loadingShare}
className="w-full py-2 px-3 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20 hover:border-rose-500 flex items-center justify-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full py-2 px-3 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white-500 rounded-xl text-xs font-bold transition-all border border-rose-500/20 hover:border-rose-500 flex items-center justify-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loadingShare ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -1621,7 +1657,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
setMobileShowDetail(true);
}
}}
className="p-1.5 bg-indigo-950/50 hover:bg-indigo-600 text-indigo-300 hover:text-white border border-indigo-800/40 hover:border-indigo-500 rounded-lg text-xs font-bold transition-all"
className="p-1.5 bg-indigo-950/550 hover:bg-indigo-600 text-blue-500 hover:text-white border border-indigo-800/40 hover:border-indigo-500 rounded-lg text-xs font-bold transition-all"
title="Trò chuyện"
>
<MessageSquare className="w-3.5 h-3.5" />
+13 -13
View File
@@ -358,7 +358,7 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
<input
type="text"
placeholder="Tìm kiếm nội dung ghi chú..."
className="w-full pl-10 pr-4 py-3 bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-sm"
className="w-full pl-10 pr-4 py-3 bg-[var(--surface)] dark:bg-[var(--background-alt)] rounded-2xl border border-[var(--border)] dark:border-[var(--border)] shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-sm text-[var(--text-primary)] dark:text-[var(--text-primary)] placeholder-[var(--text-muted)]"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
@@ -387,7 +387,7 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
value={noteForm.title}
onChange={(e) => setNoteForm({ ...noteForm, title: e.target.value })}
/>
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm overflow-hidden min-h-[500px] flex flex-col">
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm overflow-hidden min-h-[500px] flex flex-col h-full">
<ReactQuill
ref={quillRef}
theme="snow"
@@ -395,7 +395,7 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
onChange={(content) => setNoteForm({ ...noteForm, content })}
modules={quillModules}
placeholder="Bắt đầu viết cảm nhận của bạn tại đây..."
className="flex-1 flex flex-col [&_.ql-container]:flex-1 [&_.ql-container]:flex [&_.ql-container]:flex-col [&_.ql-editor]:flex-1 [&_.ql-editor]:text-base [&_.ql-toolbar]:flex [&_.ql-toolbar]:flex-wrap sm:[&_.ql-toolbar]:flex-nowrap [&_.ql-toolbar]:border-0 [&_.ql-toolbar]:border-b [&_.ql-toolbar]:border-[var(--border)] [&_.ql-editor_table]:border-collapse [&_.ql-editor_table]:w-full [&_.ql-editor_td]:border [&_.ql-editor_td]:border-[var(--border)] [&_.ql-editor_td]:p-2 [&_.ql-editor_.ql-align-center]:text-center [&_.ql-editor_.ql-align-right]:text-right [&_.ql-editor_.ql-align-justify]:text-justify [&_.ql-picker-options]:!z-[50] [&_.ql-picker-options]:!shadow-xl [&_.ql-picker-options]:!rounded-xl [&_.ql-picker-options]:!border-[var(--border)] [&_.ql-stroke]:!stroke-[var(--text-secondary)] [&_.ql-fill]:!fill-[var(--text-secondary)] [&_button:hover_.ql-stroke]:!stroke-amber-500 [&_button:hover_.ql-fill]:!stroke-amber-500 [&_button.ql-active_.ql-stroke]:!stroke-amber-500"
className="flex-1 flex flex-col [&_.ql-container]:flex-1 [&_.ql-container]:flex [&_.ql-container]:flex-col [&_.ql-container]:h-full [&_.ql-container]:min-h-0 [&_.ql-editor]:flex-1 [&_.ql-editor]:overflow-y-auto [&_.ql-editor]:text-base [&_.ql-toolbar]:flex [&_.ql-toolbar]:flex-wrap sm:[&_.ql-toolbar]:flex-nowrap [&_.ql-toolbar]:border-0 [&_.ql-toolbar]:border-b [&_.ql-toolbar]:border-[var(--border)] [&_.ql-editor_table]:border-collapse [&_.ql-editor_table]:w-full [&_.ql-editor_td]:border [&_.ql-editor_td]:border-[var(--border)] [&_.ql-editor_td]:p-2 [&_.ql-editor_.ql-align-center]:text-center [&_.ql-editor_.ql-align-right]:text-right [&_.ql-editor_.ql-align-justify]:text-justify [&_.ql-picker-options]:!z-[50] [&_.ql-picker-options]:!shadow-xl [&_.ql-picker-options]:!rounded-xl [&_.ql-picker-options]:!border-[var(--border)] [&_.ql-stroke]:!stroke-[var(--text-secondary)] [&_.ql-fill]:!fill-[var(--text-secondary)] [&_button:hover_.ql-stroke]:!stroke-amber-500 [&_button:hover_.ql-fill]:!stroke-amber-500 [&_button.ql-active_.ql-stroke]:!stroke-amber-500"
/>
</div>
<div className="flex gap-3">
@@ -416,26 +416,26 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
) : filteredNotes.length > 0 ? (
<div className="grid grid-cols-1 gap-4">
{filteredNotes.map((note) => (
<div key={note.id} className="bg-[var(--surface)] p-5 rounded-3xl border border-[var(--border)] shadow-sm hover:shadow-md transition-all group">
<div key={note.id} className="bg-[var(--surface)] dark:bg-[var(--surface-muted)] p-5 rounded-3xl border border-[var(--border)] dark:border-[var(--border)] shadow-sm hover:shadow-md transition-all group">
<div className="flex justify-between items-start mb-3">
<div>
<h4 className="font-bold text-[var(--text-primary)]">{note.title}</h4>
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] font-bold uppercase mt-1">
<h4 className="font-bold text-[var(--text-primary)] dark:text-[var(--text-primary)]">{note.title}</h4>
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] dark:text-[var(--text-muted)] font-bold uppercase mt-1">
<Calendar className="w-3 h-3" />
{new Date(note.createdAt).toLocaleDateString('vi-VN')}
{new Date(note.createdAt).toLocaleDateString("vi-VN")}
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => handleEditNote(note)}
className="p-2 text-[var(--text-muted)] hover:text-blue-500 hover:bg-[var(--background)] rounded-xl transition-all opacity-0 group-hover:opacity-100"
className="p-2 text-[var(--text-muted)] dark:text-[var(--text-muted)] hover:text-blue-500 hover:bg-[var(--background)] dark:hover:bg-[var(--background)] rounded-xl transition-all opacity-0 group-hover:opacity-100"
title="Sửa ghi chú"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteNote(note.id)}
className="p-2 text-[var(--text-muted)] hover:text-red-500 hover:bg-[var(--background)] rounded-xl transition-all opacity-0 group-hover:opacity-100"
className="p-2 text-[var(--text-muted)] dark:text-[var(--text-muted)] hover:text-red-500 hover:bg-[var(--background)] dark:hover:bg-[var(--background)] rounded-xl transition-all opacity-0 group-hover:opacity-100"
title="Xóa ghi chú"
>
<Trash2 className="w-4 h-4" />
@@ -443,19 +443,19 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
</div>
</div>
<div
className="text-sm text-[var(--text-secondary)] line-clamp-3 prose prose-sm max-w-none ql-editor !p-0 [&_table]:border-collapse [&_table]:w-full [&_td]:border [&_td]:border-[var(--border)] [&_td]:p-2 [&_.ql-align-center]:text-center [&_.ql-align-right]:text-right [&_.ql-align-justify]:text-justify"
className="text-sm text-[var(--text-secondary)] dark:text-[var(--text-secondary)] prose prose-sm max-w-none overflow-y-auto max-h-[280px] pr-1 [&_h2]:text-base [&_h2]:font-bold [&_h2]:mt-3 [&_h2]:mb-2 [&_h2]:text-[var(--text-primary)] [&_h4]:text-sm [&_h4]:font-bold [&_h4]:mt-2 [&_h4]:mb-1 [&_h4]:text-[var(--text-primary)] [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_li]:mb-1 [&_p]:mb-2 [&_p]:break-words"
dangerouslySetInnerHTML={{ __html: note.content }}
/>
</div>
))}
</div>
) : (
<div className="text-center py-24 bg-[var(--surface)] rounded-[40px] border-2 border-dashed border-[var(--border)] shadow-inner">
<div className="text-center py-24 bg-[var(--surface)] dark:bg-[var(--background-alt)] rounded-[40px] border-2 border-dashed border-[var(--border)] dark:border-[var(--border)] shadow-inner">
<div className="w-20 h-20 bg-amber-50 rounded-3xl flex items-center justify-center mx-auto mb-6 text-amber-500">
<FileText className="w-10 h-10" />
</div>
<h3 className="text-xl font-bold text-gray-900">{searchQuery ? 'Không tìm thấy ghi chú' : 'Chưa có ghi chú nào'}</h3>
<p className="text-sm text-gray-400 max-w-xs mx-auto mt-2">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{searchQuery ? 'Không tìm thấy ghi chú' : 'Chưa có ghi chú nào'}</h3>
<p className="text-sm text-gray-400 dark:text-slate-400 max-w-xs mx-auto mt-2">
{searchQuery ? 'Hãy thử tìm kiếm với từ khóa khác.' : 'Hãy lưu lại những cảm nhận, lịch trình riêng hoặc các lưu ý quan trọng cho hành trình của bạn.'}
</p>
{!searchQuery && (
+375 -123
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react';
import { io } from 'socket.io-client';
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import { robotoBase64 } from '../utils/pdfFont';
import { robotoBase64, robotoBoldBase64 } from '../utils/pdfFont';
import { ItineraryTimeline } from '../components/ItineraryTimeline';
import { ExpenseManager } from '../components/ExpenseManager';
import { useTourStore } from '@/store/useTourStore';
@@ -148,28 +148,40 @@ const combineSegmentRoutes = (segmentRoutes: OSRMRoute[][], selectedIndices: num
// Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình
const MapTourBounds = ({ locations }: { locations: any[] }) => {
const map = useMap();
// Tạo một key dựa trên giá trị tọa độ để tránh chạy lại khi chỉ thay đổi tham chiếu mảng
const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]);
const shouldFitRef = useRef(true);
const prevLocKeyRef = useRef<string | null>(null);
const locKey = useMemo(() => JSON.stringify(locations.map((l: any) => [l.latitude, l.longitude])), [locations]);
useEffect(() => {
if (locations.length > 0) {
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
if (locations.length === 0) return;
if (prevLocKeyRef.current !== null && prevLocKeyRef.current !== locKey) {
shouldFitRef.current = true;
}
prevLocKeyRef.current = locKey;
if (!shouldFitRef.current) return;
const bounds = L.latLngBounds(locations.map((l: any) => [l.latitude, l.longitude]));
if (locations.length === 1) {
// Chỉ thực hiện nếu bản đồ chưa ở đúng vị trí (tránh trigger moveend liên tục)
map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true });
} else {
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
}
}
}, [locKey, map]);
shouldFitRef.current = false;
}, [locKey, map, locations.length]);
return null;
};
// Component Helper để di chuyển tâm bản đồ về vị trí người dùng khi nhấn nút
const RecenterUser = ({ position, trigger }: { position: [number, number] | null, trigger: number }) => {
const map = useMap();
const lastTriggerRef = useRef(0);
useEffect(() => {
if (position && trigger > 0) {
if (position && trigger > lastTriggerRef.current) {
lastTriggerRef.current = trigger;
map.setView(position, 16, { animate: true });
}
}, [trigger, position, map]);
@@ -210,7 +222,7 @@ const MapRotationHandler = ({ rotation }: { rotation: number }) => {
// 1. Dùng translateZ(0) để kích hoạt GPU trên iOS.
// 2. Giảm scale xuống ~1.6 (vừa đủ che góc) để giảm tải cho bộ nhớ đệm đồ họa.
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)';
container.style.transition = 'transform 0.15s ease-out';
}, [rotation, map]);
return null;
};
@@ -348,12 +360,14 @@ export const TourDetailPage = ({
onBack,
tourId,
isPublicView = false,
onOpenNotes
onOpenNotes,
onOpenNavigationPage
}: {
onBack: () => void,
tourId: string,
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
const { t } = useTranslation();
@@ -486,12 +500,12 @@ export const TourDetailPage = ({
const handleExportPDF = async () => {
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.addFont("Roboto-Regular.ttf", "Roboto", "normal");
doc.addFileToVFS("Roboto-Bold.ttf", robotoBoldBase64);
doc.addFont("Roboto-Bold.ttf", "Roboto", "bold");
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'}`;
doc.setFontSize(16);
doc.text(titleText, 148.5, 15, { align: 'center' });
@@ -508,48 +522,99 @@ export const TourDetailPage = ({
doc.text(dateRangeStr, 148.5, startY, { align: 'center' });
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 tableRows: any[] = [];
const formatDateTime = (dateStr: 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}`;
};
const legRowRanges: Record<string, { start: number; count: number }> = {};
const pdfMapLinks: { [key: number]: string } = {};
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) {
currentTour.legs.forEach((leg: any, legIdx: number) => {
const legName = leg.note || `Chặng ${legIdx + 1}`;
if (leg.locations && leg.locations.length > 0) {
leg.locations.forEach((loc: any) => {
const currentStt = stt++;
const legNameCellObj = {
content: legName,
styles: { fontStyle: 'bold' as const }
};
const arrivalStr = loc.arrivalTime ? `Đến: ${formatDateTime(loc.arrivalTime)}` : '';
const departureStr = loc.departureTime ? `Đi: ${formatDateTime(loc.departureTime)}` : '';
const timeText = [arrivalStr, departureStr].filter(Boolean).join('\n');
const legLocations = (leg.locations || []).filter((loc: any) =>
loc && (loc.plannedStart || loc.plannedEnd || loc.name)
);
const locName = loc.name;
const addressStr = loc.address ? `\n📍 Địa chỉ: ${loc.address}` : '';
const locationText = `${locName}${addressStr}`;
const startRow = globalRowIndex;
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([
currentStt,
timeText,
legName,
locationText,
stt++,
timeStr,
locIdx === 0 ? legNameCellObj : '',
locationCellObj,
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 +623,108 @@ export const TourDetailPage = ({
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, {
head: [tableColumn],
headStyles: {
fillColor: [37, 99, 235],
textColor: [255, 255, 255],
font: 'Roboto',
fontStyle: 'bold',
halign: 'center',
valign: 'middle'
},
body: tableRows,
startY: startY,
theme: 'grid',
headStyles: { fillColor: [37, 99, 235], textColor: [255, 255, 255], fontStyle: 'normal' },
styles: { font: "Roboto", fontSize: 9, cellPadding: 3, overflow: 'linebreak' },
columnStyles: {
0: { cellWidth: 12, halign: 'center' }, // STT
1: { cellWidth: 50 }, // Ngày giờ
2: { cellWidth: 45 }, // Chặng
3: { cellWidth: 90 }, // Địa điểm
4: { cellWidth: 70 } // Ghi chú
styles: {
font: "Roboto",
fontSize: 9,
cellPadding: 3,
overflow: 'linebreak',
valign: 'top'
},
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 {
@@ -716,6 +867,8 @@ export const TourDetailPage = ({
// Theo dõi hướng thiết bị (la bàn)
useEffect(() => {
let lastHeading: number | null = null;
const handleOrientation = (event: any) => {
let heading: number | null = null;
@@ -725,18 +878,18 @@ export const TourDetailPage = ({
}
// 2. Đối với Android (Chrome): Cần kiểm tra tính tuyệt đối của dữ liệu
else if (event.alpha !== null && event.alpha !== undefined) {
// Chrome trên Android chỉ cung cấp hướng la bàn chuẩn khi event.absolute là true
// hoặc khi nhận từ sự kiện 'deviceorientationabsolute'
if (event.absolute === true || event.type === 'deviceorientationabsolute') {
// Alpha trên Android tăng theo chiều ngược kim đồng hồ (0=North, 90=West)
// Cần chuyển đổi sang chiều kim đồng hồ để khớp với logic quay bản đồ
heading = (360 - event.alpha) % 360;
}
}
if (heading !== null) {
// Chỉ cập nhật nếu hướng thay đổi lớn hơn 2 độ để giảm tải vẽ lại
if (lastHeading === null || Math.abs(heading - lastHeading) > 2) {
lastHeading = heading;
setDeviceOrientationHeading(heading);
}
}
};
// Đăng ký cả hai loại sự kiện để hỗ trợ tối đa các dòng điện thoại
@@ -969,6 +1122,8 @@ export const TourDetailPage = ({
// State cho Modal ghi chú nhanh
const [quickNoteLocName, setQuickNoteLocName] = useState<string | null>(null);
const [quickNoteLegId, setQuickNoteLegId] = useState<string | null>(null);
const [quickNoteLocation, setQuickNoteLocation] = useState<any>(null);
const [quickNoteInput, setQuickNoteInput] = useState('');
// Đồng bộ hóa các ô nhập dữ liệu cài đặt với currentTour khi tour tải/cập nhật
@@ -1089,6 +1244,31 @@ export const TourDetailPage = ({
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
const deleteTour = useTourStore(state => state.deleteTour);
const toggleCompassMode = async () => {
if (!isHeadingMode) {
if (
typeof DeviceOrientationEvent !== 'undefined' &&
typeof (DeviceOrientationEvent as any).requestPermission === 'function'
) {
try {
const permissionState = await (DeviceOrientationEvent as any).requestPermission();
if (permissionState === 'granted') {
setIsHeadingMode(true);
} else {
alert("Để xoay bản đồ theo hướng di chuyển, vui lòng cấp quyền truy cập cảm biến hướng (La bàn).");
}
} catch (error) {
console.error("Error requesting compass permission:", error);
setIsHeadingMode(true);
}
} else {
setIsHeadingMode(true);
}
} else {
setIsHeadingMode(false);
setMapRotation(0);
}
};
// Khôi phục vị trí và mức zoom từ localStorage
const [initialViewState] = useState(() => {
@@ -1559,62 +1739,76 @@ export const TourDetailPage = ({
}
};
// Hàm ghi chú nhanh cho từng địa điểm, tự động append vào note chung của Tour
const handleQuickNote = (locationName: string) => {
if (isPublicView) return;
setQuickNoteLocName(locationName);
const reverseGeocode = async (lat: number, lng: number): Promise<string> => {
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`, {
headers: { 'Accept': 'application/json' }
});
if (res.ok) {
const data = await res.json();
return data.display_name || '';
}
} catch (e) {
console.warn('Reverse geocode failed:', e);
}
return '';
};
const handleQuickNote = async (data: { legId: string; location: any; leg: any }) => {
if (isPublicView || !currentTour) return;
setQuickNoteLocName(data.location.name);
setQuickNoteLegId(data.legId);
setQuickNoteLocation(data.location);
setQuickNoteInput('');
};
// Hàm xử lý submit ghi chú nhanh từ modal
const submitQuickNote = () => {
if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour) return;
const submitQuickNote = async () => {
if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour || !quickNoteLegId || !quickNoteLocation) return;
const content = quickNoteInput;
const storedUser = localStorage.getItem('user');
const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' };
const userName = user.name || 'Thành viên';
const now = new Date().toLocaleString('vi-VN');
const noteTitle = `Ghi chú của hành trình: ${currentTour.title}`;
const savedNotes = localStorage.getItem('my_journey_notes');
let notes = [];
try {
notes = savedNotes ? JSON.parse(savedNotes) : [];
} catch (e) { notes = []; }
let targetNote = notes.find((n: any) => n.tourId === currentTour.id || n.title === noteTitle);
// Tạo khối nội dung dạng "Textbox" chuyên nghiệp
const newContentLine = `
<div class="quick-note-box" style="border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; background: #fffbeb; border-radius: 8px; border: 1px solid #fef3c7; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: #b45309; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;">🕒 ${now} 👤 ${userName}</span>
</div>
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${quickNoteLocName}:</strong> ${content}</p>
</div>
<p></p>
`;
if (targetNote) {
targetNote.tourId = currentTour.id;
targetNote.title = noteTitle;
targetNote.content += newContentLine;
} else {
const newNote = {
id: Date.now().toString(),
tourId: currentTour.id,
title: noteTitle,
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${currentTour.title}</strong> của bạn tại đây...</p>` + newContentLine,
createdAt: new Date().toISOString()
};
notes.unshift(newNote);
let resolvedAddress = quickNoteLocation.address || '';
const hasCoords = quickNoteLocation.latitude && quickNoteLocation.longitude;
if ((!resolvedAddress || resolvedAddress.trim() === '') && hasCoords) {
const geo = await reverseGeocode(+quickNoteLocation.latitude, +quickNoteLocation.longitude);
resolvedAddress = geo || `${quickNoteLocation.latitude}, ${quickNoteLocation.longitude}`;
}
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
const formattedTime = quickNoteLocation.plannedStart
? new Date(quickNoteLocation.plannedStart).toLocaleString('vi-VN')
: "Thời gian tùy hứng";
const userInputText = content;
const noteSnippet = `<h4>📅 ${formattedTime} | 📍 ${resolvedAddress}</h4><ul><li><strong>Nhật ký:</strong> ${userInputText}</li></ul>`;
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/v1/tours/${currentTour.id}/notes/insert`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
legId: quickNoteLegId,
noteSnippet
})
});
if (res.ok) {
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
} else {
const err = await res.json().catch(() => ({}));
notify({ title: 'Lỗi', message: err.message || 'Lỗi khi lưu ghi chú nhanh.', type: 'error' });
}
} catch (error) {
console.error('Lỗi khi lưu ghi chú nhanh:', error);
notify({ title: 'Lỗi', message: 'Lỗi mạng khi lưu ghi chú nhanh.', type: 'error' });
} finally {
setQuickNoteLocName(null);
setQuickNoteLegId(null);
setQuickNoteLocation(null);
setQuickNoteInput('');
}
};
// Hàm xử lý xóa Tour vĩnh viễn
@@ -1738,6 +1932,8 @@ export const TourDetailPage = ({
)}
</div>
</div>
{!(activeTab === 'plan' && viewMode === 'map') && (
<>
{/* Tour Header Info */}
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
<img
@@ -2001,10 +2197,13 @@ export const TourDetailPage = ({
</div>
)}
</div>
</>
)}
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} px-4 pb-24`}>
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full !px-0 !pb-0' : 'max-w-2xl mx-auto px-4 pb-24'}`}>
{/* Tab Switcher */}
{!(activeTab === 'plan' && viewMode === 'map') && (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-[60px] z-40">
{tabs.map((tab) => (
<button
@@ -2029,12 +2228,14 @@ export const TourDetailPage = ({
</button>
))}
</div>
)}
{/* Tab Panels */}
<div className="transition-opacity duration-300">
{activeTab === 'plan' && (
<div className="animate-in fade-in slide-in-from-bottom-2">
{/* View Mode Toggle */}
{viewMode !== 'map' && (
<div className="flex justify-center items-center mb-3">
<div className="bg-gray-100 dark:bg-slate-800 p-1 rounded-2xl flex gap-1">
<button
@@ -2051,8 +2252,10 @@ export const TourDetailPage = ({
</button>
</div>
</div>
)}
{/* Export Buttons */}
{viewMode !== 'map' && (
<div className="flex justify-center gap-2 mb-6">
<button
onClick={handleExportCSV}
@@ -2067,6 +2270,7 @@ export const TourDetailPage = ({
📥 {t('exportPDF') || 'Xuất PDF'}
</button>
</div>
)}
{viewMode === 'timeline' ? (
<ItineraryTimeline onAddLocation={(legId, isStart, isEnd) => {
@@ -2079,22 +2283,20 @@ export const TourDetailPage = ({
setEditingLocation(loc);
setTargetLegId(loc.legId);
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 isEnd = loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0;
setIsStartPointAction(!!isStart);
setIsEndPointAction(!!isEnd);
setIsAddLocationOpen(true);
}}
onQuickNote={(locName: string) => handleQuickNote(locName)}
onQuickNote={(data) => handleQuickNote(data)}
onSuccess={() => fetchTour(tourId)}
isPublicView={isPublicView}
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-[calc(100vh-53px)] w-full md:rounded-3xl overflow-hidden md:shadow-xl md:border-4 md:border-white relative animate-in fade-in duration-500">
{/* Search Bar Overlay - Thanh tìm kiếm địa điểm trên bản đồ lộ trình */}
{!isPublicView && (
<div className="absolute top-3 right-3 z-[1001] w-48 md:w-64">
@@ -2275,8 +2477,11 @@ export const TourDetailPage = ({
<div className="flex flex-col gap-1">
<button
onClick={() => handleQuickNote(loc.name)}
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-black transition-all border border-amber-100"
onClick={() => {
const leg = legs.find((l: any) => l.locations.some((lo: any) => lo.id === loc.id));
handleQuickNote({ legId: loc.legId || leg?.id || '', location: loc, leg: leg || {} });
}}
className="flex items-center justify-center gap-1.5 py-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 duration-200 border border-gray-100"
>
<FileText className="w-3 h-3" />
GHI CHÚ NHANH
@@ -2317,6 +2522,38 @@ export const TourDetailPage = ({
</MarkerClusterGroup>
</MapContainer>
{/* Floating Compass Button */}
<button
onClick={toggleCompassMode}
className={`absolute bottom-16 right-4 z-[1001] w-10 h-10 rounded-xl border flex items-center justify-center shadow-xl transition-all active:scale-95 ${
isHeadingMode
? 'bg-blue-600 border-blue-400 text-white shadow-md'
: 'bg-white/90 backdrop-blur-md border-white text-gray-500 hover:bg-gray-100'
}`}
title={isHeadingMode ? "Khóa hướng Bắc" : "Xoay theo hướng nhìn"}
>
<Compass className="w-5 h-5 transition-transform duration-300" style={{ transform: `rotate(${mapRotation}deg)` }} />
</button>
{/* Floating Locate User Button */}
<button
onClick={() => setLocateTrigger(prev => prev + 1)}
disabled={!userLocation}
className={`absolute bottom-4 right-4 z-[1001] w-10 h-10 bg-white/90 backdrop-blur-md rounded-xl border border-white shadow-xl text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
title="Vị trí của tôi"
>
<LocateFixed className="w-5 h-5" />
</button>
{/* Floating Back to Timeline Button */}
<button
onClick={() => setViewMode('timeline')}
className="absolute top-3 left-14 z-[1001] h-9 px-3 bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-white text-gray-700 hover:bg-gray-50 hover:text-blue-600 transition-all active:scale-95 flex items-center gap-1.5 text-xs font-bold"
title="Quay lại danh sách chặng"
>
<List className="w-4 h-4" /> Danh sách
</button>
{/* Menu ngữ cảnh khi click chuột phải vào con đường */}
{routeMenu && (
<div
@@ -2429,13 +2666,7 @@ export const TourDetailPage = ({
</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>
@@ -2898,7 +3129,7 @@ export const TourDetailPage = ({
value={adultCountInput}
onChange={(e) => setAdultCountInput(Number(e.target.value))}
min="0"
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
className="w-full px-4 py-2 border border-yotripLight-khaki rounded-xl focus:ring-yotripLight-text focus:border-yotripLight-text bg-white text-yotripLight-text dark:bg-yotripDark-surface dark:text-white dark:border-yotripDark-surface-muted dark:focus:ring-yotripDark-lime dark:focus:border-yotripDark-lime transition-colors duration-200"
/>
</div>
<div>
@@ -2909,7 +3140,7 @@ export const TourDetailPage = ({
value={childCountInput}
onChange={(e) => setChildCountInput(Number(e.target.value))}
min="0"
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
className="w-full px-4 py-2 border border-yotripLight-khaki rounded-xl focus:ring-yotripLight-text focus:border-yotripLight-text bg-white text-yotripLight-text dark:bg-yotripDark-surface dark:text-white dark:border-yotripDark-surface-muted dark:focus:ring-yotripDark-lime dark:focus:border-yotripDark-lime transition-colors duration-200"
/>
</div>
<div>
@@ -2921,7 +3152,7 @@ export const TourDetailPage = ({
onChange={(e) => setChildDiscountInput(Number(e.target.value))}
min="0"
max="100"
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-blue-500 focus:border-blue-500"
className="w-full px-4 py-2 border border-yotripLight-khaki rounded-xl focus:ring-yotripLight-text focus:border-yotripLight-text bg-white text-yotripLight-text dark:bg-yotripDark-surface dark:text-white dark:border-yotripDark-surface-muted dark:focus:ring-yotripDark-lime dark:focus:border-yotripDark-lime transition-colors duration-200"
/>
</div>
</div>
@@ -3022,7 +3253,7 @@ export const TourDetailPage = ({
</div>
{/* Floating Action Button (Mobile) */}
{((activeTab === 'plan' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && (
{((activeTab === 'plan' && viewMode === 'timeline' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
<button
onClick={() => {
@@ -3109,6 +3340,29 @@ export const TourDetailPage = ({
})}
</MapContainer>
{/* Floating Compass Button (Fullscreen) */}
<button
onClick={toggleCompassMode}
className={`absolute bottom-22 right-6 z-[1001] w-12 h-12 rounded-2xl border flex items-center justify-center shadow-xl transition-all active:scale-95 ${
isHeadingMode
? 'bg-blue-600 border-blue-400 text-white shadow-md'
: 'bg-white/90 backdrop-blur-md border-white text-gray-500 hover:bg-gray-100'
}`}
title={isHeadingMode ? "Khóa hướng Bắc" : "Xoay theo hướng nhìn"}
>
<Compass className="w-6 h-6 transition-transform duration-300" style={{ transform: `rotate(${mapRotation}deg)` }} />
</button>
{/* Floating Locate User Button (Fullscreen) */}
<button
onClick={() => setLocateTrigger(prev => prev + 1)}
disabled={!userLocation}
className={`absolute bottom-8 right-6 z-[1001] w-12 h-12 bg-white/90 backdrop-blur-md rounded-2xl border border-white shadow-xl text-blue-600 hover:bg-blue-50 transition-all active:scale-95 flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
title="Vị trí của tôi"
>
<LocateFixed className="w-6 h-6" />
</button>
{/* Overlay điều khiển trên bản đồ toàn màn hình */}
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 z-[1001] flex flex-col gap-2">
<button
@@ -3125,9 +3379,7 @@ export const TourDetailPage = ({
<button onClick={() => { setTravelMode('foot'); setIsMapControlsOpen(false); }} className={`w-9 h-9 flex items-center justify-center rounded-xl ${travelMode === 'foot' ? 'bg-blue-600 text-white' : 'text-gray-500'}`}><Footprints className="w-4 h-4" /></button>
<button
onClick={() => {
const newMode = !isHeadingMode;
setIsHeadingMode(newMode);
if (!newMode) setMapRotation(0);
toggleCompassMode();
setIsMapControlsOpen(false);
}}
className={`w-9 h-9 flex items-center justify-center rounded-xl border-t border-gray-100 transition-all ${isHeadingMode ? 'bg-blue-600 text-white' : 'text-gray-500'}`}
+462
View File
@@ -0,0 +1,462 @@
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, destination, hasInteractedRef }: { coords: [number, number][], destination: { lat: number; lng: number; name: string } | null, hasInteractedRef: React.MutableRefObject<boolean> }) => {
const map = useMap();
useEffect(() => {
if (coords.length > 0 && !hasInteractedRef.current && destination) {
map.fitBounds(coords, { padding: [60, 60], maxZoom: 15 });
}
}, [map, coords, destination, hasInteractedRef]);
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 = '';
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,
hasInteractedRef
}: {
onUserInteraction: () => void;
hasInteractedRef: React.MutableRefObject<boolean>;
}) => {
useMapEvents({
movestart: () => { hasInteractedRef.current = true; },
zoomstart: () => { hasInteractedRef.current = true; },
dragstart: () => { hasInteractedRef.current = true; },
});
return null;
};
const MapRefSetter = ({ mapRef }: { mapRef: React.MutableRefObject<L.Map | null> }) => {
const map = useMap();
useEffect(() => {
mapRef.current = map;
}, [map, mapRef]);
return null;
};
const MapInteractionWatcher = ({ hasInteractedRef }: { hasInteractedRef: React.MutableRefObject<boolean> }) => {
const map = useMap();
useEffect(() => {
const onZoomEnd = () => {
map._userZoomLevel = map.getZoom();
hasInteractedRef.current = true;
};
const onMoveEnd = () => {
map._userCenter = map.getCenter();
hasInteractedRef.current = true;
};
map.on('zoomend', onZoomEnd);
map.on('moveend', onMoveEnd);
return () => {
map.off('zoomend', onZoomEnd);
map.off('moveend', onMoveEnd);
};
}, [map, hasInteractedRef]);
return null;
};
const MapSizeHandler = ({ mapRef }: { mapRef: React.MutableRefObject<L.Map | null> }) => {
const map = useMap();
const prevSizeRef = useRef<{ width: number; height: number } | null>(null);
useEffect(() => {
const container = map.getContainer();
if (!container) return;
prevSizeRef.current = { width: container.clientWidth, height: container.clientHeight };
const observer = new ResizeObserver(() => {
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
const prev = prevSizeRef.current;
if ((prev && (Math.abs(newWidth - prev.width) > 2 || Math.abs(newHeight - prev.height) > 2)) || newWidth === 0 || newHeight === 0) {
prevSizeRef.current = { width: newWidth, height: newHeight };
const userZoom = (map as any)._userZoomLevel as number | undefined;
map.invalidateSize({ animate: false });
if (userZoom !== undefined && Math.abs(map.getZoom() - userZoom) > 0.01) {
map.setZoom(userZoom, { animate: false });
}
}
});
observer.observe(container);
return () => observer.disconnect();
}, [map, mapRef]);
return null;
};
export const TourNavigationPage: React.FC<TourNavigationPageProps> = ({ tourId, routeData, onBack }) => {
const [isCompassActive, setIsCompassActive] = useState(false);
const [isLocatingUser, setIsLocatingUser] = useState(false);
const [isTrackingLocation, setIsTrackingLocation] = 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 trackingWatchIdRef = useRef<number | null>(null);
const compassWatchIdRef = useRef<number | null>(null);
const fetchLockRef = useRef(false);
const hasInteractedRef = useRef(false);
const isProgrammaticMoveRef = 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]);
// Effect 1: Live tracking using watchPosition
useEffect(() => {
if (isTrackingLocation) {
if (navigator.geolocation) {
console.log("GPS Location Tracking engaged. Syncing viewport to center...");
trackingWatchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
const { latitude, longitude } = position.coords;
if (mapRef.current) {
const currentZoom = mapRef.current.getZoom();
isProgrammaticMoveRef.current = true;
mapRef.current.setView([latitude, longitude], Math.max(currentZoom, 16), { animate: true });
setTimeout(() => {
isProgrammaticMoveRef.current = false;
}, 100);
}
},
(error) => console.error("GPS stream tracking lost:", error),
{ enableHighAccuracy: true }
);
}
} else {
if (trackingWatchIdRef.current !== null) {
navigator.geolocation.clearWatch(trackingWatchIdRef.current);
trackingWatchIdRef.current = null;
console.log("GPS Location Tracking disabled. Map control released to user.");
}
}
return () => {
if (trackingWatchIdRef.current !== null) {
navigator.geolocation.clearWatch(trackingWatchIdRef.current);
trackingWatchIdRef.current = null;
}
};
}, [isTrackingLocation]);
// Effect 2: Gesture detection to auto-toggle tracking OFF when user interacts
useEffect(() => {
if (!mapRef.current) return;
const map = mapRef.current;
const breakTrackingOnGesture = () => {
if (isProgrammaticMoveRef.current) {
return;
}
if (isTrackingLocation) {
console.log("User touch map interaction detected. Disengaging auto-center lock.");
setIsTrackingLocation(false);
}
if (isCompassActive) {
console.log("User touch map interaction detected. Disengaging compass lock.");
setIsCompassActive(false);
}
};
map.on('dragstart', breakTrackingOnGesture);
map.on('zoomstart', breakTrackingOnGesture);
map.on('movestart', breakTrackingOnGesture);
return () => {
map.off('dragstart', breakTrackingOnGesture);
map.off('zoomstart', breakTrackingOnGesture);
map.off('movestart', breakTrackingOnGesture);
};
}, [isTrackingLocation, isCompassActive]);
const handleUserInteraction = useCallback(() => {
hasInteractedRef.current = true;
if (isCompassActive) {
setIsCompassActive(false);
}
}, [isCompassActive]);
useEffect(() => {
if (!isCompassActive) {
setCurrentHeading(0);
return;
}
if (navigator.geolocation) {
compassWatchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
if (mapRef.current) {
isProgrammaticMoveRef.current = true;
mapRef.current.setView([position.coords.latitude, position.coords.longitude], undefined, { animate: true });
setTimeout(() => {
isProgrammaticMoveRef.current = false;
}, 100);
}
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 (compassWatchIdRef.current !== null) {
navigator.geolocation.clearWatch(compassWatchIdRef.current);
compassWatchIdRef.current = null;
}
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-10 h-10 bg-red-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-white text-xs font-black">Đ</div>`,
iconSize: [40, 40],
iconAnchor: [20, 20]
}), []);
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];
const centerOnUser = () => {
if (navigator.geolocation) {
setIsLocatingUser(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setIsLocatingUser(false);
if (mapRef.current) {
const currentZoom = mapRef.current.getZoom();
isProgrammaticMoveRef.current = true;
mapRef.current.setView([position.coords.latitude, position.coords.longitude], Math.max(currentZoom, 16), { animate: true });
setTimeout(() => {
isProgrammaticMoveRef.current = false;
}, 100);
hasInteractedRef.current = true;
}
},
(err) => {
setIsLocatingUser(false);
console.error("Cannot get user location:", err);
},
{ enableHighAccuracy: true }
);
}
};
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 shrink-0"
style={{ paddingTop: 'calc(0.75rem + env(safe-area-inset-top, 0px))' }}>
<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 min-h-0" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
<MapContainer
center={center}
zoom={14}
className="absolute inset-0 h-full w-full"
zoomControl={true}
attributionControl={false}
scrollWheelZoom={true}
doubleClickZoom={true}
touchZoom={true}
dragging={true}
inertia={true}
maxZoom={20}
minZoom={2}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
<MapRefSetter mapRef={mapRef} />
<MapInteractionWatcher hasInteractedRef={hasInteractedRef} />
<FitBounds coords={routeGeometry || []} destination={routeData?.destination || null} hasInteractedRef={hasInteractedRef} />
{routeGeometry && (
<Polyline positions={routeGeometry} color="#2563eb" weight={6} opacity={0.9} smoothFactor={1} />
)}
<MapSizeHandler mapRef={mapRef} />
<MapRotationHandler rotation={currentHeading} />
<CompassInteractionDetector onUserInteraction={handleUserInteraction} hasInteractedRef={hasInteractedRef} />
</MapContainer>
{/* Locate User Button */}
<button
onClick={centerOnUser}
className={`absolute bottom-8 left-6 z-[999] w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
isLocatingUser
? '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'
}`}
style={{ bottom: 'calc(2rem + env(safe-area-inset-bottom, 0px))' }}
title="Định vị vị trí hiện tại của bạn"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
</button>
{/* FLOATING LIVE GPS TRACKING CENTER LOCK BUTTON */}
<button
onClick={() => setIsTrackingLocation(!isTrackingLocation)}
className={`absolute bottom-24 right-6 z-[999] w-14 h-14 rounded-full border-2 flex items-center justify-center shadow-2xl transition-all active:scale-95 ${
isTrackingLocation
? 'bg-green-600 border-green-400 text-white animate-pulse'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-green-500'
}`}
style={{ bottom: 'calc(6rem + env(safe-area-inset-bottom, 0px))' }}
title={isTrackingLocation ? "Tắt tự động định tâm vị trí" : "Bật tự động định tâm theo vị trí của bạn"}
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
</button>
<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'
}`}
style={{ bottom: 'calc(2rem + env(safe-area-inset-bottom, 0px))' }}
>
<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