Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d39ae7b0 | |||
| 047170d4be | |||
| fc96ee9eb8 | |||
| 0a584a13c7 | |||
| f78c72ad60 | |||
| 8cca4a83ca | |||
| bc49e081c6 | |||
| 9e26aabce6 | |||
| 3e1a5db1a6 | |||
| baa83aad8a | |||
| 302a82e887 | |||
| 63da413b3c | |||
| 02c493f7e0 | |||
| b939fc959d | |||
| 5464d90948 |
Vendored
+8
@@ -1 +1,9 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
import { OnGatewayConnection } from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
export declare class CommentGateway implements OnGatewayConnection {
|
||||||
|
server: Server;
|
||||||
|
handleConnection(client: Socket): void;
|
||||||
|
handleJoinTour(client: Socket, tourId: string): void;
|
||||||
|
notifyNewComment(tourId: string, data: any): void;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+160
-5
@@ -45,6 +45,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|||||||
return function (target, key) { decorator(target, key, paramIndex); }
|
return function (target, key) { decorator(target, key, paramIndex); }
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CommentGateway = void 0;
|
||||||
const dotenv = __importStar(require("dotenv"));
|
const dotenv = __importStar(require("dotenv"));
|
||||||
const path = __importStar(require("path"));
|
const path = __importStar(require("path"));
|
||||||
const envPath = path.resolve(process.cwd(), '..', '.env');
|
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||||
@@ -52,6 +53,8 @@ dotenv.config({ path: envPath });
|
|||||||
require("reflect-metadata");
|
require("reflect-metadata");
|
||||||
const core_1 = require("@nestjs/core");
|
const core_1 = require("@nestjs/core");
|
||||||
const common_1 = require("@nestjs/common");
|
const common_1 = require("@nestjs/common");
|
||||||
|
const websockets_1 = require("@nestjs/websockets");
|
||||||
|
const socket_io_1 = require("socket.io");
|
||||||
const prisma_service_1 = require("../prisma/prisma.service");
|
const prisma_service_1 = require("../prisma/prisma.service");
|
||||||
const bcrypt = __importStar(require("bcrypt"));
|
const bcrypt = __importStar(require("bcrypt"));
|
||||||
const admin_guard_1 = require("./auth/admin.guard");
|
const admin_guard_1 = require("./auth/admin.guard");
|
||||||
@@ -154,17 +157,68 @@ AuthController = __decorate([
|
|||||||
(0, common_1.Controller)('auth'),
|
(0, common_1.Controller)('auth'),
|
||||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService, jwt_1.JwtService])
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService, jwt_1.JwtService])
|
||||||
], AuthController);
|
], AuthController);
|
||||||
|
let PublicTourController = class PublicTourController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async getPublicTourDetails(id) {
|
||||||
|
const tour = await this.prisma.tour.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
participants: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, name: true, email: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
photos: true,
|
||||||
|
legs: {
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
include: {
|
||||||
|
expenses: {
|
||||||
|
include: {
|
||||||
|
location: { select: { name: true, plannedStart: true } },
|
||||||
|
paidBy: { select: { name: true } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
locations: {
|
||||||
|
orderBy: { plannedStart: 'asc' },
|
||||||
|
include: { _count: { select: { comments: true } } }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!tour)
|
||||||
|
throw new common_1.NotFoundException(`Không tìm thấy Tour`);
|
||||||
|
return tour;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Get)(':id/public'),
|
||||||
|
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], PublicTourController.prototype, "getPublicTourDetails", null);
|
||||||
|
PublicTourController = __decorate([
|
||||||
|
(0, common_1.Controller)('tours'),
|
||||||
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||||
|
], PublicTourController);
|
||||||
let TourController = class TourController {
|
let TourController = class TourController {
|
||||||
constructor(prisma) {
|
constructor(prisma) {
|
||||||
this.prisma = prisma;
|
this.prisma = prisma;
|
||||||
}
|
}
|
||||||
async createTour(body, req) {
|
async createTour(body, req) {
|
||||||
const { title, startDate, endDate, adultCount, childCount, childDiscount } = body;
|
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
|
||||||
return this.prisma.tour.create({
|
return this.prisma.tour.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
startDate: startDate ? new Date(startDate) : null,
|
startDate: startDate ? new Date(startDate) : null,
|
||||||
endDate: endDate ? new Date(endDate) : null,
|
endDate: endDate ? new Date(endDate) : null,
|
||||||
|
tags: tags || [],
|
||||||
adultCount: adultCount || 1,
|
adultCount: adultCount || 1,
|
||||||
childCount: childCount || 0,
|
childCount: childCount || 0,
|
||||||
childDiscount: childDiscount || 0,
|
childDiscount: childDiscount || 0,
|
||||||
@@ -332,6 +386,7 @@ let TourController = class TourController {
|
|||||||
title: body.title,
|
title: body.title,
|
||||||
description: body.description,
|
description: body.description,
|
||||||
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
||||||
|
tags: body.tags,
|
||||||
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||||
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
|
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
|
||||||
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
||||||
@@ -354,11 +409,18 @@ let TourController = class TourController {
|
|||||||
},
|
},
|
||||||
take: 20,
|
take: 20,
|
||||||
include: {
|
include: {
|
||||||
|
participants: {
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
select: { role: true }
|
||||||
|
},
|
||||||
photos: { take: 1 },
|
photos: { take: 1 },
|
||||||
legs: {
|
legs: {
|
||||||
orderBy: { sequence: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
locations: { orderBy: { plannedStart: 'asc' } }
|
locations: {
|
||||||
|
orderBy: { plannedStart: 'asc' },
|
||||||
|
include: { _count: { select: { comments: true } } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,7 +441,16 @@ let TourController = class TourController {
|
|||||||
legs: {
|
legs: {
|
||||||
orderBy: { sequence: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
locations: { orderBy: { plannedStart: 'asc' } },
|
expenses: {
|
||||||
|
include: {
|
||||||
|
location: { select: { name: true, plannedStart: true } },
|
||||||
|
paidBy: { select: { name: true } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
locations: {
|
||||||
|
orderBy: { plannedStart: 'asc' },
|
||||||
|
include: { _count: { select: { comments: true } } }
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1014,6 +1085,90 @@ UserController = __decorate([
|
|||||||
(0, common_1.Controller)('users'),
|
(0, common_1.Controller)('users'),
|
||||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||||
], UserController);
|
], UserController);
|
||||||
|
let CommentGateway = class CommentGateway {
|
||||||
|
handleConnection(client) {
|
||||||
|
console.log(`[WS] Client connected: ${client.id}`);
|
||||||
|
}
|
||||||
|
handleJoinTour(client, tourId) {
|
||||||
|
client.join(`tour_${tourId}`);
|
||||||
|
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
|
||||||
|
}
|
||||||
|
notifyNewComment(tourId, data) {
|
||||||
|
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
exports.CommentGateway = CommentGateway;
|
||||||
|
__decorate([
|
||||||
|
(0, websockets_1.WebSocketServer)(),
|
||||||
|
__metadata("design:type", socket_io_1.Server)
|
||||||
|
], CommentGateway.prototype, "server", void 0);
|
||||||
|
__decorate([
|
||||||
|
(0, websockets_1.SubscribeMessage)('joinTour'),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
|
||||||
|
__metadata("design:returntype", void 0)
|
||||||
|
], CommentGateway.prototype, "handleJoinTour", null);
|
||||||
|
exports.CommentGateway = CommentGateway = __decorate([
|
||||||
|
(0, websockets_1.WebSocketGateway)({ cors: { origin: '*' } })
|
||||||
|
], CommentGateway);
|
||||||
|
let CommentController = class CommentController {
|
||||||
|
constructor(prisma, commentGateway) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
this.commentGateway = commentGateway;
|
||||||
|
}
|
||||||
|
async getComments(locationId) {
|
||||||
|
return this.prisma.comment.findMany({
|
||||||
|
where: { locationId },
|
||||||
|
include: {
|
||||||
|
user: { select: { name: true } }
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async addComment(locationId, body, req) {
|
||||||
|
const comment = await this.prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
content: body.content,
|
||||||
|
locationId,
|
||||||
|
userId: req.user.id
|
||||||
|
},
|
||||||
|
include: { user: { select: { name: true } } }
|
||||||
|
});
|
||||||
|
const location = await this.prisma.location.findUnique({
|
||||||
|
where: { id: locationId },
|
||||||
|
include: { leg: { select: { tourId: true } } }
|
||||||
|
});
|
||||||
|
if (location?.leg?.tourId) {
|
||||||
|
this.commentGateway.notifyNewComment(location.leg.tourId, {
|
||||||
|
...comment,
|
||||||
|
locationId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Get)(':locationId/comments'),
|
||||||
|
__param(0, (0, common_1.Param)('locationId', common_1.ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], CommentController.prototype, "getComments", null);
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||||
|
(0, common_1.Post)(':locationId/comments'),
|
||||||
|
__param(0, (0, common_1.Param)('locationId', common_1.ParseUUIDPipe)),
|
||||||
|
__param(1, (0, common_1.Body)()),
|
||||||
|
__param(2, (0, common_1.Req)()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], CommentController.prototype, "addComment", null);
|
||||||
|
CommentController = __decorate([
|
||||||
|
(0, common_1.Controller)('locations'),
|
||||||
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||||
|
CommentGateway])
|
||||||
|
], CommentController);
|
||||||
let AppModule = class AppModule {
|
let AppModule = class AppModule {
|
||||||
};
|
};
|
||||||
AppModule = __decorate([
|
AppModule = __decorate([
|
||||||
@@ -1024,8 +1179,8 @@ AppModule = __decorate([
|
|||||||
signOptions: { expiresIn: '1d' },
|
signOptions: { expiresIn: '1d' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController],
|
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController],
|
||||||
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, rbac_middleware_1.TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard],
|
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, rbac_middleware_1.TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway],
|
||||||
exports: [prisma_service_1.PrismaService]
|
exports: [prisma_service_1.PrismaService]
|
||||||
})
|
})
|
||||||
], AppModule);
|
], AppModule);
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -5,15 +5,17 @@
|
|||||||
"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": {
|
||||||
@@ -22,14 +24,17 @@
|
|||||||
"@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",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"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",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2",
|
||||||
|
"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[];
|
||||||
@@ -73,6 +73,8 @@ model User {
|
|||||||
receivedJoinRequests JoinRequest[] @relation("JoinRequester")
|
receivedJoinRequests JoinRequest[] @relation("JoinRequester")
|
||||||
uploadedPhotos Photo[]
|
uploadedPhotos Photo[]
|
||||||
paidExpenses Expense[] @relation("ExpensePaidBy")
|
paidExpenses Expense[] @relation("ExpensePaidBy")
|
||||||
|
comments Comment[]
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Tour {
|
model Tour {
|
||||||
@@ -88,6 +90,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])
|
||||||
|
|
||||||
@@ -157,6 +160,7 @@ 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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Expense {
|
model Expense {
|
||||||
@@ -188,3 +192,13 @@ model Photo {
|
|||||||
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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Comment {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
locationId String
|
||||||
|
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|||||||
+25
-2
@@ -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}`);
|
||||||
|
|||||||
+140
-7
@@ -7,7 +7,9 @@ dotenv.config({ path: envPath });
|
|||||||
|
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException } from '@nestjs/common';
|
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { AdminGuard } from './auth/admin.guard';
|
import { AdminGuard } from './auth/admin.guard';
|
||||||
@@ -101,6 +103,46 @@ class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('tours') // Controller mới để xử lý các tour công khai
|
||||||
|
class PublicTourController {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get(':id/public')
|
||||||
|
async getPublicTourDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const tour = await this.prisma.tour.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
participants: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, name: true, email: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
photos: true,
|
||||||
|
legs: {
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
include: {
|
||||||
|
expenses: {
|
||||||
|
include: {
|
||||||
|
location: { select: { name: true, plannedStart: true } },
|
||||||
|
paidBy: { select: { name: true } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
locations: {
|
||||||
|
orderBy: { plannedStart: 'asc' },
|
||||||
|
include: { _count: { select: { comments: true } } }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tour) throw new NotFoundException(`Không tìm thấy Tour`);
|
||||||
|
return tour;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('tours')
|
@Controller('tours')
|
||||||
class TourController {
|
class TourController {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
@@ -108,12 +150,14 @@ class TourController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post()
|
@Post()
|
||||||
async createTour(@Body() body: any, @Req() req: any) {
|
async createTour(@Body() body: any, @Req() req: any) {
|
||||||
const { title, startDate, endDate, adultCount, childCount, childDiscount } = body;
|
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
|
||||||
return this.prisma.tour.create({
|
return this.prisma.tour.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
startDate: startDate ? new Date(startDate) : null,
|
startDate: startDate ? new Date(startDate) : null,
|
||||||
endDate: endDate ? new Date(endDate) : null,
|
endDate: endDate ? new Date(endDate) : null,
|
||||||
|
tags: tags || [],
|
||||||
adultCount: adultCount || 1,
|
adultCount: adultCount || 1,
|
||||||
childCount: childCount || 0,
|
childCount: childCount || 0,
|
||||||
childDiscount: childDiscount || 0,
|
childDiscount: childDiscount || 0,
|
||||||
@@ -326,6 +370,7 @@ class TourController {
|
|||||||
title: body.title,
|
title: body.title,
|
||||||
description: body.description,
|
description: body.description,
|
||||||
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
||||||
|
tags: body.tags,
|
||||||
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||||
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
|
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
|
||||||
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
||||||
@@ -356,11 +401,18 @@ class TourController {
|
|||||||
},
|
},
|
||||||
take: 20,
|
take: 20,
|
||||||
include: {
|
include: {
|
||||||
|
participants: {
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
select: { role: true }
|
||||||
|
},
|
||||||
photos: { take: 1 },
|
photos: { take: 1 },
|
||||||
legs: {
|
legs: {
|
||||||
orderBy: { sequence: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
locations: { orderBy: { plannedStart: 'asc' } }
|
locations: {
|
||||||
|
orderBy: { plannedStart: 'asc' },
|
||||||
|
include: { _count: { select: { comments: true } } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,7 +436,16 @@ class TourController {
|
|||||||
legs: {
|
legs: {
|
||||||
orderBy: { sequence: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
locations: { orderBy: { plannedStart: 'asc' } },
|
expenses: {
|
||||||
|
include: {
|
||||||
|
location: { select: { name: true, plannedStart: true } },
|
||||||
|
paidBy: { select: { name: true } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
locations: {
|
||||||
|
orderBy: { plannedStart: 'asc' },
|
||||||
|
include: { _count: { select: { comments: true } } }
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -871,15 +932,87 @@ class UserController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@WebSocketGateway({ cors: { origin: '*' } })
|
||||||
|
export class CommentGateway implements OnGatewayConnection {
|
||||||
|
@WebSocketServer() server: Server;
|
||||||
|
|
||||||
|
handleConnection(client: Socket) {
|
||||||
|
console.log(`[WS] Client connected: ${client.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('joinTour')
|
||||||
|
handleJoinTour(client: Socket, tourId: string) {
|
||||||
|
client.join(`tour_${tourId}`);
|
||||||
|
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyNewComment(tourId: string, data: any) {
|
||||||
|
// Gửi thông báo tới tất cả client trong phòng của Tour này
|
||||||
|
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('locations')
|
||||||
|
class CommentController {
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private commentGateway: CommentGateway
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':locationId/comments')
|
||||||
|
// Cho phép khách xem bình luận mà không cần đăng nhập
|
||||||
|
async getComments(@Param('locationId', ParseUUIDPipe) locationId: string) {
|
||||||
|
return this.prisma.comment.findMany({
|
||||||
|
where: { locationId },
|
||||||
|
include: {
|
||||||
|
user: { select: { name: true } }
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post(':locationId/comments')
|
||||||
|
async addComment(
|
||||||
|
@Param('locationId', ParseUUIDPipe) locationId: string,
|
||||||
|
@Body() body: { content: string },
|
||||||
|
@Req() req: any
|
||||||
|
) {
|
||||||
|
const comment = await this.prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
content: body.content,
|
||||||
|
locationId,
|
||||||
|
userId: req.user.id
|
||||||
|
},
|
||||||
|
include: { user: { select: { name: true } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tìm tourId để gửi thông báo vào đúng phòng
|
||||||
|
const location = await this.prisma.location.findUnique({
|
||||||
|
where: { id: locationId },
|
||||||
|
include: { leg: { select: { tourId: true } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (location?.leg?.tourId) {
|
||||||
|
this.commentGateway.notifyNewComment(location.leg.tourId, {
|
||||||
|
...comment,
|
||||||
|
locationId // Gửi kèm locationId để UI biết điểm nào cần tăng số lượng
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET || 'super-secret',
|
secret: process.env.JWT_SECRET || 'super-secret',
|
||||||
signOptions: { expiresIn: '1d' },
|
signOptions: { expiresIn: '1d' },
|
||||||
}),
|
}) as any,
|
||||||
],
|
],
|
||||||
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController],
|
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController],
|
||||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard],
|
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway],
|
||||||
exports: [PrismaService]
|
exports: [PrismaService]
|
||||||
})
|
})
|
||||||
class AppModule {}
|
class AppModule {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<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" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<title>Travel Planner</title>
|
<title>Travel Planner</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"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",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"zustand": "^5.0.1"
|
"zustand": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+91
-67
@@ -1,88 +1,112 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { 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 { useTourStore } from './store/useTourStore';
|
||||||
|
import { ConfirmProvider } from './hooks/useConfirm';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const viewTourId = params.get('viewTour');
|
||||||
|
|
||||||
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 [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup'>(viewTourId ? 'tourDetail' : 'landing');
|
||||||
|
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||||
|
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
||||||
|
|
||||||
|
// Lấy action từ store
|
||||||
const fetchTour = useTourStore(state => state.fetchTour);
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
|
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails);
|
||||||
|
|
||||||
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 parsedUser = JSON.parse(savedUser);
|
if (viewTourId) {
|
||||||
setUser(parsedUser);
|
// Không gọi replaceState ngay để tránh mất ID khi component re-render hoặc refresh
|
||||||
|
} else {
|
||||||
|
// Kiểm tra đăng nhập bình thường nếu không có tham số viewTour
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const storedUser = localStorage.getItem('user');
|
||||||
|
if (token && storedUser) {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(storedUser));
|
||||||
|
setCurrentPage('explore'); // Chuyển đến bản đồ khám phá nếu đã đăng nhập
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Lỗi khi phân tích dữ liệu người dùng từ localStorage", e);
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
setCurrentPage('landing'); // Quay về trang Landing nếu dữ liệu lỗi
|
||||||
}
|
}
|
||||||
setIsUserLoaded(true); // Đánh dấu user đã được load
|
} else {
|
||||||
|
setCurrentPage('landing'); // Quay về trang Landing nếu chưa đăng nhập
|
||||||
// Kiểm tra xem hệ thống đã được cài đặt chưa
|
|
||||||
fetch(`/api/v1/auth/status`)
|
|
||||||
.then(res => res.ok ? res.json() : Promise.reject())
|
|
||||||
.then(data => setIsInitialSetup(!!data.isInitialSetup))
|
|
||||||
.catch(() => setIsInitialSetup(false));
|
|
||||||
}, []); // 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
|
|
||||||
useEffect(() => {
|
|
||||||
if (isUserLoaded && user && view === 'landing') {
|
|
||||||
setView('explore');
|
|
||||||
}
|
}
|
||||||
}, [isUserLoaded, user, view]);
|
}
|
||||||
|
}, []); // Chỉ chạy một lần khi component mount
|
||||||
|
|
||||||
const handleLoginSuccess = (userData: any) => {
|
const handleLoginSuccess = (loggedInUser: any) => {
|
||||||
setUser(userData);
|
setUser(loggedInUser);
|
||||||
|
setCurrentPage('explore');
|
||||||
};
|
};
|
||||||
|
|
||||||
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) => {
|
||||||
|
setCurrentTourId(tourId);
|
||||||
|
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
|
||||||
|
setCurrentPage('tourDetail');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackFromTourDetail = () => {
|
||||||
|
setCurrentTourId(null);
|
||||||
|
setIsPublicTourView(false);
|
||||||
|
// Quay về trang khám phá nếu đã đăng nhập, ngược lại quay về Landing
|
||||||
|
if (user) {
|
||||||
|
setCurrentPage('explore');
|
||||||
|
} else {
|
||||||
|
setCurrentPage('landing');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackFromSignup = () => {
|
||||||
|
setCurrentPage('landing');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignupSuccess = () => {
|
||||||
|
setCurrentPage('landing'); // Quay về trang Landing sau khi đăng ký thành công
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<ConfirmProvider>
|
||||||
{view === 'landing' && (
|
{(() => {
|
||||||
<LandingPage
|
if (currentPage === 'tourDetail') {
|
||||||
isInitialSetup={isInitialSetup}
|
return (
|
||||||
onContinue={() => setView('explore')}
|
<TourDetailPage
|
||||||
onGoToSignup={() => setView('signup')}
|
tourId={currentTourId!}
|
||||||
onGoToMap={() => setView('explore')}
|
onBack={handleBackFromTourDetail}
|
||||||
onLoginSuccess={handleLoginSuccess}
|
isPublicView={isPublicTourView}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'signup' && (
|
|
||||||
<SignupPage
|
|
||||||
onBack={() => setView('landing')}
|
|
||||||
onSuccess={() => setView('landing')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'explore' && (
|
|
||||||
<ExploreMap
|
|
||||||
onBack={() => setView('landing')}
|
|
||||||
onLogout={user ? handleLogout : undefined}
|
|
||||||
user={user}
|
|
||||||
onViewTour={(id) => {
|
|
||||||
fetchTour(id);
|
|
||||||
setView('detail');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'detail' && (
|
|
||||||
<TourDetailPage onBack={() => setView('explore')} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
if (currentPage === 'explore') {
|
||||||
|
return <ExploreMap onBack={handleBackFromTourDetail} onLogout={handleLogout} user={user} onViewTour={handleViewTour} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage === 'signup') {
|
||||||
|
return <SignupPage onBack={handleBackFromSignup} onSuccess={handleSignupSuccess} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
|
||||||
|
})()}
|
||||||
|
</ConfirmProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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, Clock, Map as MapIcon, Navigation, Search } from 'lucide-react';
|
||||||
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
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 +60,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, isPublicView = false }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isPublicView?: boolean }) => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -77,9 +78,13 @@ 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, mapCenter, currentTour, userRole } = useTourStore();
|
||||||
|
|
||||||
// 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,7 +101,7 @@ 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 || '',
|
||||||
@@ -117,11 +122,31 @@ 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 đó làm tham chiếu)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && !formData.name) {
|
if (isOpen && !editingLocation && formData.legId && !formData.name) {
|
||||||
|
const selectedLeg = legs.find(l => l.id === formData.legId);
|
||||||
|
|
||||||
|
if (selectedLeg && selectedLeg.locations && selectedLeg.locations.length > 0) {
|
||||||
|
// Di chuyển đến địa điểm cuối cùng của chặng để 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 {
|
||||||
|
// Nếu chặng chưa có điểm nào, 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 : '');
|
||||||
@@ -129,6 +154,55 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
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 = 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 = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
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');
|
||||||
|
|
||||||
|
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,8 +220,60 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUseCurrentLocation = () => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
alert("Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị GPS.");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
alert(errorMessage);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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();
|
||||||
@@ -155,6 +281,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
try {
|
try {
|
||||||
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),
|
||||||
@@ -188,21 +315,79 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
|||||||
|
|
||||||
{/* 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-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group">
|
||||||
<MapContainer center={[formData.latitude, formData.longitude]} zoom={13} className="h-full w-full">
|
<MapContainer center={currentCoords} zoom={13} className="h-full w-full">
|
||||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
<Marker position={[formData.latitude, formData.longitude]} />
|
<Marker position={currentCoords} />
|
||||||
<MapPicker center={[formData.latitude, formData.longitude]} onPick={handlePickLocation} />
|
<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 className="relative">
|
||||||
<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"
|
<div className="relative group">
|
||||||
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
|
<input
|
||||||
|
required
|
||||||
|
placeholder="Gõ để tìm kiếm đị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"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={e => handleSearchLocation(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
||||||
|
{isSearching ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||||
|
) : formData.name ? (
|
||||||
|
<button type="button" onClick={() => { setFormData({...formData, name: ''}); setSearchResults([]); setHasNoResults(false); }} className="hover:text-red-500 transition-colors">
|
||||||
|
<X className="w-4 h-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Search className="w-4 h-4 text-gray-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(searchResults.length > 0 || hasNoResults) && (
|
||||||
|
<div className="absolute z-[5000] left-0 right-0 mt-2 bg-white border border-gray-200 rounded-2xl shadow-2xl overflow-hidden max-h-64 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
|
||||||
|
{hasNoResults ? (
|
||||||
|
<div className="px-4 py-6 text-center text-gray-400 text-sm italic">
|
||||||
|
Không tìm thấy địa điểm nào phù hợp...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
searchResults.map((result, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); selectSearchResult(result); }}
|
||||||
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-all flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="font-bold text-sm text-gray-900 line-clamp-1">
|
||||||
|
{result.namedetails?.name || result.display_name.split(',')[0]}
|
||||||
|
</div>
|
||||||
|
{result.type && (
|
||||||
|
<span className="text-[9px] 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 line-clamp-2 leading-tight">{result.display_name}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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 +405,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>
|
||||||
@@ -269,7 +458,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>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ interface AddMemberModalProps {
|
|||||||
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 }) => {
|
||||||
@@ -134,7 +135,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">
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
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 { useConfirm } from '@/hooks/useConfirm';
|
||||||
|
|
||||||
|
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 confirm = useConfirm();
|
||||||
|
|
||||||
|
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
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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,6 +54,20 @@ 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);
|
||||||
@@ -57,12 +76,14 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
|||||||
const memberIds = members.map((m) => m.id);
|
const memberIds = members.map((m) => m.id);
|
||||||
const tour = await createTour({
|
const tour = await createTour({
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
memberIds,
|
memberIds,
|
||||||
adultCount,
|
adultCount,
|
||||||
childCount,
|
childCount,
|
||||||
childDiscount
|
childDiscount,
|
||||||
|
tags: selectedTags
|
||||||
});
|
});
|
||||||
onSuccess(tour);
|
onSuccess(tour);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -94,6 +115,56 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
|||||||
/>
|
/>
|
||||||
</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>
|
||||||
|
|||||||
@@ -56,18 +56,26 @@ export const ExpenseManager = () => {
|
|||||||
const avgPerLeg = legs.length > 0 ? totalAmount / legs.length : 0;
|
const avgPerLeg = legs.length > 0 ? totalAmount / legs.length : 0;
|
||||||
|
|
||||||
// Danh sách phẳng tất cả chi phí để hiển thị bảng
|
// Danh sách phẳng tất cả chi phí để hiển thị bảng
|
||||||
const flatExpenses = (legs || []).flatMap(leg =>
|
const flatExpenses: any[] = [];
|
||||||
(leg.expenses || []).map((exp: any) => ({
|
[...(legs || [])].sort((a, b) => a.sequence - b.sequence).forEach((leg, legIdx) => {
|
||||||
|
const legExpenses = (leg.expenses || []).map((exp: any) => ({
|
||||||
...exp,
|
...exp,
|
||||||
legSequence: leg.sequence,
|
legSequence: leg.sequence,
|
||||||
// Lấy ngày của Location nếu có, nếu không lấy ngày của Leg
|
legDisplayIndex: legIdx + 1, // Số thứ tự chặng liên tục (1, 2, 3...)
|
||||||
date: exp.location?.plannedStart || leg.startDate || null
|
date: exp.location?.plannedStart || leg.startDate || null
|
||||||
}))
|
})).sort((a: any, b: any) => {
|
||||||
).sort((a, b) => {
|
|
||||||
if (!a.date || !b.date) return 0;
|
if (!a.date || !b.date) return 0;
|
||||||
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
legExpenses.forEach((exp: any, idx: number) => {
|
||||||
|
flatExpenses.push({
|
||||||
|
...exp,
|
||||||
|
isFirstInLeg: idx === 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const childRateFactor = 1 - (Number(discount) / 100);
|
const childRateFactor = 1 - (Number(discount) / 100);
|
||||||
const weightedCount = adults + (children * childRateFactor);
|
const weightedCount = adults + (children * childRateFactor);
|
||||||
const adultPrice = totalAmount / weightedCount;
|
const adultPrice = totalAmount / weightedCount;
|
||||||
@@ -126,10 +134,10 @@ export const ExpenseManager = () => {
|
|||||||
|
|
||||||
// Cấu hình bảng dữ liệu
|
// Cấu hình bảng dữ liệu
|
||||||
const tableColumn = ["STT", "Ngày giờ", "Chặng", "Dịch vụ", "Số tiền (VNĐ)", "Người chi"];
|
const tableColumn = ["STT", "Ngày giờ", "Chặng", "Dịch vụ", "Số tiền (VNĐ)", "Người chi"];
|
||||||
const tableRows = stats.list.map((exp: any, index: number) => [
|
const tableRows = stats.list.map((exp: any) => [
|
||||||
index + 1,
|
exp.isFirstInLeg ? exp.legSequence : '',
|
||||||
exp.date ? format(new Date(exp.date), 'dd/MM HH:mm') : '--/--',
|
exp.date ? format(new Date(exp.date), 'd/M - p') : '--/--',
|
||||||
`Chặng ${exp.legSequence}`,
|
exp.isFirstInLeg ? `Chặng ${exp.legSequence}` : '',
|
||||||
exp.description || 'Không có mô tả',
|
exp.description || 'Không có mô tả',
|
||||||
Number(exp.amount).toLocaleString(),
|
Number(exp.amount).toLocaleString(),
|
||||||
exp.paidBy?.name || 'Chưa rõ'
|
exp.paidBy?.name || 'Chưa rõ'
|
||||||
@@ -305,22 +313,35 @@ export const ExpenseManager = () => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-50">
|
<tbody className="divide-y divide-gray-50">
|
||||||
{stats.list.length > 0 ? (
|
{stats.list.length > 0 ? (
|
||||||
stats.list.map((exp: any, index: number) => (
|
stats.list.map((exp: any) => (
|
||||||
<tr key={exp.id} className="hover:bg-blue-50/20 transition-colors text-sm">
|
<tr key={exp.id} className={`hover:bg-blue-50/20 transition-colors text-sm ${!exp.isFirstInLeg ? 'bg-gray-50/10' : ''}`}>
|
||||||
<td className="px-4 py-4 text-center font-bold text-gray-400">{index + 1}</td>
|
<td className="px-4 py-4 text-center font-black text-gray-400">
|
||||||
|
{exp.isFirstInLeg ? exp.legDisplayIndex : ''}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4">
|
||||||
<div className="flex items-center gap-1.5 text-gray-600 font-medium">
|
<div className="flex items-center gap-1.5 text-gray-600 font-medium">
|
||||||
<Calendar className="w-3.5 h-3.5 opacity-40" />
|
<Calendar className="w-3.5 h-3.5 opacity-40" />
|
||||||
{exp.date ? format(new Date(exp.date), 'dd/MM HH:mm') : '--/--'}
|
{exp.date ? format(new Date(exp.date), 'd/M - p') : '--/--'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4 whitespace-nowrap">
|
<td className="px-4 py-4 whitespace-nowrap">
|
||||||
|
{exp.isFirstInLeg ? (
|
||||||
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-lg font-bold text-[10px]">
|
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-lg font-bold text-[10px]">
|
||||||
Chặng {exp.legSequence}
|
Chặng {exp.legSequence}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="ml-6 border-l-2 border-blue-100/50 h-4" />
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4 font-semibold text-gray-800">
|
<td className="px-4 py-4 font-semibold text-gray-800">
|
||||||
{exp.description || 'Không có mô tả'}
|
{exp.location?.name ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-gray-900">{exp.description || 'Chi phí dịch vụ'}</span>
|
||||||
|
<span className="text-[10px] text-blue-500 font-bold uppercase tracking-tighter">@{exp.location.name}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
exp.description || 'Không có mô tả'
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4 text-right font-black text-blue-600">
|
<td className="px-4 py-4 text-right font-black text-blue-600">
|
||||||
{Number(exp.amount).toLocaleString()}đ
|
{Number(exp.amount).toLocaleString()}đ
|
||||||
@@ -337,26 +358,15 @@ export const ExpenseManager = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
// Khung bảng mẫu khi chưa có dữ liệu (Placeholder)
|
|
||||||
[1, 2, 3].map((i) => (
|
[1, 2, 3].map((i) => (
|
||||||
<tr key={`sample-${i}`} className="opacity-30 grayscale pointer-events-none select-none text-sm">
|
<tr key={`sample-${i}`} className="opacity-30 grayscale pointer-events-none select-none text-sm">
|
||||||
<td className="px-4 py-4 text-center font-bold text-gray-300">{i}</td>
|
<td className="px-4 py-4 text-center font-bold text-gray-300">{i}</td>
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4 text-gray-300">--/-- --:--</td>
|
||||||
<div className="flex items-center gap-1.5 text-gray-300 font-medium">
|
<td className="px-4 py-4 text-gray-300">Chặng -</td>
|
||||||
<Calendar className="w-3.5 h-3.5 opacity-20" /> --/-- --:--
|
<td className="px-4 py-4 text-gray-300 italic">Chưa có dữ liệu...</td>
|
||||||
</div>
|
<td className="px-4 py-4 text-right text-gray-300">0đ</td>
|
||||||
</td>
|
<td className="px-4 py-4 text-gray-300">Chưa rõ</td>
|
||||||
<td className="px-4 py-4 whitespace-nowrap">
|
<td className="px-4 py-4 text-gray-200">-</td>
|
||||||
<span className="px-2 py-1 bg-gray-100 text-gray-400 rounded-lg font-bold text-[10px]">Chặng -</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4 text-gray-300 italic">Ví dụ: Vé tham quan, Tiền ăn trưa...</td>
|
|
||||||
<td className="px-4 py-4 text-right font-black text-gray-300">0đ</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex items-center gap-1.5 text-gray-300">
|
|
||||||
<User className="w-3.5 h-3.5 opacity-20" /> Chưa có dữ liệu
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4 text-xs text-gray-200 italic">-</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } 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 } from 'lucide-react';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
import { ConfirmModal } from '@/components/ConfirmModal';
|
import { ConfirmModal } from '@/components/ConfirmModal';
|
||||||
|
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 +39,9 @@ const formatTravelTime = (minutes: number) => {
|
|||||||
|
|
||||||
export const ItineraryTimeline = ({
|
export const ItineraryTimeline = ({
|
||||||
onAddLocation,
|
onAddLocation,
|
||||||
onEditLocation
|
onEditLocation,
|
||||||
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => {
|
isPublicView = false
|
||||||
|
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => 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 +49,49 @@ 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 fetchTour = useTourStore(state => state.fetchTour);
|
||||||
const deleteLocation = useTourStore(state => state.deleteLocation);
|
const deleteLocation = useTourStore(state => state.deleteLocation);
|
||||||
|
|
||||||
|
// Khai báo logic canEdit để sử dụng trong toàn bộ component
|
||||||
|
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
|
|
||||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
||||||
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('');
|
||||||
|
|
||||||
|
// 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 => ({
|
||||||
|
...leg,
|
||||||
|
locations: leg.locations.map(loc =>
|
||||||
|
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 => ({
|
||||||
|
...leg,
|
||||||
|
locations: leg.locations.map(loc =>
|
||||||
|
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);
|
||||||
@@ -170,10 +208,19 @@ export const ItineraryTimeline = ({
|
|||||||
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
|
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
|
||||||
<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">
|
||||||
@@ -182,7 +229,7 @@ export const ItineraryTimeline = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
{canEdit && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAddLocation?.(leg.id)}
|
onClick={() => onAddLocation?.(leg.id)}
|
||||||
@@ -216,7 +263,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,7 +278,7 @@ 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 */}
|
{/* Vertical Line for the whole leg */}
|
||||||
@@ -309,7 +356,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>
|
||||||
@@ -325,7 +372,18 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-right flex flex-col items-end">
|
<div className="text-right flex flex-col items-end">
|
||||||
<div className="flex items-center text-sm font-medium text-blue-600">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
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 mb-2"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
BÌNH LUẬN {location._count?.comments > 0 && `(${location._count.comments})`}
|
||||||
|
</button>
|
||||||
|
<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') : '--:--'}
|
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +392,7 @@ export const ItineraryTimeline = ({
|
|||||||
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 && !isStartPoint && !isEndPoint && ( // Only show edit/delete if canEdit
|
||||||
<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={() => 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" />
|
||||||
@@ -378,7 +436,7 @@ export const ItineraryTimeline = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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}
|
||||||
@@ -523,6 +581,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,212 @@
|
|||||||
|
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 { useConfirm } from '@/hooks/useConfirm';
|
||||||
|
|
||||||
|
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 confirm = useConfirm();
|
||||||
|
|
||||||
|
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
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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={async () => {
|
||||||
|
const isConfirmed = await confirm({
|
||||||
|
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.'
|
||||||
|
});
|
||||||
|
if (isConfirmed) handleDelete(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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, Tooltip } from 'react-leaflet';
|
||||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2 } from 'lucide-react';
|
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon } from 'lucide-react';
|
||||||
import { UserManagementModal } from '@/components/UserManagementModal';
|
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||||
|
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
|
||||||
import { CreateTourModal } from '../components/CreateTourModal';
|
import { CreateTourModal } from '../components/CreateTourModal';
|
||||||
|
|
||||||
// Fix lỗi icon mặc định của Leaflet
|
// Fix lỗi icon mặc định của Leaflet
|
||||||
@@ -27,6 +28,16 @@ function RecenterMap({ position }: { position: [number, number] }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Component Helper để đóng menu khi tương tác với bản đồ
|
||||||
|
function MapEvents({ onMapAction }: { onMapAction: () => void }) {
|
||||||
|
useMapEvents({
|
||||||
|
click: onMapAction,
|
||||||
|
movestart: onMapAction,
|
||||||
|
dragstart: onMapAction,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ
|
// Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ
|
||||||
function MapTracker() {
|
function MapTracker() {
|
||||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
@@ -52,6 +63,8 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
const fetchTour = useTourStore(state => state.fetchTour);
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
|
|
||||||
|
const notificationModal = useNotificationModal();
|
||||||
|
|
||||||
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
|
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
|
||||||
const [initialViewState] = useState(() => {
|
const [initialViewState] = useState(() => {
|
||||||
const saved = localStorage.getItem('map_view_state');
|
const saved = localStorage.getItem('map_view_state');
|
||||||
@@ -66,8 +79,58 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [selectedFilterTag, setSelectedFilterTag] = useState<string | null>(null);
|
||||||
|
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
|
||||||
|
|
||||||
|
// Tổng hợp nhãn từ danh sách Tour đang có để hiển thị bộ lọc đầy đủ (bao gồm cả nhãn tùy chỉnh)
|
||||||
|
const allFilterTags = React.useMemo(() => {
|
||||||
|
const tagsSet = new Set(availableTags);
|
||||||
|
publicTours.forEach(tour => {
|
||||||
|
tour.tags?.forEach((tag: string) => tagsSet.add(tag));
|
||||||
|
});
|
||||||
|
return Array.from(tagsSet);
|
||||||
|
}, [publicTours]);
|
||||||
|
|
||||||
|
// State cho menu chuột phải chia sẻ
|
||||||
|
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean } | null>(null);
|
||||||
|
|
||||||
|
const handleShare = (id: string, title: string) => {
|
||||||
|
const shareUrl = `${window.location.origin}?viewTour=${id}`;
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: title,
|
||||||
|
text: `Khám phá hành trình du lịch: ${title}`,
|
||||||
|
url: shareUrl,
|
||||||
|
}).catch(() => {});
|
||||||
|
} else if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||||
|
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!', 'success');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Giải pháp dự phòng cho môi trường không có HTTPS
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = shareUrl;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!', 'success');
|
||||||
|
} catch (err) {}
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
setShareMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredTours = React.useMemo(() => {
|
||||||
|
if (!selectedFilterTag) return publicTours;
|
||||||
|
return publicTours.filter(tour => tour.tags?.includes(selectedFilterTag));
|
||||||
|
}, [publicTours, selectedFilterTag]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
|
||||||
|
if (user || localStorage.getItem('token')) {
|
||||||
fetchPublicTours();
|
fetchPublicTours();
|
||||||
|
}
|
||||||
|
|
||||||
// Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị
|
// Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị
|
||||||
if (!initialViewState) {
|
if (!initialViewState) {
|
||||||
@@ -136,6 +199,33 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bộ lọc theo Tag */}
|
||||||
|
<div className="absolute top-24 left-6 z-[1000] flex flex-col gap-2 pointer-events-none">
|
||||||
|
<div className="bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white/20 pointer-events-auto flex flex-col gap-2 max-w-[200px]">
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1">
|
||||||
|
<Filter className="w-3.5 h-3.5 text-blue-600" />
|
||||||
|
<span className="text-[11px] font-black uppercase text-gray-500 tracking-wider">Lọc theo loại</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedFilterTag(null)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||||
|
>
|
||||||
|
Tất cả
|
||||||
|
</button>
|
||||||
|
{allFilterTags.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => setSelectedFilterTag(tag === selectedFilterTag ? null : tag)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={userPos}
|
center={userPos}
|
||||||
zoom={mapZoom}
|
zoom={mapZoom}
|
||||||
@@ -150,11 +240,14 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
{/* Theo dõi di chuyển bản đồ */}
|
{/* Theo dõi di chuyển bản đồ */}
|
||||||
<MapTracker />
|
<MapTracker />
|
||||||
|
|
||||||
|
{/* Đóng menu khi tương tác bản đồ */}
|
||||||
|
<MapEvents onMapAction={() => setShareMenu(null)} />
|
||||||
|
|
||||||
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
||||||
<RecenterMap position={userPos} />
|
<RecenterMap position={userPos} />
|
||||||
|
|
||||||
<MarkerClusterGroup chunkedLoading>
|
<MarkerClusterGroup chunkedLoading>
|
||||||
{publicTours.map((tour) => {
|
{filteredTours.map((tour) => {
|
||||||
const startLoc = tour.legs?.[0]?.locations?.[0];
|
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||||
const markerPos = startLoc
|
const markerPos = startLoc
|
||||||
@@ -162,18 +255,32 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
: userPos;
|
: userPos;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={tour.id}>
|
|
||||||
<Marker
|
<Marker
|
||||||
|
key={tour.id}
|
||||||
position={markerPos}
|
position={markerPos}
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
click: () => onViewTour(tour.id)
|
click: () => onViewTour(tour.id),
|
||||||
|
contextmenu: (e) => {
|
||||||
|
// Kiểm tra quyền chia sẻ (OWNER, MANAGER, MEMBER)
|
||||||
|
const role = tour.participants?.[0]?.role;
|
||||||
|
const canShare = ['OWNER', 'MANAGER', 'MEMBER'].includes(role);
|
||||||
|
|
||||||
|
// Hiển thị menu tại vị trí chuột
|
||||||
|
setShareMenu({
|
||||||
|
x: e.containerPoint.x,
|
||||||
|
y: e.containerPoint.y,
|
||||||
|
id: tour.id,
|
||||||
|
title: tour.title,
|
||||||
|
canShare
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
icon={L.divIcon({
|
icon={L.divIcon({
|
||||||
className: 'custom-bubble',
|
className: 'custom-bubble',
|
||||||
html: `
|
html: `
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
||||||
<img src="${tourImage}" class="w-full h-full object-cover" />
|
<img src="${tourImage}" class="w-full h-full object-cover" onerror="this.src='https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=100'"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||||
S
|
S
|
||||||
@@ -183,13 +290,50 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
iconSize: [48, 48],
|
iconSize: [48, 48],
|
||||||
iconAnchor: [24, 24]
|
iconAnchor: [24, 24]
|
||||||
})}
|
})}
|
||||||
/>
|
>
|
||||||
</React.Fragment>
|
<Tooltip direction="top" offset={[0, -20]} opacity={1}>
|
||||||
|
<div className="p-1 max-w-[180px]">
|
||||||
|
<div className="font-black text-blue-600 text-[11px] mb-0.5 uppercase tracking-tight truncate">{tour.title}</div>
|
||||||
|
{tour.tags && tour.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-1">
|
||||||
|
{tour.tags.map((tag: string) => (
|
||||||
|
<span key={tag} className="px-1.5 py-0.5 bg-blue-50 text-blue-500 rounded text-[8px] font-bold border border-blue-100">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tour.description && (
|
||||||
|
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic">
|
||||||
|
{tour.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* Context Menu Chia sẻ */}
|
||||||
|
{shareMenu && (
|
||||||
|
<div
|
||||||
|
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200"
|
||||||
|
style={{ top: shareMenu.y, left: shareMenu.x }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{shareMenu.canShare ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleShare(shareMenu.id, shareMenu.title)}
|
||||||
|
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-2 text-xs text-gray-400 italic">Bạn không có quyền chia sẻ tour này</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Admin Modal */}
|
{/* Admin Modal */}
|
||||||
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
||||||
|
|
||||||
@@ -202,6 +346,14 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
onViewTour(tour.id);
|
onViewTour(tour.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<NotificationModal
|
||||||
|
isOpen={notificationModal.modalState?.isOpen ?? false}
|
||||||
|
title={notificationModal.modalState?.title}
|
||||||
|
message={notificationModal.modalState?.message}
|
||||||
|
type={notificationModal.modalState?.type}
|
||||||
|
onConfirm={() => notificationModal.closeModal()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
||||||
import { ExpenseManager } from '../components/ExpenseManager';
|
import { ExpenseManager } from '../components/ExpenseManager';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
@@ -6,6 +7,7 @@ import { AddLocationModal } from '@/components/AddLocationModal';
|
|||||||
import { AddMemberModal } from '../components/AddMemberModal';
|
import { AddMemberModal } from '../components/AddMemberModal';
|
||||||
import { ConfirmModal } from '../components/ConfirmModal';
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
|
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
|
||||||
|
import { CommentModal } from '@/components/CommentModal';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet';
|
||||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||||
@@ -25,7 +27,10 @@ import {
|
|||||||
Flag,
|
Flag,
|
||||||
Clock,
|
Clock,
|
||||||
Check,
|
Check,
|
||||||
X
|
X,
|
||||||
|
MessageSquare,
|
||||||
|
Share2,
|
||||||
|
Tag as TagIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
@@ -161,7 +166,7 @@ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.Lat
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBack: () => void, tourId: string, isPublicView?: boolean }) => {
|
||||||
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
||||||
const currentTour = useTourStore(state => state.currentTour);
|
const currentTour = useTourStore(state => state.currentTour);
|
||||||
const legs = useTourStore(state => state.legs);
|
const legs = useTourStore(state => state.legs);
|
||||||
@@ -184,10 +189,53 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
const [adultCountInput, setAdultCountInput] = useState(currentTour?.adultCount ?? 0);
|
const [adultCountInput, setAdultCountInput] = useState(currentTour?.adultCount ?? 0);
|
||||||
const [childCountInput, setChildCountInput] = useState(currentTour?.childCount ?? 0);
|
const [childCountInput, setChildCountInput] = useState(currentTour?.childCount ?? 0);
|
||||||
const [childDiscountInput, setChildDiscountInput] = useState(currentTour?.childDiscount ?? 0);
|
const [childDiscountInput, setChildDiscountInput] = useState(currentTour?.childDiscount ?? 0);
|
||||||
|
const [tagsInput, setTagsInput] = useState<string[]>(currentTour?.tags ?? []);
|
||||||
|
const [customTag, setCustomTag] = useState('');
|
||||||
|
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 [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
||||||
|
const [commentLocationId, setCommentLocationId] = useState('');
|
||||||
|
const [commentLocationName, setCommentLocationName] = useState('');
|
||||||
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
||||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
||||||
|
|
||||||
const fetchTour = useTourStore(state => state.fetchTour);
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
|
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentTour) {
|
||||||
|
setTagsInput(currentTour.tags || []);
|
||||||
|
}
|
||||||
|
}, [currentTour]);
|
||||||
|
|
||||||
|
// Hàm tối ưu để cập nhật số lượng bình luận mà không cần fetch lại toàn bộ Tour
|
||||||
|
const handleCommentIncrement = (locationId: string) => {
|
||||||
|
const currentLegs = useTourStore.getState().legs;
|
||||||
|
const updatedLegs = currentLegs.map(leg => ({
|
||||||
|
...leg,
|
||||||
|
locations: leg.locations.map(loc =>
|
||||||
|
loc.id === locationId
|
||||||
|
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
|
||||||
|
: loc
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
// Cập nhật trực tiếp vào Store
|
||||||
|
useTourStore.setState({ legs: updatedLegs });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommentDecrement = (locationId: string) => {
|
||||||
|
const currentLegs = useTourStore.getState().legs;
|
||||||
|
const updatedLegs = currentLegs.map(leg => ({
|
||||||
|
...leg,
|
||||||
|
locations: leg.locations.map(loc =>
|
||||||
|
loc.id === locationId
|
||||||
|
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
|
||||||
|
: loc
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
useTourStore.setState({ legs: updatedLegs });
|
||||||
|
};
|
||||||
|
|
||||||
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||||
const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint);
|
const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint);
|
||||||
@@ -212,17 +260,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi
|
// SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi
|
||||||
|
// Nếu là public view, không có quyền chỉnh sửa
|
||||||
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
const canInvite = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
const canInvite = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||||
const isOwner = userRole === 'OWNER';
|
const isOwner = isPublicView ? false : userRole === 'OWNER';
|
||||||
|
const canShare = isPublicView || ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
|
if (isPublicView) {
|
||||||
|
fetchPublicTourDetails(tourId);
|
||||||
|
} else if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
|
||||||
fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([]));
|
fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([]));
|
||||||
}
|
}
|
||||||
}, [currentTour, userRole]);
|
// Fetch tour details when tourId changes or public view status changes
|
||||||
|
if (tourId) { isPublicView ? fetchPublicTourDetails(tourId) : fetchTour(tourId); }
|
||||||
|
}, [tourId, isPublicView, userRole]); // Add tourId to dependencies
|
||||||
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||||
|
|
||||||
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
|
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
|
||||||
@@ -232,21 +284,66 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
if (initialViewState) {
|
if (initialViewState) {
|
||||||
setMapCenter(initialViewState.center);
|
setMapCenter(initialViewState.center);
|
||||||
}
|
}
|
||||||
const loadData = async () => {
|
// This useEffect is for initial load, but we now have tourId prop
|
||||||
// Nếu chưa có tour nào trong store, thử tải danh sách public trước
|
// The fetching logic is moved to the useEffect above that depends on tourId and isPublicView
|
||||||
if (publicTours.length === 0) {
|
// So this useEffect can be simplified or removed if its only purpose was initial data load.
|
||||||
await fetchPublicTours();
|
// if (publicTours.length === 0 && !isPublicView) { // Only fetch public tours if not in public view and not already loaded
|
||||||
|
// fetchPublicTours();
|
||||||
|
// }
|
||||||
|
}, [initialViewState]); // Removed publicTours, currentTour, fetchPublicTours, fetchTour from dependencies
|
||||||
|
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
if (!currentTour) return;
|
||||||
|
// Tạo link với query param ?viewTour=...
|
||||||
|
const shareUrl = `${window.location.origin}?viewTour=${currentTour.id}`;
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: currentTour.title,
|
||||||
|
url: shareUrl,
|
||||||
|
}).catch(() => {});
|
||||||
|
} else if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||||
|
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chia sẻ chuyến đi!', 'success');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Giải pháp dự phòng cho môi trường không có HTTPS (truy cập qua IP)
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = shareUrl;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
notificationModal.openModal('Thành công', 'Đã sao chép liên kết chia sẻ chuyến đi!', 'success');
|
||||||
|
} catch (err) {}
|
||||||
|
document.body.removeChild(textArea);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
// Thiết lập kết nối WebSocket Real-time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Lấy tour đầu tiên từ danh sách khám phá để hiển thị demo
|
if (!currentTour) return;
|
||||||
if (publicTours.length > 0 && !currentTour) {
|
|
||||||
fetchTour(publicTours[0].id);
|
const socket = io(); // Kết nối qua Proxy của Vite (cùng origin)
|
||||||
}
|
|
||||||
}, [publicTours, currentTour, fetchTour]);
|
socket.on('connect', () => {
|
||||||
|
socket.emit('joinTour', currentTour.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('commentAdded', (data: any) => {
|
||||||
|
// Cập nhật UI ngay lập tức khi bất kỳ ai bình luận
|
||||||
|
handleCommentIncrement(data.locationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { socket.disconnect(); };
|
||||||
|
}, [currentTour?.id]);
|
||||||
|
|
||||||
|
// This useEffect is for initial demo loading, might not be needed if tourId is always passed
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (publicTours.length > 0 && !currentTour && !isPublicView) {
|
||||||
|
// fetchTour(publicTours[0].id);
|
||||||
|
// }
|
||||||
|
// }, [publicTours, currentTour, fetchTour, isPublicView]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -351,6 +448,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
adultCount: adultCountInput,
|
adultCount: adultCountInput,
|
||||||
childCount: childCountInput,
|
childCount: childCountInput,
|
||||||
childDiscount: childDiscountInput,
|
childDiscount: childDiscountInput,
|
||||||
|
tags: tagsInput
|
||||||
});
|
});
|
||||||
notificationModal.openModal('Thành công', 'Đã cập nhật thông tin chuyến đi.', 'success');
|
notificationModal.openModal('Thành công', 'Đã cập nhật thông tin chuyến đi.', 'success');
|
||||||
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
|
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
|
||||||
@@ -414,12 +512,20 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
|
<h1 className="text-lg font-bold text-gray-800 truncate px-4 flex-1 text-center">
|
||||||
{tourInfo.title}
|
{tourInfo.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="w-10" /> {/* Spacer */}
|
{/* Nút chia sẻ: Chỉ dành cho OWNER, MANAGER, MEMBER */}
|
||||||
|
{canShare && (
|
||||||
|
<button
|
||||||
|
onClick={handleShare}
|
||||||
|
className="p-2 hover:bg-blue-50 text-blue-600 rounded-full transition-colors"
|
||||||
|
title="Chia sẻ tour"
|
||||||
|
>
|
||||||
|
<Share2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tour Header Info */}
|
{/* Tour Header Info */}
|
||||||
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
|
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
|
||||||
<img
|
<img
|
||||||
@@ -433,6 +539,18 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<div className="max-w-2xl mx-auto space-y-4">
|
<div className="max-w-2xl mx-auto space-y-4">
|
||||||
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
|
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
|
||||||
|
|
||||||
|
{/* Nhãn hiển thị ngay dưới Tiêu đề */}
|
||||||
|
{currentTour?.tags && currentTour.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{currentTour.tags.map((tag: string) => (
|
||||||
|
<span key={tag} className="px-2.5 py-1 bg-white/20 backdrop-blur-md border border-white/30 rounded-lg text-[10px] font-black uppercase tracking-wider">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mô tả hiển thị dưới Nhãn */}
|
||||||
{currentTour?.description && (
|
{currentTour?.description && (
|
||||||
<p className="text-sm md:text-base text-white/90 max-w-xl line-clamp-3 md:line-clamp-none bg-black/20 backdrop-blur-sm p-4 rounded-2xl border border-white/10 italic leading-relaxed">
|
<p className="text-sm md:text-base text-white/90 max-w-xl line-clamp-3 md:line-clamp-none bg-black/20 backdrop-blur-sm p-4 rounded-2xl border border-white/10 italic leading-relaxed">
|
||||||
<Quote className="w-4 h-4 inline-block mr-2 opacity-50" />
|
<Quote className="w-4 h-4 inline-block mr-2 opacity-50" />
|
||||||
@@ -465,7 +583,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
|
|
||||||
{/* Member Avatars Stack */}
|
{/* Member Avatars Stack */}
|
||||||
<div className="flex items-center gap-2 mt-4">
|
<div className="flex items-center gap-2 mt-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2"> {/* Always show participants */}
|
||||||
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
|
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
|
||||||
<button
|
<button
|
||||||
key={p.userId || i}
|
key={p.userId || i}
|
||||||
@@ -479,7 +597,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
|
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{isOwner && joinRequests.slice(0, 3).map((req: any) => (
|
{isOwner && !isPublicView && joinRequests.slice(0, 3).map((req: any) => ( // Hide join requests in public view
|
||||||
<div key={req.id} className="relative group">
|
<div key={req.id} className="relative group">
|
||||||
<div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg">
|
<div className="w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg">
|
||||||
{req.user?.name?.charAt(0) || '?'}
|
{req.user?.name?.charAt(0) || '?'}
|
||||||
@@ -552,6 +670,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!isPublicView && ( // Hide add member button in public view
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!currentTour) return;
|
if (!currentTour) return;
|
||||||
@@ -564,6 +683,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -571,7 +691,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
|
|
||||||
{/* Financial Quick-View Widget or Quote */}
|
{/* Financial Quick-View Widget or Quote */}
|
||||||
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
|
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
|
||||||
<div
|
<div // Always show quote if public view, otherwise show financial widget if has access
|
||||||
onClick={() => hasFinanceAccess && setActiveTab('expense')}
|
onClick={() => hasFinanceAccess && setActiveTab('expense')}
|
||||||
className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200 cursor-pointer hover:scale-[1.02] active:scale-95' : 'bg-white text-gray-600 border border-gray-100'}`}
|
className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200 cursor-pointer hover:scale-[1.02] active:scale-95' : 'bg-white text-gray-600 border border-gray-100'}`}
|
||||||
>
|
>
|
||||||
@@ -584,7 +704,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
|
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : ( // If no finance access or is public view, show quote
|
||||||
<div className="flex items-start gap-4 py-2">
|
<div className="flex items-start gap-4 py-2">
|
||||||
<Quote className="w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" />
|
<Quote className="w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" />
|
||||||
<p className="italic text-lg font-medium leading-relaxed">"{randomQuote}"</p>
|
<p className="italic text-lg font-medium leading-relaxed">"{randomQuote}"</p>
|
||||||
@@ -628,7 +748,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id as any)}
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${
|
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||||
@@ -672,7 +792,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
setTargetLegId(loc.legId);
|
setTargetLegId(loc.legId);
|
||||||
setMapCenter([loc.latitude, loc.longitude]);
|
setMapCenter([loc.latitude, loc.longitude]);
|
||||||
setIsAddLocationOpen(true);
|
setIsAddLocationOpen(true);
|
||||||
}} />
|
}} isPublicView={isPublicView} />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
||||||
<MapContainer
|
<MapContainer
|
||||||
@@ -682,7 +802,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
preferCanvas={true}
|
preferCanvas={true}
|
||||||
>
|
>
|
||||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
{canEdit && <MapContextMenu onAction={handleMapAction} />}
|
{canEdit && !isPublicView && <MapContextMenu onAction={handleMapAction} />} {/* Hide map context menu in public view */}
|
||||||
|
|
||||||
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
||||||
<MapTourBounds locations={allLocations} />
|
<MapTourBounds locations={allLocations} />
|
||||||
@@ -708,8 +828,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
return (
|
return (
|
||||||
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}>
|
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}>
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="font-bold">{loc.name}</div>
|
<div className="p-1">
|
||||||
<div className="text-xs text-gray-500">{loc.type}</div>
|
<div className="font-bold text-gray-900">{loc.name}</div>
|
||||||
|
<div className="text-[10px] text-gray-500 mb-2 uppercase tracking-tight">{loc.type}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCommentLocationId(loc.id);
|
||||||
|
setCommentLocationName(loc.name);
|
||||||
|
setIsCommentModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg text-[10px] font-black transition-all border border-blue-100"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
BÌNH LUẬN {loc._count?.comments > 0 && `(${loc._count.comments})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
@@ -717,7 +850,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
<div className="absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white">
|
<div className="absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white">
|
||||||
Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải để ghim địa điểm
|
{isPublicView ? 'Xem chi tiết lộ trình' : 'Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải để ghim địa điểm'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -745,7 +878,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && !isPublicView && ( // Hide settings tab in public view
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95">
|
<div className="p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
@@ -903,6 +1036,46 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1 flex items-center gap-2">
|
||||||
|
<TagIcon className="w-3 h-3" /> Phân loại Tour
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableTags.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTagsInput(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all border ${
|
||||||
|
tagsInput.includes(tag)
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: '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(), setTagsInput(prev => customTag.trim() && !prev.includes(customTag.trim()) ? [...prev, customTag.trim()] : prev), setCustomTag(''))}
|
||||||
|
placeholder="Thêm nhãn tùy chỉnh..."
|
||||||
|
className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { if (customTag.trim() && !tagsInput.includes(customTag.trim())) { setTagsInput([...tagsInput, customTag.trim()]); setCustomTag(''); } }}
|
||||||
|
className="px-4 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all border border-blue-100"
|
||||||
|
>
|
||||||
|
Thêm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleUpdateTourInfo}
|
onClick={handleUpdateTourInfo}
|
||||||
className="w-full mt-6 px-4 py-4 bg-blue-600 hover:bg-blue-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-blue-100 active:scale-95"
|
className="w-full mt-6 px-4 py-4 bg-blue-600 hover:bg-blue-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-blue-100 active:scale-95"
|
||||||
@@ -923,7 +1096,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Action Button (Mobile) */}
|
{/* Floating Action Button (Mobile) */}
|
||||||
{canEdit && (
|
{canEdit && !isPublicView && ( // Hide floating action button in public view
|
||||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -948,6 +1121,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
onRemoveMember={(userId) => removeMember(currentTour.id, userId)}
|
onRemoveMember={(userId) => removeMember(currentTour.id, userId)}
|
||||||
onMemberAdded={() => fetchTour(currentTour.id)}
|
onMemberAdded={() => fetchTour(currentTour.id)}
|
||||||
userRole={userRole || undefined}
|
userRole={userRole || undefined}
|
||||||
|
isPublicView={isPublicView} // Pass isPublicView
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -959,6 +1133,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
initialLegId={targetLegId || undefined}
|
initialLegId={targetLegId || undefined}
|
||||||
editingLocation={editingLocation}
|
editingLocation={editingLocation}
|
||||||
tourId={currentTour.id}
|
tourId={currentTour.id}
|
||||||
|
isPublicView={isPublicView} // Pass isPublicView
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1030,6 +1205,15 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
type={notificationModal.modalState?.type}
|
type={notificationModal.modalState?.type}
|
||||||
onConfirm={() => notificationModal.closeModal()}
|
onConfirm={() => notificationModal.closeModal()}
|
||||||
/>
|
/>
|
||||||
|
<CommentModal
|
||||||
|
isOpen={isCommentModalOpen}
|
||||||
|
onClose={() => setIsCommentModalOpen(false)}
|
||||||
|
locationId={commentLocationId}
|
||||||
|
locationName={commentLocationName}
|
||||||
|
isPublicView={isPublicView} // Pass isPublicView
|
||||||
|
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||||
|
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -28,6 +28,7 @@ interface TourState {
|
|||||||
setActiveLegId: (id: string | null) => void;
|
setActiveLegId: (id: string | null) => void;
|
||||||
setMapCenter: (pos: [number, number]) => void;
|
setMapCenter: (pos: [number, number]) => void;
|
||||||
fetchTour: (id: string) => Promise<void>;
|
fetchTour: (id: string) => Promise<void>;
|
||||||
|
fetchPublicTourDetails: (tourId: string) => Promise<void>;
|
||||||
fetchPublicTours: () => Promise<void>;
|
fetchPublicTours: () => Promise<void>;
|
||||||
createJoinRequest: (tourId: string, userId?: string) => Promise<any>;
|
createJoinRequest: (tourId: string, userId?: string) => Promise<any>;
|
||||||
fetchJoinRequests: (tourId: string) => Promise<any[]>;
|
fetchJoinRequests: (tourId: string) => Promise<any[]>;
|
||||||
@@ -47,10 +48,9 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
setActiveLegId: (id) => set({ activeLegId: id }),
|
setActiveLegId: (id) => set({ activeLegId: id }),
|
||||||
setMapCenter: (pos) => set({ mapCenter: pos }),
|
setMapCenter: (pos) => set({ mapCenter: pos }),
|
||||||
fetchTour: async (id: string) => {
|
fetchTour: async (id: string) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
const response = await fetch(`/api/v1/tours/${id}`, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -75,11 +75,10 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
fetchPublicTours: async () => {
|
fetchPublicTours: async () => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/explore`, {
|
const response = await fetch(`/api/v1/tours/explore`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
}
|
}
|
||||||
@@ -89,9 +88,27 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
set({ publicTours: data });
|
set({ publicTours: data });
|
||||||
},
|
},
|
||||||
|
fetchPublicTourDetails: async (tourId: string) => {
|
||||||
|
try {
|
||||||
|
// Reset state cũ trước khi tải dữ liệu mới
|
||||||
|
set({ currentTour: null, legs: [], userRole: 'VIEWER_ONLY' });
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/tours/${tourId}/public`);
|
||||||
|
if (!response.ok) throw new Error('Không thể tải tour công khai');
|
||||||
|
const data = await response.json();
|
||||||
|
const legs = data.legs || [];
|
||||||
|
set({
|
||||||
|
currentTour: data,
|
||||||
|
legs,
|
||||||
|
userRole: 'VIEWER_ONLY',
|
||||||
|
activeLegId: legs.length > 0 ? legs[0].id : null
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Lỗi khi tải tour công khai:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
createTour: async (tourData: any) => {
|
createTour: async (tourData: any) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -108,9 +125,8 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
return tour;
|
return tour;
|
||||||
},
|
},
|
||||||
updateTourDetails: async (tourId: string, data: any) => {
|
updateTourDetails: async (tourId: string, data: any) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}`, {
|
const response = await fetch(`/api/v1/tours/${tourId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -125,8 +141,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateTour: async (id: string, data: any) => {
|
updateTour: async (id: string, data: any) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${id}`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -140,8 +155,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
get().fetchPublicTours();
|
get().fetchPublicTours();
|
||||||
},
|
},
|
||||||
deleteTour: async (id: string) => {
|
deleteTour: async (id: string) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${id}`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
@@ -157,8 +171,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
get().fetchPublicTours();
|
get().fetchPublicTours();
|
||||||
},
|
},
|
||||||
addLeg: async (tourId: string, data: any) => {
|
addLeg: async (tourId: string, data: any) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${tourId}/legs`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/legs`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -170,8 +183,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
get().fetchTour(tourId);
|
get().fetchTour(tourId);
|
||||||
},
|
},
|
||||||
initializeLegs: async (tourId: string, count: number) => {
|
initializeLegs: async (tourId: string, count: number) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${tourId}/legs/batch`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/legs/batch`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -183,8 +195,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
await get().fetchTour(tourId);
|
await get().fetchTour(tourId);
|
||||||
},
|
},
|
||||||
updateLeg: async (legId: string, data: any) => {
|
updateLeg: async (legId: string, data: any) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/legs/${legId}`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
|
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -198,8 +209,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
if (currentTour) get().fetchTour(currentTour.id);
|
if (currentTour) get().fetchTour(currentTour.id);
|
||||||
},
|
},
|
||||||
deleteLeg: async (legId: string) => {
|
deleteLeg: async (legId: string) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/legs/${legId}`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/legs/${legId}`, {
|
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
@@ -214,8 +224,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
if (currentTour) get().fetchTour(currentTour.id);
|
if (currentTour) get().fetchTour(currentTour.id);
|
||||||
},
|
},
|
||||||
addLocation: async (tourId: string, locationData: any) => {
|
addLocation: async (tourId: string, locationData: any) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${tourId}/locations`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/locations`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -228,8 +237,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
get().fetchTour(tourId);
|
get().fetchTour(tourId);
|
||||||
},
|
},
|
||||||
updateLocation: async (locationId: string, data: any) => {
|
updateLocation: async (locationId: string, data: any) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/locations/${locationId}`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
|
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -243,8 +251,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
if (currentTour) get().fetchTour(currentTour.id);
|
if (currentTour) get().fetchTour(currentTour.id);
|
||||||
},
|
},
|
||||||
deleteLocation: async (locationId: string) => {
|
deleteLocation: async (locationId: string) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/locations/${locationId}`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
|
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
@@ -256,8 +263,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
if (currentTour) get().fetchTour(currentTour.id);
|
if (currentTour) get().fetchTour(currentTour.id);
|
||||||
},
|
},
|
||||||
updateTourStartPoint: async (tourId: string, data: any) => {
|
updateTourStartPoint: async (tourId: string, data: any) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${tourId}/start-point`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/start-point`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -271,8 +277,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
await get().fetchTour(tourId);
|
await get().fetchTour(tourId);
|
||||||
},
|
},
|
||||||
updateTourEndPoint: async (tourId: string, data: any) => {
|
updateTourEndPoint: async (tourId: string, data: any) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${tourId}/end-point`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/end-point`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -284,8 +289,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
await get().fetchTour(tourId);
|
await get().fetchTour(tourId);
|
||||||
},
|
},
|
||||||
optimizeRouting: async (legId: string) => {
|
optimizeRouting: async (legId: string) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/routing/optimize/${legId}`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/routing/optimize/${legId}`, {
|
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
const { locations, totalDistance } = await response.json();
|
const { locations, totalDistance } = await response.json();
|
||||||
@@ -299,8 +303,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeMember: async (tourId: string, userId: string) => {
|
removeMember: async (tourId: string, userId: string) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${tourId}/members/${userId}`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members/${userId}`, {
|
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
@@ -311,8 +314,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
if (currentTour) get().fetchTour(currentTour.id);
|
if (currentTour) get().fetchTour(currentTour.id);
|
||||||
},
|
},
|
||||||
addMember: async (tourId: string, member: { userId: string; role?: string }) => {
|
addMember: async (tourId: string, member: { userId: string; role?: string }) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${tourId}/members`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -328,8 +330,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
if (currentTour) get().fetchTour(currentTour.id);
|
if (currentTour) get().fetchTour(currentTour.id);
|
||||||
},
|
},
|
||||||
createJoinRequest: async (tourId: string, userId?: string) => {
|
createJoinRequest: async (tourId: string, userId?: string) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -344,8 +345,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
fetchJoinRequests: async (tourId: string) => {
|
fetchJoinRequests: async (tourId: string) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests`, {
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
}
|
}
|
||||||
@@ -357,8 +357,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
acceptJoinRequest: async (tourId: string, requestId: string) => {
|
acceptJoinRequest: async (tourId: string, requestId: string) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${tourId}/join-requests/${requestId}/accept`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests/${requestId}/accept`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
@@ -372,8 +371,7 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
if (currentTour) get().fetchTour(currentTour.id);
|
if (currentTour) get().fetchTour(currentTour.id);
|
||||||
},
|
},
|
||||||
rejectJoinRequest: async (tourId: string, requestId: string) => {
|
rejectJoinRequest: async (tourId: string, requestId: string) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const response = await fetch(`/api/v1/tours/${tourId}/join-requests/${requestId}/reject`, {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests/${requestId}/reject`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||||
|
|||||||
+25
-3
@@ -1,9 +1,22 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
// Nạp các biến môi trường từ thư mục gốc
|
||||||
|
const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
|
||||||
|
|
||||||
|
// Cấu hình HTTPS nếu tìm thấy file chứng chỉ (ví dụ đặt tại thư mục gốc của dự án)
|
||||||
|
const httpsConfig = fs.existsSync('../key.pem') && fs.existsSync('../cert.pem')
|
||||||
|
? {
|
||||||
|
key: fs.readFileSync('../key.pem'),
|
||||||
|
cert: fs.readFileSync('../cert.pem'),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -13,11 +26,20 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 3002,
|
port: 3002,
|
||||||
host: true,
|
host: true,
|
||||||
|
// Cho phép các host từ file .env
|
||||||
|
allowedHosts: env.ALLOWED_HOSTS ? env.ALLOWED_HOSTS.split(',') : true,
|
||||||
|
https: httpsConfig,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:3001',
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/socket.io': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
ws: true,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
Generated
+882
-51
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
|
|
||||||
|
interface ConfirmOptions {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmContext = createContext<((options: ConfirmOptions) => Promise<boolean>) | undefined>(undefined);
|
||||||
|
|
||||||
|
export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
resolve?: (value: boolean) => void;
|
||||||
|
}>({ isOpen: false });
|
||||||
|
|
||||||
|
const confirm = useCallback((options: ConfirmOptions) => {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
setState({
|
||||||
|
isOpen: true,
|
||||||
|
title: options.title,
|
||||||
|
message: options.message,
|
||||||
|
resolve,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const resolve = state.resolve;
|
||||||
|
setState({ isOpen: false, resolve: undefined });
|
||||||
|
resolve?.(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
const resolve = state.resolve;
|
||||||
|
setState({ isOpen: false, resolve: undefined });
|
||||||
|
resolve?.(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmContext.Provider value={confirm}>
|
||||||
|
{children}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={state.isOpen}
|
||||||
|
title={state.title}
|
||||||
|
message={state.message}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</ConfirmContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConfirm = () => {
|
||||||
|
const confirm = useContext(ConfirmContext);
|
||||||
|
if (!confirm) throw new Error('useConfirm must be used within a ConfirmProvider');
|
||||||
|
return confirm;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user