89 Commits

Author SHA1 Message Date
3dtours 6b15e7ff02 fix: lỗi hiển thị trong itineraryTimeline 2026-06-23 11:40:41 +07:00
3dtours a65be48d36 fix: lướt xem ảnh ở màn hình index của người dùng public 2026-06-22 22:16:51 +07:00
3dtours ac91f6e4c8 fix: admin login khi close tự động logout 2026-06-22 20:22:13 +07:00
3dtours 09b1ee882d fix: login với Google Oauth lỗi token invite 2026-06-22 20:08:11 +07:00
3dtours acf867a375 fix: guest hiển thị lời mời này không tồn tại 2026-06-22 16:54:32 +07:00
3dtours dd2635a44d fix: guest reload page to accessed MemberDashboard 2 2026-06-22 15:00:42 +07:00
3dtours 29c19c6372 fix: guest reload page to accessed MemberDashboard 2026-06-22 12:45:29 +07:00
3dtours 01d6d7439f fix: admin empty thùng rác 2026-06-22 12:28:21 +07:00
3dtours 860395cb14 fix: guest and admin logic 2026-06-22 11:41:34 +07:00
3dtours b1a539235b fix: lỗi hiển thị ở frontend 2026-06-21 21:53:14 +07:00
3dtours 403c169ddd feat: tính năng chia sẻ khẩn cấp 2026-06-21 12:52:47 +07:00
3dtours 8d79cd76f6 feat: tíng năng trò chuyện giữa các thành viên và tin nhắn trực tiếp cho bạn bè 2026-06-21 09:46:37 +07:00
3dtours 392a4d4766 fix: khách mời join vào tour gán với tên thêm thủ công 2026-06-21 07:37:20 +07:00
3dtours 55fba75fda fix: khách mời join vào tour thông qua link mời 2026-06-21 07:11:26 +07:00
3dtours 36157bd53b bugs: lỗi khi người dùng link để tham gia tour nhưng không xuất hiện trong danh sách thành viên 2026-06-20 20:59:01 +07:00
3dtours 52706cab7d fix: thêm thành viên ngoài hệ thống vào Tour 2026-06-20 17:56:56 +07:00
3dtours ae97f061e8 fix: sửa lỗi hiển thị logo trên trang pdf 2026-06-20 17:02:26 +07:00
3dtours e34d197dd0 fix: sửa lỗi hiển thị thumbnail khi chia sẻ trên facebook 2026-06-20 14:44:31 +07:00
3dtours fdf41e05fc fix: sửa lỗi hiển thị trên browser của điện thoại 2026-06-20 12:19:30 +07:00
3dtours ae74035ad5 fix: cho phép người đã tải ảnh lên được quyền chỉnh sửa 2026-06-20 08:27:58 +07:00
3dtours ac11bb56db fix: bố cục lại cách hiển thị thông tin ảnh 2026-06-20 08:04:12 +07:00
3dtours 5081546cdf feat: cho phép người dùng chọn tọa độ trên bản đồ khi sửa ảnh 2026-06-20 07:40:50 +07:00
3dtours 9da8a9494d feat: cho phép người dùng chỉnh sửa thông tin ảnh đã upload 2026-06-20 07:37:01 +07:00
3dtours 4373ed684a fix: ảnh phải giữ nguyên hướng chụp 2026-06-20 07:28:28 +07:00
3dtours def0e0d0f9 fix: sửa lỗi các nút bị che dưới thanh địa chỉ của chrome, hỗ trợ iphone upload ảnh heic 2026-06-20 07:05:25 +07:00
3dtours 36e8658dad fix: sửa lỗi iphone ko thể upload 2026-06-19 22:49:51 +07:00
3dtours 6c1b77d7d2 feat: tải ảnh lên bằng tài khoản public và cho phép bình luận 2026-06-19 22:38:16 +07:00
3dtours 4640526e4b fix: thay đổi ảnh nền trang chủ 2026-06-19 12:38:17 +07:00
3dtours c7692ab9b6 fix: thay đổi giao diện trang chủ 2026-06-19 12:00:19 +07:00
3dtours 36fbbb9386 fix: remove leaflet liên kết trên bản đồ 2026-06-19 10:34:03 +07:00
3dtours ef11309399 feat: chuẩn bị backend cho tính năng anonymous user upload ảnh không cần đăng nhập 2026-06-19 08:46:59 +07:00
3dtours 6f2dd0fc01 fix: lỗi đọc nhầm .env ở thư mục backend 2026-06-19 08:13:35 +07:00
3dtours 623f0164c8 feat: cài đặt tính năng gửi mã OTP qua mail xác thực 2026-06-18 23:00:12 +07:00
3dtours 88b47099b1 feat: cài đặt tính năng gửi mã OTP qua mail xác thực 2026-06-18 23:00:03 +07:00
3dtours 659d2a0840 fix: hiển thị market chỉ hướng người dùng di chuyển trên iOS 2026-06-18 21:24:08 +07:00
3dtours 68950dba10 fix: hiển thị market chỉ hướng người dùng di chuyển 2026-06-18 10:51:47 +07:00
3dtours 3e215ae8e8 fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ đã mượt 2026-06-17 22:41:45 +07:00
3dtours e7b8c672e0 fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ đã mượt nhưng đến góc là xoay tròn 2026-06-17 22:36:05 +07:00
3dtours 2462850e2e fix: xoay màn hình theo hướng người nhìn trong màn hình bản đồ vẫn còn xoay tròn 2026-06-17 22:23:01 +07:00
3dtours abb61f65cd feat: xoay màn hình theo hướng người nhìn trong màn hình bản đồ vẫn còn lag 2026-06-17 22:20:57 +07:00
3dtours da32d59161 feat: nhấn nút toggle status full bản đồ, nhấn nút bản đồ thì giao diện cũ 2026-06-17 21:53:46 +07:00
3dtours 40b1c4f2ab feat: nhấn nút toggle status bản đồ sẽ hiển thị toàn màn hình 2026-06-17 21:41:43 +07:00
3dtours 55de887f87 feat: nhấn nút toggle status để chuyển đến bản đồ di chuyển 2026-06-17 21:30:41 +07:00
3dtours ffe3cb6ecd fix: tạm dừng thuật toán tìm đường đi 2026-06-17 21:16:31 +07:00
3dtours 69c67a1636 fix: sửa lại các tuyến đường đề xuất trên bản đồ 2026-06-17 20:49:30 +07:00
3dtours f181009fa5 fix: sửa lại các nút hiển thị trên màn hình gom lại thành 1 nút 2026-06-17 20:08:28 +07:00
3dtours b6551da147 fix: sửa lại cho phép chỉnh sửa thời gian bắt đầu và kết thúc của điểm xuất phát và điểm kết thúc 2026-06-17 19:46:55 +07:00
3dtours 2afb2971da fix: sửa lại hiển thị của các nút trên bản đồ 2026-06-17 19:37:29 +07:00
3dtours 14ffbb8657 fix: xóa bảng hiển thị mẹo trên bản đồ 2026-06-17 19:34:13 +07:00
3dtours fbe5ef873b feat: thêm tính năng hiển thị tooltip mẹo nhấn chuột phải 2026-06-17 19:30:58 +07:00
3dtours d6ee1dde74 feat: thêm tính năng nút la bàn để xoay map theo hướng Bắc hoặc theo hướng người dùng di chuyển 2026-06-17 19:19:29 +07:00
3dtours 288b40ad12 feat: thêm tính năng tìm tôi để tự động di chuyển bản đồ về vị trí của người dùng 2026-06-17 19:14:18 +07:00
3dtours 3a3296c340 feat: thêm tính năng hiển thị vị trí người dùng trên bản đồ 2026-06-17 18:27:52 +07:00
3dtours e664a3797e feat: thêm tính năng đường dành cho xe máy và đi bộ 2026-06-17 18:00:45 +07:00
3dtours 36418673db feat: thêm tính năng vẽ đường di chuyển trên bản đồ 2026-06-17 17:44:51 +07:00
3dtours 98e4a4a340 fix: lỗi không xuất hiện nút sửa cho điểm bắt đầu và điểm kết thúc 2026-06-17 17:39:53 +07:00
3dtours bfbbb66747 fix: lỗi bong bóng Tour vẫn xuất hiện và điểm kết thúc 2026-06-17 17:33:47 +07:00
3dtours 1933b58f84 fix: lỗi bong bóng Tour vẫn xuất hiện và điểm bắt đầu là điểm xuất phát 2026-06-17 17:29:40 +07:00
3dtours 05dc80bb6d fix: lỗi bong bóng Tour vẫn hiển thị sau khi xóa 2026-06-17 17:15:11 +07:00
3dtours 4d63bff214 feat: thêm nút xóa tour trong phần cài đặt của Tour 2026-06-17 15:48:00 +07:00
3dtours c5474f1ff6 fix: sửa tính năng hiển thị quãng đường tại điểm đến kế tiếp 2026-06-17 15:37:18 +07:00
3dtours 7ad785fed9 feat: thêm tính năng ghi note cho Tour 2026-06-17 12:28:24 +07:00
3dtours e66f242c2c fix: click vào nút bình luận không load dược page 2026-06-16 21:44:49 +07:00
3dtours 6026c2227a fix: UI của trang MyPhotoPage lỗi 2026-06-16 21:25:08 +07:00
3dtours 1eed9b00de feat: xem ảnh theo cấu trúc của lightroom 2026-06-16 20:38:40 +07:00
3dtours 1709638bfd optimize: xác nhận redis đã nén dữ liệu 2026-06-16 20:22:53 +07:00
3dtours 37b5d14d7e optimize: sử dụng redis để cache cho các response từ server 2026-06-16 20:17:17 +07:00
3dtours 2fabdb79df fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user 2026-06-16 18:54:55 +07:00
3dtours 00554224a1 fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user 2026-06-16 18:54:24 +07:00
3dtours 88b2182789 feat: thêm chức năng upload ảnh 2026-06-16 17:14:22 +07:00
3dtours 989d60643b feat: thêm nút tìm kiếm địa điểm cho phần tạo tour, phần thêm địa điểm 2026-06-16 15:45:26 +07:00
3dtours 1772ced959 feat: nút lọc filter tags dropdown 2026-06-16 15:35:45 +07:00
3dtours c005009da2 feat: Chuyển khám phá địa điểm thành box tìm kiếm địa điểm trên bản đồ 2026-06-16 15:28:10 +07:00
3dtours bfd18e05dd feat: tạo hook useNotification.tsx để dùng chung toàn hệ thống 2026-06-16 12:41:00 +07:00
3dtours 29d39ae7b0 feat: tạo hook useConfirm.tsx để dùng chung toàn hệ thống 2026-06-16 12:29:09 +07:00
3dtours 047170d4be feat: cho phép người dùng và người quản lí xóa bình luận 2026-06-16 12:21:44 +07:00
3dtours fc96ee9eb8 fix: tìm kiếm địa điểm với cách gõ tên trên bản đồ 2026-06-16 12:15:01 +07:00
3dtours 0a584a13c7 feat: tìm kiếm địa điểm trên bản đồ 2026-06-16 11:37:21 +07:00
3dtours f78c72ad60 fix: hiển thị mô tả và tên của hành trình có thêm tags 2026-06-16 11:21:43 +07:00
3dtours 8cca4a83ca fix: tags không có trong csdl gây nên lỗi crash backend 2026-06-16 11:15:39 +07:00
3dtours bc49e081c6 feat: cho phép lấy thời gian và địa điểm hiện tại để gán cho điểm 2026-06-16 10:55:18 +07:00
3dtours 9e26aabce6 Sửa lỗi hiển thị chi phí ở điểm của chặng 2026-06-16 10:36:38 +07:00
3dtours 3e1a5db1a6 Sửa lỗi hiển thị bảng liệt kê chi phí 2026-06-16 10:28:33 +07:00
3dtours baa83aad8a Sửa lỗi người nhận được line xem được nội dung Tour nhưng không xem được bình luận 2026-06-16 09:56:59 +07:00
3dtours 302a82e887 Sửa lỗi người nhận được liên kết chia sẻ không xem được nội dung Tour 2026-06-16 09:54:32 +07:00
3dtours 63da413b3c Sửa lỗi OWNER, MANAGER không thể chia sẻ link 2026-06-16 09:33:15 +07:00
3dtours 02c493f7e0 Thêm tính năng click chuột phải vào bong bóng để lấy link chia sẻ 2026-06-16 08:46:45 +07:00
3dtours b939fc959d Thêm tính năng hiển thị tooltip trên bong bóng Tour 2026-06-16 08:35:48 +07:00
3dtours 5464d90948 Thêm tính năng bình luận ở mỗi điểm của chặng 2026-06-16 08:27:54 +07:00
130 changed files with 29292 additions and 1480 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules/
server/node_modules/
# Bỏ qua cấu hình hệ thống và Git
.git/
.idea/
.vscode/
# Bỏ qua các file log và build
*.log
dist/
build/
out/
+2 -1
View File
@@ -1,3 +1,4 @@
{ {
"continue.enableConsole": true "continue.enableConsole": true,
"remote.autoForwardPortsFallback": 0
} }
+102
View File
@@ -0,0 +1,102 @@
# To AI Agent: Complete Component Relocation - Move Navigation Tabs to Top Header and Fix Button Text Wrapping
## 1. Context & Structural Defects Analysis
We are fixing a persistent UI layout failure on the mobile view (`yotrip.labz.io.vn`) as highlighted in the annotated screenshot `image_c61225.png`:
- **Component Misplacement (Lower Red Box):** The sub-navigation tabs (`Lộ trình`, `Chi phí`, `Ảnh`, `Trò chuyện`...) are currently detached and floating erratically in the middle of the chat history viewport, overlapping message bubbles.
- **Empty Empty Top Gap (Upper Red Box):** There is an empty dark/blank bar container sitting directly below the Main Tour Title Header ("Hoa vàng cỏ xanh 20...").
- **Text Wrapping Bug:** The labels on the floating navigation items are wrapping onto multiple lines (e.g., `Lộ\ntrình`, `Chi\nphí`), ruining the horizontal tab presentation.
---
## 2. Refactoring Objectives
1. **DOM Relocation:** Completely extract the Sub-Navigation Tabs out of the chat message block. Relocate and mount it directly into the upper red box zone so it sits flush immediately underneath the Main Tour Header.
2. **Text & Width Optimization:** Enforce strict styles on the tab container so that all navigation buttons adjust dynamically to fit within the viewport width. The button text names **MUST NOT** wrap down to a new line (`white-space: nowrap`).
---
## 3. Targeted Fix Instructions
### Step 1: Correct the DOM / React Component Tree Hierarchy
Ensure the navigation component sits inside the sticky top layout zone, completely separated from the scrollable chat list container:
```html
<div class="chat-viewport-wrapper">
<div class="fixed-top-header-block">
<header class="main-tour-header">
</header>
<nav class="sub-navigation-tabs">
<button class="nav-tab-item">Lộ trình</button>
<button class="nav-tab-item">Chi phí</button>
<button class="nav-tab-item">Ảnh</button>
<button class="nav-tab-item active">Trò chuyện</button>
<button class="nav-tab-item">Thành viên</button>
<button class="nav-tab-item">Cài đặt</button>
</nav>
</div>
<main class="chat-messages-scroll-area">
</main>
</div>
### Step 2: Apply CSS/Tailwind Rules for Button Shrinkage & Single-Line Restraints
Apply these structural adjustments to make sure the tabs fill the screen width evenly without line breaks:
/* Container for header components stacked on top */
.fixed-top-header-block {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
position: sticky !important;
top: 0 !important;
z-index: 50 !important;
background-color: #ffffff;
}
/* FIX: Ensure tabs fit inside the viewport layout cleanly */
.sub-navigation-tabs {
display: flex !important;
width: 100% !important;
margin: 0 !important;
padding: 4px 8px !important; /* Tight padding to utilize screen real estate */
border-radius: 0 !important;
box-shadow: none !important;
border-bottom: 1px solid #e5e7eb;
background-color: #ffffff;
/* Distribute items evenly across the width */
justify-content: space-between !important;
align-items: center !important;
}
/* FIX: Absolute enforcement of no word-wrapping on buttons */
.nav-tab-item {
display: flex !important;
flex-direction: column !important; /* Stack icon and text if applicable */
align-items: center !important;
justify-content: center !important;
flex: 1 1 0% !important; /* Allow dynamic even shrinkage */
min-width: 0 !important; /* Overcomes default flex minimums */
/* Text layout fixes */
white-space: nowrap !important; /* PREVENT TEXT FROM WRAPPING TO NEW LINE */
font-size: 11px !important; /* Scaled down to prevent out-of-bounds clipping */
padding: 4px 2px !important;
text-align: center !important;
}
/* Handle icon sizes inside tabs if applicable */
.nav-tab-item svg, .nav-tab-item i {
font-size: 16px !important;
margin-bottom: 2px !important;
}
## 4. Verification Check for AI Agent
[ ] Verify that the navigation row is no longer hovering over the chat text bubble flow.
[ ] Confirm there is zero dead space or black margin layers between the tour header and the navigation buttons.
[ ] Inspect text elements for words like "Lộ trình" and "Chi phí"—they must read completely on a single line horizontally.
+142
View File
@@ -0,0 +1,142 @@
# 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.
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
.env
.git
.gitignore
+38
View File
@@ -0,0 +1,38 @@
FROM node:20-alpine AS base
# Install openssl for Prisma
RUN apk add --no-cache openssl
WORKDIR /usr/src/app
COPY package*.json ./
COPY prisma ./prisma/
# Development stage
FROM base AS development
RUN npm install
COPY . .
RUN npx prisma generate
EXPOSE 3001
CMD ["npm", "run", "start:dev"]
# Build stage for production
FROM base AS build
RUN npm install
COPY . .
RUN npx prisma generate
RUN npm run build
RUN npm prune --production
# Production stage
FROM node:20-alpine AS production
RUN apk add --no-cache openssl
WORKDIR /usr/src/app
COPY package*.json ./
COPY --from=build /usr/src/app/node_modules ./node_modules
COPY --from=build /usr/src/app/dist ./dist
COPY --from=build /usr/src/app/prisma ./prisma
EXPOSE 3001
CMD ["node", "dist/src/main.js"]
+39
View File
@@ -0,0 +1,39 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5';
const ownerUserId = '5b2053bb-f523-4a11-817c-f47fef7322bb'; // owner@travel.com
// Check if owner@travel.com is already a participant
const existing = await prisma.tourParticipant.findFirst({
where: { tourId, userId: ownerUserId }
});
if (existing) {
await prisma.tourParticipant.update({
where: { id: existing.id },
data: { role: 'OWNER' }
});
console.log('Updated existing participant to OWNER');
} else {
// Demote current owner to MEMBER or just keep them
const result = await prisma.tourParticipant.create({
data: {
tourId,
userId: ownerUserId,
role: 'OWNER'
}
});
console.log('Created new OWNER participant:', result);
}
// Update tour createdById to ownerUserId
await prisma.tour.update({
where: { id: tourId },
data: { createdById: ownerUserId }
});
console.log('Updated tour creator to owner@travel.com');
}
main().catch(console.error).finally(() => prisma.$disconnect());
+4
View File
@@ -1,4 +1,8 @@
declare const JwtAuthGuard_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>; declare const JwtAuthGuard_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
export declare class JwtAuthGuard extends JwtAuthGuard_base { export declare class JwtAuthGuard extends JwtAuthGuard_base {
} }
declare const JwtAuthGuardNoAnonymous_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
export declare class JwtAuthGuardNoAnonymous extends JwtAuthGuardNoAnonymous_base {
handleRequest(err: any, user: any, info: any): any;
}
export {}; export {};
+17 -1
View File
@@ -6,13 +6,29 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
return c > 3 && r && Object.defineProperty(target, key, r), r; return c > 3 && r && Object.defineProperty(target, key, r), r;
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.JwtAuthGuard = void 0; exports.JwtAuthGuardNoAnonymous = exports.JwtAuthGuard = void 0;
const common_1 = require("@nestjs/common"); const common_1 = require("@nestjs/common");
const passport_1 = require("@nestjs/passport"); const passport_1 = require("@nestjs/passport");
const common_2 = require("@nestjs/common");
let JwtAuthGuard = class JwtAuthGuard extends (0, passport_1.AuthGuard)('jwt') { let JwtAuthGuard = class JwtAuthGuard extends (0, passport_1.AuthGuard)('jwt') {
}; };
exports.JwtAuthGuard = JwtAuthGuard; exports.JwtAuthGuard = JwtAuthGuard;
exports.JwtAuthGuard = JwtAuthGuard = __decorate([ exports.JwtAuthGuard = JwtAuthGuard = __decorate([
(0, common_1.Injectable)() (0, common_1.Injectable)()
], JwtAuthGuard); ], JwtAuthGuard);
let JwtAuthGuardNoAnonymous = class JwtAuthGuardNoAnonymous extends (0, passport_1.AuthGuard)('jwt') {
handleRequest(err, user, info) {
if (err || !user) {
throw err || new common_2.UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
}
if (user.isAnonymous) {
throw new common_2.UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
}
return user;
}
};
exports.JwtAuthGuardNoAnonymous = JwtAuthGuardNoAnonymous;
exports.JwtAuthGuardNoAnonymous = JwtAuthGuardNoAnonymous = __decorate([
(0, common_1.Injectable)()
], JwtAuthGuardNoAnonymous);
//# sourceMappingURL=jwt-auth.guard.js.map //# sourceMappingURL=jwt-auth.guard.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAGtC,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B"} {"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAC7C,2CAAuD;AAGhD,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B;AAI9C,IAAM,uBAAuB,GAA7B,MAAM,uBAAwB,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;IAC3D,aAAa,CAAC,GAAQ,EAAE,IAAS,EAAE,IAAS;QAC1C,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YACjB,MAAM,GAAG,IAAI,IAAI,8BAAqB,CAAC,6CAA6C,CAAC,CAAC;QACxF,CAAC;QAGD,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;YACrB,MAAM,IAAI,8BAAqB,CAAC,gGAAgG,CAAC,CAAC;QACpI,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAbY,0DAAuB;kCAAvB,uBAAuB;IADnC,IAAA,mBAAU,GAAE;GACA,uBAAuB,CAanC"}
+3 -2
View File
@@ -5,8 +5,8 @@ export declare class JwtStrategy extends JwtStrategy_base {
constructor(prisma: PrismaService); constructor(prisma: PrismaService);
validate(payload: any): Promise<{ validate(payload: any): Promise<{
id: string; id: string;
email: string; email: string | null;
passwordHash: string; passwordHash: string | null;
name: string | null; name: string | null;
phone: string | null; phone: string | null;
address: string | null; address: string | null;
@@ -14,6 +14,7 @@ export declare class JwtStrategy extends JwtStrategy_base {
createdAt: Date; createdAt: Date;
isAdmin: boolean; isAdmin: boolean;
isBlocked: boolean; isBlocked: boolean;
isAnonymous: boolean;
}>; }>;
} }
export {}; export {};
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"jwt.strategy.js","sourceRoot":"","sources":["../../../src/auth/jwt.strategy.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAmE;AACnE,+CAAoD;AACpD,+CAAoD;AACpD,gEAA4D;AAGrD,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,IAAA,2BAAgB,EAAC,uBAAQ,CAAC;IACzD,YAAoB,MAAqB;QACvC,KAAK,CAAC;YACJ,cAAc,EAAE,yBAAU,CAAC,2BAA2B,EAAE;YACxD,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,cAAc;SACtD,CAAC,CAAC;QALe,WAAM,GAAN,MAAM,CAAe;IAMzC,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,OAAY;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,GAAG,EAAE;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,8BAAqB,CAAC,sDAAsD,CAAC,CAAC;QAC1F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AApBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAoBvB"} {"version":3,"file":"jwt.strategy.js","sourceRoot":"","sources":["../../../src/auth/jwt.strategy.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAmE;AACnE,+CAAoD;AACpD,+CAAoD;AACpD,gEAA4D;AAGrD,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,IAAA,2BAAgB,EAAC,uBAAQ,CAAC;IACzD,YAAoB,MAAqB;QACvC,KAAK,CAAC;YACJ,cAAc,EAAE,yBAAU,CAAC,2BAA2B,EAAE;YACxD,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,cAAc;SACtD,CAAC,CAAC;QALe,WAAM,GAAN,MAAM,CAAe;IAMzC,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,OAAY;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,GAAG,EAAE;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,8BAAqB,CAAC,sDAAsD,CAAC,CAAC;QAC1F,CAAC;QAID,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAtBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAsBvB"}
+10
View File
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Cache } from 'cache-manager';
import { HttpAdapterHost } from '@nestjs/core';
export declare class CompressCacheInterceptor implements NestInterceptor {
private cacheManager;
private readonly httpAdapterHost;
constructor(cacheManager: Cache, httpAdapterHost: HttpAdapterHost);
intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>>;
}
+123
View File
@@ -0,0 +1,123 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CompressCacheInterceptor = void 0;
const common_1 = require("@nestjs/common");
const rxjs_1 = require("rxjs");
const operators_1 = require("rxjs/operators");
const cache_manager_1 = require("@nestjs/cache-manager");
const zlib = __importStar(require("zlib"));
const util_1 = require("util");
const core_1 = require("@nestjs/core");
const gzip = (0, util_1.promisify)(zlib.gzip);
const gunzip = (0, util_1.promisify)(zlib.gunzip);
const COMPRESSION_THRESHOLD = 100;
let CompressCacheInterceptor = class CompressCacheInterceptor {
constructor(cacheManager, httpAdapterHost) {
this.cacheManager = cacheManager;
this.httpAdapterHost = httpAdapterHost;
}
async intercept(context, next) {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const request = context.getArgByIndex(0);
const response = context.getArgByIndex(1);
if (httpAdapter.getRequestMethod(request) !== 'GET') {
return next.handle();
}
const cacheKey = httpAdapter.getRequestUrl(request);
let cachedData = await this.cacheManager.get(cacheKey);
if (cachedData) {
try {
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
const decompressed = await gunzip(cachedData);
const jsonString = decompressed.toString('utf8');
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
return (0, rxjs_1.of)(JSON.parse(jsonString));
}
else {
const jsonString = cachedData.toString();
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
return (0, rxjs_1.of)(JSON.parse(jsonString));
}
}
catch (e) {
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
await this.cacheManager.del(cacheKey);
}
}
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
return next.handle().pipe((0, operators_1.tap)(async (data) => {
if (!data)
return;
const jsonString = JSON.stringify(data);
const ttl = 60000;
if (jsonString.length > COMPRESSION_THRESHOLD) {
try {
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
await this.cacheManager.set(cacheKey, compressed, ttl);
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
}
catch (e) {
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
}
else {
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
}));
}
};
exports.CompressCacheInterceptor = CompressCacheInterceptor;
exports.CompressCacheInterceptor = CompressCacheInterceptor = __decorate([
(0, common_1.Injectable)(),
__param(0, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [Object, core_1.HttpAdapterHost])
], CompressCacheInterceptor);
//# sourceMappingURL=compress-cache.interceptor.js.map
@@ -0,0 +1 @@
{"version":3,"file":"compress-cache.interceptor.js","sourceRoot":"","sources":["../../../src/common/compress-cache.interceptor.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAAoG;AACpG,+BAAsC;AACtC,8CAAqC;AACrC,yDAAsD;AAEtD,2CAA6B;AAC7B,+BAAiC;AACjC,uCAA+C;AAG/C,MAAM,IAAI,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAClC,MAAM,MAAM,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAItC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAG3B,IAAM,wBAAwB,GAA9B,MAAM,wBAAwB;IACnC,YACiC,YAAmB,EACjC,eAAgC;QADlB,iBAAY,GAAZ,YAAY,CAAO;QACjC,oBAAe,GAAf,eAAe,CAAiB;IAChD,CAAC;IAEJ,KAAK,CAAC,SAAS,CAAC,OAAyB,EAAE,IAAiB;QAC1D,MAAM,WAAW,GAAG,IAAI,CAAC,eAAe,CAAC,WAAW,CAAC;QACrD,MAAM,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QACzC,MAAM,QAAQ,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QAG1C,IAAI,WAAW,CAAC,gBAAgB,CAAC,OAAO,CAAC,KAAK,KAAK,EAAE,CAAC;YACpD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC;QACvB,CAAC;QAED,MAAM,QAAQ,GAAG,WAAW,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;QACpD,IAAI,UAAU,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAS,QAAQ,CAAC,CAAC;QAE/D,IAAI,UAAU,EAAE,CAAC;YACf,IAAI,CAAC;gBAEH,IAAI,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;oBAC7G,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,CAAC;oBAC9C,MAAM,UAAU,GAAG,YAAY,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;oBAEjD,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,kBAAkB,CAAC,CAAC;oBAC/D,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;qBAAM,CAAC;oBAEN,MAAM,UAAU,GAAG,UAAU,CAAC,QAAQ,EAAE,CAAC;oBACzC,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,oBAAoB,CAAC,CAAC;oBACjE,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;YACH,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,OAAO,CAAC,KAAK,CAAC,8CAA8C,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;gBAE5E,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YACxC,CAAC;QACH,CAAC;QAID,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;QAEnD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CACvB,IAAA,eAAG,EAAC,KAAK,EAAE,IAAI,EAAE,EAAE;YACjB,IAAI,CAAC,IAAI;gBAAE,OAAO;YAElB,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YACxC,MAAM,GAAG,GAAG,KAAK,CAAC;YAElB,IAAI,UAAU,CAAC,MAAM,GAAG,qBAAqB,EAAE,CAAC;gBAC9C,IAAI,CAAC;oBACH,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC;oBAC/D,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;oBACvD,OAAO,CAAC,GAAG,CAAC,kCAAkC,QAAQ,KAAK,UAAU,CAAC,MAAM,OAAO,UAAU,CAAC,MAAM,SAAS,CAAC,CAAC;gBAEjH,CAAC;gBAAC,OAAO,CAAC,EAAE,CAAC;oBACX,OAAO,CAAC,KAAK,CAAC,mCAAmC,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;oBAEjE,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;gBACzD,CAAC;YACH,CAAC;iBAAM,CAAC;gBAEN,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;YACzD,CAAC;QACH,CAAC,CAAC,CACH,CAAC;IACJ,CAAC;CACF,CAAA;AAxEY,4DAAwB;mCAAxB,wBAAwB;IADpC,IAAA,mBAAU,GAAE;IAGR,WAAA,IAAA,eAAM,EAAC,6BAAa,CAAC,CAAA;6CACY,sBAAe;GAHxC,wBAAwB,CAwEpC"}
+34
View File
@@ -1 +1,35 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { OnGatewayConnection } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { PrismaService } from '../prisma/prisma.service';
import { ParticipantRole } from '@prisma/client';
import { Reflector } from '@nestjs/core';
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { Cache } from 'cache-manager';
export declare class CommentGateway implements OnGatewayConnection {
server: Server;
handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void;
handleJoinPhoto(client: Socket, photoId: string): void;
notifyNewComment(tourId: string, data: any): void;
notifyNewPhotoComment(photoId: string, data: any): void;
handleJoinUser(client: Socket, userId: string): void;
notifyNewMessage(receiverId: string, data: any): void;
notifyConnectionAccepted(requesterId: string, data: any): void;
notifyJoinRequestAccepted(userId: string, data: any): void;
}
export declare const ROLES_KEY = "roles";
export declare const Roles: (...roles: ParticipantRole[]) => import("@nestjs/common").CustomDecorator<string>;
export declare class TourRoleGuard implements CanActivate {
private reflector;
private prisma;
private cacheManager;
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
canActivate(context: ExecutionContext): Promise<boolean>;
}
export declare class EmailService {
private transporter;
constructor();
sendOTP(email: string, otp: string): Promise<any>;
sendTourInvitation(email: string, tourTitle: string, inviteLink: string): Promise<any>;
}
+3979 -98
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+80
View File
@@ -0,0 +1,80 @@
> backend@0.0.1 start:dev
> nest start --watch
[10:46:05 PM] Starting compilation in watch mode...
[10:46:08 PM] Found 0 errors. Watching for file changes.
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [NestFactory] Starting Nest application...
--- [PRISMA CHECK] ---
DATABASE_URL nhận được: ĐÃ ĐỌC THÀNH CÔNG ✔️
----------------------
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] ConfigHostModule dependencies initialized +40ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] JwtModule dependencies initialized +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] CacheModule dependencies initialized +15ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [InstanceLoader] AppModule dependencies initialized +2ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [WebSocketsController] CommentGateway subscribed to the "joinTour" message +11ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [WebSocketsController] CommentGateway subscribed to the "joinPhoto" message +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AppController {/api/v1}: +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1, GET} route +2ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AuthController {/api/v1/auth}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/convert-guest, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/create-guest, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/status, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/login, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/signup/request, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/auth/signup/verify, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PublicTourController {/api/v1/tours}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id/public, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] TourController {/api/v1/tours}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/locations, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/start-point, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/end-point, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/legs/batch, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/legs, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, PATCH} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/explore, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:id, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/members, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests/:requestId/accept, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/join-requests/:requestId/reject, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/members/:userId, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/tours/:tourId/photos, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] UserController {/api/v1/users}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/me/photos, GET} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/:id, PATCH} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/users/block/:id, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] RoutingController {/api/v1/routing}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/routing/optimize/:legId, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] LegController {/api/v1/legs}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/legs/:id, PATCH} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/legs/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] LocationController {/api/v1/locations}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:id, PATCH} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:id, DELETE} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] CommentController {/api/v1/locations}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:locationId/comments, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/locations/:locationId/comments, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PhotoController {/api/v1/photos}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/photos/upload-anonymous, POST} route +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/photos/:id, DELETE} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] PublicPhotoController {/api/v1/public-photos}: +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos/:photoId/comments, GET} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/public-photos/:photoId/comments, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RoutesResolver] AdminOtpController {/api/v1/admin/otp}: +1ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/admin/otp/send, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [RouterExplorer] Mapped {/api/v1/admin/otp/verify, POST} route +0ms
[Nest] 2108058 - 06/19/2026, 10:46:09 PM  LOG [NestApplication] Nest application successfully started +16ms
🚀 Server is running on: http://localhost:3001
[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.
[WS] Client connected: sIV1IoQvXb-xaa9iAAAC
[WS] Client sIV1IoQvXb-xaa9iAAAC joined room: photo_557913e6-fcb4-4dae-8cff-a6ff52da3386
+18 -3
View File
@@ -2,34 +2,49 @@
"name": "backend", "name": "backend",
"version": "0.0.1", "version": "0.0.1",
"scripts": { "scripts": {
"build": "nest build",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"db:generate": "dotenv -e ../.env -- prisma generate --schema=prisma/schema.prisma", "db:generate": "dotenv -e ../.env -- prisma generate --schema=prisma/schema.prisma",
"db:migrate": "dotenv -e ../.env -- prisma migrate dev --schema=prisma/schema.prisma", "db:migrate": "dotenv -e ../.env -- prisma migrate dev --schema=prisma/schema.prisma",
"db:seed": "node --loader ts-node/esm seed.ts" "db:seed": "dotenv -e ../.env -- tsx seed.ts"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^11.0.23", "@nestjs/cli": "^11.0.23",
"@types/bcrypt": "^5.0.2",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/pg": "^8.11.6", "@types/pg": "^8.11.6",
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.2",
"prisma": "^5.16.2", "prisma": "^5.16.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.5.3" "typescript": "^5.5.3"
}, },
"dependencies": { "dependencies": {
"@nestjs/cache-manager": "^3.1.3",
"@nestjs/common": "^11.1.27", "@nestjs/common": "^11.1.27",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.27", "@nestjs/core": "^11.1.27",
"@nestjs/jwt": "^11.0.2", "@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.27", "@nestjs/platform-express": "^11.1.27",
"@prisma/client": "^5.16.2", "@nestjs/platform-socket.io": "^11.1.27",
"@nestjs/websockets": "^11.1.27",
"@prisma/adapter-pg": "^5.16.2", "@prisma/adapter-pg": "^5.16.2",
"@prisma/client": "^5.16.2",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"cache-manager-redis-yet": "^5.1.5",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"exifr": "^7.1.3",
"heic-convert": "^2.1.0",
"nodemailer": "^9.0.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.12.0", "pg": "^8.12.0",
"redis": "^6.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2" "rxjs": "^7.8.2",
"sharp": "^0.35.1",
"socket.io": "^4.8.3"
} }
} }
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"locationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "tags" TEXT[];
@@ -0,0 +1,15 @@
-- DropForeignKey
ALTER TABLE "Photo" DROP CONSTRAINT "Photo_tourId_fkey";
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "originalUrl" TEXT,
ALTER COLUMN "tourId" DROP NOT NULL,
ALTER COLUMN "imageUrl" DROP NOT NULL;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAnonymous" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "email" DROP NOT NULL,
ALTER COLUMN "passwordHash" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Comment" ADD COLUMN "photoId" TEXT,
ALTER COLUMN "locationId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_photoId_fkey" FOREIGN KEY ("photoId") REFERENCES "Photo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Location" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "TourParticipant" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,22 @@
/*
Warnings:
- The primary key for the `TourParticipant` table will be changed. If it partially fails, the table could be left without primary key constraint.
- A unique constraint covering the columns `[tourId,userId]` on the table `TourParticipant` will be added. If there are existing duplicate values, this will fail.
- The required column `id` was added to the `TourParticipant` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable
ALTER TABLE "TourParticipant" DROP CONSTRAINT "TourParticipant_pkey",
ADD COLUMN "displayName" TEXT,
ADD COLUMN "id" TEXT;
UPDATE "TourParticipant" SET "id" = md5(random()::text);
ALTER TABLE "TourParticipant" ALTER COLUMN "id" SET NOT NULL,
ALTER COLUMN "userId" DROP NOT NULL;
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_pkey" PRIMARY KEY ("id");
-- CreateIndex
CREATE UNIQUE INDEX "TourParticipant_tourId_userId_key" ON "TourParticipant"("tourId", "userId");
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "TourInvitation" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiredAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TourInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TourInvitation_token_key" ON "TourInvitation"("token");
-- CreateIndex
CREATE UNIQUE INDEX "TourInvitation_tourId_email_key" ON "TourInvitation"("tourId", "email");
-- AddForeignKey
ALTER TABLE "TourInvitation" ADD CONSTRAINT "TourInvitation_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "ConnectionStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ConnectionType" AS ENUM ('FRIEND', 'FAMILY');
-- CreateTable
CREATE TABLE "UserConnection" (
"id" TEXT NOT NULL,
"requesterId" TEXT NOT NULL,
"receiverId" TEXT NOT NULL,
"status" "ConnectionStatus" NOT NULL DEFAULT 'PENDING',
"type" "ConnectionType" NOT NULL DEFAULT 'FRIEND',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DirectMessage" (
"id" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"receiverId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DirectMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "UserConnection_requesterId_idx" ON "UserConnection"("requesterId");
-- CreateIndex
CREATE INDEX "UserConnection_receiverId_idx" ON "UserConnection"("receiverId");
-- CreateIndex
CREATE UNIQUE INDEX "UserConnection_requesterId_receiverId_key" ON "UserConnection"("requesterId", "receiverId");
-- CreateIndex
CREATE INDEX "DirectMessage_senderId_idx" ON "DirectMessage"("senderId");
-- CreateIndex
CREATE INDEX "DirectMessage_receiverId_idx" ON "DirectMessage"("receiverId");
-- AddForeignKey
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,31 @@
-- AlterTable
ALTER TABLE "DirectMessage" ADD COLUMN "attachmentUrl" TEXT,
ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION;
-- CreateTable
CREATE TABLE "TourMessage" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"attachmentUrl" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TourMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TourMessage_tourId_idx" ON "TourMessage"("tourId");
-- CreateIndex
CREATE INDEX "TourMessage_senderId_idx" ON "TourMessage"("senderId");
-- AddForeignKey
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,73 @@
-- CreateTable
CREATE TABLE "WordFilter" (
"id" TEXT NOT NULL,
"word" TEXT NOT NULL,
"replacement" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WordFilter_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModerationSetting" (
"id" TEXT NOT NULL,
"blockNsfw" BOOLEAN NOT NULL DEFAULT false,
"blurFaces" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "ModerationSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TourRating" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"targetUserId" TEXT NOT NULL,
"raterUserId" TEXT NOT NULL,
"honesty" INTEGER NOT NULL DEFAULT 5,
"transparency" INTEGER NOT NULL DEFAULT 5,
"enthusiasm" INTEGER NOT NULL DEFAULT 5,
"cheerfulness" INTEGER NOT NULL DEFAULT 5,
"seriousness" INTEGER NOT NULL DEFAULT 5,
"planning" INTEGER NOT NULL DEFAULT 5,
"survival" INTEGER NOT NULL DEFAULT 5,
"averageScore" DOUBLE PRECISION NOT NULL DEFAULT 5.0,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TourRating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TourShare" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TourShare_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WordFilter_word_key" ON "WordFilter"("word");
-- CreateIndex
CREATE UNIQUE INDEX "TourRating_tourId_targetUserId_raterUserId_key" ON "TourRating"("tourId", "targetUserId", "raterUserId");
-- CreateIndex
CREATE UNIQUE INDEX "TourShare_tourId_key" ON "TourShare"("tourId");
-- CreateIndex
CREATE UNIQUE INDEX "TourShare_token_key" ON "TourShare"("token");
-- AddForeignKey
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_raterUserId_fkey" FOREIGN KEY ("raterUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourShare" ADD CONSTRAINT "TourShare_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "BusinessReport" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"email" TEXT,
"address" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"reason" TEXT NOT NULL,
"isBlacklisted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BusinessReport_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,49 @@
-- AlterTable
ALTER TABLE "ModerationSetting" ADD COLUMN "trashRetentionDays" INTEGER NOT NULL DEFAULT 30;
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "deletedAt" TIMESTAMP(3),
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "deletedAt" TIMESTAMP(3),
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "TourNote" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "TourNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RecommendedLocation" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT,
"email" TEXT,
"address" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"description" TEXT NOT NULL,
"stars" INTEGER NOT NULL DEFAULT 5,
"isApproved" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RecommendedLocation_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "TourNote" ADD CONSTRAINT "TourNote_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourNote" ADD CONSTRAINT "TourNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Photo" ADD COLUMN "flaggedAt" TIMESTAMP(3),
ADD COLUMN "flaggedReason" TEXT,
ADD COLUMN "isFlagged" BOOLEAN NOT NULL DEFAULT false;
+220 -8
View File
@@ -57,8 +57,8 @@ enum PrivacyLevel {
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String? @unique
passwordHash String passwordHash String?
name String? name String?
phone String? phone String?
address String? address String?
@@ -66,6 +66,7 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
isBlocked Boolean @default(false) isBlocked Boolean @default(false)
isAnonymous Boolean @default(false)
createdTours Tour[] @relation("TourCreator") createdTours Tour[] @relation("TourCreator")
tourParticipations TourParticipant[] tourParticipations TourParticipant[]
@@ -73,6 +74,15 @@ model User {
receivedJoinRequests JoinRequest[] @relation("JoinRequester") receivedJoinRequests JoinRequest[] @relation("JoinRequester")
uploadedPhotos Photo[] uploadedPhotos Photo[]
paidExpenses Expense[] @relation("ExpensePaidBy") paidExpenses Expense[] @relation("ExpensePaidBy")
comments Comment[]
sentConnections UserConnection[] @relation("ConnectionRequester")
receivedConnections UserConnection[] @relation("ConnectionReceiver")
sentMessages DirectMessage[] @relation("MessageSender")
receivedMessages DirectMessage[] @relation("MessageReceiver")
tourMessages TourMessage[]
receivedRatings TourRating[] @relation("RatedUser")
sentRatings TourRating[] @relation("RatingUser")
tourNotes TourNote[]
} }
model Tour { model Tour {
@@ -88,6 +98,7 @@ model Tour {
childCount Int @default(0) childCount Int @default(0)
childDiscount Int @default(30) childDiscount Int @default(30)
tags String[]
createdById String createdById String
creator User @relation("TourCreator", fields: [createdById], references: [id]) creator User @relation("TourCreator", fields: [createdById], references: [id])
@@ -95,6 +106,13 @@ model Tour {
joinRequests JoinRequest[] joinRequests JoinRequest[]
legs Leg[] legs Leg[]
photos Photo[] photos Photo[]
invitations TourInvitation[]
tourMessages TourMessage[]
ratings TourRating[]
share TourShare?
notes TourNote[]
isDeleted Boolean @default(false)
deletedAt DateTime?
} }
model JoinRequest { model JoinRequest {
@@ -114,14 +132,18 @@ model JoinRequest {
} }
model TourParticipant { model TourParticipant {
id String @id @default(uuid())
tourId String tourId String
userId String userId String?
role ParticipantRole @default(MEMBER) role ParticipantRole @default(MEMBER)
displayName String?
adultCount Int @default(1)
childCount Int @default(0)
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade) tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([tourId, userId]) @@unique([tourId, userId])
} }
model Leg { model Leg {
@@ -157,6 +179,9 @@ model Location {
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade) leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
expenses Expense[] expenses Expense[]
photos Photo[] photos Photo[]
comments Comment[]
createdAt DateTime @default(now())
} }
model Expense { model Expense {
@@ -176,15 +201,202 @@ model Expense {
model Photo { model Photo {
id String @id @default(uuid()) id String @id @default(uuid())
tourId String tourId String?
locationId String? locationId String?
uploaderId String uploaderId String
imageUrl String imageUrl String?
originalUrl String?
capturedAt DateTime @default(now()) capturedAt DateTime @default(now())
metadata Json? metadata Json?
privacy PrivacyLevel @default(TOUR_ONLY) privacy PrivacyLevel @default(TOUR_ONLY)
isFlagged Boolean @default(false)
flaggedReason String?
flaggedAt DateTime?
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade) tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
uploader User @relation(fields: [uploaderId], references: [id]) uploader User @relation(fields: [uploaderId], references: [id])
comments Comment[]
isDeleted Boolean @default(false)
deletedAt DateTime?
} }
model Comment {
id String @id @default(uuid())
content String
createdAt DateTime @default(now())
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
photoId String?
photo Photo? @relation(fields: [photoId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model TourInvitation {
id String @id @default(uuid())
tourId String
email String
role ParticipantRole @default(MEMBER)
token String @unique
createdAt DateTime @default(now())
expiredAt DateTime
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
@@unique([tourId, email])
}
enum ConnectionStatus {
PENDING
ACCEPTED
REJECTED
}
enum ConnectionType {
FRIEND
FAMILY
}
model UserConnection {
id String @id @default(uuid())
requesterId String
receiverId String
status ConnectionStatus @default(PENDING)
type ConnectionType @default(FRIEND)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
requester User @relation("ConnectionRequester", fields: [requesterId], references: [id], onDelete: Cascade)
receiver User @relation("ConnectionReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
@@unique([requesterId, receiverId])
@@index([requesterId])
@@index([receiverId])
}
model DirectMessage {
id String @id @default(uuid())
senderId String
receiverId String
content String @db.Text
attachmentUrl String?
latitude Float?
longitude Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade)
receiver User @relation("MessageReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
@@index([senderId])
@@index([receiverId])
}
model TourMessage {
id String @id @default(uuid())
tourId String
senderId String
content String @db.Text
attachmentUrl String?
latitude Float?
longitude Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
@@index([tourId])
@@index([senderId])
}
model WordFilter {
id String @id @default(uuid())
word String @unique
replacement String
createdAt DateTime @default(now())
}
model ModerationSetting {
id String @id @default(uuid())
blockNsfw Boolean @default(false)
blurFaces Boolean @default(false)
trashRetentionDays Int @default(30)
}
model TourRating {
id String @id @default(uuid())
tourId String
targetUserId String
raterUserId String
honesty Int @default(5)
transparency Int @default(5)
enthusiasm Int @default(5)
cheerfulness Int @default(5)
seriousness Int @default(5)
planning Int @default(5)
survival Int @default(5)
averageScore Float @default(5.0)
comment String? @db.Text
createdAt DateTime @default(now())
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
targetUser User @relation("RatedUser", fields: [targetUserId], references: [id], onDelete: Cascade)
raterUser User @relation("RatingUser", fields: [raterUserId], references: [id], onDelete: Cascade)
@@unique([tourId, targetUserId, raterUserId])
}
model TourShare {
id String @id @default(uuid())
tourId String @unique
token String @unique @default(uuid())
isEnabled Boolean @default(true)
createdAt DateTime @default(now())
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
}
model BusinessReport {
id String @id @default(uuid())
type String // e.g. "USER", "RESTAURANT", "HOTEL", "HOMESTAY"
name String
phone String?
email String?
address String?
latitude Float?
longitude Float?
reason String @db.Text
isBlacklisted Boolean @default(false)
createdAt DateTime @default(now())
}
model TourNote {
id String @id @default(uuid())
tourId String
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isDeleted Boolean @default(false)
deletedAt DateTime?
}
model RecommendedLocation {
id String @id @default(uuid())
type String // e.g. "RESTAURANT", "HOTEL", "HOMESTAY"
name String
phone String?
email String?
address String?
latitude Float?
longitude Float?
description String @db.Text
stars Int @default(5)
isApproved Boolean @default(false)
createdAt DateTime @default(now())
}
+19
View File
@@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import * as dotenv from 'dotenv';
import * as path from 'path';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
const users = await prisma.user.findMany();
console.log('USERS:', users);
}
main().finally(() => pool.end());
+25 -2
View File
@@ -1,8 +1,12 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg'; import { Pool } from 'pg';
import 'dotenv/config'; import * as dotenv from 'dotenv';
import * as bcrypt from 'bcrypt'; import * as path from 'path';
import bcrypt from 'bcrypt';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool); const adapter = new PrismaPg(pool);
@@ -90,6 +94,25 @@ async function main() {
}, },
}); });
console.log('--- Đang tạo bình luận mẫu... ---');
const dinhDocLap = await prisma.location.findFirst({ where: { name: 'Dinh Độc Lập' } });
if (dinhDocLap) {
await prisma.comment.createMany({
data: [
{
content: 'Chỗ này rất đẹp, giàu giá trị lịch sử!',
locationId: dinhDocLap.id,
userId: owner.id,
},
{
content: 'Nên đi vào buổi sáng cho mát mẻ mọi người nhé.',
locationId: dinhDocLap.id,
userId: photoMember.id,
}
],
});
}
console.log('--- Seed dữ liệu hoàn tất! ---'); console.log('--- Seed dữ liệu hoàn tất! ---');
console.log(`Email đăng nhập Owner: ${owner.email}`); console.log(`Email đăng nhập Owner: ${owner.email}`);
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`); console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
+18
View File
@@ -1,5 +1,23 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { UnauthorizedException } from '@nestjs/common';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {} export class JwtAuthGuard extends AuthGuard('jwt') {}
// Guard that rejects anonymous/guest users - used for dashboard and sensitive endpoints
@Injectable()
export class JwtAuthGuardNoAnonymous extends AuthGuard('jwt') {
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
}
// Reject anonymous/temporary users
if (user.isAnonymous) {
throw new UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
}
return user;
}
}
+2
View File
@@ -22,6 +22,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn'); throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
} }
// Note: We allow anonymous users to pass JWT validation
// Individual endpoints decide whether to accept anonymous users based on their guard
return user; return user;
} }
} }
@@ -0,0 +1,91 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Inject } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import * as zlib from 'zlib';
import { promisify } from 'util';
import { HttpAdapterHost } from '@nestjs/core';
// Promisify các hàm nén/giải nén
const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);
// Ngưỡng nén: Chỉ nén nếu chuỗi JSON lớn hơn ngưỡng này (bytes)
// Nén dữ liệu quá nhỏ có thể làm tăng kích thước do overhead của header nén
const COMPRESSION_THRESHOLD = 100;
@Injectable()
export class CompressCacheInterceptor implements NestInterceptor {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private readonly httpAdapterHost: HttpAdapterHost, // Để truy cập request/response
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const request = context.getArgByIndex(0);
const response = context.getArgByIndex(1);
// Chỉ áp dụng cho các request GET
if (httpAdapter.getRequestMethod(request) !== 'GET') {
return next.handle();
}
const cacheKey = httpAdapter.getRequestUrl(request);
let cachedData = await this.cacheManager.get<Buffer>(cacheKey);
if (cachedData) {
try {
// Kiểm tra xem dữ liệu có phải là Buffer và có Gzip header (0x1f 0x8b) không
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
const decompressed = await gunzip(cachedData);
const jsonString = decompressed.toString('utf8');
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
return of(JSON.parse(jsonString));
} else {
// Dữ liệu không nén (lưu dưới dạng string hoặc buffer thường)
const jsonString = cachedData.toString();
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
return of(JSON.parse(jsonString));
}
} catch (e) {
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
// Nếu giải nén lỗi, coi như cache miss và xóa cache bị lỗi
await this.cacheManager.del(cacheKey);
}
}
// Cache miss hoặc giải nén lỗi, tiếp tục xử lý request
// Đặt header MISS ngay lập tức trước khi chạy logic Controller
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
return next.handle().pipe(
tap(async (data) => { // Sử dụng tap để thực hiện side effect (lưu cache) mà không thay đổi dữ liệu gốc
if (!data) return;
const jsonString = JSON.stringify(data);
const ttl = 60000; // TTL mặc định 1 phút (có thể cấu hình từ CACHE_TTL.DEFAULT)
if (jsonString.length > COMPRESSION_THRESHOLD) {
try {
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
await this.cacheManager.set(cacheKey, compressed, ttl);
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
// Không setHeader ở đây vì response có thể đã gửi xong
} catch (e) {
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
// Nếu nén lỗi, lưu dữ liệu không nén làm fallback
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
} else {
// Dữ liệu quá nhỏ, lưu không nén
await this.cacheManager.set(cacheKey, jsonString, ttl);
}
})
);
}
}
+3890 -81
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
async function test() {
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'owner@travel.com', password: '123456' })
});
const loginData = await loginRes.json();
if (loginRes.ok) {
const token = loginData.access_token;
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Tour ID from seed
const res = await fetch(`http://localhost:3001/api/v1/tours/${tourId}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ displayName: 'Offline Member X', role: 'MEMBER' })
});
console.log('Add status:', res.status);
const data = await res.json();
console.log('Add response:', data);
} else {
console.error('Login failed:', loginData);
}
}
test().catch(console.error);
+14
View File
@@ -0,0 +1,14 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const users = await prisma.user.findMany();
console.log('--- Users in DB ---');
console.log(users);
const participants = await prisma.tourParticipant.findMany();
console.log('--- Tour Participants in DB ---');
console.log(participants);
}
main().catch(console.error).finally(() => prisma.$disconnect());
+17
View File
@@ -0,0 +1,17 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tours = await prisma.tour.findMany({
include: {
participants: {
include: {
user: { select: { email: true, name: true } }
}
}
}
});
console.log(JSON.stringify(tours, null, 2));
}
main().finally(() => prisma.$disconnect());
+36
View File
@@ -0,0 +1,36 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const tourId = '84d84c21-c12b-4a18-9655-043c85f5c0c5'; // Use existing tour ID from seed
console.log('Inserting first manual member...');
try {
const p1 = await prisma.tourParticipant.create({
data: {
tourId,
role: 'MEMBER',
displayName: 'Manual Member 1'
}
});
console.log('Inserted:', p1);
} catch (err) {
console.error('Failed to insert first:', err);
}
console.log('Inserting second manual member...');
try {
const p2 = await prisma.tourParticipant.create({
data: {
tourId,
role: 'MEMBER',
displayName: 'Manual Member 2'
}
});
console.log('Inserted:', p2);
} catch (err) {
console.error('Failed to insert second:', err);
}
}
main().catch(console.error).finally(() => prisma.$disconnect());
+22
View File
@@ -0,0 +1,22 @@
async function test() {
const loginRes = await fetch('http://localhost:3001/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'photomember@travel.com', password: '123456' })
});
const loginData = await loginRes.json();
if (loginRes.ok) {
const token = loginData.access_token;
const usersRes = await fetch('http://localhost:3001/api/v1/users?q=owner@travel.com', {
headers: { 'Authorization': `Bearer ${token}` }
});
console.log('Users status:', usersRes.status);
const usersData = await usersRes.json();
console.log('Users data:', usersData);
} else {
console.error('Login failed:', loginData);
}
}
test().catch(console.error);
Binary file not shown.

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

+73
View File
@@ -0,0 +1,73 @@
services:
postgres:
image: postgres:15-alpine
container_name: yotrip-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: travel_db
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: yotrip-redis
ports:
- "6379:6379"
backend:
build:
context: ./backend
target: development
container_name: yotrip-backend
command: >
sh -c "npx prisma migrate dev --schema=prisma/schema.prisma && npm run start:dev"
ports:
- "3001:3001"
volumes:
- ./backend:/usr/src/app
- /usr/src/app/node_modules
environment:
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
REDIS_URL: "redis://redis:6379"
PORT: 3001
ADMIN_SECRET_KEY: "yotrip_secret_admin_key"
JWT_SECRET: "super-secret"
FRONTEND_URL: "http://localhost:5173"
NODE_ENV: development
SMTP_HOST: "${SMTP_HOST}"
SMTP_PORT: "${SMTP_PORT}"
SMTP_SECURE: "${SMTP_SECURE}"
SMTP_USER: "${SMTP_USER}"
SMTP_PASS: "${SMTP_PASS}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
frontend:
build:
context: ./frontend
target: development
container_name: yotrip-frontend
command: npm run dev -- --host 0.0.0.0
ports:
- "5173:5173"
volumes:
- ./frontend:/usr/src/app
- /usr/src/app/node_modules
environment:
VITE_API_URL: "http://localhost:3001"
depends_on:
- backend
volumes:
pg_data:
+68
View File
@@ -0,0 +1,68 @@
services:
postgres:
image: postgres:15-alpine
container_name: yotrip-db-prod
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: travel_db
volumes:
- pg_data_prod:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: yotrip-redis-prod
restart: always
backend:
build:
context: ./backend
target: production
container_name: yotrip-backend-prod
restart: always
command: >
sh -c "npx prisma migrate deploy --schema=prisma/schema.prisma && node dist/src/main.js"
ports:
- "3001:3001"
volumes:
- ./backend/uploads:/usr/src/app/uploads
environment:
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
REDIS_URL: "redis://redis:6379"
PORT: 3001
ADMIN_SECRET_KEY: "${ADMIN_SECRET_KEY}"
JWT_SECRET: "super-secret"
FRONTEND_URL: "${FRONTEND_URL}"
NODE_ENV: production
SMTP_HOST: "${SMTP_HOST}"
SMTP_PORT: "${SMTP_PORT}"
SMTP_SECURE: "${SMTP_SECURE}"
SMTP_USER: "${SMTP_USER}"
SMTP_PASS: "${SMTP_PASS}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
frontend:
build:
context: ./frontend
target: production
args:
- VITE_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
container_name: yotrip-frontend-prod
restart: always
ports:
- "3002:80"
depends_on:
- backend
volumes:
pg_data_prod:
+68
View File
@@ -0,0 +1,68 @@
services:
postgres:
image: postgres:15-alpine
container_name: yotrip-db-prod
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: travel_db
volumes:
- pg_data_prod:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d travel_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: yotrip-redis-prod
restart: always
backend:
build:
context: ./backend
target: production
container_name: yotrip-backend-prod
restart: always
command: >
sh -c "npx prisma migrate deploy --schema=prisma/schema.prisma && node dist/src/main.js"
ports:
- "3001:3001"
volumes:
- ./backend/uploads:/usr/src/app/uploads
environment:
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
REDIS_URL: "redis://redis:6379"
PORT: 3001
ADMIN_SECRET_KEY: "${ADMIN_SECRET_KEY}"
JWT_SECRET: "super-secret"
FRONTEND_URL: "${FRONTEND_URL}"
NODE_ENV: production
SMTP_HOST: "${SMTP_HOST}"
SMTP_PORT: "${SMTP_PORT}"
SMTP_SECURE: "${SMTP_SECURE}"
SMTP_USER: "${SMTP_USER}"
SMTP_PASS: "${SMTP_PASS}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
frontend:
build:
context: ./frontend
target: production
args:
- VITE_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
container_name: yotrip-frontend-prod
restart: always
ports:
- "3002:80"
depends_on:
- backend
volumes:
pg_data_prod:
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
npm-debug.log
.env
.git
.gitignore
+28
View File
@@ -0,0 +1,28 @@
FROM node:22-alpine AS base
WORKDIR /usr/src/app
COPY package*.json ./
# Development stage
FROM base AS development
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
# Build stage for production
FROM base AS build
ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
RUN npm install
COPY . .
RUN npm run build
# Production stage using Nginx
FROM nginx:1.25-alpine AS production
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<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">
</head>
<body>
<div id="root"></div>
</body>
</html>
+1
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -2,10 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<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> <title>Travel Planner</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>
+43
View File
@@ -0,0 +1,43 @@
server {
listen 80;
server_name yotrip.labz.io.vn localhost;
client_max_body_size 50M;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/v1/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Serve uploaded files from backend
location /uploads/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Proxy WebSocket connection
location /socket.io/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+7
View File
@@ -10,18 +10,25 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"date-fns": "^4.4.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.284.0", "lucide-react": "^0.284.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0", "react-leaflet-cluster": "^2.1.0",
"react-quill": "^2.0.0",
"react-quill-new": "^3.8.3",
"socket.io-client": "^4.8.3",
"zustand": "^5.0.1" "zustand": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.1", "@tailwindcss/postcss": "^4.3.1",
"@types/leaflet": "^1.9.12", "@types/leaflet": "^1.9.12",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"postcss": "^8.5.15", "postcss": "^8.5.15",
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 224 KiB

File diff suppressed because one or more lines are too long
+305 -65
View File
@@ -1,88 +1,328 @@
import React, { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { LandingPage } from '@/pages/LandingPage'; import { LandingPage } from './pages/LandingPage';
import { TourDetailPage } from '@/pages/TourDetailPage'; import { ExploreMap } from './pages/ExploreMap';
import { ExploreMap } from '@/pages/ExploreMap'; import { TourDetailPage } from './pages/TourDetailPage';
import { SignupPage } from '@/pages/SignupPage'; import SignupPage from './pages/SignupPage';
import { useTourStore } from '@/store/useTourStore'; import { MyPhotosPage } from './pages/MyPhotosPage';
import { MyNotePage } from './pages/MyNotePage';
import { JoinTourPage } from './pages/JoinTourPage';
import { MemberDashboard } from './pages/MemberDashboard';
import { AdminDashboard } from './pages/AdminDashboard';
import { ShareJourneyPage } from './pages/ShareJourneyPage';
import { ConfirmProvider } from './hooks/useConfirm';
import { NotificationProvider } from './hooks/useNotification';
function App() {
const params = new URLSearchParams(window.location.search);
const viewTourId = params.get('viewTour');
const pathParts = window.location.pathname.split('/');
const isJourneyShare = pathParts[1] === 'journey' && pathParts[2];
const journeyTokenVal = isJourneyShare ? pathParts[2] : null;
const App = () => {
type View = 'landing' | 'explore' | 'detail' | 'signup';
const [view, setView] = useState<View>('landing');
const [isInitialSetup, setIsInitialSetup] = useState(false);
const [user, setUser] = useState<any>(null); const [user, setUser] = useState<any>(null);
const [isUserLoaded, setIsUserLoaded] = useState(false); // Trạng thái để biết user đã được load từ localStorage chưa const [shareJourneyToken, setShareJourneyToken] = useState<string | null>(journeyTokenVal);
const fetchTour = useTourStore(state => state.fetchTour); const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'admin' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney'>(
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');
useEffect(() => { useEffect(() => {
// Khôi phục phiên đăng nhập từ localStorage const params = new URLSearchParams(window.location.search);
const savedUser = localStorage.getItem('user'); const viewTourId = params.get('viewTour');
if (savedUser) { const isJoinTour = window.location.pathname === '/join-tour' || params.has('token');
const parsedUser = JSON.parse(savedUser); const pathParts = window.location.pathname.split('/');
setUser(parsedUser); const journeyToken = pathParts[1] === 'journey' && pathParts[2] ? pathParts[2] : null;
// Check if user just finished uploading from a public tour
const fromPublicUpload = localStorage.getItem('fromPublicUpload');
if (fromPublicUpload) {
localStorage.removeItem('fromPublicUpload');
setCurrentPage('landing');
return;
} }
setIsUserLoaded(true); // Đánh dấu user đã được load
// Kiểm tra xem hệ thng đã được cài đặt chưa // Khôi phục thông tin đăng nhập nếu có
fetch(`/api/v1/auth/status`) const token = localStorage.getItem('token');
.then(res => res.ok ? res.json() : Promise.reject()) const guestToken = localStorage.getItem('guest_token');
.then(data => setIsInitialSetup(!!data.isInitialSetup)) const storedUser = localStorage.getItem('user');
.catch(() => setIsInitialSetup(false)); const storedGuestUser = localStorage.getItem('guest_user');
}, []); // Chạy một lần khi component mount
// Effect để xử lý chuyển hướng nếu user đã đăng nhập và đang ở trang landing let loggedInUser = null;
useEffect(() => { if (token && storedUser) {
if (isUserLoaded && user && view === 'landing') { try {
setView('explore'); loggedInUser = JSON.parse(storedUser);
setUser(loggedInUser);
} catch (e) {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
} }
}, [isUserLoaded, user, view]);
const handleLoginSuccess = (userData: any) => { if (journeyToken) {
setUser(userData); setShareJourneyToken(journeyToken);
setCurrentPage('shareJourney');
} else if (isJoinTour) {
setCurrentPage('joinTour');
} else if (viewTourId) {
setCurrentPage('tourDetail');
} else {
// CRITICAL: Check for guest token FIRST - guests should NEVER access dashboard
// regardless of whether they also have other tokens
if (guestToken) {
setCurrentPage('landing');
} else if (loggedInUser) {
// Real authenticated user (no guest token)
if (loggedInUser.isAdmin) {
setCurrentPage('admin');
} else {
setCurrentPage('dashboard');
}
} else {
setCurrentPage('landing');
}
}
}, []);
const handleLoginSuccess = (loggedInUser: any) => {
setUser(loggedInUser);
// Nếu có pending token, ta vẫn giữ ở trang joinTour để nó tự động thực hiện join
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else if (loggedInUser.isAdmin) {
// Nếu là admin, chuyển đến admin dashboard
setCurrentPage('admin');
} else {
// Only set to dashboard if this is a real user (has token), not a guest
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
if (token && !guestToken) {
setCurrentPage('dashboard');
} else {
setCurrentPage('landing');
}
}
}; };
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('user');
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user');
setUser(null); setUser(null);
setView('landing'); setCurrentPage('landing');
};
const handleViewTour = (tourId: string, fromPage?: 'explore' | 'dashboard') => {
setCurrentTourId(tourId);
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
setPreviousPage(fromPage || (currentPage === 'dashboard' ? 'dashboard' : 'explore'));
setCurrentPage('tourDetail');
};
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 handleBackFromSignup = () => {
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else {
setCurrentPage('landing');
}
};
const handleSignupSuccess = () => {
// Sau khi đăng ký, ta có thể tự động đăng nhập hoặc quay lại landing/joinTour
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
const token = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (token && storedUser) {
try {
const loggedInUser = JSON.parse(storedUser);
setUser(loggedInUser);
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else {
setCurrentPage('dashboard');
}
return;
} catch (e) {}
}
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else {
setCurrentPage('landing');
}
};
const handleBackFromExplore = () => {
// Only allow real users (with token, not guest_token)
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
const isRealUser = token && !guestToken;
if (user && isRealUser) {
setCurrentPage('dashboard');
} else {
setCurrentPage('landing');
}
};
const handleGoToDashboard = () => {
// Only allow real users (with token, not guest_token)
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
const isRealUser = token && !guestToken;
if (user && isRealUser) {
setPreviousPage('explore');
setCurrentPage('dashboard');
} else {
setCurrentPage('landing');
}
};
const handleGoToHome = () => {
window.history.pushState({}, '', '/');
setShareJourneyToken(null);
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
// Only real authenticated users can access dashboard, not guests
const isRealUser = token && !guestToken;
setCurrentPage(isRealUser ? 'dashboard' : 'landing');
}; };
return ( return (
<div className="app-container"> <ConfirmProvider>
{view === 'landing' && ( <NotificationProvider>
<LandingPage {(() => {
isInitialSetup={isInitialSetup} if (currentPage === 'admin') {
onContinue={() => setView('explore')} return (
onGoToSignup={() => setView('signup')} <AdminDashboard
onGoToMap={() => setView('explore')}
onLoginSuccess={handleLoginSuccess}
/>
)}
{view === 'signup' && (
<SignupPage
onBack={() => setView('landing')}
onSuccess={() => setView('landing')}
/>
)}
{view === 'explore' && (
<ExploreMap
onBack={() => setView('landing')}
onLogout={user ? handleLogout : undefined}
user={user} user={user}
onViewTour={(id) => { onNavigate={setCurrentPage}
fetchTour(id); />
setView('detail'); );
}
if (currentPage === 'dashboard') {
// SECURITY: Prevent any guest from accessing dashboard
const guestToken = localStorage.getItem('guest_token');
if (guestToken) {
console.warn('[App] Guest user attempted to access dashboard - forcing redirect to landing');
setCurrentPage('landing');
return (
<LandingPage
onLoginSuccess={handleLoginSuccess}
onGoToSignup={() => setCurrentPage('signup')}
/>
);
}
return (
<MemberDashboard
user={user}
onLogout={handleLogout}
onExploreTours={() => setCurrentPage('explore')}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
/>
);
}
if (currentPage === 'tourDetail') {
return (
<TourDetailPage
tourId={currentTourId!}
onBack={handleBackFromTourDetail}
isPublicView={isPublicTourView}
onOpenNotes={() => setCurrentPage('notes')}
/>
);
}
if (currentPage === 'notes') {
return <MyNotePage tourId={currentTourId!} onBack={() => setCurrentPage('tourDetail')} />;
}
if (currentPage === 'explore') {
return (
<ExploreMap
onBack={handleBackFromExplore}
onLogout={handleLogout}
user={user}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
onLoginSuccess={handleLoginSuccess}
onGoToDashboard={handleGoToDashboard}
/>
);
}
if (currentPage === 'myPhotos') {
return (
<MyPhotosPage onBack={() => setCurrentPage('explore')} />
);
}
if (currentPage === 'signup') {
return <SignupPage onBack={handleBackFromSignup} onSuccess={handleSignupSuccess} />;
}
if (currentPage === 'joinTour') {
return (
<JoinTourPage
onLoginSuccess={handleLoginSuccess}
onGoToSignup={() => setCurrentPage('signup')}
onViewTour={(tourId) => {
setCurrentTourId(tourId);
setIsPublicTourView(false);
setCurrentPage('tourDetail');
}}
onGoToHome={() => {
const loggedIn = !!localStorage.getItem('token');
setCurrentPage(loggedIn ? 'explore' : 'landing');
}} }}
/> />
)}
{view === 'detail' && (
<TourDetailPage onBack={() => setView('explore')} />
)}
</div>
); );
}; }
if (currentPage === 'shareJourney') {
return (
<ShareJourneyPage
token={shareJourneyToken!}
onGoToHome={handleGoToHome}
/>
);
}
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
})()}
</NotificationProvider>
</ConfirmProvider>
);
}
export default App; export default App;
+274 -26
View File
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useMemo } from 'react';
import { X, MapPin, Loader2, Clock, Map as MapIcon } from 'lucide-react'; import { format, parseISO } from 'date-fns';
import { useTourStore } from '@/store/useTourStore.js'; import { X, MapPin, Loader2, Map as MapIcon, Navigation, Search } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore';
import { useNotification } from '@/hooks/useNotification';
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
@@ -59,7 +61,7 @@ const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, cen
); );
}; };
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any }) => { export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation, isStartPoint = false, isEndPoint = false, isPublicView = false, onSuccess }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isStartPoint?: boolean, isEndPoint?: boolean, isPublicView?: boolean, onSuccess?: () => void }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
address: '', address: '',
@@ -77,9 +79,14 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
plannedEnd: '' plannedEnd: ''
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [searchResults, setSearchResults] = useState<any[]>([]);
const [hasNoResults, setHasNoResults] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const searchTimeout = useRef<any>(null);
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng // Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
const { legs, addLocation, updateLocation, mapCenter, currentTour } = useTourStore(); const { legs, addLocation, updateLocation, updateTourStartPoint, updateTourEndPoint, mapCenter, currentTour, userRole } = useTourStore();
const notify = useNotification();
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook // 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
useEffect(() => { useEffect(() => {
@@ -96,12 +103,14 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
type: editingLocation.type || 'VISIT', type: editingLocation.type || 'VISIT',
legId: editingLocation.legId || '', legId: editingLocation.legId || '',
note: editingLocation.note || '', note: editingLocation.note || '',
expenseAmount: expense?.amount?.toString() || '', expenseAmount: expense?.amount ? Number(expense.amount).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".") : '',
expenseCategory: expense?.category || 'OTHER', expenseCategory: expense?.category || 'OTHER',
expenseDescription: expense?.description || '', expenseDescription: expense?.description || '',
expenseNote: expense?.note || '', expenseNote: expense?.note || '',
paidById: expense?.paidById || '', paidById: expense?.paidById || '',
plannedStart: editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : '', plannedStart: isStartPoint
? (editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : '')
: (editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : ''),
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : '' plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
}); });
} else { } else {
@@ -117,17 +126,105 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
} }
}, [initialLegId, editingLocation, isOpen]); }, [initialLegId, editingLocation, isOpen]);
// Memoize tọa độ để tránh việc bản đồ tự động reset tâm khi re-render (ví dụ khi gõ tìm kiếm)
const currentCoords = useMemo<[number, number]>(
() => [formData.latitude, formData.longitude],
[formData.latitude, formData.longitude]
);
// Tự động cập nhật vị trí bản đồ theo chặng được chọn (Lấy điểm cuối của chặng trước/hiện tại làm tham chiếu tiếp nối)
useEffect(() => { useEffect(() => {
if (isOpen && !formData.name) { if (isOpen && !editingLocation && formData.legId && !formData.name) {
const selectedLeg = legs.find(l => l.id === formData.legId);
if (selectedLeg) {
if (selectedLeg.locations && selectedLeg.locations.length > 0) {
// Di chuyển đến địa điểm cuối cùng của chặng hiện tại để người dùng thấy điểm nối tiếp
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
setFormData(prev => ({
...prev,
latitude: lastLoc.latitude,
longitude: lastLoc.longitude
}));
} else {
// Chặng trống -> Lấy địa điểm cuối của chặng trước làm tọa độ tiếp nối
const currentLegIdx = legs.findIndex(l => l.id === formData.legId);
const prevLeg = currentLegIdx > 0 ? legs[currentLegIdx - 1] : null;
if (prevLeg && prevLeg.locations && prevLeg.locations.length > 0) {
const lastLoc = prevLeg.locations[prevLeg.locations.length - 1];
setFormData(prev => ({
...prev,
latitude: lastLoc.latitude,
longitude: lastLoc.longitude
}));
} else {
// Nếu không có chặng trước hoặc chặng trước trống, mặc định dùng vị trí trung tâm hiện tại của tour
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] })); setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
} }
}, [isOpen, mapCenter]); }
}
}
}, [formData.legId, isOpen, editingLocation, legs, mapCenter]);
// 2. Thực hiện các tính toán và hàm xử lý // 2. Thực hiện các tính toán và hàm xử lý
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : ''); const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
const targetLeg = legs.find(l => l.id === currentLegId); const targetLeg = legs.find(l => l.id === currentLegId);
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới'); const titleText = isStartPoint ? 'Thiết lập Điểm xuất phát' :
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm'); isEndPoint ? 'Thiết lập Điểm kết thúc' :
editingLocation ? `Sửa địa điểm: ${editingLocation.name}` :
(initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
const buttonText = isStartPoint ? 'Xác nhận Điểm xuất phát' :
isEndPoint ? 'Xác nhận Điểm kết thúc' :
editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
const handleSearchLocation = (query: string) => {
setFormData(prev => ({ ...prev, name: query }));
if (searchTimeout.current) clearTimeout(searchTimeout.current);
if (query.trim().length < 2) {
setSearchResults([]);
setIsSearching(false);
setHasNoResults(false);
return;
}
setIsSearching(true);
setHasNoResults(false);
searchTimeout.current = setTimeout(async () => {
try {
// Loại bỏ countrycodes=vn để tìm kiếm rộng hơn, thêm namedetails=1 để lấy tên chính xác
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=15&addressdetails=1&namedetails=1&accept-language=vi`, {
headers: {
'Accept-Language': 'vi'
}
});
const data = await res.json();
setSearchResults(data);
setHasNoResults(data.length === 0);
} catch (e) {
console.error("Lỗi tìm kiếm địa điểm:", e);
} finally {
setIsSearching(false);
}
}, 500);
};
const selectSearchResult = (result: any) => {
const lat = parseFloat(result.lat);
const lon = parseFloat(result.lon);
// Ưu tiên lấy tên từ namedetails nếu có, nếu không lấy phần đầu của display_name
const locationName = result.namedetails?.name || result.display_name.split(',')[0];
setFormData(prev => ({
...prev,
name: locationName,
address: result.display_name,
latitude: lat,
longitude: lon
}));
setSearchResults([]);
};
const handlePickLocation = async (latlng: L.LatLng) => { const handlePickLocation = async (latlng: L.LatLng) => {
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng })); setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
@@ -146,15 +243,83 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
} catch (e) {} } catch (e) {}
}; };
const handleUseCurrentLocation = () => {
if (!navigator.geolocation) {
notify({ title: 'Thông báo', message: "Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị GPS.", type: 'info' });
return;
}
// Kiểm tra môi trường Secure Context (HTTPS) - Bắt buộc cho Geolocation trên Mobile
if (!window.isSecureContext) {
alert("Tính năng định vị GPS yêu cầu kết nối bảo mật (HTTPS). Nếu bạn đang truy cập qua địa chỉ IP, vui lòng sử dụng HTTPS hoặc Localhost.");
return;
}
navigator.geolocation.getCurrentPosition(
async (pos) => {
const latlng = L.latLng(pos.coords.latitude, pos.coords.longitude);
const now = new Date();
const formattedTime = format(now, "yyyy-MM-dd'T'HH:mm");
setFormData(prev => ({
...prev,
latitude: latlng.lat,
longitude: latlng.lng,
plannedStart: formattedTime
}));
// Tự động thực hiện reverse geocoding để lấy tên địa điểm và địa chỉ
handlePickLocation(latlng);
},
(err) => {
let errorMessage = "Không thể lấy vị trí: ";
switch(err.code) {
case err.PERMISSION_DENIED:
errorMessage += "Bạn đã từ chối quyền truy cập vị trí.";
break;
case err.POSITION_UNAVAILABLE:
errorMessage += "Thông tin vị trí không khả dụng.";
break;
case err.TIMEOUT:
errorMessage += "Hết thời gian chờ yêu cầu định vị.";
break;
default: errorMessage += err.message;
}
notify({ title: 'Lỗi định vị', message: errorMessage, type: 'error' });
},
{
enableHighAccuracy: true, // Ưu tiên dùng GPS thay vì Wifi/Cell tower
timeout: 10000, // Chờ tối đa 10 giây
maximumAge: 0 // Không dùng vị trí cũ trong cache
}
);
};
// 3. Early return phải nằm SAU tất cả các khai báo Hook // 3. Early return phải nằm SAU tất cả các khai báo Hook
if (!isOpen) return null; if (!isOpen || isPublicView) return null; // Do not render if public view
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
try { try {
if (isStartPoint) {
await updateTourStartPoint(tourId, {
name: formData.name || "Điểm xuất phát",
latitude: parseFloat(formData.latitude as any),
longitude: parseFloat(formData.longitude as any),
plannedEnd: formData.plannedStart,
});
} else if (isEndPoint) {
await updateTourEndPoint(tourId, {
name: formData.name || "Điểm kết thúc",
latitude: parseFloat(formData.latitude as any),
longitude: parseFloat(formData.longitude as any),
plannedStart: formData.plannedStart,
});
} else {
const payload: any = { const payload: any = {
...formData, ...formData,
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
legId: currentLegId, legId: currentLegId,
latitude: parseFloat(formData.latitude as any), latitude: parseFloat(formData.latitude as any),
longitude: parseFloat(formData.longitude as any), longitude: parseFloat(formData.longitude as any),
@@ -165,9 +330,16 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
} else { } else {
await addLocation(tourId, payload); await addLocation(tourId, payload);
} }
}
notify({
title: 'Thành công',
message: isStartPoint ? 'Đã thiết lập điểm xuất phát.' : isEndPoint ? 'Đã thiết lập điểm kết thúc.' : editingLocation ? 'Đã cập nhật địa điểm.' : 'Đã thêm địa điểm mới.',
type: 'success'
});
onSuccess?.(); // Gọi callback onSuccess sau khi thành công
onClose(); onClose();
} catch (error) { } catch (error) {
alert('Lỗi khi lưu địa điểm'); notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -187,22 +359,91 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
</div> </div>
{/* Mini Map Picker */} {/* Mini Map Picker */}
<div className="h-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group"> <div className="h-64 w-full rounded-3xl overflow-hidden mb-6 border border-gray-100 relative shadow-xl group">
<MapContainer center={[formData.latitude, formData.longitude]} zoom={13} className="h-full w-full"> {/* Map Search Bar Overlay - Tích hợp tìm kiếm trực tiếp trên bản đồ */}
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> <div className="absolute top-3 left-3 right-3 z-[1001] pointer-events-none">
<Marker position={[formData.latitude, formData.longitude]} /> <div className="relative max-w-sm pointer-events-auto">
<MapPicker center={[formData.latitude, formData.longitude]} onPick={handlePickLocation} /> <div className="relative group">
<input
type="text"
placeholder="Tìm địa điểm trên bản đồ..."
className="w-full pl-11 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold text-gray-800"
value={formData.name}
onChange={e => handleSearchLocation(e.target.value)}
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
{isSearching ? (
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
) : formData.name && (
<button
type="button"
onClick={() => { setFormData({...formData, name: ''}); setSearchResults([]); setHasNoResults(false); }}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-full text-gray-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Dropdown kết quả tìm kiếm ngay trong khung bản đồ */}
{(searchResults.length > 0 || hasNoResults) && (
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-40 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
{hasNoResults ? (
<div className="px-4 py-4 text-center text-gray-400 text-xs italic">Không tìm thấy đa điểm phù hợp...</div>
) : (
searchResults.map((result, idx) => (
<button
key={idx}
type="button"
onClick={() => selectSearchResult(result)}
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-colors flex flex-col gap-0.5"
>
<div className="flex items-center justify-between gap-2">
<div className="font-bold text-xs text-gray-900 truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
{result.type && (
<span className="text-[8px] font-black uppercase text-blue-400 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100 shrink-0">{result.type}</span>
)}
</div>
<div className="text-[10px] text-gray-500 truncate leading-tight">{result.display_name}</div>
</button>
))
)}
</div>
)}
</div>
</div>
<MapContainer center={currentCoords} zoom={13} className="h-full w-full" zoomControl={false} attributionControl={false}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="" />
<Marker position={currentCoords} />
<MapPicker center={currentCoords} onPick={handlePickLocation} />
</MapContainer> </MapContainer>
<div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-gray-500 shadow-sm border border-gray-100"> <div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-gray-500 shadow-sm border border-gray-100">
CHUỘT PHẢI Đ CHỌN VỊ TRÍ CHUỘT PHẢI Đ CHỌN VỊ TRÍ
</div> </div>
</div> </div>
{/* Nút lấy vị trí và thời gian hiện tại - Chỉ dành cho OWNER/MANAGER khi thêm mới */}
{!editingLocation && (userRole === 'OWNER' || userRole === 'MANAGER') && (
<button
type="button"
onClick={handleUseCurrentLocation}
className="w-full mb-6 py-4 bg-indigo-50 hover:bg-indigo-100 text-indigo-600 rounded-2xl flex items-center justify-center gap-2 text-xs font-black uppercase tracking-widest border border-indigo-100 transition-all active:scale-95 shadow-sm"
>
<Navigation className="w-4 h-4 fill-current" /> Sử dụng vị trí & thời gian hiện tại
</button>
)}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-1">Tên đa điểm</label> <label className="block text-sm font-bold text-gray-700 mb-1">Tên đa điểm</label>
<input required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none" <input
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} /> required
placeholder="Tên địa điểm..."
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all font-bold"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-1">Đa chỉ</label> <label className="block text-sm font-bold text-gray-700 mb-1">Đa chỉ</label>
@@ -220,9 +461,13 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label> <label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label>
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm" <input type="text" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
placeholder="0" placeholder="0"
value={formData.expenseAmount} onChange={e => setFormData({...formData, expenseAmount: e.target.value})} /> value={formData.expenseAmount} onChange={e => {
const rawValue = e.target.value.replace(/\D/g, ""); // Chỉ lấy số
const formattedValue = rawValue.replace(/\B(?=(\d{3})+(?!\d))/g, "."); // Thêm dấu chấm
setFormData({...formData, expenseAmount: formattedValue});
}} />
</div> </div>
<div> <div>
<label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label> <label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label>
@@ -253,12 +498,12 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm" <select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}> value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
<option value="">-- Chọn người thanh toán --</option> <option value="">-- Chọn người thanh toán --</option>
{currentTour?.participants?.map((p: any) => { {currentTour?.participants?.filter((p: any) => p.user || p.displayName)?.map((p: any) => {
const name = p.user?.name; const name = p.user?.name || p.displayName;
const email = p.user?.email; const email = p.user?.email;
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' '); const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
return ( return (
<option key={p.userId} value={p.userId}>{label || p.userId}</option> <option key={p.id} value={p.userId || p.id}>{label || p.userId || p.id}</option>
); );
})} })}
</select> </select>
@@ -269,7 +514,10 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none" <select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}> value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}>
{legs.map(leg => ( {legs.map(leg => (
<option key={leg.id} value={leg.id}>Chặng {leg.sequence}: {leg.note || 'Không có tên'}</option> <option key={leg.id} value={leg.id}>
Chặng {leg.sequence}: {leg.note || 'Không có tên'}
{leg.startDate ? ` (${format(parseISO(leg.startDate), 'dd/MM')})` : ''}
</option>
))} ))}
</select> </select>
</div> </div>
+249 -61
View File
@@ -1,18 +1,22 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react'; import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock } from 'lucide-react';
import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
interface AddMemberModalProps { interface AddMemberModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
tourId: string; tourId: string;
participants?: Array<{ userId: string; role: string; user?: { id: string; name: string; email: string } }>; participants?: Array<{ id: string; userId?: string | null; role: string; displayName?: string | null; user?: { id: string; name: string; email: string } | null }>;
joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>; joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>;
onRemoveMember?: (userId: string) => Promise<void>; onRemoveMember?: (userId: string) => Promise<void>;
onMemberAdded?: () => void; onMemberAdded?: () => void;
userRole?: string; userRole?: string; // User's role in the tour
isPublicView?: boolean; // New prop to indicate public view
} }
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => { export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole, isPublicView }) => {
const [activeTab, setActiveTab] = useState<'search' | 'email'>('search');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -21,15 +25,51 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [fetchError, setFetchError] = useState(''); const [fetchError, setFetchError] = useState('');
const [submitError, setSubmitError] = useState(''); const [submitError, setSubmitError] = useState('');
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [confirmTarget, setConfirmTarget] = useState<{ userId: string; name: string } | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]); // Trạng thái cho tab mời qua email
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY'>('MEMBER');
const [inviteLoading, setInviteLoading] = useState(false);
const [inviteError, setInviteError] = useState('');
const [inviteSuccess, setInviteSuccess] = useState('');
const confirm = useConfirm();
const notify = useNotification();
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId).filter(Boolean) as string[]), [participants]);
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]); const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]);
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]); const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER'; const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
const canInviteByEmail = userRole === 'OWNER' || userRole === 'MANAGER';
const handleManualAdd = async () => {
const name = query.trim();
if (!name) return;
setSubmitting(true);
setSubmitError('');
try {
const res = await fetch(`/api/v1/tours/${tourId}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ displayName: name, role }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || data.error || 'Thao tác thất bại');
}
await onMemberAdded?.();
onClose();
} catch (err: any) {
setSubmitError(err.message || 'Thao tác thất bại');
} finally {
setSubmitting(false);
}
};
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true); setLoading(true);
@@ -48,10 +88,44 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
} }
}; };
const handleSendInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim()) return;
setInviteLoading(true);
setInviteError('');
setInviteSuccess('');
try {
const res = await fetch(`/api/v1/tours/${tourId}/invitations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'Gửi lời mời thất bại');
}
setInviteSuccess(`Lời mời đã được gửi thành công đến ${inviteEmail}!`);
setInviteEmail('');
notify({ title: 'Thành công', message: `Lời mời đã gửi tới ${inviteEmail}`, type: 'success' });
await onMemberAdded?.();
} catch (err: any) {
setInviteError(err.message || 'Thao tác thất bại');
} finally {
setInviteLoading(false);
}
};
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen || activeTab !== 'search') return;
const delayDebounceFn = setTimeout(() => {
fetchUsers(); fetchUsers();
}, [isOpen]); }, 300);
return () => clearTimeout(delayDebounceFn);
}, [query, isOpen, activeTab]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@@ -60,28 +134,31 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
setRole('MEMBER'); setRole('MEMBER');
setFetchError(''); setFetchError('');
setSubmitError(''); setSubmitError('');
setInviteEmail('');
setInviteRole('MEMBER');
setInviteError('');
setInviteSuccess('');
setActiveTab('search');
} }
}, [isOpen]); }, [isOpen]);
const handleRemove = async (userId: string, memberName: string) => { const handleRemove = async (memberIdOrUserId: string, memberName: string) => {
if (!onRemoveMember) return; if (!onRemoveMember) return;
setConfirmTarget({ userId, name: memberName }); const isConfirmed = await confirm({
setIsConfirmOpen(true); title: 'Xóa thành viên',
}; message: `Bạn có chắc chắn muốn xóa ${memberName} khỏi tour?`
});
const confirmRemove = async () => { if (isConfirmed) {
if (!confirmTarget || !onRemoveMember) return;
try { try {
await onRemoveMember(confirmTarget.userId); await onRemoveMember(memberIdOrUserId);
} catch (err: any) { } catch (err: any) {
setSubmitError(err.message || 'Không thể xóa thành viên'); setSubmitError(err.message || 'Không thể xóa thành viên');
} finally { }
setIsConfirmOpen(false);
setConfirmTarget(null);
} }
}; };
const handleRequestAction = async (reqId: string, action: 'accept' | 'reject', userName: string) => { const handleRequestAction = async (reqId: string, action: 'accept' | 'reject') => {
if (!onMemberAdded) return; if (!onMemberAdded) return;
setActionLoading(reqId); setActionLoading(reqId);
try { try {
@@ -98,7 +175,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
} }
await onMemberAdded(); await onMemberAdded();
} catch (err: any) { } catch (err: any) {
alert(err.message || 'Thao tác thất bại'); notify({ title: 'Lỗi', message: err.message || 'Thao tác thất bại', type: 'error' });
} finally { } finally {
setActionLoading(null); setActionLoading(null);
} }
@@ -134,7 +211,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
} }
}; };
if (!isOpen) return null; if (!isOpen || isPublicView) return null; // Do not render if public view
return ( return (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
@@ -143,10 +220,10 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50"> <div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div> <div>
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2"> <h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
<UserPlus className="w-5 h-5 text-blue-600" /> {canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'} <UserPlus className="w-5 h-5 text-blue-600" /> {canCreateDirectly ? 'Thành viên hành trình' : 'Mời tham gia tour'}
</h2> </h2>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'} {canCreateDirectly ? 'Quản lý, thêm thành viên và mời người khác tham gia.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'}
</p> </p>
</div> </div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors"> <button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
@@ -154,11 +231,37 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</button> </button>
</div> </div>
<div className="p-5 space-y-4"> {canInviteByEmail && (
<div className="flex border-b border-gray-100 bg-gray-50/30">
<button
onClick={() => setActiveTab('search')}
className={`flex-1 py-3 text-center text-sm font-bold border-b-2 transition-all ${
activeTab === 'search'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Tìm thành viên
</button>
<button
onClick={() => setActiveTab('email')}
className={`flex-1 py-3 text-center text-sm font-bold border-b-2 transition-all ${
activeTab === 'email'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Mời qua Email
</button>
</div>
)}
<div className="p-5 space-y-4 overflow-y-auto flex-1">
{/* Danh sách thành viên hiện tại */}
<div> <div>
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.length})</p> <p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.filter(p => p.user || p.displayName).length})</p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{participants.map((p) => { {participants.filter(p => p.user || p.displayName).map((p) => {
const rawToken = localStorage.getItem('token'); const rawToken = localStorage.getItem('token');
let currentUserId: string | null = null; let currentUserId: string | null = null;
try { try {
@@ -170,15 +273,16 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const isCurrentUser = currentUserId && p.userId === currentUserId; const isCurrentUser = currentUserId && p.userId === currentUserId;
const isOwner = p.role === 'OWNER'; const isOwner = p.role === 'OWNER';
const canRemove = onRemoveMember && !isCurrentUser && !isOwner; const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
const memberName = p.user?.name || p.displayName || p.userId || 'Thành viên';
return ( return (
<div key={p.userId} className="flex flex-col items-center gap-1"> <div key={p.id} className="flex flex-col items-center gap-1">
<div className="relative"> <div className="relative">
<div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden"> <div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
{p.user?.name?.charAt(0) || '?'} {memberName.charAt(0)}
</div> </div>
{canRemove && ( {canRemove && (
<button <button
onClick={() => handleRemove(p.userId, p.user?.name || p.userId)} onClick={() => handleRemove(p.userId || p.id, memberName)}
className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white" className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
aria-label="Remove item" aria-label="Remove item"
> >
@@ -186,7 +290,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</button> </button>
)} )}
</div> </div>
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{p.user?.name || p.userId}</span> <span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{memberName}</span>
</div> </div>
); );
})} })}
@@ -196,6 +300,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</div> </div>
</div> </div>
{/* Danh sách chờ duyệt */}
{joinRequests.length > 0 && ( {joinRequests.length > 0 && (
<div> <div>
<p className="text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1"> <p className="text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1">
@@ -211,7 +316,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<button <button
type="button" type="button"
disabled={actionLoading === req.id} disabled={actionLoading === req.id}
onClick={() => handleRequestAction(req.id, 'accept', req.user?.name || req.userId)} onClick={() => handleRequestAction(req.id, 'accept')}
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50" className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
aria-label="Accept" aria-label="Accept"
> >
@@ -220,7 +325,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<button <button
type="button" type="button"
disabled={actionLoading === req.id} disabled={actionLoading === req.id}
onClick={() => handleRequestAction(req.id, 'reject', req.user?.name || req.userId)} onClick={() => handleRequestAction(req.id, 'reject')}
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1" className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
aria-label="Reject" aria-label="Reject"
> >
@@ -235,11 +340,15 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</div> </div>
)} )}
<hr className="border-gray-100" />
{activeTab === 'search' ? (
<div className="space-y-4">
{canCreateDirectly && ( {canCreateDirectly && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền</label> <label className="text-xs font-bold text-gray-700 ml-1">Phân quyền khi thêm</label>
<select <select
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm font-bold text-gray-800"
value={role} value={role}
onChange={(e) => setRole(e.target.value as any)} onChange={(e) => setRole(e.target.value as any)}
> >
@@ -253,6 +362,26 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
)} )}
<div className="space-y-2"> <div className="space-y-2">
<div className="relative mb-2">
<input
type="text"
placeholder="Tìm kiếm theo tên hoặc địa chỉ email..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-10 pr-10 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-gray-800"
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-200 rounded-full text-gray-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{fetchError && ( {fetchError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100"> <div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{fetchError} {fetchError}
@@ -266,7 +395,28 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
{loading ? ( {loading ? (
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div> <div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
) : ( ) : (
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1"> <div className="space-y-2 max-h-[30vh] overflow-y-auto pr-1">
{query.trim() && canCreateDirectly && (
<button
type="button"
onClick={handleManualAdd}
disabled={submitting}
className="w-full flex items-center gap-3 p-3 rounded-2xl border border-dashed border-blue-300 hover:border-blue-400 bg-blue-50/20 text-blue-700 transition-all text-left mb-2"
>
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
+
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold">Thêm thành viên thủ công</div>
<div className="text-[11px] text-gray-500">Thêm "{query.trim()}" trực tiếp vào danh sách thành viên</div>
</div>
{submitting ? (
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
) : (
<span className="px-2.5 py-1 bg-blue-600 text-white rounded-xl text-xs font-bold transition-all">Thêm</span>
)}
</button>
)}
{visibleUsers.map((u) => { {visibleUsers.map((u) => {
const isSelected = selectedUser === u.id; const isSelected = selectedUser === u.id;
return ( return (
@@ -284,14 +434,8 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<div className="flex-1 text-left"> <div className="flex-1 text-left">
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div> <div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
<div className="text-[11px] text-gray-500">{u.email}</div> <div className="text-[11px] text-gray-500">{u.email}</div>
<div className="text-[11px] text-gray-400">{u.phone || ''} {u.address ? `${u.address}` : ''}</div>
</div> </div>
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500"> <div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100">USER</span>
)}
{isSelected && <Shield className="w-3 h-3 text-blue-600" />} {isSelected && <Shield className="w-3 h-3 text-blue-600" />}
</div> </div>
</button> </button>
@@ -303,9 +447,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</div> </div>
)} )}
</div> </div>
</div> <div className="flex justify-end gap-2 pt-2">
<div className="p-5 border-t border-gray-100 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors"> <button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
Hủy Hủy
</button> </button>
@@ -318,26 +460,72 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</button> </button>
</div> </div>
</div> </div>
) : (
<form onSubmit={handleSendInvite} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 ml-1">Email người nhận</label>
<input
required
type="email"
placeholder="nhap.email@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-gray-800"
/>
</div>
{isConfirmOpen && ( <div className="space-y-1.5">
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4"> <label className="text-xs font-bold text-gray-700 ml-1">Vai trò trong Tour</label>
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={() => setIsConfirmOpen(false)} /> <select
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5"> className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm font-bold text-gray-800"
<h3 className="text-base font-bold text-gray-900">Xác nhận xóa thành viên</h3> value={inviteRole}
<p className="mt-2 text-sm text-gray-600"> onChange={(e) => setInviteRole(e.target.value as any)}
Bạn chắc muốn xóa <span className="font-semibold text-gray-800">{confirmTarget?.name}</span> khỏi tour này? >
</p> <option value="MEMBER">MEMBER (Thành viên tài chính)</option>
<div className="mt-4 flex justify-end gap-2"> <option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE (Thành viên phi tài chính)</option>
<button onClick={() => setIsConfirmOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors"> <option value="MANAGER">MANAGER (Đng quản trị viên)</option>
Hủy <option value="VIEWER_ONLY">VIEWER_ONLY (Chỉ xem thông tin)</option>
</button> </select>
<button onClick={confirmRemove} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
Xóa
</button>
</div>
</div> </div>
{inviteError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{inviteError}
</div> </div>
)} )}
{inviteSuccess && (
<div className="p-3 bg-green-50 text-green-700 rounded-xl text-xs font-bold border border-green-100">
{inviteSuccess}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors"
>
Đóng
</button>
<button
type="submit"
disabled={inviteLoading || !inviteEmail}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all flex items-center gap-2"
>
{inviteLoading ? (
<>
Đang gửi...
<Loader2 className="w-4 h-4 animate-spin" />
</>
) : (
'Gửi thư mời'
)}
</button>
</div>
</form>
)}
</div>
</div>
</div> </div>
); );
}; };
+239
View File
@@ -0,0 +1,239 @@
import React, { useState, useRef } from 'react';
import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useTourStore } from '@/store/useTourStore';
import { processImageModeration } from '@/hooks/useImageModeration';
import { compressImage } from '../utils/image';
interface AddPhotoModalProps {
isOpen: boolean;
onClose: () => void;
tourId: string;
onSuccess?: () => void;
isPublicView?: boolean;
}
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess, isPublicView }) => {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
const fetchTour = useTourStore(state => state.fetchTour);
if (!isOpen) return null;
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
const newValidFiles: File[] = [];
const newValidPreviews: string[] = [];
setIsProcessing(true);
notify({ title: 'Đang kiểm duyệt...', message: 'Đang kiểm tra và lọc hình ảnh của bạn...', type: 'info' });
try {
for (const file of files) {
// 1. Kiểm tra cơ bản: Tệp có dung lượng hay không
if (!file || file.size === 0) {
notify({ title: 'Lỗi tệp', message: `Tệp ${file.name} trống hoặc không khả dụng.`, type: 'error' });
continue;
}
// Nén ảnh trước
const compressedFile = await compressImage(file);
// 2. Chạy kiểm duyệt hình ảnh
const moderationResult = await processImageModeration(compressedFile);
if (moderationResult.blocked) {
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
continue;
}
const processedFile = moderationResult.file;
const previewUrl = URL.createObjectURL(processedFile);
// 3. Kiểm tra tính toàn vẹn: Thử load ảnh vào bộ nhớ để xác nhận file "tồn tại" và đọc được
const isValidImage = await new Promise<boolean>((resolve) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = previewUrl;
});
if (isValidImage) {
newValidFiles.push(processedFile);
newValidPreviews.push(previewUrl);
} else {
URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ
notify({ title: 'Lỗi ảnh', message: `Không thể đọc nội dung ảnh: ${file.name}. Vui lòng kiểm tra lại file.`, type: 'error' });
}
}
setSelectedFiles(prev => [...prev, ...newValidFiles]);
setPreviews(prev => [...prev, ...newValidPreviews]);
} catch (err) {
console.error('File checking error:', err);
notify({ title: 'Lỗi', message: 'Lỗi trong quá trình kiểm duyệt ảnh.', type: 'error' });
} finally {
setIsProcessing(false);
}
}
};
const removeFile = (index: number) => {
// Thu hồi URL khi xóa khỏi danh sách chờ để giải phóng bộ nhớ
URL.revokeObjectURL(previews[index]);
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
setPreviews(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedFiles.length === 0) return;
setIsUploading(true);
try {
// Lấy tọa độ hiện tại của người dùng làm dự phòng nếu ảnh EXIF không có GPS
const location = await Promise.race([
new Promise<GeolocationPosition | null>((resolve) => {
if (!navigator.geolocation) {
resolve(null);
} else {
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos),
() => resolve(null),
{ timeout: 4000, enableHighAccuracy: true }
);
}
}),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
]);
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('images', file);
});
if (location) {
formData.append('latitude', location.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString());
}
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
});
if (!response.ok) throw new Error('Upload failed');
notify({
title: 'Thành công',
message: `Đã tải lên ${selectedFiles.length} ảnh.`,
type: 'success'
});
// Giải phóng bộ nhớ sau khi hoàn tất
previews.forEach(url => URL.revokeObjectURL(url));
setSelectedFiles([]);
setPreviews([]);
// Refresh tour data (applies to both authenticated and public users)
// This ensures newly uploaded photos appear immediately without requiring a page reload
fetchTour(tourId);
if (onSuccess) onSuccess();
// For public users: redirect to landing page after upload (after data refresh)
// For authenticated users: close modal and show updated tour
if (isPublicView) {
onClose();
// Give time for tour data to refresh before redirecting
setTimeout(() => {
localStorage.setItem('fromPublicUpload', 'true');
window.location.href = '/';
}, 1500);
} else {
onClose();
}
} catch (error) {
notify({
title: 'Lỗi',
message: 'Không thể tải ảnh lên. Vui lòng thử lại.',
type: 'error'
});
} finally {
setIsUploading(false);
}
};
return (
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<ImageIcon className="w-6 h-6 text-blue-600" /> Tải nh lên
</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-200 rounded-[32px] p-10 flex flex-col items-center justify-center cursor-pointer hover:bg-blue-50/50 hover:border-blue-200 transition-all mb-6 group"
>
<input type="file" ref={fileInputRef} className="hidden" multiple accept="image/*" onChange={handleFileChange} />
<div className="w-16 h-16 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-600 mb-4 group-hover:scale-110 transition-transform shadow-inner">
<Upload className="w-8 h-8" />
</div>
<p className="text-sm font-black text-gray-700">Nhấn đ chọn nh</p>
<p className="text-xs text-gray-400 mt-1 font-medium">Hỗ trợ JPG, PNG, WEBP</p>
</div>
{previews.length > 0 && (
<div className="flex-1 overflow-y-auto mb-6 pr-2">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3">Đã chọn {previews.length} tệp</p>
<div className="grid grid-cols-3 gap-3">
{previews.map((src, idx) => (
<div key={idx} className="relative aspect-square rounded-2xl overflow-hidden border border-gray-100 shadow-sm group">
<img src={src} className="w-full h-full object-cover" alt="preview" />
<button
type="button"
onClick={() => removeFile(idx)}
className="absolute top-1.5 right-1.5 p-1.5 bg-red-500/80 backdrop-blur-sm text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
<button
disabled={isUploading || isProcessing || selectedFiles.length === 0}
className="w-full py-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-black uppercase tracking-widest rounded-2xl flex items-center justify-center gap-2 shadow-lg shadow-blue-100 transition-all active:scale-95"
>
{isUploading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : isProcessing ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Đang xử nh...
</>
) : (
'Xác nhận tải lên'
)}
</button>
</form>
</div>
</div>
);
};
+218
View File
@@ -0,0 +1,218 @@
import React, { useState, useEffect } from 'react';
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
import { io } from 'socket.io-client';
import { useTourStore } from '@/store/useTourStore';
import { ConfirmModal } from './ConfirmModal';
interface Comment {
id: string;
userName: string;
content: string;
createdAt: string;
userId?: string;
}
interface CommentModalProps {
isOpen: boolean;
onClose: () => void;
locationId: string;
locationName: string;
onCommentAdded?: () => void; // Callback to update comment count on parent
onCommentDeleted?: () => void;
isPublicView?: boolean; // New prop to indicate public view
}
export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, locationId, locationName, onCommentAdded, onCommentDeleted, isPublicView = false }) => {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [confirmState, setConfirmState] = useState({ open: false, commentId: '' });
const userRole = useTourStore(state => state.userRole);
const currentUserId = React.useMemo(() => {
try {
const user = JSON.parse(localStorage.getItem('user') || '{}');
return user.id;
} catch { return null; }
}, []);
const fetchComments = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
headers
});
if (res.ok) {
const data = await res.json();
setComments(data.map((c: any) => ({
id: c.id,
userName: c.user?.name || 'Ẩn danh',
content: c.content,
createdAt: c.createdAt,
userId: c.userId
})));
}
} catch (error) {
console.error('Lỗi khi tải bình luận:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!isOpen || !locationId) return;
fetchComments();
// Lắng nghe bình luận mới qua Proxy (không cần hardcode URL)
const socket = io();
socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể
socket.on('commentAdded', (newCommentData: any) => {
if (newCommentData.locationId === locationId) {
setComments(prev => {
// Tránh trùng lặp nếu chính mình gửi
if (prev.find(c => c.id === newCommentData.id)) return prev;
return [...prev, {
id: newCommentData.id,
userName: newCommentData.user?.name || 'Ẩn danh',
content: newCommentData.content,
createdAt: newCommentData.createdAt,
userId: newCommentData.userId || newCommentData.user?.id
}];
});
}
});
return () => { socket.disconnect(); };
}, [isOpen, locationId]);
const handleSend = async () => {
if (!newComment.trim()) return;
try {
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ content: newComment })
});
if (res.ok) {
setNewComment('');
fetchComments();
onCommentAdded?.();
}
} catch (error) {
console.error('Lỗi khi gửi bình luận:', error);
}
};
const handleDelete = async (commentId: string) => {
try {
const res = await fetch(`/api/v1/locations/comments/${commentId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (res.ok) {
setComments(prev => prev.filter(c => c.id !== commentId));
onCommentDeleted?.();
}
} catch (error) {
console.error('Lỗi khi xóa bình luận:', error);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[5000] 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={onClose} />
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-white sticky top-0 z-10">
<div>
<h3 className="text-xl font-black text-gray-900 flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-600" />
Bình luận
</h3>
<p className="text-xs text-gray-500 font-bold uppercase tracking-widest mt-1 truncate max-w-[250px]">{locationName}</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* Comment List */}
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-50/50">
{isLoading ? (
<div className="flex justify-center py-10"><Loader2 className="w-6 h-6 animate-spin text-blue-600" /></div>
) : comments.length === 0 ? (
<div className="text-center py-10 text-gray-400 italic text-sm">Chưa bình luận nào.</div>
) : (
comments.map((c) => (
<div key={c.id} className="flex gap-3 animate-in slide-in-from-left-2 duration-300">
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0 border border-blue-200">
<User className="w-4 h-4 text-blue-600" />
</div>
<div className="flex-1">
<div className="bg-white p-3 rounded-2xl rounded-tl-none border border-gray-100 shadow-sm">
<div className="flex justify-between items-start mb-1">
<p className="text-xs font-black text-gray-900">{c.userName}</p>
{(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && (
<button
onClick={() => setConfirmState({ open: true, commentId: c.id })}
className="text-gray-400 hover:text-red-500 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<p className="text-sm text-gray-600 leading-relaxed">{c.content}</p>
</div>
<p className="text-[10px] text-gray-400 mt-1 ml-1 font-medium">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
))
)}
</div>
{/* Input Area */}
<div className="p-4 bg-white border-t border-gray-100">
<div className="relative flex items-center gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder={isPublicView ? 'Đăng nhập để bình luận...' : 'Viết bình luận...'}
className="flex-1 bg-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
/>
<button onClick={handleSend} disabled={!newComment.trim() || isPublicView} className="p-3 bg-blue-600 text-white rounded-2xl hover:bg-blue-700 disabled:opacity-50 disabled:bg-gray-300 transition-all active:scale-95 shadow-lg shadow-blue-100">
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
<ConfirmModal
isOpen={confirmState.open}
title="Xóa bình luận"
message="Bạn có chắc chắn muốn xóa bình luận này không? Hành động này sẽ không thể hoàn tác."
onConfirm={() => {
handleDelete(confirmState.commentId);
setConfirmState({ open: false, commentId: '' });
}}
onCancel={() => setConfirmState({ open: false, commentId: '' })}
/>
</div>
);
};
+35 -24
View File
@@ -1,39 +1,50 @@
import React, { useState } from 'react'; import React from 'react';
import { X } from 'lucide-react'; import { AlertTriangle, X } from 'lucide-react';
interface ConfirmModalProps { interface ConfirmModalProps {
isOpen: boolean; isOpen: boolean;
title?: string; title?: string;
message: string; message?: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
} }
export const ConfirmModal: React.FC<ConfirmModalProps> = ({ export const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, title, message, onConfirm, onCancel }) => {
isOpen,
title = 'Xác nhận',
message,
confirmText = 'Xác nhận',
cancelText = 'Hủy',
onConfirm,
onCancel,
}) => {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-[2200] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[6000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={onCancel} /> {/* Backdrop */}
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5"> <div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onCancel} />
<h3 className="text-base font-bold text-gray-900">{title}</h3>
<p className="mt-2 text-sm text-gray-600">{message}</p> {/* Modal Content */}
<div className="mt-4 flex justify-end gap-2"> <div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<button onClick={onCancel} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors"> <div className="flex justify-between items-center mb-4">
{cancelText} <div className="w-12 h-12 rounded-2xl bg-red-50 flex items-center justify-center text-red-500 shadow-inner">
<AlertTriangle className="w-6 h-6" />
</div>
<button onClick={onCancel} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button> </button>
<button onClick={onConfirm} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors"> </div>
{confirmText}
<h3 className="text-xl font-black text-gray-900 mb-2">{title || 'Xác nhận'}</h3>
<p className="text-sm text-gray-500 mb-8 leading-relaxed">
{message || 'Bạn có chắc chắn muốn thực hiện hành động này không?'}
</p>
<div className="grid grid-cols-2 gap-3">
<button
onClick={onCancel}
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
>
Hủy bỏ
</button>
<button
onClick={onConfirm}
className="py-4 bg-red-600 hover:bg-red-700 text-white font-bold rounded-2xl shadow-lg shadow-red-100 transition-all active:scale-95"
>
Xác nhận
</button> </button>
</div> </div>
</div> </div>
@@ -0,0 +1,239 @@
import React, { useState, useEffect } from 'react';
import { X, MapPin, Search, Loader2 } from 'lucide-react';
import { MapContainer, TileLayer, Marker, useMap, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTranslation } from '../hooks/useTranslation';
// Fix Leaflet default marker icon bug
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
interface CoordinateSelectModalProps {
isOpen: boolean;
onClose: () => void;
initialLat?: number;
initialLng?: number;
onSelect: (lat: number, lng: number) => void;
}
function MapClickEvents({ onClick }: { onClick: (lat: number, lng: number) => void }) {
useMapEvents({
click: (e) => {
onClick(e.latlng.lat, e.latlng.lng);
}
});
return null;
}
function MapInvalidator() {
const map = useMap();
useEffect(() => {
const timer = setTimeout(() => {
map.invalidateSize();
}, 250);
return () => clearTimeout(timer);
}, [map]);
return null;
}
function RecenterMap({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.setView(position, map.getZoom());
}, [position, map]);
return null;
}
export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
isOpen,
onClose,
initialLat,
initialLng,
onSelect
}) => {
const { t } = useTranslation();
const defaultCenter: [number, number] = [10.7769, 106.7009]; // TP.HCM default
const [position, setPosition] = useState<[number, number]>(defaultCenter);
const [hasSelected, setHasSelected] = useState(false);
// Search States
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
if (isOpen) {
if (typeof initialLat === 'number' && typeof initialLng === 'number' && !isNaN(initialLat) && !isNaN(initialLng)) {
setPosition([initialLat, initialLng]);
setHasSelected(true);
} else {
setPosition(defaultCenter);
setHasSelected(false);
}
setSearchQuery('');
setSearchResults([]);
}
}, [isOpen, initialLat, initialLng]);
if (!isOpen) return null;
const handleMapClick = (lat: number, lng: number) => {
setPosition([lat, lng]);
setHasSelected(true);
};
const handleConfirm = () => {
onSelect(position[0], position[1]);
onClose();
};
const handleSearch = async () => {
if (!searchQuery.trim()) return;
setIsSearching(true);
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&accept-language=vi&limit=5`);
if (res.ok) {
const data = await res.json();
setSearchResults(data);
}
} catch (e) {
console.error('Error during Nominatim search:', e);
} finally {
setIsSearching(false);
}
};
const handleSelectResult = (place: any) => {
const lat = parseFloat(place.lat);
const lng = parseFloat(place.lon);
setPosition([lat, lng]);
setHasSelected(true);
setSearchResults([]);
setSearchQuery(place.display_name);
};
return (
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4 animate-in fade-in duration-200">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Content */}
<div className="relative w-full max-w-2xl h-[550px] bg-white dark:bg-slate-900 rounded-3xl shadow-2xl overflow-hidden flex flex-col border border-gray-100 dark:border-slate-800 animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="p-4 border-b border-gray-100 dark:border-slate-800/80 flex items-center justify-between bg-white dark:bg-slate-900 shrink-0">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-blue-500" />
<div className="text-left">
<h3 className="font-extrabold text-sm text-gray-900 dark:text-white">{t('chooseLocationMap')}</h3>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">{t('clickMapSelectCoords')}</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full transition-all text-gray-400 hover:text-gray-650"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Map Body */}
<div className="flex-1 bg-gray-50 dark:bg-slate-950 relative min-h-[300px]" style={{ zIndex: 10 }}>
{/* Floating Search Panel */}
<div className="absolute top-4 left-4 right-4 sm:right-auto z-[1000] sm:w-80 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md rounded-2xl border border-slate-150 dark:border-slate-800 shadow-xl p-2 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<div className="flex-1 relative flex items-center">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder={t('searchPlaceholder')}
className="w-full bg-slate-50 dark:bg-slate-800 border-0 outline-none rounded-xl pl-8 pr-3 py-2 text-xs text-slate-800 dark:text-slate-100"
/>
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5" />
</div>
<button
type="button"
onClick={handleSearch}
disabled={isSearching}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-bold px-3 py-2 rounded-xl text-xs transition-all active:scale-95 shrink-0 flex items-center gap-1"
>
{isSearching ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : t('confirm')}
</button>
</div>
{searchResults.length > 0 && (
<div className="max-h-48 overflow-y-auto divide-y divide-gray-100 dark:divide-slate-800/50 bg-white dark:bg-slate-900 rounded-xl border border-slate-150 dark:border-slate-800 shadow-inner">
{searchResults.map((r, i) => (
<button
key={i}
type="button"
onClick={() => handleSelectResult(r)}
className="w-full text-left px-3 py-2.5 text-[10px] text-gray-700 dark:text-slate-350 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors truncate block"
title={r.display_name}
>
{r.display_name}
</button>
))}
</div>
)}
</div>
<MapContainer
center={position}
zoom={13}
attributionControl={false}
style={{ width: '100%', height: '100%', zIndex: 1 }}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapClickEvents onClick={handleMapClick} />
<MapInvalidator />
<RecenterMap position={position} />
{hasSelected && (
<Marker position={position} icon={DefaultIcon} />
)}
</MapContainer>
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-100 dark:border-slate-800/80 flex items-center justify-between bg-white dark:bg-slate-900 shrink-0">
<div className="text-xs text-gray-500 dark:text-slate-400">
{hasSelected ? (
<span className="font-semibold text-gray-700 dark:text-slate-200">
{t('coordsLabel')}: {position[0].toFixed(6)}, {position[1].toFixed(6)}
</span>
) : (
<span className="italic text-gray-400 dark:text-slate-500">{t('noCoordsSelected')}</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-200 dark:border-slate-800 hover:bg-gray-50 dark:hover:bg-slate-800 text-gray-700 dark:text-slate-300 rounded-xl text-xs font-bold transition-all animate-fade-in"
>
{t('cancel')}
</button>
<button
onClick={handleConfirm}
disabled={!hasSelected}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-200 disabled:text-gray-400 dark:disabled:bg-slate-800 dark:disabled:text-slate-650 disabled:cursor-not-allowed text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-blue-500/10"
>
{t('confirm')}
</button>
</div>
</div>
</div>
</div>
);
};
+129 -30
View File
@@ -1,9 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Trash2, Users } from 'lucide-react'; import { Trash2, Users, Tag as TagIcon } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => { export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
const [adultCount, setAdultCount] = useState(2); const [adultCount, setAdultCount] = useState(2);
@@ -12,6 +13,10 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const createTour = useTourStore((state) => state.createTour); const createTour = useTourStore((state) => state.createTour);
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [customTag, setCustomTag] = useState('');
const [members, setMembers] = useState<any[]>([]); const [members, setMembers] = useState<any[]>([]);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<any[]>([]); const [results, setResults] = useState<any[]>([]);
@@ -49,20 +54,42 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
setMembers((prev) => prev.filter((m) => m.id !== userId)); setMembers((prev) => prev.filter((m) => m.id !== userId));
}; };
const toggleTag = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
);
};
const addCustomTag = () => {
const tag = customTag.trim();
if (tag && !selectedTags.includes(tag)) {
setSelectedTags([...selectedTags, tag]);
setCustomTag('');
}
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
setError(''); setError('');
try { try {
const memberIds = members.map((m) => m.id); const membersPayload = members.map((m) => {
if (m.isManual) {
return { displayName: m.name };
} else {
return { userId: m.id };
}
});
const tour = await createTour({ const tour = await createTour({
title, title,
description,
startDate, startDate,
endDate, endDate,
memberIds, members: membersPayload,
adultCount, adultCount,
childCount, childCount,
childDiscount childDiscount,
tags: selectedTags
}); });
onSuccess(tour); onSuccess(tour);
onClose(); onClose();
@@ -76,30 +103,80 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
return ( return (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} /> <div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden p-6"> <div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center mb-4"> <div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h2 className="text-xl font-bold text-gray-900">Tạo Tour mới</h2> <h2 className="text-xl font-bold text-gray-900">Tạo Tour mới</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors"></button> <button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors"></button>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="p-5 space-y-4 overflow-y-auto flex-1">
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label> <label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label>
<input <input
required required
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="VD: Khám phá Đà Lạt" placeholder="VD: Khám phá Đà Lạt"
/> />
</div> </div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2 flex items-center gap-2">
<TagIcon className="w-4 h-4" /> Phân loại Tour
</label>
<div className="flex flex-wrap gap-2">
{availableTags.map(tag => (
<button
key={tag}
type="button"
onClick={() => toggleTag(tag)}
className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all border ${
selectedTags.includes(tag)
? 'bg-blue-600 text-white border-blue-600 shadow-md shadow-blue-100'
: 'bg-white text-gray-500 border-gray-200 hover:border-blue-300'
}`}
>
{tag}
</button>
))}
</div>
<div className="flex gap-2 mt-3">
<input
type="text"
value={customTag}
onChange={(e) => setCustomTag(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustomTag())}
placeholder="Thêm nhãn tùy chỉnh..."
className="flex-1 px-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={addCustomTag}
className="px-4 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all"
>
Thêm
</button>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1"> tả chuyến đi</label>
<textarea
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 resize-none text-sm"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Viết vài dòng giới thiệu về hành trình..."
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label> <label className="block text-sm font-bold text-gray-700 mb-1">Bắt đu</label>
<input <input
type="date" type="date"
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={startDate} value={startDate}
onChange={(e) => setStartDate(e.target.value)} onChange={(e) => setStartDate(e.target.value)}
/> />
@@ -108,7 +185,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
<label className="block text-sm font-bold text-gray-700 mb-1">Kết thúc</label> <label className="block text-sm font-bold text-gray-700 mb-1">Kết thúc</label>
<input <input
type="date" type="date"
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm"
value={endDate} value={endDate}
onChange={(e) => setEndDate(e.target.value)} onChange={(e) => setEndDate(e.target.value)}
/> />
@@ -140,50 +217,72 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
<p className="text-[10px] text-blue-400 italic font-medium">* Dùng đ tính toán đơn giá bình quân trong báo cáo chi phí.</p> <p className="text-[10px] text-blue-400 italic font-medium">* Dùng đ tính toán đơn giá bình quân trong báo cáo chi phí.</p>
</div> </div>
<div> <div className="space-y-2">
<label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label> <label className="block text-sm font-bold text-gray-700">Thành viên tham gia ({members.length})</label>
<div className="flex flex-wrap gap-3"> {members.length > 0 && (
{members.map((m) => ( <div className="flex flex-wrap gap-3 mb-3 p-3 bg-gray-50 rounded-2xl border border-gray-100">
<div key={m.id} className="relative"> {members.map((m) => {
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden"> const initial = m.name?.charAt(0) || '?';
{m.name} return (
<div key={m.id} className="flex flex-col items-center gap-1">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
{initial}
</div> </div>
<button <button
type="button" type="button"
onClick={() => removeMember(m.id)} onClick={() => removeMember(m.id)}
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors" className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
aria-label="Remove item" aria-label="Remove item"
> >
<Trash2 size={12} /> <Trash2 size={10} />
</button> </button>
<div className="text-[10px] text-center mt-1 max-w-[70px] truncate">{m.name}</div>
</div> </div>
))} <span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{m.name}</span>
</div>
);
})}
</div>
)}
<div className="relative"> <div className="relative">
<input <input
className="w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-sm font-bold text-gray-800"
placeholder="Tìm email..." placeholder="Tìm email hoặc nhập tên thành viên ngoài hệ thống..."
value={query} value={query}
onChange={(e) => searchUsers(e.target.value)} onChange={(e) => searchUsers(e.target.value)}
/> />
{results.length > 0 && ( {(results.length > 0 || query.trim()) && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto"> <div className="absolute bottom-full mb-2 left-0 right-0 bg-white border border-gray-100 rounded-2xl shadow-xl z-20 max-h-48 overflow-y-auto p-2 space-y-1">
{results.map((u) => ( {query.trim() && (
<button
type="button"
onClick={() => {
const name = query.trim();
setMembers((prev) => (prev.some((m) => m.name.toLowerCase() === name.toLowerCase()) ? prev : [...prev, { id: `manual-${Date.now()}`, name, isManual: true }]));
setQuery('');
setResults([]);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 rounded-xl text-blue-600 font-bold flex items-center gap-2"
>
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-600 text-sm font-black">+</span>
<span>Thêm thành viên ngoài hệ thống: "{query.trim()}"</span>
</button>
)}
{results.filter(u => !members.some(m => m.id === u.id)).map((u) => (
<button <button
key={u.id} key={u.id}
type="button" type="button"
onClick={() => confirmAddMember(u)} onClick={() => confirmAddMember(u)}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50" className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 rounded-xl flex flex-col"
> >
<span className="font-bold text-gray-900">{u.name}</span> <span className="font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</span>
<span className="block text-xs text-gray-500">{u.email}</span> <span className="text-xs text-gray-500">{u.email}</span>
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
</div>
{error && <p className="text-xs text-red-600 mt-2">{error}</p>} {error && <p className="text-xs text-red-600 mt-2">{error}</p>}
</div> </div>
File diff suppressed because one or more lines are too long
+263 -90
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { format, differenceInMinutes, parseISO } from 'date-fns'; import { format, differenceInMinutes, parseISO } from 'date-fns';
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft } from 'lucide-react'; import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare, FileText, Flag } from 'lucide-react';
import { useTourStore } from '@/store/useTourStore'; import { useTourStore } from '@/store/useTourStore';
import { ConfirmModal } from '@/components/ConfirmModal'; import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
import { CommentModal } from '@/components/CommentModal';
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => { const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
if (!actual) return null; if (!actual) return null;
@@ -38,8 +40,19 @@ const formatTravelTime = (minutes: number) => {
export const ItineraryTimeline = ({ export const ItineraryTimeline = ({
onAddLocation, onAddLocation,
onEditLocation onEditLocation,
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => { onQuickNote,
onNavigate,
onSuccess,
isPublicView = false
}: {
onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void,
onEditLocation?: (location: any) => void,
onQuickNote?: (name: string) => void,
onNavigate?: (location: any) => void,
onSuccess?: () => void,
isPublicView?: boolean
}) => {
// Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết // Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết
const currentTour = useTourStore(state => state.currentTour); const currentTour = useTourStore(state => state.currentTour);
const legs = useTourStore(state => state.legs); const legs = useTourStore(state => state.legs);
@@ -47,13 +60,57 @@ export const ItineraryTimeline = ({
const optimizeRouting = useTourStore(state => state.optimizeRouting); const optimizeRouting = useTourStore(state => state.optimizeRouting);
const addLeg = useTourStore(state => state.addLeg); const addLeg = useTourStore(state => state.addLeg);
const updateLeg = useTourStore(state => state.updateLeg); const updateLeg = useTourStore(state => state.updateLeg);
// Removed: const { isPublicView } = useTourStore(state => state); // isPublicView is passed as a prop
const deleteLeg = useTourStore(state => state.deleteLeg); const deleteLeg = useTourStore(state => state.deleteLeg);
const initializeLegs = useTourStore(state => state.initializeLegs); const initializeLegs = useTourStore(state => state.initializeLegs);
const deleteLocation = useTourStore(state => state.deleteLocation); const deleteLocation = useTourStore(state => state.deleteLocation);
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false }); // Khai báo logic canEdit để sử dụng trong toàn bộ component
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
const confirm = useConfirm();
const notify = useNotification();
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false); const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
const [tempLegCount, setTempLegCount] = useState(3); const [tempLegCount, setTempLegCount] = useState(3);
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
const [commentLocationId, setCommentLocationId] = useState('');
const [commentLocationName, setCommentLocationName] = useState('');
// State to track single expanded stage (exclusive single-expansion mode)
const [expandedStageId, setExpandedStageId] = useState<string | null>(legs.length > 0 ? legs[0]?.id : null);
const toggleStageExpanded = (legId: string) => {
// Exclusive mode: if clicking the same stage, close it. Otherwise, open only the clicked one.
setExpandedStageId(prevId => prevId === legId ? null : legId);
};
// Tối ưu hóa: Cập nhật UI ngay lập tức bằng cách can thiệp vào State của Store
const handleCommentIncrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map((leg: any) => ({
...leg,
locations: leg.locations.map((loc: any) =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
: loc
)
}));
// Dùng setState của Zustand để cập nhật một phần dữ liệu
useTourStore.setState({ legs: updatedLegs });
};
const handleCommentDecrement = (locationId: string) => {
const currentLegs = useTourStore.getState().legs;
const updatedLegs = currentLegs.map((leg: any) => ({
...leg,
locations: leg.locations.map((loc: any) =>
loc.id === locationId
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
: loc
)
}));
useTourStore.setState({ legs: updatedLegs });
};
// State cho Modal sửa chặng // State cho Modal sửa chặng
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
@@ -65,9 +122,10 @@ export const ItineraryTimeline = ({
endDate: '' endDate: ''
}); });
const toggleComplete = async (locationId: string) => { const handleStatusClick = (location: any) => {
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái if (onNavigate) {
console.log("Toggle status for location:", locationId); onNavigate(location);
}
}; };
const handleAddLeg = async () => { const handleAddLeg = async () => {
@@ -113,40 +171,48 @@ export const ItineraryTimeline = ({
}; };
const handleDeleteLeg = async (legId: string) => { const handleDeleteLeg = async (legId: string) => {
setConfirmState({ const isConfirmed = await confirm({
open: true,
title: 'Xóa chặng', title: 'Xóa chặng',
message: 'Bạn có chắc chắn muốn xóa chặng này?', message: 'Bạn có chắc chắn muốn xóa chặng này?'
onConfirm: async () => { });
if (isConfirmed) {
try { try {
await deleteLeg(legId); await deleteLeg(legId);
onSuccess?.();
} catch (err: any) { } catch (err: any) {
alert(err.message); notify({ title: 'Lỗi', message: err.message, type: 'error' });
} finally { }
setConfirmState({ open: false });
} }
},
});
}; };
const handleDeleteLocation = async (id: string) => { const handleDeleteLocation = async (id: string) => {
setConfirmState({ const isConfirmed = await confirm({
open: true,
title: 'Xóa địa điểm', title: 'Xóa địa điểm',
message: 'Bạn có chắc chắn muốn xóa địa điểm này?', message: 'Bạn có chắc chắn muốn xóa địa điểm này?'
onConfirm: async () => { });
if (isConfirmed) {
try { try {
await deleteLocation(id); await deleteLocation(id);
} catch (err: any) { alert(err.message); } finally { onSuccess?.();
setConfirmState({ open: false }); } catch (err: any) {
notify({ title: 'Lỗi', message: err.message, type: 'error' });
}
} }
},
});
}; };
// Tạo mảng phẳng tất cả địa điểm để tính toán quãng đường liên tục giữa các chặng
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) {
setExpandedStageId(legs[0].id);
}
}, [legs]);
return ( return (
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen"> <div id="itinerary-timeline-print-zone" className="timeline-scroll-container itinerary-timeline-container">
<div className="px-2 pt-4">
{legs.length === 0 ? ( {legs.length === 0 ? (
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200"> <div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" /> <List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
@@ -165,15 +231,27 @@ export const ItineraryTimeline = ({
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null; const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
return ( return (
<div key={leg.id} className="relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300"> <section key={leg.id} className={`folder-node-wrapper ${expandedStageId === leg.id ? 'expanded' : 'collapsed'}`}>
{/* Leg Header */} {/* Folder header row - clickable to toggle exclusive expansion */}
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2"> <div
onClick={() => toggleStageExpanded(leg.id)}
className="folder-header-row animate-in fade-in slide-in-from-bottom-4 duration-300 hover:bg-gray-50/50 transition-colors"
>
<div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1"> <div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm"> <span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm shrink-0">
{leg.sequence} {leg.sequence}
</span> </span>
{leg.note || `Chi tiết Chặng ${leg.sequence}`} <div className="flex flex-col overflow-hidden">
<span className="truncate leading-tight">{leg.note || `Chi tiết Chặng ${leg.sequence}`}</span>
{leg.startDate && (
<span className="text-[10px] text-gray-400 font-black uppercase tracking-wider flex items-center gap-1 mt-0.5">
<CalendarIcon className="w-2.5 h-2.5" />
{format(parseISO(leg.startDate), 'dd/MM/yyyy')}
{leg.endDate && leg.endDate !== leg.startDate && ` - ${format(parseISO(leg.endDate), 'dd/MM/yyyy')}`}
</span>
)}
</div>
</div> </div>
{prevLegLastLoc && ( {prevLegLastLoc && (
<div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10"> <div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10">
@@ -181,24 +259,24 @@ export const ItineraryTimeline = ({
</div> </div>
)} )}
</div> </div>
<div className="flex items-center gap-2 ml-4"> <div className="flex items-center gap-2 ml-4" onClick={(e) => e.stopPropagation()}>
{['OWNER', 'MANAGER'].includes(userRole || '') && ( {canEdit && (
<> <>
<button <button
onClick={() => onAddLocation?.(leg.id)} 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 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
title="Thêm địa điểm vào chặng này" title="Thêm địa điểm vào chặng này"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => handleEditLeg(leg)} 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 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
> >
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => handleDeleteLeg(leg.id)} 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 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
@@ -216,7 +294,7 @@ export const ItineraryTimeline = ({
</div> </div>
</div> </div>
)} )}
{totalDwellMinutes > 0 && ( {totalDwellMinutes > 0 && ( // Always show dwell time
<div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1"> <div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
Dừng: {formatTravelTime(totalDwellMinutes)} Dừng: {formatTravelTime(totalDwellMinutes)}
@@ -231,32 +309,81 @@ export const ItineraryTimeline = ({
<Zap className="w-3 h-3" /> <Zap className="w-3 h-3" />
Tối ưu Tối ưu
</button> </button>
)} )} {/* Only show optimize button if canEdit */}
</div> </div>
{/* Vertical Line for the whole leg */} {/* Scrollable content body with proper z-index layering */}
{/* Mở rộng đường kẻ xuống dưới (bottom-[-3rem]) để nối liền với chặng tiếp theo */} <div className="child-nodes-list-wrapper">
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} /> {/* Folder child content box - Grid accordion for exclusive expansion */}
<div className={`folder-child-content-box ${expandedStageId === leg.id ? 'expanded' : ''}`}>
<div className="child-nodes-list relative">
{/* Vertical Line for the whole leg - Dynamic height */}
<div className="absolute left-6 top-16 w-0.5 bg-blue-100 -z-0 h-full" />
<div className="ml-2"> <div className="ml-2">
{leg.locations.map((location, idx) => { {/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
// Logic quan trọng: Gán điểm cuối chặng này nối với điểm đầu chặng sau {legIdx === 0 && !leg.locations.some((loc: any) => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0]; <div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
const distanceToNext = nextLocation <div className="z-10 mt-1.5 mr-4">
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude) <div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-blue-200 flex items-center justify-center text-blue-400">
: null; <MapPin className="w-4 h-4" />
</div>
</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"
>
<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>
</div>
<Plus className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
</button>
</div>
)}
const averageSpeed = 35; // km/h {/* Nút thêm nhanh "Điểm kết thúc" cho Chặng cuối nếu chưa có */}
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null; {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">
<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"
>
<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>
</div>
<Plus className="w-5 h-5 text-red-500 group-hover:scale-110 transition-transform" />
</button>
</div>
)}
const dwellMinutes = (location.plannedStart && location.plannedEnd) {leg.locations.map((location: any) => {
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart)) // Tìm vị trí của điểm này trong toàn bộ hành trình
const globalIdx = allLocations.findIndex((loc: any) => loc.id === location.id);
const prevLocation = globalIdx > 0 ? allLocations[globalIdx - 1] : null;
const distanceFromPrev = prevLocation
? calculateDistance(prevLocation.latitude, prevLocation.longitude, location.latitude, location.longitude)
: null; : null;
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id); const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
const isStartPoint = legs[0]?.id === leg.id && idx === 0; // Nhận diện điểm mốc dựa trên timestamp đặc biệt (0) thay vì chỉ số mảng
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1; const isStartPoint = location.plannedStart && new Date(location.plannedStart).getTime() === 0;
const isEndPoint = location.plannedEnd && new Date(location.plannedEnd).getTime() === 0;
const plannedTimeStr = isStartPoint ? location.plannedEnd : location.plannedStart;
const hasValidPlannedTime = plannedTimeStr && new Date(plannedTimeStr).getTime() !== 0;
const dwellMinutes = (location.plannedStart && location.plannedEnd && !isStartPoint && !isEndPoint)
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
: null;
return ( return (
<div key={location.id}> <div key={location.id}>
@@ -264,7 +391,7 @@ export const ItineraryTimeline = ({
{/* Timeline Node */} {/* Timeline Node */}
<div className="z-10 mt-1.5 mr-4"> <div className="z-10 mt-1.5 mr-4">
<button <button
onClick={() => toggleComplete(location.id)} onClick={() => handleStatusClick(location)}
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`} className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
> >
{location.status === 'COMPLETED' ? ( {location.status === 'COMPLETED' ? (
@@ -276,9 +403,12 @@ export const ItineraryTimeline = ({
</div> </div>
{/* Card Content */} {/* Card Content */}
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${ <div
onClick={() => onNavigate?.(location)}
className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 cursor-pointer ${
location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md' location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
}`}> }`}
>
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
{isStartPoint && ( {isStartPoint && (
@@ -287,7 +417,13 @@ export const ItineraryTimeline = ({
{isEndPoint && ( {isEndPoint && (
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span> <span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
)} )}
<h3 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}> <h3
onClick={(e) => {
e.stopPropagation();
onNavigate?.(location);
}}
className={`font-semibold text-lg cursor-pointer hover:text-blue-600 transition-colors select-none ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}
>
{location.name} {location.name}
</h3> </h3>
<div className="flex items-center text-sm text-gray-500 mt-1"> <div className="flex items-center text-sm text-gray-500 mt-1">
@@ -309,7 +445,7 @@ export const ItineraryTimeline = ({
<div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1"> <div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1">
<div className="flex items-center gap-1 font-bold"> <div className="flex items-center gap-1 font-bold">
<Zap className="w-3 h-3" /> <Zap className="w-3 h-3" />
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ ({locationExpense.category})</span> <span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ</span>
</div> </div>
{locationExpense.description && ( {locationExpense.description && (
<div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div> <div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div>
@@ -324,22 +460,60 @@ export const ItineraryTimeline = ({
)} )}
</div> </div>
<div className="text-right flex flex-col items-end"> <div className="text-right flex flex-col items-end" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center text-sm font-medium text-blue-600"> <div className="flex gap-1 mb-2">
{onQuickNote && !isPublicView && (
<button
onClick={(e) => {
e.stopPropagation();
onQuickNote(location.name);
}}
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"
title="Ghi chú nhanh"
>
<FileText className="w-3 h-3" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setCommentLocationId(location.id);
setCommentLocationName(location.name);
setIsCommentModalOpen(true);
}}
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"
>
<MessageSquare className="w-3 h-3" />
{location._count?.comments > 0 && `(${location._count.comments})`}
</button>
</div>
<div className="flex items-center text-sm font-black text-blue-600">
<Clock className="w-3 h-3 mr-1" /> <Clock className="w-3 h-3 mr-1" />
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'} {hasValidPlannedTime ? format(parseISO(plannedTimeStr), 'HH:mm') : '--:--'}
</div> </div>
{location.status === 'COMPLETED' && location.actualStart && ( {location.status === 'COMPLETED' && location.actualStart && (
<div className="text-[10px] text-gray-400 mt-1 italic"> <div className="text-[10px] text-gray-400 mt-1 italic">
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')} Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
</div> </div>
)} )}
{['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && ( {canEdit && ( // Allow editing and deleting of all locations if user has edit permissions
<div className="flex gap-1 mt-2"> <div className="flex gap-1 mt-2">
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors"> <button
onClick={(e) => {
e.stopPropagation();
onEditLocation?.(location);
}}
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
>
<Edit2 className="w-3.5 h-3.5" /> <Edit2 className="w-3.5 h-3.5" />
</button> </button>
<button onClick={() => handleDeleteLocation(location.id)} className="p-1 text-gray-400 hover:text-red-600 transition-colors"> <button
onClick={(e) => {
e.stopPropagation();
handleDeleteLocation(location.id);
}}
className="p-1 text-gray-400 hover:text-red-600 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
@@ -348,22 +522,17 @@ export const ItineraryTimeline = ({
</div> </div>
{/* Logic tính toán độ lệch thời gian */} {/* Logic tính toán độ lệch thời gian */}
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} /> <TimeVariance planned={hasValidPlannedTime ? plannedTimeStr : ''} actual={location.actualStart || null} />
</div> </div>
</div> </div>
{distanceToNext !== null && travelTimeMinutes !== null && ( {/* Hiển thị quãng đường di chuyển từ điểm trước ĐẾN điểm hiện tại */}
<div className="ml-4 -mt-4 mb-2 flex items-center gap-3"> {distanceFromPrev !== null && distanceFromPrev > 0 && (
<div className="w-8 flex justify-center"> <div className="ml-14 -mt-4 mb-6 flex items-center gap-2 animate-in fade-in slide-in-from-left-2 duration-500">
<Navigation className="w-3 h-3 text-blue-400 rotate-180" /> <div className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-xl border border-blue-100 shadow-sm">
</div> <Navigation className="w-3 h-3 rotate-45" />
<div className="flex items-center gap-2"> <span className="text-[10px] font-black uppercase tracking-tighter">
<span className="text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100"> +{distanceFromPrev.toFixed(1)} km từ {prevLocation?.name.split(',')[0]}
{distanceToNext.toFixed(2)} km
</span>
<span className="text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
~ {formatTravelTime(travelTimeMinutes)}
</span> </span>
</div> </div>
</div> </div>
@@ -371,14 +540,17 @@ export const ItineraryTimeline = ({
</div> </div>
); );
})} })}
</div> </div> {/* End of ml-2 wrapper */}
</div> </div> {/* End of child-nodes-list */}
</div> {/* End of folder-child-content-box */}
</div> {/* End of child-nodes-list-wrapper */}
</section>
); );
}) })
)} )}
{/* Actions at the bottom of the list */} {/* Actions at the bottom of the list */}
{['OWNER', 'MANAGER'].includes(userRole || '') && ( {canEdit && ( // Only show these buttons if canEdit
<div className="flex flex-col gap-3 pb-20 mt-8"> <div className="flex flex-col gap-3 pb-20 mt-8">
<button <button
onClick={handleDeclareLegs} onClick={handleDeclareLegs}
@@ -395,14 +567,6 @@ export const ItineraryTimeline = ({
</button> </button>
</div> </div>
)} )}
</div>
<ConfirmModal
isOpen={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={() => confirmState.onConfirm?.()}
onCancel={() => setConfirmState({ open: false })}
/>
{/* Modal Khai báo số chặng (Popover) */} {/* Modal Khai báo số chặng (Popover) */}
{isLegCountModalOpen && ( {isLegCountModalOpen && (
@@ -523,6 +687,15 @@ export const ItineraryTimeline = ({
</div> </div>
</div> </div>
)} )}
<CommentModal
isOpen={isCommentModalOpen}
onClose={() => setIsCommentModalOpen(false)}
locationId={commentLocationId}
locationName={commentLocationName}
isPublicView={isPublicView}
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
/>
</div> </div>
); );
}; };
@@ -0,0 +1,330 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Mail, Lock, ArrowRight, Loader2, LogIn } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
interface JoinTourLoginModalProps {
isOpen: boolean;
onClose: () => void;
inviteToken: string;
onJoinSuccess?: (user: any, tourData: any) => void;
onSwitchToSignup?: () => void;
}
export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
isOpen,
onClose,
inviteToken,
onJoinSuccess,
onSwitchToSignup
}) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const notify = useNotification();
// Ref to store the latest inviteToken to avoid stale closure issue
const inviteTokenRef = useRef(inviteToken);
useEffect(() => {
inviteTokenRef.current = inviteToken;
}, [inviteToken]);
const handleGoogleLogin = async (googleResponse: any) => {
setError('');
setIsLoading(true);
try {
const response = await fetch(`/api/v1/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: googleResponse.credential }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập Google thất bại');
}
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
// Use the latest inviteToken from ref
let currentToken = inviteTokenRef.current;
console.log('[JoinTourLogin] Attempting to join with token:', currentToken?.substring(0, 10) + '...');
// Also check pendingInviteToken as fallback
let pendingToken = localStorage.getItem('pendingInviteToken');
// If no token found, try to get from URL
if (!currentToken && !pendingToken) {
const params = new URLSearchParams(window.location.search);
const urlToken = params.get('token');
if (urlToken) {
currentToken = urlToken;
localStorage.setItem('pendingInviteToken', urlToken);
console.log('[JoinTourLogin] Using token from URL params:', urlToken.substring(0, 10) + '...');
}
}
const tokenToUse = currentToken || pendingToken;
if (!tokenToUse) {
console.error('[JoinTourLogin] No invite token available');
throw new Error('Không có mã lời mời để tham gia tour');
}
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: tokenToUse }),
});
console.log('[JoinTourLogin] Join response status:', joinRes.status);
const joinData = await joinRes.json().catch(() => ({}));
if (joinRes.ok) {
console.log('[JoinTourLogin] Join successful');
notify({
title: 'Thành công',
message: joinData.message || 'Bạn đã gia nhập tour!',
type: 'success'
});
localStorage.removeItem('pendingInviteToken');
if (onJoinSuccess) {
onJoinSuccess(data.user, joinData);
}
onClose();
} else {
// Token invalid or expired - clear it and show error
console.log('[JoinTourLogin] Token invalid, clearing from storage');
localStorage.removeItem('pendingInviteToken');
// Email mismatch or other error
const errorMessage = joinData.message || `Không thể gia nhập tour (HTTP ${joinRes.status})`;
console.error('[JoinTourLogin] Join failed:', errorMessage);
setError(`Lỗi gia nhập tour: ${errorMessage}`);
setIsLoading(false);
}
} catch (e: any) {
console.error('[JoinTourLogin] Join exception:', e);
setError(`Lỗi khi gia nhập: ${e.message}`);
setIsLoading(false);
}
} catch (err: any) {
console.error('[JoinTourLogin] Google login error:', err);
setError(err.message);
setIsLoading(false);
}
};
const handleEmailPasswordJoin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const response = await fetch(`/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập thất bại');
}
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
// Use the latest inviteToken from ref
const tokenToUse = inviteTokenRef.current || localStorage.getItem('pendingInviteToken');
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: tokenToUse }),
});
const joinData = await joinRes.json().catch(() => ({}));
if (joinRes.ok) {
notify({
title: 'Thành công',
message: joinData.message || 'Bạn đã gia nhập tour!',
type: 'success'
});
localStorage.removeItem('pendingInviteToken');
if (onJoinSuccess) {
onJoinSuccess(data.user, joinData);
}
onClose();
} else {
const errorMessage = joinData.message || 'Không thể gia nhập tour';
setError(`Lỗi gia nhập tour: ${errorMessage}`);
setIsLoading(false);
}
} catch (e: any) {
setError(`Lỗi khi gia nhập: ${e.message}`);
setIsLoading(false);
}
} catch (err: any) {
console.error('[JoinTourLogin] Login error:', err);
setError(err.message);
setIsLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
const timer = setTimeout(() => {
if (typeof window !== 'undefined' && (window as any).google) {
try {
(window as any).google.accounts.id.initialize({
client_id: (import.meta as any).env.VITE_GOOGLE_CLIENT_ID || '864264639911-dummyid.apps.googleusercontent.com',
callback: handleGoogleLogin,
});
(window as any).google.accounts.id.renderButton(
document.getElementById('google-signin-btn-join-tour'),
{ theme: 'outline', size: 'large', width: '380' }
);
} catch (e) {
console.error('Lỗi khởi tạo Google Sign-in:', e);
}
}
}, 100);
return () => clearTimeout(timer);
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/50">
<div className="w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="relative h-32 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 overflow-hidden">
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_50%,rgba(255,255,255,.3)_0%,transparent_50%)]" />
</div>
<button
onClick={onClose}
className="absolute top-4 right-4 z-10 p-2 hover:bg-white/20 rounded-full transition-colors"
>
<X className="w-5 h-5 text-white" />
</button>
<div className="absolute inset-0 flex items-center justify-center">
<LogIn className="w-12 h-12 text-white opacity-80" />
</div>
</div>
{/* Content */}
<div className="p-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2 text-center">
Gia nhập tour
</h2>
<p className="text-center text-gray-600 mb-6">
Đăng nhập đ tham gia chuyến du lịch này
</p>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
{/* Google OAuth Button */}
<div className="mb-6 flex justify-center">
<div id="google-signin-btn-join-tour" className="w-full" />
</div>
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Hoặc</span>
</div>
</div>
{/* Email/Password Form */}
<form onSubmit={handleEmailPasswordJoin} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-3.5 w-5 h-5 text-gray-400" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Mật khẩu
</label>
<div className="relative">
<Lock className="absolute left-3 top-3.5 w-5 h-5 text-gray-400" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Nhập mật khẩu"
required
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-blue-500 to-purple-500 text-white font-semibold py-3 rounded-lg hover:shadow-lg transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Đang gia nhập...
</>
) : (
<>
<ArrowRight className="w-5 h-5" />
Gia nhập tour
</>
)}
</button>
</form>
{/* Signup Link */}
<div className="mt-6 text-center text-sm text-gray-600">
Chưa tài khoản?{' '}
<button
onClick={() => {
onClose();
if (onSwitchToSignup) onSwitchToSignup();
}}
className="font-semibold text-blue-500 hover:text-blue-600 transition"
>
Đăng tại đây
</button>
</div>
</div>
</div>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More