Thêm tính năng bình luận ở mỗi điểm của chặng
This commit is contained in:
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
+97
-4
@@ -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");
|
||||||
@@ -358,7 +361,10 @@ let TourController = class TourController {
|
|||||||
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 +385,10 @@ let TourController = class TourController {
|
|||||||
legs: {
|
legs: {
|
||||||
orderBy: { sequence: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
locations: { orderBy: { plannedStart: 'asc' } },
|
locations: {
|
||||||
|
orderBy: { plannedStart: 'asc' },
|
||||||
|
include: { _count: { select: { comments: true } } }
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1014,6 +1023,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.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'),
|
||||||
|
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||||
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||||
|
CommentGateway])
|
||||||
|
], CommentController);
|
||||||
let AppModule = class AppModule {
|
let AppModule = class AppModule {
|
||||||
};
|
};
|
||||||
AppModule = __decorate([
|
AppModule = __decorate([
|
||||||
@@ -1024,8 +1117,8 @@ AppModule = __decorate([
|
|||||||
signOptions: { expiresIn: '1d' },
|
signOptions: { expiresIn: '1d' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController],
|
controllers: [AppController, AuthController, 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;
|
||||||
@@ -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 {
|
||||||
@@ -157,6 +159,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 +191,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}`);
|
||||||
|
|||||||
+85
-6
@@ -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';
|
||||||
@@ -360,7 +362,10 @@ class TourController {
|
|||||||
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 +389,10 @@ class TourController {
|
|||||||
legs: {
|
legs: {
|
||||||
orderBy: { sequence: 'asc' },
|
orderBy: { sequence: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
locations: { orderBy: { plannedStart: 'asc' } },
|
locations: {
|
||||||
|
orderBy: { plannedStart: 'asc' },
|
||||||
|
include: { _count: { select: { comments: true } } }
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -871,15 +879,86 @@ 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')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
class CommentController {
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private commentGateway: CommentGateway
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':locationId/comments')
|
||||||
|
async getComments(@Param('locationId', ParseUUIDPipe) locationId: string) {
|
||||||
|
return this.prisma.comment.findMany({
|
||||||
|
where: { locationId },
|
||||||
|
include: {
|
||||||
|
user: { select: { name: true } }
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@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, 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": {
|
||||||
@@ -29,4 +30,4 @@
|
|||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Send, MessageSquare, User, Loader2 } from 'lucide-react';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: string;
|
||||||
|
userName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
locationId: string;
|
||||||
|
locationName: string;
|
||||||
|
onCommentAdded?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, locationId, locationName }) => {
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchComments = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
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
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<p className="text-xs font-black text-gray-900 mb-1">{c.userName}</p>
|
||||||
|
<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="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()} 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,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;
|
||||||
@@ -49,11 +50,30 @@ export const ItineraryTimeline = ({
|
|||||||
const updateLeg = useTourStore(state => state.updateLeg);
|
const updateLeg = useTourStore(state => state.updateLeg);
|
||||||
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);
|
||||||
|
|
||||||
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 });
|
||||||
|
};
|
||||||
|
|
||||||
// State cho Modal sửa chặng
|
// State cho Modal sửa chặng
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
@@ -325,7 +345,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>
|
||||||
@@ -523,6 +554,13 @@ export const ItineraryTimeline = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<CommentModal
|
||||||
|
isOpen={isCommentModalOpen}
|
||||||
|
onClose={() => setIsCommentModalOpen(false)}
|
||||||
|
locationId={commentLocationId}
|
||||||
|
locationName={commentLocationName}
|
||||||
|
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -67,7 +67,10 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
|||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPublicTours();
|
// Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
|
||||||
|
if (user || localStorage.getItem('token')) {
|
||||||
|
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) {
|
||||||
@@ -162,29 +165,28 @@ 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)
|
||||||
}}
|
}}
|
||||||
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 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
|
||||||
|
</div>
|
||||||
</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">
|
`,
|
||||||
S
|
iconSize: [48, 48],
|
||||||
</div>
|
iconAnchor: [24, 24]
|
||||||
</div>
|
})}
|
||||||
`,
|
/>
|
||||||
iconSize: [48, 48],
|
|
||||||
iconAnchor: [24, 24]
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
|
|||||||
@@ -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,8 @@ import {
|
|||||||
Flag,
|
Flag,
|
||||||
Clock,
|
Clock,
|
||||||
Check,
|
Check,
|
||||||
X
|
X,
|
||||||
|
MessageSquare
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
@@ -184,10 +187,29 @@ 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 [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);
|
||||||
|
|
||||||
|
// 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 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);
|
||||||
@@ -241,6 +263,24 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Thiết lập kết nối WebSocket Real-time
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentTour) return;
|
||||||
|
|
||||||
|
const socket = io(); // Kết nối qua Proxy của Vite (cùng origin)
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Lấy tour đầu tiên từ danh sách khám phá để hiển thị demo
|
// Lấy tour đầu tiên từ danh sách khám phá để hiển thị demo
|
||||||
if (publicTours.length > 0 && !currentTour) {
|
if (publicTours.length > 0 && !currentTour) {
|
||||||
@@ -708,8 +748,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>
|
||||||
);
|
);
|
||||||
@@ -1030,6 +1083,13 @@ 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}
|
||||||
|
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
Generated
+883
-52
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user