Thêm tính năng bình luận ở mỗi điểm của chặng

This commit is contained in:
2026-06-16 08:27:54 +07:00
parent c5530f36df
commit 5464d90948
15 changed files with 1427 additions and 97 deletions
+8
View File
@@ -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;
}
+97 -4
View File
@@ -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);
+1 -1
View File
File diff suppressed because one or more lines are too long
+8 -3
View File
@@ -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;
+13
View File
@@ -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
View File
@@ -1,8 +1,12 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg'; import { Pool } from 'pg';
import 'dotenv/config'; import * as dotenv from 'dotenv';
import * as bcrypt from 'bcrypt'; import * as path from 'path';
import bcrypt from 'bcrypt';
const envPath = path.resolve(process.cwd(), '..', '.env');
dotenv.config({ path: envPath });
const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool); const adapter = new PrismaPg(pool);
@@ -90,6 +94,25 @@ async function main() {
}, },
}); });
console.log('--- Đang tạo bình luận mẫu... ---');
const dinhDocLap = await prisma.location.findFirst({ where: { name: 'Dinh Độc Lập' } });
if (dinhDocLap) {
await prisma.comment.createMany({
data: [
{
content: 'Chỗ này rất đẹp, giàu giá trị lịch sử!',
locationId: dinhDocLap.id,
userId: owner.id,
},
{
content: 'Nên đi vào buổi sáng cho mát mẻ mọi người nhé.',
locationId: dinhDocLap.id,
userId: photoMember.id,
}
],
});
}
console.log('--- Seed dữ liệu hoàn tất! ---'); console.log('--- Seed dữ liệu hoàn tất! ---');
console.log(`Email đăng nhập Owner: ${owner.email}`); console.log(`Email đăng nhập Owner: ${owner.email}`);
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`); console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
+85 -6
View File
@@ -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 {}
+1
View File
@@ -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>
+2 -1
View File
@@ -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"
} }
} }
+160
View File
@@ -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 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>
);
};
+40 -2
View File
@@ -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>
); );
}; };
+25 -23
View File
@@ -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>
+63 -3
View File
@@ -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>
); );
}; };
+883 -52
View File
File diff suppressed because it is too large Load Diff