Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b15e7ff02 | |||
| a65be48d36 | |||
| ac91f6e4c8 | |||
| 09b1ee882d | |||
| acf867a375 | |||
| dd2635a44d | |||
| 29c19c6372 | |||
| 01d6d7439f | |||
| 860395cb14 | |||
| b1a539235b | |||
| 403c169ddd | |||
| 8d79cd76f6 | |||
| 392a4d4766 | |||
| 55fba75fda | |||
| 36157bd53b | |||
| 52706cab7d | |||
| ae97f061e8 | |||
| e34d197dd0 | |||
| fdf41e05fc | |||
| ae74035ad5 | |||
| ac11bb56db | |||
| 5081546cdf | |||
| 9da8a9494d | |||
| 4373ed684a | |||
| def0e0d0f9 | |||
| 36e8658dad | |||
| 6c1b77d7d2 | |||
| 4640526e4b | |||
| c7692ab9b6 | |||
| 36fbbb9386 | |||
| ef11309399 | |||
| 6f2dd0fc01 | |||
| 623f0164c8 | |||
| 88b47099b1 | |||
| 659d2a0840 | |||
| 68950dba10 | |||
| 3e215ae8e8 | |||
| e7b8c672e0 | |||
| 2462850e2e | |||
| abb61f65cd | |||
| da32d59161 | |||
| 40b1c4f2ab | |||
| 55de887f87 | |||
| ffe3cb6ecd | |||
| 69c67a1636 | |||
| f181009fa5 | |||
| b6551da147 | |||
| 2afb2971da | |||
| 14ffbb8657 | |||
| fbe5ef873b | |||
| d6ee1dde74 | |||
| 288b40ad12 | |||
| 3a3296c340 | |||
| e664a3797e | |||
| 36418673db | |||
| 98e4a4a340 | |||
| bfbbb66747 | |||
| 1933b58f84 | |||
| 05dc80bb6d | |||
| 4d63bff214 | |||
| c5474f1ff6 | |||
| 7ad785fed9 | |||
| e66f242c2c | |||
| 6026c2227a | |||
| 1eed9b00de | |||
| 1709638bfd | |||
| 37b5d14d7e | |||
| 2fabdb79df | |||
| 00554224a1 | |||
| 88b2182789 | |||
| 989d60643b | |||
| 1772ced959 | |||
| c005009da2 | |||
| bfd18e05dd | |||
| 29d39ae7b0 | |||
| 047170d4be | |||
| fc96ee9eb8 | |||
| 0a584a13c7 | |||
| f78c72ad60 | |||
| 8cca4a83ca | |||
| bc49e081c6 | |||
| 9e26aabce6 | |||
| 3e1a5db1a6 | |||
| baa83aad8a | |||
| 302a82e887 | |||
| 63da413b3c | |||
| 02c493f7e0 | |||
| b939fc959d | |||
| 5464d90948 |
@@ -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/
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"continue.enableConsole": true
|
"continue.enableConsole": true,
|
||||||
|
"remote.autoForwardPortsFallback": 0
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
@@ -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"]
|
||||||
@@ -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());
|
||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
@@ -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>>;
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
|
||||||
|
> backend@0.0.1 start:dev
|
||||||
|
> nest start --watch
|
||||||
|
|
||||||
|
[2J[3J[H[[90m10:46:05 PM[0m] Starting compilation in watch mode...
|
||||||
|
|
||||||
|
[[90m10:46:08 PM[0m] Found 0 errors. Watching for file changes.
|
||||||
|
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[NestFactory] [39m[32mStarting Nest application...[39m
|
||||||
|
--- [PRISMA CHECK] ---
|
||||||
|
DATABASE_URL nhận được: ĐÃ ĐỌC THÀNH CÔNG ✔️
|
||||||
|
----------------------
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigHostModule dependencies initialized[39m[38;5;3m +40ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mJwtModule dependencies initialized[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigModule dependencies initialized[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mCacheModule dependencies initialized[39m[38;5;3m +15ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mAppModule dependencies initialized[39m[38;5;3m +2ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[WebSocketsController] [39m[32mCommentGateway subscribed to the "joinTour" message[39m[38;5;3m +11ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[WebSocketsController] [39m[32mCommentGateway subscribed to the "joinPhoto" message[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAppController {/api/v1}:[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1, GET} route[39m[38;5;3m +2ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAuthController {/api/v1/auth}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/convert-guest, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/create-guest, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/status, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/login, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/signup/request, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/auth/signup/verify, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPublicTourController {/api/v1/tours}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id/public, GET} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mTourController {/api/v1/tours}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/locations, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/start-point, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/end-point, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/legs/batch, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/legs, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, PATCH} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/explore, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:id, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/members, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests, GET} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests/:requestId/accept, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/join-requests/:requestId/reject, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/members/:userId, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/tours/:tourId/photos, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mUserController {/api/v1/users}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/me/photos, GET} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/:id, PATCH} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/users/block/:id, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mRoutingController {/api/v1/routing}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/routing/optimize/:legId, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mLegController {/api/v1/legs}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/legs/:id, PATCH} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/legs/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mLocationController {/api/v1/locations}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:id, PATCH} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:id, DELETE} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mCommentController {/api/v1/locations}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:locationId/comments, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/locations/:locationId/comments, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPhotoController {/api/v1/photos}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/photos/upload-anonymous, POST} route[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/photos/:id, DELETE} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mPublicPhotoController {/api/v1/public-photos}:[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos/:photoId/comments, GET} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/public-photos/:photoId/comments, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RoutesResolver] [39m[32mAdminOtpController {/api/v1/admin/otp}:[39m[38;5;3m +1ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/admin/otp/send, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[RouterExplorer] [39m[32mMapped {/api/v1/admin/otp/verify, POST} route[39m[38;5;3m +0ms[39m
|
||||||
|
[32m[Nest] 2108058 - [39m06/19/2026, 10:46:09 PM [32m LOG[39m [38;5;3m[NestApplication] [39m[32mNest application successfully started[39m[38;5;3m +16ms[39m
|
||||||
|
🚀 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
|
||||||
@@ -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;
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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());
|
||||||
@@ -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());
|
||||||
@@ -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());
|
||||||
@@ -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);
|
||||||
|
After Width: | Height: | Size: 868 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 562 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 756 KiB |
|
After Width: | Height: | Size: 500 KiB |
|
After Width: | Height: | Size: 518 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 646 KiB |
|
After Width: | Height: | Size: 844 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 516 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 561 KiB |
|
After Width: | Height: | Size: 844 KiB |
|
After Width: | Height: | Size: 233 KiB |
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
@@ -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;"]
|
||||||
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 224 KiB |
@@ -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>
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 224 KiB |
@@ -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ệ thống đã đượ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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 có 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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ử lý ảnh...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Xác nhận tải lên'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 có 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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">Mô 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 có tài khoản?{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
if (onSwitchToSignup) onSwitchToSignup();
|
||||||
|
}}
|
||||||
|
className="font-semibold text-blue-500 hover:text-blue-600 transition"
|
||||||
|
>
|
||||||
|
Đăng ký tại đây
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||