feat: tíng năng trò chuyện giữa các thành viên và tin nhắn trực tiếp cho bạn bè
This commit is contained in:
Vendored
+12
-8
@@ -6,6 +6,18 @@ import { ParticipantRole } from '@prisma/client';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Cache } from 'cache-manager';
|
||||
export declare class CommentGateway implements OnGatewayConnection {
|
||||
server: Server;
|
||||
handleConnection(client: Socket): void;
|
||||
handleJoinTour(client: Socket, tourId: string): void;
|
||||
handleJoinPhoto(client: Socket, photoId: string): void;
|
||||
notifyNewComment(tourId: string, data: any): void;
|
||||
notifyNewPhotoComment(photoId: string, data: any): void;
|
||||
handleJoinUser(client: Socket, userId: string): void;
|
||||
notifyNewMessage(receiverId: string, data: any): void;
|
||||
notifyConnectionAccepted(requesterId: string, data: any): void;
|
||||
notifyJoinRequestAccepted(userId: string, data: any): void;
|
||||
}
|
||||
export declare const ROLES_KEY = "roles";
|
||||
export declare const Roles: (...roles: ParticipantRole[]) => import("@nestjs/common").CustomDecorator<string>;
|
||||
export declare class TourRoleGuard implements CanActivate {
|
||||
@@ -21,11 +33,3 @@ export declare class EmailService {
|
||||
sendOTP(email: string, otp: string): Promise<any>;
|
||||
sendTourInvitation(email: string, tourTitle: string, inviteLink: string): Promise<any>;
|
||||
}
|
||||
export declare class CommentGateway implements OnGatewayConnection {
|
||||
server: Server;
|
||||
handleConnection(client: Socket): void;
|
||||
handleJoinTour(client: Socket, tourId: string): void;
|
||||
handleJoinPhoto(client: Socket, photoId: string): void;
|
||||
notifyNewComment(tourId: string, data: any): void;
|
||||
notifyNewPhotoComment(photoId: string, data: any): void;
|
||||
}
|
||||
|
||||
Vendored
+484
-43
@@ -48,7 +48,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CommentGateway = exports.EmailService = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = void 0;
|
||||
exports.EmailService = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = exports.CommentGateway = void 0;
|
||||
const path = __importStar(require("path"));
|
||||
const fs = __importStar(require("fs"));
|
||||
const crypto = __importStar(require("crypto"));
|
||||
@@ -80,6 +80,64 @@ const compress_cache_interceptor_1 = require("./common/compress-cache.intercepto
|
||||
const gzip = (0, util_1.promisify)(zlib.gzip);
|
||||
const gunzip = (0, util_1.promisify)(zlib.gunzip);
|
||||
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads');
|
||||
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}`);
|
||||
}
|
||||
handleJoinPhoto(client, photoId) {
|
||||
client.join(`photo_${photoId}`);
|
||||
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
|
||||
}
|
||||
notifyNewComment(tourId, data) {
|
||||
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
|
||||
}
|
||||
notifyNewPhotoComment(photoId, data) {
|
||||
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
|
||||
}
|
||||
handleJoinUser(client, userId) {
|
||||
client.join(`user_${userId}`);
|
||||
console.log(`[WS] Client ${client.id} joined user room: user_${userId}`);
|
||||
}
|
||||
notifyNewMessage(receiverId, data) {
|
||||
this.server.to(`user_${receiverId}`).emit('messageReceived', data);
|
||||
}
|
||||
notifyConnectionAccepted(requesterId, data) {
|
||||
this.server.to(`user_${requesterId}`).emit('connectionAccepted', data);
|
||||
}
|
||||
notifyJoinRequestAccepted(userId, data) {
|
||||
this.server.to(`user_${userId}`).emit('joinRequestAccepted', 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);
|
||||
__decorate([
|
||||
(0, websockets_1.SubscribeMessage)('joinPhoto'),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
|
||||
__metadata("design:returntype", void 0)
|
||||
], CommentGateway.prototype, "handleJoinPhoto", null);
|
||||
__decorate([
|
||||
(0, websockets_1.SubscribeMessage)('joinUser'),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
|
||||
__metadata("design:returntype", void 0)
|
||||
], CommentGateway.prototype, "handleJoinUser", null);
|
||||
exports.CommentGateway = CommentGateway = __decorate([
|
||||
(0, websockets_1.WebSocketGateway)({ cors: { origin: '*' } })
|
||||
], CommentGateway);
|
||||
const CACHE_TTL = {
|
||||
DEFAULT: 600000,
|
||||
RESOURCE_TO_TOUR: 3600000,
|
||||
@@ -454,6 +512,7 @@ let AuthController = class AuthController {
|
||||
name: user.name,
|
||||
avatar: user.avatar,
|
||||
isAdmin: user.isAdmin,
|
||||
isGoogle: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -653,10 +712,11 @@ PublicTourController = __decorate([
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
], PublicTourController);
|
||||
let TourController = class TourController {
|
||||
constructor(prisma, emailService, cacheManager) {
|
||||
constructor(prisma, emailService, cacheManager, commentGateway) {
|
||||
this.prisma = prisma;
|
||||
this.emailService = emailService;
|
||||
this.cacheManager = cacheManager;
|
||||
this.commentGateway = commentGateway;
|
||||
}
|
||||
async createTour(body, req) {
|
||||
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags, members } = body;
|
||||
@@ -1213,6 +1273,15 @@ let TourController = class TourController {
|
||||
data: { status: 'ACCEPTED' },
|
||||
}),
|
||||
]);
|
||||
const tour = await this.prisma.tour.findUnique({
|
||||
where: { id: tourId },
|
||||
select: { title: true }
|
||||
});
|
||||
this.commentGateway.notifyJoinRequestAccepted(joinRequest.userId, {
|
||||
tourId,
|
||||
tourTitle: tour?.title || 'Hành trình',
|
||||
requestId
|
||||
});
|
||||
return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' };
|
||||
}
|
||||
async rejectJoinRequest(tourId, requestId, req) {
|
||||
@@ -1707,7 +1776,7 @@ TourController = __decorate([
|
||||
(0, common_1.Controller)('tours'),
|
||||
__param(2, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
EmailService, Object])
|
||||
EmailService, Object, CommentGateway])
|
||||
], TourController);
|
||||
let LocationController = class LocationController {
|
||||
constructor(prisma, cacheManager) {
|
||||
@@ -2361,45 +2430,6 @@ UserController = __decorate([
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
], 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}`);
|
||||
}
|
||||
handleJoinPhoto(client, photoId) {
|
||||
client.join(`photo_${photoId}`);
|
||||
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
|
||||
}
|
||||
notifyNewComment(tourId, data) {
|
||||
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
|
||||
}
|
||||
notifyNewPhotoComment(photoId, data) {
|
||||
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', 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);
|
||||
__decorate([
|
||||
(0, websockets_1.SubscribeMessage)('joinPhoto'),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
|
||||
__metadata("design:returntype", void 0)
|
||||
], CommentGateway.prototype, "handleJoinPhoto", null);
|
||||
exports.CommentGateway = CommentGateway = __decorate([
|
||||
(0, websockets_1.WebSocketGateway)({ cors: { origin: '*' } })
|
||||
], CommentGateway);
|
||||
let CommentController = class CommentController {
|
||||
constructor(prisma, commentGateway) {
|
||||
this.prisma = prisma;
|
||||
@@ -2961,6 +2991,417 @@ PublicPhotoController = __decorate([
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
CommentGateway])
|
||||
], PublicPhotoController);
|
||||
let ConnectionController = class ConnectionController {
|
||||
constructor(prisma, commentGateway) {
|
||||
this.prisma = prisma;
|
||||
this.commentGateway = commentGateway;
|
||||
}
|
||||
async getConnections(req) {
|
||||
const currentUserId = req.user.id;
|
||||
const connections = await this.prisma.userConnection.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId: currentUserId },
|
||||
{ receiverId: currentUserId }
|
||||
],
|
||||
status: 'ACCEPTED'
|
||||
},
|
||||
include: {
|
||||
requester: { select: { id: true, name: true, email: true, avatar: true } },
|
||||
receiver: { select: { id: true, name: true, email: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
const formattedConnections = connections.map(conn => {
|
||||
const isRequester = conn.requesterId === currentUserId;
|
||||
const targetUser = isRequester ? conn.receiver : conn.requester;
|
||||
return {
|
||||
id: conn.id,
|
||||
targetUser,
|
||||
type: conn.type,
|
||||
status: conn.status,
|
||||
createdAt: conn.createdAt
|
||||
};
|
||||
});
|
||||
const receivedRequests = await this.prisma.userConnection.findMany({
|
||||
where: {
|
||||
receiverId: currentUserId,
|
||||
status: 'PENDING'
|
||||
},
|
||||
include: {
|
||||
requester: { select: { id: true, name: true, email: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
const sentRequests = await this.prisma.userConnection.findMany({
|
||||
where: {
|
||||
requesterId: currentUserId,
|
||||
status: 'PENDING'
|
||||
},
|
||||
include: {
|
||||
receiver: { select: { id: true, name: true, email: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
return {
|
||||
connections: formattedConnections,
|
||||
receivedRequests: receivedRequests.map(r => ({ id: r.id, requester: r.requester, type: r.type, createdAt: r.createdAt })),
|
||||
sentRequests: sentRequests.map(r => ({ id: r.id, receiver: r.receiver, type: r.type, createdAt: r.createdAt }))
|
||||
};
|
||||
}
|
||||
async sendRequest(req, body) {
|
||||
const requesterId = req.user.id;
|
||||
const { receiverId } = body;
|
||||
if (requesterId === receiverId) {
|
||||
throw new common_1.BadRequestException('Bạn không thể gửi lời mời kết nối cho chính mình.');
|
||||
}
|
||||
const receiver = await this.prisma.user.findUnique({ where: { id: receiverId } });
|
||||
if (!receiver) {
|
||||
throw new common_1.NotFoundException('Không tìm thấy người nhận.');
|
||||
}
|
||||
const existing = await this.prisma.userConnection.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId, receiverId },
|
||||
{ requesterId: receiverId, receiverId }
|
||||
]
|
||||
}
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.status === 'ACCEPTED') {
|
||||
throw new common_1.BadRequestException('Hai bạn đã kết nối với nhau.');
|
||||
}
|
||||
if (existing.status === 'PENDING') {
|
||||
throw new common_1.BadRequestException('Yêu cầu kết nối đang chờ xử lý.');
|
||||
}
|
||||
await this.prisma.userConnection.delete({ where: { id: existing.id } });
|
||||
}
|
||||
const newConn = await this.prisma.userConnection.create({
|
||||
data: {
|
||||
requesterId,
|
||||
receiverId,
|
||||
status: 'PENDING',
|
||||
type: 'FRIEND'
|
||||
}
|
||||
});
|
||||
return { success: true, connection: newConn };
|
||||
}
|
||||
async updateRequest(id, body, req) {
|
||||
const currentUserId = req.user.id;
|
||||
const conn = await this.prisma.userConnection.findUnique({ where: { id } });
|
||||
if (!conn) {
|
||||
throw new common_1.NotFoundException('Không tìm thấy bản ghi kết nối.');
|
||||
}
|
||||
if (body.status) {
|
||||
if (conn.receiverId !== currentUserId) {
|
||||
throw new common_1.ForbiddenException('Bạn không có quyền thực hiện hành động này.');
|
||||
}
|
||||
const updated = await this.prisma.userConnection.update({
|
||||
where: { id },
|
||||
data: { status: body.status }
|
||||
});
|
||||
if (body.status === 'ACCEPTED') {
|
||||
const receiverUser = await this.prisma.user.findUnique({
|
||||
where: { id: currentUserId },
|
||||
select: { name: true }
|
||||
});
|
||||
this.commentGateway.notifyConnectionAccepted(conn.requesterId, {
|
||||
connectionId: conn.id,
|
||||
acceptedByName: receiverUser?.name || 'Ai đó',
|
||||
acceptedById: currentUserId
|
||||
});
|
||||
}
|
||||
return { success: true, connection: updated };
|
||||
}
|
||||
if (body.type) {
|
||||
if (conn.requesterId !== currentUserId && conn.receiverId !== currentUserId) {
|
||||
throw new common_1.ForbiddenException('Bạn không có quyền thực hiện hành động này.');
|
||||
}
|
||||
if (conn.status !== 'ACCEPTED') {
|
||||
throw new common_1.BadRequestException('Chỉ có thể thay đổi phân nhóm sau khi đã chấp nhận kết nối.');
|
||||
}
|
||||
const updated = await this.prisma.userConnection.update({
|
||||
where: { id },
|
||||
data: { type: body.type }
|
||||
});
|
||||
return { success: true, connection: updated };
|
||||
}
|
||||
throw new common_1.BadRequestException('Yêu cầu không hợp lệ.');
|
||||
}
|
||||
async deleteConnection(id, req) {
|
||||
const currentUserId = req.user.id;
|
||||
const conn = await this.prisma.userConnection.findUnique({ where: { id } });
|
||||
if (!conn) {
|
||||
throw new common_1.NotFoundException('Không tìm thấy bản ghi kết nối.');
|
||||
}
|
||||
if (conn.requesterId !== currentUserId && conn.receiverId !== currentUserId) {
|
||||
throw new common_1.ForbiddenException('Bạn không có quyền thực hiện hành động này.');
|
||||
}
|
||||
await this.prisma.userConnection.delete({ where: { id } });
|
||||
return { success: true, message: 'Đã hủy kết nối thành công.' };
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.Get)(),
|
||||
__param(0, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], ConnectionController.prototype, "getConnections", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)(),
|
||||
__param(0, (0, common_1.Req)()),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], ConnectionController.prototype, "sendRequest", null);
|
||||
__decorate([
|
||||
(0, common_1.Patch)(':id'),
|
||||
__param(0, (0, common_1.Param)('id', 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)
|
||||
], ConnectionController.prototype, "updateRequest", null);
|
||||
__decorate([
|
||||
(0, common_1.Delete)(':id'),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], ConnectionController.prototype, "deleteConnection", null);
|
||||
ConnectionController = __decorate([
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
(0, common_1.Controller)('connections'),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
CommentGateway])
|
||||
], ConnectionController);
|
||||
let DirectMessageController = class DirectMessageController {
|
||||
constructor(prisma, commentGateway) {
|
||||
this.prisma = prisma;
|
||||
this.commentGateway = commentGateway;
|
||||
}
|
||||
async getMessages(userId, req) {
|
||||
const currentUserId = req.user.id;
|
||||
const conn = await this.prisma.userConnection.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId: currentUserId, receiverId: userId },
|
||||
{ requesterId: userId, receiverId: currentUserId }
|
||||
],
|
||||
status: 'ACCEPTED'
|
||||
}
|
||||
});
|
||||
if (!conn) {
|
||||
throw new common_1.ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.');
|
||||
}
|
||||
const messages = await this.prisma.directMessage.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ senderId: currentUserId, receiverId: userId },
|
||||
{ senderId: userId, receiverId: currentUserId }
|
||||
]
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
return messages;
|
||||
}
|
||||
async sendMessage(req, body) {
|
||||
const senderId = req.user.id;
|
||||
const { receiverId, content, attachmentUrl, latitude, longitude } = body;
|
||||
if ((!content || !content.trim()) && !attachmentUrl && latitude === undefined) {
|
||||
throw new common_1.BadRequestException('Tin nhắn phải có nội dung, ảnh đính kèm hoặc vị trí.');
|
||||
}
|
||||
const conn = await this.prisma.userConnection.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId: senderId, receiverId: receiverId },
|
||||
{ requesterId: receiverId, receiverId: senderId }
|
||||
],
|
||||
status: 'ACCEPTED'
|
||||
}
|
||||
});
|
||||
if (!conn) {
|
||||
throw new common_1.ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.');
|
||||
}
|
||||
const message = await this.prisma.directMessage.create({
|
||||
data: {
|
||||
senderId,
|
||||
receiverId,
|
||||
content: content || '',
|
||||
attachmentUrl,
|
||||
latitude,
|
||||
longitude
|
||||
},
|
||||
include: {
|
||||
sender: { select: { id: true, name: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
this.commentGateway.notifyNewMessage(receiverId, message);
|
||||
return message;
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.Get)(':userId'),
|
||||
__param(0, (0, common_1.Param)('userId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], DirectMessageController.prototype, "getMessages", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)(),
|
||||
__param(0, (0, common_1.Req)()),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], DirectMessageController.prototype, "sendMessage", null);
|
||||
DirectMessageController = __decorate([
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
(0, common_1.Controller)('messages'),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
CommentGateway])
|
||||
], DirectMessageController);
|
||||
let UploadController = class UploadController {
|
||||
async uploadAttachment(files) {
|
||||
if (!files || files.length === 0) {
|
||||
throw new common_1.BadRequestException('Vui lòng chọn ảnh.');
|
||||
}
|
||||
const file = files[0];
|
||||
const attachmentsDir = path.join(UPLOAD_ROOT, 'attachments');
|
||||
if (!fs.existsSync(attachmentsDir)) {
|
||||
fs.mkdirSync(attachmentsDir, { recursive: true });
|
||||
}
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const extension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const filename = `${uniqueSuffix}${extension}`;
|
||||
const filePath = path.join(attachmentsDir, filename);
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
return {
|
||||
url: `/uploads/attachments/${filename}`
|
||||
};
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.Post)(),
|
||||
(0, common_1.UseInterceptors)((0, platform_express_1.FilesInterceptor)('image', 1)),
|
||||
__param(0, (0, common_1.UploadedFiles)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Array]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], UploadController.prototype, "uploadAttachment", null);
|
||||
UploadController = __decorate([
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
(0, common_1.Controller)('upload')
|
||||
], UploadController);
|
||||
let TourMessageController = class TourMessageController {
|
||||
constructor(prisma, commentGateway) {
|
||||
this.prisma = prisma;
|
||||
this.commentGateway = commentGateway;
|
||||
}
|
||||
async getTourMessages(tourId, req) {
|
||||
const userId = req.user.id;
|
||||
const participant = await this.prisma.tourParticipant.findFirst({
|
||||
where: { tourId, userId }
|
||||
});
|
||||
if (!participant) {
|
||||
throw new common_1.ForbiddenException('Bạn không phải là thành viên của hành trình này.');
|
||||
}
|
||||
const messages = await this.prisma.tourMessage.findMany({
|
||||
where: { tourId },
|
||||
include: {
|
||||
sender: { select: { id: true, name: true, avatar: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
return messages;
|
||||
}
|
||||
async sendTourMessage(tourId, req, body) {
|
||||
const senderId = req.user.id;
|
||||
const { content, attachmentUrl, latitude, longitude, taggedUserIds } = body;
|
||||
console.log(`[sendTourMessage] senderId=${senderId}, tourId=${tourId}, taggedUserIds=`, taggedUserIds);
|
||||
if ((!content || !content.trim()) && !attachmentUrl && latitude === undefined) {
|
||||
throw new common_1.BadRequestException('Tin nhắn phải có nội dung, ảnh đính kèm hoặc vị trí.');
|
||||
}
|
||||
const participant = await this.prisma.tourParticipant.findFirst({
|
||||
where: { tourId, userId: senderId }
|
||||
});
|
||||
if (!participant) {
|
||||
throw new common_1.ForbiddenException('Bạn không phải là thành viên của hành trình này.');
|
||||
}
|
||||
const tourMessage = await this.prisma.tourMessage.create({
|
||||
data: {
|
||||
tourId,
|
||||
senderId,
|
||||
content: content || '',
|
||||
attachmentUrl,
|
||||
latitude,
|
||||
longitude
|
||||
},
|
||||
include: {
|
||||
sender: { select: { id: true, name: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
this.commentGateway.server.to(`tour_${tourId}`).emit('tourMessageReceived', {
|
||||
tourId,
|
||||
message: tourMessage
|
||||
});
|
||||
const hasTags = Array.isArray(taggedUserIds) && taggedUserIds.length > 0;
|
||||
const otherParticipants = await this.prisma.tourParticipant.findMany({
|
||||
where: {
|
||||
tourId,
|
||||
userId: {
|
||||
not: senderId,
|
||||
in: hasTags ? taggedUserIds : undefined
|
||||
},
|
||||
NOT: { userId: null }
|
||||
}
|
||||
});
|
||||
console.log(`[sendTourMessage] otherParticipants found count: ${otherParticipants.length}`, otherParticipants.map(o => o.userId));
|
||||
const tour = await this.prisma.tour.findUnique({
|
||||
where: { id: tourId },
|
||||
select: { title: true }
|
||||
});
|
||||
for (const p of otherParticipants) {
|
||||
if (p.userId) {
|
||||
const isTagged = hasTags && taggedUserIds.includes(p.userId);
|
||||
console.log(`[sendTourMessage] Emitting tourMessageNotification to user_${p.userId}, isTagged: ${isTagged}`);
|
||||
this.commentGateway.server.to(`user_${p.userId}`).emit('tourMessageNotification', {
|
||||
tourId,
|
||||
senderName: req.user.name || 'Thành viên',
|
||||
tourTitle: tour?.title || 'Hành trình',
|
||||
content: isTagged ? `@Bạn: ${content || ''}` : content || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'),
|
||||
isTagged
|
||||
});
|
||||
}
|
||||
}
|
||||
return tourMessage;
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.Get)(),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourMessageController.prototype, "getTourMessages", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)(),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Req)()),
|
||||
__param(2, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourMessageController.prototype, "sendTourMessage", null);
|
||||
TourMessageController = __decorate([
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
(0, common_1.Controller)('tours/:tourId/messages'),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
CommentGateway])
|
||||
], TourMessageController);
|
||||
let AppModule = class AppModule {
|
||||
};
|
||||
AppModule = __decorate([
|
||||
@@ -2985,7 +3426,7 @@ AppModule = __decorate([
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController, ConnectionController, DirectMessageController, UploadController, TourMessageController],
|
||||
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector, EmailService, config_1.ConfigService],
|
||||
exports: [prisma_service_1.PrismaService]
|
||||
})
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,57 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ConnectionStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ConnectionType" AS ENUM ('FRIEND', 'FAMILY');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserConnection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"requesterId" TEXT NOT NULL,
|
||||
"receiverId" TEXT NOT NULL,
|
||||
"status" "ConnectionStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"type" "ConnectionType" NOT NULL DEFAULT 'FRIEND',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UserConnection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DirectMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"senderId" TEXT NOT NULL,
|
||||
"receiverId" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DirectMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UserConnection_requesterId_idx" ON "UserConnection"("requesterId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UserConnection_receiverId_idx" ON "UserConnection"("receiverId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserConnection_requesterId_receiverId_key" ON "UserConnection"("requesterId", "receiverId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DirectMessage_senderId_idx" ON "DirectMessage"("senderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DirectMessage_receiverId_idx" ON "DirectMessage"("receiverId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "DirectMessage" ADD COLUMN "attachmentUrl" TEXT,
|
||||
ADD COLUMN "latitude" DOUBLE PRECISION,
|
||||
ADD COLUMN "longitude" DOUBLE PRECISION;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TourMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"senderId" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"attachmentUrl" TEXT,
|
||||
"latitude" DOUBLE PRECISION,
|
||||
"longitude" DOUBLE PRECISION,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TourMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TourMessage_tourId_idx" ON "TourMessage"("tourId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TourMessage_senderId_idx" ON "TourMessage"("senderId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -75,7 +75,11 @@ model User {
|
||||
uploadedPhotos Photo[]
|
||||
paidExpenses Expense[] @relation("ExpensePaidBy")
|
||||
comments Comment[]
|
||||
|
||||
sentConnections UserConnection[] @relation("ConnectionRequester")
|
||||
receivedConnections UserConnection[] @relation("ConnectionReceiver")
|
||||
sentMessages DirectMessage[] @relation("MessageSender")
|
||||
receivedMessages DirectMessage[] @relation("MessageReceiver")
|
||||
tourMessages TourMessage[]
|
||||
}
|
||||
|
||||
model Tour {
|
||||
@@ -100,6 +104,7 @@ model Tour {
|
||||
legs Leg[]
|
||||
photos Photo[]
|
||||
invitations TourInvitation[]
|
||||
tourMessages TourMessage[]
|
||||
}
|
||||
|
||||
model JoinRequest {
|
||||
@@ -227,3 +232,67 @@ model TourInvitation {
|
||||
|
||||
@@unique([tourId, email])
|
||||
}
|
||||
|
||||
enum ConnectionStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum ConnectionType {
|
||||
FRIEND
|
||||
FAMILY
|
||||
}
|
||||
|
||||
model UserConnection {
|
||||
id String @id @default(uuid())
|
||||
requesterId String
|
||||
receiverId String
|
||||
status ConnectionStatus @default(PENDING)
|
||||
type ConnectionType @default(FRIEND)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
requester User @relation("ConnectionRequester", fields: [requesterId], references: [id], onDelete: Cascade)
|
||||
receiver User @relation("ConnectionReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([requesterId, receiverId])
|
||||
@@index([requesterId])
|
||||
@@index([receiverId])
|
||||
}
|
||||
|
||||
model DirectMessage {
|
||||
id String @id @default(uuid())
|
||||
senderId String
|
||||
receiverId String
|
||||
content String @db.Text
|
||||
attachmentUrl String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade)
|
||||
receiver User @relation("MessageReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([senderId])
|
||||
@@index([receiverId])
|
||||
}
|
||||
|
||||
model TourMessage {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
senderId String
|
||||
content String @db.Text
|
||||
attachmentUrl String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tourId])
|
||||
@@index([senderId])
|
||||
}
|
||||
|
||||
+484
-31
@@ -39,6 +39,55 @@ const gunzip = promisify(zlib.gunzip);
|
||||
// Khai báo vị trí thư mục upload cụ thể
|
||||
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads');
|
||||
|
||||
@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}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('joinPhoto')
|
||||
handleJoinPhoto(client: Socket, photoId: string) {
|
||||
client.join(`photo_${photoId}`);
|
||||
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
notifyNewPhotoComment(photoId: string, data: any) {
|
||||
// Gửi thông báo tới tất cả client trong phòng của Ảnh này
|
||||
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
|
||||
}
|
||||
|
||||
@SubscribeMessage('joinUser')
|
||||
handleJoinUser(client: Socket, userId: string) {
|
||||
client.join(`user_${userId}`);
|
||||
console.log(`[WS] Client ${client.id} joined user room: user_${userId}`);
|
||||
}
|
||||
|
||||
notifyNewMessage(receiverId: string, data: any) {
|
||||
this.server.to(`user_${receiverId}`).emit('messageReceived', data);
|
||||
}
|
||||
|
||||
notifyConnectionAccepted(requesterId: string, data: any) {
|
||||
this.server.to(`user_${requesterId}`).emit('connectionAccepted', data);
|
||||
}
|
||||
|
||||
notifyJoinRequestAccepted(userId: string, data: any) {
|
||||
this.server.to(`user_${userId}`).emit('joinRequestAccepted', data);
|
||||
}
|
||||
}
|
||||
|
||||
// Cấu hình TTL (mili giây) cho từng loại dữ liệu
|
||||
const CACHE_TTL = {
|
||||
DEFAULT: 600000, // 10 phút mặc định
|
||||
@@ -469,6 +518,7 @@ class AuthController {
|
||||
name: user.name,
|
||||
avatar: user.avatar,
|
||||
isAdmin: user.isAdmin,
|
||||
isGoogle: true,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -629,7 +679,8 @@ class TourController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailService: EmailService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
private commentGateway: CommentGateway
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -1320,6 +1371,17 @@ class TourController {
|
||||
}),
|
||||
]);
|
||||
|
||||
const tour = await this.prisma.tour.findUnique({
|
||||
where: { id: tourId },
|
||||
select: { title: true }
|
||||
});
|
||||
|
||||
this.commentGateway.notifyJoinRequestAccepted(joinRequest.userId, {
|
||||
tourId,
|
||||
tourTitle: tour?.title || 'Hành trình',
|
||||
requestId
|
||||
});
|
||||
|
||||
return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' };
|
||||
}
|
||||
|
||||
@@ -2319,36 +2381,7 @@ 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}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('joinPhoto')
|
||||
handleJoinPhoto(client: Socket, photoId: string) {
|
||||
client.join(`photo_${photoId}`);
|
||||
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
notifyNewPhotoComment(photoId: string, data: any) {
|
||||
// Gửi thông báo tới tất cả client trong phòng của Ảnh này
|
||||
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('locations')
|
||||
class CommentController {
|
||||
@@ -2896,6 +2929,426 @@ class PublicPhotoController {
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('connections')
|
||||
class ConnectionController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private commentGateway: CommentGateway
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getConnections(@Req() req: any) {
|
||||
const currentUserId = req.user.id;
|
||||
// Get connections where status is ACCEPTED
|
||||
const connections = await this.prisma.userConnection.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId: currentUserId },
|
||||
{ receiverId: currentUserId }
|
||||
],
|
||||
status: 'ACCEPTED'
|
||||
},
|
||||
include: {
|
||||
requester: { select: { id: true, name: true, email: true, avatar: true } },
|
||||
receiver: { select: { id: true, name: true, email: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// Format connections to return the target user and relation details
|
||||
const formattedConnections = connections.map(conn => {
|
||||
const isRequester = conn.requesterId === currentUserId;
|
||||
const targetUser = isRequester ? conn.receiver : conn.requester;
|
||||
return {
|
||||
id: conn.id,
|
||||
targetUser,
|
||||
type: conn.type, // FRIEND / FAMILY
|
||||
status: conn.status,
|
||||
createdAt: conn.createdAt
|
||||
};
|
||||
});
|
||||
|
||||
// Get pending requests received by current user
|
||||
const receivedRequests = await this.prisma.userConnection.findMany({
|
||||
where: {
|
||||
receiverId: currentUserId,
|
||||
status: 'PENDING'
|
||||
},
|
||||
include: {
|
||||
requester: { select: { id: true, name: true, email: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// Get pending requests sent by current user
|
||||
const sentRequests = await this.prisma.userConnection.findMany({
|
||||
where: {
|
||||
requesterId: currentUserId,
|
||||
status: 'PENDING'
|
||||
},
|
||||
include: {
|
||||
receiver: { select: { id: true, name: true, email: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
connections: formattedConnections,
|
||||
receivedRequests: receivedRequests.map(r => ({ id: r.id, requester: r.requester, type: r.type, createdAt: r.createdAt })),
|
||||
sentRequests: sentRequests.map(r => ({ id: r.id, receiver: r.receiver, type: r.type, createdAt: r.createdAt }))
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
async sendRequest(@Req() req: any, @Body() body: { receiverId: string }) {
|
||||
const requesterId = req.user.id;
|
||||
const { receiverId } = body;
|
||||
|
||||
if (requesterId === receiverId) {
|
||||
throw new BadRequestException('Bạn không thể gửi lời mời kết nối cho chính mình.');
|
||||
}
|
||||
|
||||
const receiver = await this.prisma.user.findUnique({ where: { id: receiverId } });
|
||||
if (!receiver) {
|
||||
throw new NotFoundException('Không tìm thấy người nhận.');
|
||||
}
|
||||
|
||||
// Check if relation already exists
|
||||
const existing = await this.prisma.userConnection.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId, receiverId },
|
||||
{ requesterId: receiverId, receiverId }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'ACCEPTED') {
|
||||
throw new BadRequestException('Hai bạn đã kết nối với nhau.');
|
||||
}
|
||||
if (existing.status === 'PENDING') {
|
||||
throw new BadRequestException('Yêu cầu kết nối đang chờ xử lý.');
|
||||
}
|
||||
// If rejected, allow sending request again by deleting/updating the old one
|
||||
await this.prisma.userConnection.delete({ where: { id: existing.id } });
|
||||
}
|
||||
|
||||
const newConn = await this.prisma.userConnection.create({
|
||||
data: {
|
||||
requesterId,
|
||||
receiverId,
|
||||
status: 'PENDING',
|
||||
type: 'FRIEND' // default
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, connection: newConn };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updateRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: { status?: 'ACCEPTED' | 'REJECTED'; type?: 'FRIEND' | 'FAMILY' },
|
||||
@Req() req: any
|
||||
) {
|
||||
const currentUserId = req.user.id;
|
||||
const conn = await this.prisma.userConnection.findUnique({ where: { id } });
|
||||
if (!conn) {
|
||||
throw new NotFoundException('Không tìm thấy bản ghi kết nối.');
|
||||
}
|
||||
|
||||
// Accept/Reject request
|
||||
if (body.status) {
|
||||
if (conn.receiverId !== currentUserId) {
|
||||
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này.');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.userConnection.update({
|
||||
where: { id },
|
||||
data: { status: body.status }
|
||||
});
|
||||
|
||||
if (body.status === 'ACCEPTED') {
|
||||
const receiverUser = await this.prisma.user.findUnique({
|
||||
where: { id: currentUserId },
|
||||
select: { name: true }
|
||||
});
|
||||
this.commentGateway.notifyConnectionAccepted(conn.requesterId, {
|
||||
connectionId: conn.id,
|
||||
acceptedByName: receiverUser?.name || 'Ai đó',
|
||||
acceptedById: currentUserId
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, connection: updated };
|
||||
}
|
||||
|
||||
// Change relationship type (FRIEND <-> FAMILY)
|
||||
if (body.type) {
|
||||
if (conn.requesterId !== currentUserId && conn.receiverId !== currentUserId) {
|
||||
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này.');
|
||||
}
|
||||
if (conn.status !== 'ACCEPTED') {
|
||||
throw new BadRequestException('Chỉ có thể thay đổi phân nhóm sau khi đã chấp nhận kết nối.');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.userConnection.update({
|
||||
where: { id },
|
||||
data: { type: body.type }
|
||||
});
|
||||
return { success: true, connection: updated };
|
||||
}
|
||||
|
||||
throw new BadRequestException('Yêu cầu không hợp lệ.');
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteConnection(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||
const currentUserId = req.user.id;
|
||||
const conn = await this.prisma.userConnection.findUnique({ where: { id } });
|
||||
if (!conn) {
|
||||
throw new NotFoundException('Không tìm thấy bản ghi kết nối.');
|
||||
}
|
||||
|
||||
if (conn.requesterId !== currentUserId && conn.receiverId !== currentUserId) {
|
||||
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này.');
|
||||
}
|
||||
|
||||
await this.prisma.userConnection.delete({ where: { id } });
|
||||
return { success: true, message: 'Đã hủy kết nối thành công.' };
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('messages')
|
||||
class DirectMessageController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private commentGateway: CommentGateway
|
||||
) {}
|
||||
|
||||
@Get(':userId')
|
||||
async getMessages(@Param('userId', ParseUUIDPipe) userId: string, @Req() req: any) {
|
||||
const currentUserId = req.user.id;
|
||||
|
||||
// Check connection first to ensure they are connected
|
||||
const conn = await this.prisma.userConnection.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId: currentUserId, receiverId: userId },
|
||||
{ requesterId: userId, receiverId: currentUserId }
|
||||
],
|
||||
status: 'ACCEPTED'
|
||||
}
|
||||
});
|
||||
|
||||
if (!conn) {
|
||||
throw new ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.');
|
||||
}
|
||||
|
||||
const messages = await this.prisma.directMessage.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ senderId: currentUserId, receiverId: userId },
|
||||
{ senderId: userId, receiverId: currentUserId }
|
||||
]
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async sendMessage(
|
||||
@Req() req: any,
|
||||
@Body() body: { receiverId: string; content?: string; attachmentUrl?: string; latitude?: number; longitude?: number }
|
||||
) {
|
||||
const senderId = req.user.id;
|
||||
const { receiverId, content, attachmentUrl, latitude, longitude } = body;
|
||||
|
||||
if ((!content || !content.trim()) && !attachmentUrl && latitude === undefined) {
|
||||
throw new BadRequestException('Tin nhắn phải có nội dung, ảnh đính kèm hoặc vị trí.');
|
||||
}
|
||||
|
||||
// Check connection first
|
||||
const conn = await this.prisma.userConnection.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId: senderId, receiverId: receiverId },
|
||||
{ requesterId: receiverId, receiverId: senderId }
|
||||
],
|
||||
status: 'ACCEPTED'
|
||||
}
|
||||
});
|
||||
|
||||
if (!conn) {
|
||||
throw new ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.');
|
||||
}
|
||||
|
||||
const message = await this.prisma.directMessage.create({
|
||||
data: {
|
||||
senderId,
|
||||
receiverId,
|
||||
content: content || '',
|
||||
attachmentUrl,
|
||||
latitude,
|
||||
longitude
|
||||
},
|
||||
include: {
|
||||
sender: { select: { id: true, name: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// Realtime broadcast via Socket
|
||||
this.commentGateway.notifyNewMessage(receiverId, message);
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('upload')
|
||||
class UploadController {
|
||||
@Post()
|
||||
@UseInterceptors(FilesInterceptor('image', 1))
|
||||
async uploadAttachment(@UploadedFiles() files: any[]) {
|
||||
if (!files || files.length === 0) {
|
||||
throw new BadRequestException('Vui lòng chọn ảnh.');
|
||||
}
|
||||
const file = files[0];
|
||||
const attachmentsDir = path.join(UPLOAD_ROOT, 'attachments');
|
||||
if (!fs.existsSync(attachmentsDir)) {
|
||||
fs.mkdirSync(attachmentsDir, { recursive: true });
|
||||
}
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const extension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const filename = `${uniqueSuffix}${extension}`;
|
||||
const filePath = path.join(attachmentsDir, filename);
|
||||
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
|
||||
return {
|
||||
url: `/uploads/attachments/${filename}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('tours/:tourId/messages')
|
||||
class TourMessageController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private commentGateway: CommentGateway
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getTourMessages(@Param('tourId', ParseUUIDPipe) tourId: string, @Req() req: any) {
|
||||
const userId = req.user.id;
|
||||
|
||||
// Check if user is a participant of this tour
|
||||
const participant = await this.prisma.tourParticipant.findFirst({
|
||||
where: { tourId, userId }
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new ForbiddenException('Bạn không phải là thành viên của hành trình này.');
|
||||
}
|
||||
|
||||
const messages = await this.prisma.tourMessage.findMany({
|
||||
where: { tourId },
|
||||
include: {
|
||||
sender: { select: { id: true, name: true, avatar: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async sendTourMessage(
|
||||
@Param('tourId', ParseUUIDPipe) tourId: string,
|
||||
@Req() req: any,
|
||||
@Body() body: { content?: string; attachmentUrl?: string; latitude?: number; longitude?: number; taggedUserIds?: string[] }
|
||||
) {
|
||||
const senderId = req.user.id;
|
||||
const { content, attachmentUrl, latitude, longitude, taggedUserIds } = body;
|
||||
|
||||
console.log(`[sendTourMessage] senderId=${senderId}, tourId=${tourId}, taggedUserIds=`, taggedUserIds);
|
||||
|
||||
if ((!content || !content.trim()) && !attachmentUrl && latitude === undefined) {
|
||||
throw new BadRequestException('Tin nhắn phải có nội dung, ảnh đính kèm hoặc vị trí.');
|
||||
}
|
||||
|
||||
// Check if user is a participant of this tour
|
||||
const participant = await this.prisma.tourParticipant.findFirst({
|
||||
where: { tourId, userId: senderId }
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new ForbiddenException('Bạn không phải là thành viên của hành trình này.');
|
||||
}
|
||||
|
||||
const tourMessage = await this.prisma.tourMessage.create({
|
||||
data: {
|
||||
tourId,
|
||||
senderId,
|
||||
content: content || '',
|
||||
attachmentUrl,
|
||||
latitude,
|
||||
longitude
|
||||
},
|
||||
include: {
|
||||
sender: { select: { id: true, name: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// Broadcast to the WebSocket room of this tour
|
||||
this.commentGateway.server.to(`tour_${tourId}`).emit('tourMessageReceived', {
|
||||
tourId,
|
||||
message: tourMessage
|
||||
});
|
||||
|
||||
const hasTags = Array.isArray(taggedUserIds) && taggedUserIds.length > 0;
|
||||
|
||||
// Notify other participants (via their user rooms)
|
||||
// If taggedUserIds is provided, we ONLY notify those tagged users.
|
||||
const otherParticipants = await this.prisma.tourParticipant.findMany({
|
||||
where: {
|
||||
tourId,
|
||||
userId: {
|
||||
not: senderId,
|
||||
in: hasTags ? taggedUserIds : undefined
|
||||
},
|
||||
NOT: { userId: null }
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[sendTourMessage] otherParticipants found count: ${otherParticipants.length}`, otherParticipants.map(o => o.userId));
|
||||
|
||||
const tour = await this.prisma.tour.findUnique({
|
||||
where: { id: tourId },
|
||||
select: { title: true }
|
||||
});
|
||||
|
||||
for (const p of otherParticipants) {
|
||||
if (p.userId) {
|
||||
const isTagged = hasTags && taggedUserIds.includes(p.userId);
|
||||
console.log(`[sendTourMessage] Emitting tourMessageNotification to user_${p.userId}, isTagged: ${isTagged}`);
|
||||
this.commentGateway.server.to(`user_${p.userId}`).emit('tourMessageNotification', {
|
||||
tourId,
|
||||
senderName: req.user.name || 'Thành viên',
|
||||
tourTitle: tour?.title || 'Hành trình',
|
||||
content: isTagged ? `@Bạn: ${content || ''}` : content || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'),
|
||||
isTagged
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tourMessage;
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
@@ -2919,7 +3372,7 @@ class PublicPhotoController {
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}) as any,
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController, ConnectionController, DirectMessageController, UploadController, TourMessageController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 456 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 383 KiB |
+46
-7
@@ -6,6 +6,7 @@ import SignupPage from './pages/SignupPage';
|
||||
import { MyPhotosPage } from './pages/MyPhotosPage';
|
||||
import { MyNotePage } from './pages/MyNotePage';
|
||||
import { JoinTourPage } from './pages/JoinTourPage';
|
||||
import { MemberDashboard } from './pages/MemberDashboard';
|
||||
import { useTourStore } from './store/useTourStore';
|
||||
import { ConfirmProvider } from './hooks/useConfirm';
|
||||
import { NotificationProvider } from './hooks/useNotification';
|
||||
@@ -15,11 +16,12 @@ function App() {
|
||||
const viewTourId = params.get('viewTour');
|
||||
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour'>(
|
||||
const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour'>(
|
||||
viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing')
|
||||
);
|
||||
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
||||
const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore');
|
||||
|
||||
// Lấy action từ store
|
||||
const fetchTour = useTourStore(state => state.fetchTour);
|
||||
@@ -50,7 +52,7 @@ function App() {
|
||||
setCurrentPage('tourDetail');
|
||||
} else {
|
||||
if (loggedInUser) {
|
||||
setCurrentPage('explore');
|
||||
setCurrentPage('dashboard');
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
@@ -65,7 +67,7 @@ function App() {
|
||||
if (pendingInviteToken) {
|
||||
setCurrentPage('joinTour');
|
||||
} else {
|
||||
setCurrentPage('explore');
|
||||
setCurrentPage('dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,18 +78,19 @@ function App() {
|
||||
setCurrentPage('landing');
|
||||
};
|
||||
|
||||
const handleViewTour = (tourId: string) => {
|
||||
const handleViewTour = (tourId: string, fromPage?: 'explore' | 'dashboard') => {
|
||||
setCurrentTourId(tourId);
|
||||
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
|
||||
setPreviousPage(fromPage || (currentPage === 'dashboard' ? 'dashboard' : 'explore'));
|
||||
setCurrentPage('tourDetail');
|
||||
};
|
||||
|
||||
const handleBackFromTourDetail = () => {
|
||||
setCurrentTourId(null);
|
||||
setIsPublicTourView(false);
|
||||
// Quay về trang khám phá nếu đã đăng nhập, ngược lại quay về Landing
|
||||
// Quay về trang cũ nếu đã đăng nhập, ngược lại quay về Landing
|
||||
if (user) {
|
||||
setCurrentPage('explore');
|
||||
setCurrentPage(previousPage);
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
@@ -105,6 +108,22 @@ function App() {
|
||||
const handleSignupSuccess = () => {
|
||||
// Sau khi đăng ký, ta có thể tự động đăng nhập hoặc quay lại landing/joinTour
|
||||
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
|
||||
const token = localStorage.getItem('token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (token && storedUser) {
|
||||
try {
|
||||
const loggedInUser = JSON.parse(storedUser);
|
||||
setUser(loggedInUser);
|
||||
if (pendingInviteToken) {
|
||||
setCurrentPage('joinTour');
|
||||
} else {
|
||||
setCurrentPage('dashboard');
|
||||
}
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (pendingInviteToken) {
|
||||
setCurrentPage('joinTour');
|
||||
} else {
|
||||
@@ -112,10 +131,30 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackFromExplore = () => {
|
||||
if (user) {
|
||||
setCurrentPage('dashboard');
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmProvider>
|
||||
<NotificationProvider>
|
||||
{(() => {
|
||||
if (currentPage === 'dashboard') {
|
||||
return (
|
||||
<MemberDashboard
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
onExploreTours={() => setCurrentPage('explore')}
|
||||
onViewTour={handleViewTour}
|
||||
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPage === 'tourDetail') {
|
||||
return (
|
||||
<TourDetailPage
|
||||
@@ -134,7 +173,7 @@ function App() {
|
||||
if (currentPage === 'explore') {
|
||||
return (
|
||||
<ExploreMap
|
||||
onBack={handleBackFromTourDetail}
|
||||
onBack={handleBackFromExplore}
|
||||
onLogout={handleLogout}
|
||||
user={user}
|
||||
onViewTour={handleViewTour}
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { Send, MessageSquare, Loader2, Image as ImageIcon, MapPin, Download, X } from 'lucide-react';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
|
||||
interface TourChatProps {
|
||||
tourId: string;
|
||||
}
|
||||
|
||||
export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
|
||||
const notify = useNotification();
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [attachedLocation, setAttachedLocation] = useState<{ latitude: number; longitude: number } | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isLocating, setIsLocating] = useState(false);
|
||||
|
||||
const [participants, setParticipants] = useState<any[]>([]);
|
||||
const [showMentionList, setShowMentionList] = useState(false);
|
||||
const [mentionSearch, setMentionSearch] = useState('');
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const [taggedUserIds, setTaggedUserIds] = useState<string[]>([]);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const mentionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getHeaders = () => ({
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
const currentUserId = (() => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return null;
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
const parsed = JSON.parse(jsonPayload);
|
||||
return parsed.sub || parsed.id;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// Fetch tour details to get participants (excluding current user)
|
||||
useEffect(() => {
|
||||
const fetchTourDetails = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/tours/${tourId}`, { headers: getHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.participants) {
|
||||
const memberList = data.participants
|
||||
.map((p: any) => p.user)
|
||||
.filter((u: any) => u && u.id !== currentUserId);
|
||||
setParticipants(memberList);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Lỗi khi tải thông tin thành viên tour:', err);
|
||||
}
|
||||
};
|
||||
if (tourId && currentUserId) {
|
||||
fetchTourDetails();
|
||||
}
|
||||
}, [tourId, currentUserId]);
|
||||
|
||||
// Click outside to close mention dropdown
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (mentionRef.current && !mentionRef.current.contains(e.target as Node)) {
|
||||
setShowMentionList(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
const filteredParticipants = participants.filter(p =>
|
||||
p.name.toLowerCase().includes(mentionSearch.toLowerCase())
|
||||
);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setNewMessage(value);
|
||||
|
||||
const selectionStart = e.target.selectionStart || 0;
|
||||
const textBeforeCursor = value.slice(0, selectionStart);
|
||||
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
if (lastAtIndex !== -1) {
|
||||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
||||
if (!textAfterAt.includes(' ')) {
|
||||
setShowMentionList(true);
|
||||
setMentionSearch(textAfterAt);
|
||||
setMentionIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setShowMentionList(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!showMentionList) return;
|
||||
const filtered = filteredParticipants;
|
||||
if (filtered.length === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setMentionIndex(prev => (prev + 1) % filtered.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setMentionIndex(prev => (prev - 1 + filtered.length) % filtered.length);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
insertMention(filtered[mentionIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowMentionList(false);
|
||||
}
|
||||
};
|
||||
|
||||
const insertMention = (member: { id: string; name: string }) => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
const selectionStart = input.selectionStart || 0;
|
||||
const textBeforeCursor = newMessage.slice(0, selectionStart);
|
||||
const textAfterCursor = newMessage.slice(selectionStart);
|
||||
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
if (lastAtIndex !== -1) {
|
||||
const newTextBeforeCursor = textBeforeCursor.slice(0, lastAtIndex) + `@${member.name} `;
|
||||
const updatedValue = newTextBeforeCursor + textAfterCursor;
|
||||
|
||||
setNewMessage(updatedValue);
|
||||
setShowMentionList(false);
|
||||
|
||||
if (!taggedUserIds.includes(member.id)) {
|
||||
setTaggedUserIds(prev => [...prev, member.id]);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
const cursorPosition = newTextBeforeCursor.length;
|
||||
input.setSelectionRange(cursorPosition, cursorPosition);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch past messages
|
||||
useEffect(() => {
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/tours/${tourId}/messages`, { headers: getHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessages(data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Lỗi khi tải tin nhắn:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMessages();
|
||||
}, [tourId]);
|
||||
|
||||
// Connect to socket and listen for tour messages
|
||||
useEffect(() => {
|
||||
const socket = io();
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.emit('joinTour', tourId);
|
||||
});
|
||||
|
||||
socket.on('tourMessageReceived', (data: any) => {
|
||||
if (data.tourId === tourId) {
|
||||
setMessages(prev => [...prev, data.message]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [tourId]);
|
||||
|
||||
// Autoscroll chat to bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Compress image to 2K (max 2048px longest side)
|
||||
const compressImageTo2K = (file: File): Promise<Blob> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.src = event.target?.result as string;
|
||||
img.onload = () => {
|
||||
const MAX_DIM = 2048;
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (width > MAX_DIM || height > MAX_DIM) {
|
||||
if (width > height) {
|
||||
height = Math.round((height * MAX_DIM) / width);
|
||||
width = MAX_DIM;
|
||||
} else {
|
||||
width = Math.round((width * MAX_DIM) / height);
|
||||
height = MAX_DIM;
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
resolve(file);
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
resolve(file);
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
0.85
|
||||
);
|
||||
};
|
||||
img.onerror = (err) => reject(err);
|
||||
};
|
||||
reader.onerror = (err) => reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
// Handle Image Selection
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedImage(file);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Location Sharing
|
||||
const handleGetLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
notify({
|
||||
title: 'Không hỗ trợ',
|
||||
message: 'Trình duyệt của bạn không hỗ trợ định vị GPS.',
|
||||
type: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLocating(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setAttachedLocation({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude
|
||||
});
|
||||
notify({
|
||||
title: 'Gắn vị trí thành công',
|
||||
message: 'Vị trí hiện tại đã được đính kèm vào tin nhắn.',
|
||||
type: 'success'
|
||||
});
|
||||
setIsLocating(false);
|
||||
},
|
||||
(error) => {
|
||||
console.error('Lỗi định vị:', error);
|
||||
notify({
|
||||
title: 'Lỗi GPS',
|
||||
message: 'Không thể lấy vị trí hiện tại của bạn. Hãy kiểm tra quyền truy cập.',
|
||||
type: 'error'
|
||||
});
|
||||
setIsLocating(false);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
};
|
||||
|
||||
// Upload image to backend
|
||||
const uploadImage = async (file: File): Promise<string | null> => {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
// Compress first
|
||||
const compressedBlob = await compressImageTo2K(file);
|
||||
const formData = new FormData();
|
||||
formData.append('image', compressedBlob, 'compressed.jpg');
|
||||
|
||||
const res = await fetch('/api/v1/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return data.url;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('Lỗi upload ảnh:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Send Tour Message
|
||||
const handleSendMessage = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!newMessage.trim() && !selectedImage && !attachedLocation) return;
|
||||
|
||||
let attachmentUrl = undefined;
|
||||
if (selectedImage) {
|
||||
attachmentUrl = await uploadImage(selectedImage);
|
||||
if (!attachmentUrl) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: 'Không thể tải ảnh đính kèm lên server.',
|
||||
type: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const actualTaggedUserIds = taggedUserIds.filter(userId => {
|
||||
const member = participants.find(p => p.id === userId);
|
||||
if (!member || !member.name) return false;
|
||||
|
||||
const cleanMessage = newMessage.toLowerCase();
|
||||
const nameLower = member.name.toLowerCase();
|
||||
|
||||
// Try exact match first
|
||||
if (cleanMessage.includes(`@${nameLower}`)) return true;
|
||||
|
||||
// Try match without parentheses (e.g. "Lộc Phạm (Chủ Tour)" -> "Lộc Phạm")
|
||||
const nameWithoutParentheses = member.name.split('(')[0].trim().toLowerCase();
|
||||
if (nameWithoutParentheses && cleanMessage.includes(`@${nameWithoutParentheses}`)) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const payload = {
|
||||
content: newMessage,
|
||||
attachmentUrl,
|
||||
latitude: attachedLocation?.latitude,
|
||||
longitude: attachedLocation?.longitude,
|
||||
taggedUserIds: actualTaggedUserIds
|
||||
};
|
||||
|
||||
// Reset input fields immediately
|
||||
setNewMessage('');
|
||||
setSelectedImage(null);
|
||||
setImagePreview(null);
|
||||
setAttachedLocation(null);
|
||||
setTaggedUserIds([]);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/tours/${tourId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: 'Gửi tin nhắn thất bại.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Lỗi gửi tin nhắn:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Download image file helper
|
||||
const handleDownloadImage = async (url: string, id: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = `tour-chat-photo-${id}.jpg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch (error) {
|
||||
console.error('Lỗi tải ảnh:', error);
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
// currentUserId is defined at the top
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-150 rounded-2xl shadow-lg overflow-hidden flex flex-col h-[500px]">
|
||||
{/* Chat Header */}
|
||||
<div className="p-4 border-b border-gray-150 flex items-center gap-2 bg-gray-50/50">
|
||||
<MessageSquare className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-800">Trò chuyện nhóm hành trình</h3>
|
||||
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đổi thông tin, hình ảnh và định vị giữa các thành viên</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages list */}
|
||||
<div className="flex-1 p-4 overflow-y-auto flex flex-col gap-3 min-h-0 bg-slate-50/20">
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 text-xs gap-1.5">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" /> Đang tải tin nhắn...
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-450 text-xs italic gap-1.5">
|
||||
<MessageSquare className="w-8 h-8 text-gray-300" />
|
||||
<span className="text-gray-400">Chưa có tin nhắn nào trong phòng chat nhóm này.</span>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => {
|
||||
const isMe = msg.senderId === currentUserId;
|
||||
const initials = msg.sender?.name
|
||||
? msg.sender.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()
|
||||
: 'U';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex gap-2 max-w-[80%] ${isMe ? 'self-end flex-row-reverse' : 'self-start'}`}
|
||||
>
|
||||
{!isMe && (
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 to-indigo-500 flex items-center justify-center font-bold text-[10px] text-white shadow-sm shrink-0">
|
||||
{msg.sender?.avatar ? (
|
||||
<img src={msg.sender.avatar} alt={msg.sender.name} className="w-full h-full rounded-full object-cover" />
|
||||
) : initials}
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex flex-col ${isMe ? 'items-end' : 'items-start'}`}>
|
||||
{!isMe && (
|
||||
<span className="text-[10px] font-bold text-gray-500 mb-0.5 ml-1">
|
||||
{msg.sender?.name || 'Thành viên'}
|
||||
</span>
|
||||
)}
|
||||
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${
|
||||
isMe
|
||||
? 'bg-blue-600 text-white rounded-tr-none'
|
||||
: 'bg-white text-gray-700 rounded-tl-none border border-gray-150 shadow-sm'
|
||||
}`}>
|
||||
{/* Attachment Image */}
|
||||
{msg.attachmentUrl && (
|
||||
<div className="relative rounded-lg overflow-hidden border border-black/5 max-w-xs group/img">
|
||||
<img
|
||||
src={msg.attachmentUrl}
|
||||
alt="Đính kèm"
|
||||
className="w-full max-h-48 object-cover hover:brightness-95 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDownloadImage(msg.attachmentUrl, msg.id)}
|
||||
className="absolute bottom-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md transition-all shadow-md flex items-center justify-center"
|
||||
title="Tải ảnh này về máy"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GPS Location badge */}
|
||||
{msg.latitude !== undefined && msg.latitude !== null && (
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${msg.latitude},${msg.longitude}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${
|
||||
isMe
|
||||
? 'bg-blue-700 border-blue-600 text-blue-100 hover:bg-blue-800'
|
||||
: 'bg-gray-100 border-gray-200 text-gray-750 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
|
||||
<div className="flex flex-col text-left">
|
||||
<span>Vị trí hiện tại</span>
|
||||
<span className="text-[9px] opacity-75">{msg.latitude.toFixed(6)}, {msg.longitude.toFixed(6)}</span>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Content text */}
|
||||
{msg.content && <p className="whitespace-pre-wrap break-words">{msg.content}</p>}
|
||||
</div>
|
||||
<span className="text-[8px] text-gray-400 font-bold mt-1 px-1">
|
||||
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Previews (Image & GPS Location) */}
|
||||
{(imagePreview || attachedLocation) && (
|
||||
<div className="px-4 py-2 border-t border-gray-150 bg-gray-50/80 flex flex-wrap gap-2">
|
||||
{imagePreview && (
|
||||
<div className="relative w-16 h-16 rounded-lg overflow-hidden border border-gray-200 shadow-sm">
|
||||
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedImage(null);
|
||||
setImagePreview(null);
|
||||
}}
|
||||
className="absolute top-0.5 right-0.5 p-0.5 bg-black/60 hover:bg-black text-white rounded-full transition-all"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{attachedLocation && (
|
||||
<div className="flex items-center gap-1.5 bg-rose-50 border border-rose-200 rounded-lg px-2.5 py-1 text-xs text-rose-700 font-bold">
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500 animate-pulse" />
|
||||
<span>Đã đính kèm GPS</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttachedLocation(null)}
|
||||
className="hover:text-rose-950 transition-colors ml-1"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Input wrapper */}
|
||||
<div className="relative">
|
||||
{/* Mention list dropdown */}
|
||||
{showMentionList && filteredParticipants.length > 0 && (
|
||||
<div
|
||||
ref={mentionRef}
|
||||
className="absolute bottom-full left-3 right-3 mb-2 bg-white border border-gray-200 rounded-xl shadow-xl max-h-40 overflow-y-auto z-50 flex flex-col py-1"
|
||||
>
|
||||
{filteredParticipants.map((member, index) => (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() => insertMention(member)}
|
||||
className={`px-3 py-2 text-left text-xs font-semibold flex items-center gap-2 transition-colors ${
|
||||
index === mentionIndex
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center font-bold text-[9px] text-blue-600">
|
||||
{member.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
|
||||
</div>
|
||||
<span>{member.name}</span>
|
||||
<span className="text-[10px] text-gray-400 font-medium font-mono">@{member.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Input form */}
|
||||
<form
|
||||
onSubmit={handleSendMessage}
|
||||
className="p-3 border-t border-gray-150 bg-gray-50 flex gap-2 items-center"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Attach photo button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-500 rounded-xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0"
|
||||
title="Đính kèm hình ảnh"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4 text-blue-500" />
|
||||
</button>
|
||||
|
||||
{/* Share current GPS button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGetLocation}
|
||||
disabled={isLocating}
|
||||
className={`p-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-500 rounded-xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0 ${
|
||||
isLocating ? 'animate-pulse' : ''
|
||||
}`}
|
||||
title="Chia sẻ vị trí GPS hiện tại"
|
||||
>
|
||||
{isLocating ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-rose-500" />
|
||||
) : (
|
||||
<MapPin className="w-4 h-4 text-rose-500" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
value={newMessage}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isUploading ? "Đang tải ảnh lên..." : "Nhập nội dung tin nhắn..."}
|
||||
disabled={isUploading}
|
||||
className="flex-1 bg-white border border-gray-200 rounded-xl py-2.5 px-3 text-xs text-gray-800 placeholder-gray-400 outline-none focus:border-blue-500 transition-all shadow-inner disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUploading || (!newMessage.trim() && !selectedImage && !attachedLocation)}
|
||||
className="p-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl transition-all shadow-md active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,9 +5,10 @@ const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerCluste
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock } from 'lucide-react';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft } from 'lucide-react';
|
||||
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { CreateTourModal } from '../components/CreateTourModal';
|
||||
import { PublicPhotoModal } from '../components/PublicPhotoModal';
|
||||
|
||||
@@ -65,6 +66,80 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||
|
||||
const notify = useNotification();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// Refs for mobile long-press detection
|
||||
const touchTimerRef = React.useRef<any>(null);
|
||||
const touchMovedRef = React.useRef<boolean>(false);
|
||||
|
||||
// Phân loại trạng thái Tour dựa vào thời gian
|
||||
const getTourStatus = (tour: any) => {
|
||||
const now = new Date();
|
||||
const startDate = tour.startDate ? new Date(tour.startDate) : null;
|
||||
const endDate = tour.endDate ? new Date(tour.endDate) : null;
|
||||
if (endDate && endDate < now) {
|
||||
return { color: 'white', borderClass: 'border-white', label: 'Hành trình đã kết thúc' };
|
||||
}
|
||||
if (startDate && endDate && startDate <= now && endDate >= now) {
|
||||
return { color: 'red', borderClass: 'border-rose-500', label: 'Hành trình đang diễn ra' };
|
||||
}
|
||||
return { color: 'green', borderClass: 'border-emerald-500', label: 'Hành trình mới tạo' };
|
||||
};
|
||||
|
||||
const triggerJoinConfirmation = async (tour: any) => {
|
||||
const currentUserId = user?.id;
|
||||
const myParticipant = tour.participants?.find((p: any) => p.userId === currentUserId);
|
||||
if (myParticipant) {
|
||||
notify({
|
||||
title: 'Thông báo',
|
||||
message: 'Bạn đã là thành viên của hành trình này.',
|
||||
type: 'info'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const hasPendingRequest = tour.joinRequests && tour.joinRequests.length > 0;
|
||||
if (hasPendingRequest) {
|
||||
notify({
|
||||
title: 'Thông báo',
|
||||
message: 'Bạn đã gửi yêu cầu tham gia hành trình này và đang chờ duyệt.',
|
||||
type: 'info'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Yêu cầu tham gia Tour',
|
||||
message: `Bạn có chắc chắn muốn gửi yêu cầu tham gia vào tour "${tour.title}" không?`
|
||||
});
|
||||
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/tours/${tour.id}/join-requests`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || 'Gửi yêu cầu tham gia thất bại.');
|
||||
}
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã gửi yêu cầu tham gia tour. Vui lòng chờ chủ tour duyệt.',
|
||||
type: 'success'
|
||||
});
|
||||
fetchPublicTours();
|
||||
} catch (err: any) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: err.message || 'Không thể gửi yêu cầu tham gia.',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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(() => {
|
||||
@@ -328,7 +403,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
className="w-11 h-11 flex items-center justify-center bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 shrink-0"
|
||||
title="Quay lại"
|
||||
>
|
||||
<X className="w-6 h-6 text-gray-800" />
|
||||
<ChevronLeft className="w-6 h-6 text-gray-800" />
|
||||
</button>
|
||||
|
||||
{/* Nút lọc Tag và Dropdown */}
|
||||
@@ -487,43 +562,92 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
|
||||
<MarkerClusterGroup key={`cluster-${filteredTours.length}`} chunkedLoading>
|
||||
{filteredTours.map((tour) => {
|
||||
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||
let startLoc = null;
|
||||
if (tour.legs && tour.legs.length > 0) {
|
||||
for (const leg of tour.legs) {
|
||||
if (leg.locations && leg.locations.length > 0) {
|
||||
startLoc = leg.locations[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||
const markerPos = startLoc
|
||||
? [startLoc.latitude, startLoc.longitude] as [number, number]
|
||||
: userPos;
|
||||
const status = getTourStatus(tour);
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={tour.id}
|
||||
position={markerPos}
|
||||
eventHandlers={{
|
||||
click: () => onViewTour(tour.id),
|
||||
contextmenu: (e) => {
|
||||
const currentUserId = user?.id;
|
||||
const myParticipant = tour.participants?.find((p: any) => p.userId === currentUserId);
|
||||
const isParticipant = !!myParticipant;
|
||||
const myRole = myParticipant?.role;
|
||||
const canShare = isParticipant && ['OWNER', 'MANAGER', 'MEMBER'].includes(myRole);
|
||||
const hasPendingRequest = tour.joinRequests && tour.joinRequests.length > 0;
|
||||
click: () => {
|
||||
if (status.color === 'green') {
|
||||
notify({
|
||||
title: 'Thông báo',
|
||||
message: 'Đây là hành trình mới tạo. Vui lòng nhấn chuột phải (hoặc nhấn giữ trên màn hình điện thoại) để gửi yêu cầu tham gia.',
|
||||
type: 'info'
|
||||
});
|
||||
} else {
|
||||
onViewTour(tour.id);
|
||||
}
|
||||
},
|
||||
contextmenu: (e: any) => {
|
||||
if (status.color === 'green') {
|
||||
triggerJoinConfirmation(tour);
|
||||
} else {
|
||||
const currentUserId = user?.id;
|
||||
const myParticipant = tour.participants?.find((p: any) => p.userId === currentUserId);
|
||||
const isParticipant = !!myParticipant;
|
||||
const myRole = myParticipant?.role;
|
||||
const canShare = isParticipant && ['OWNER', 'MANAGER', 'MEMBER'].includes(myRole);
|
||||
const hasPendingRequest = tour.joinRequests && tour.joinRequests.length > 0;
|
||||
|
||||
// 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,
|
||||
isParticipant,
|
||||
hasPendingRequest
|
||||
});
|
||||
// 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,
|
||||
isParticipant,
|
||||
hasPendingRequest
|
||||
});
|
||||
}
|
||||
},
|
||||
touchstart: () => {
|
||||
if (status.color === 'green') {
|
||||
if (touchTimerRef.current) {
|
||||
clearTimeout(touchTimerRef.current);
|
||||
}
|
||||
touchMovedRef.current = false;
|
||||
touchTimerRef.current = setTimeout(() => {
|
||||
if (!touchMovedRef.current) {
|
||||
triggerJoinConfirmation(tour);
|
||||
}
|
||||
}, 700);
|
||||
}
|
||||
},
|
||||
touchend: () => {
|
||||
if (touchTimerRef.current) {
|
||||
clearTimeout(touchTimerRef.current);
|
||||
touchTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
touchmove: () => {
|
||||
touchMovedRef.current = true;
|
||||
if (touchTimerRef.current) {
|
||||
clearTimeout(touchTimerRef.current);
|
||||
touchTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
}}
|
||||
} as any}
|
||||
icon={L.divIcon({
|
||||
className: 'custom-bubble',
|
||||
html: `
|
||||
<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="relative group w-14 h-14">
|
||||
<div class="w-14 h-14 rounded-full border-4 ${status.borderClass} shadow-lg overflow-hidden transition-transform group-hover:scale-110 flex items-center justify-center bg-gray-100">
|
||||
<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">
|
||||
@@ -531,13 +655,13 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [48, 48],
|
||||
iconAnchor: [24, 24]
|
||||
iconSize: [56, 56],
|
||||
iconAnchor: [28, 28]
|
||||
})}
|
||||
>
|
||||
<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>
|
||||
<Tooltip direction="top" offset={[0, -28]} opacity={1}>
|
||||
<div className="p-1.5 max-w-[180px]">
|
||||
<div className="font-black text-blue-600 text-[11px] mb-1 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) => (
|
||||
@@ -546,10 +670,17 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</div>
|
||||
)}
|
||||
{tour.description && (
|
||||
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic">
|
||||
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic mb-1.5">
|
||||
{tour.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-gray-100">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
status.color === 'green' ? 'bg-emerald-500' :
|
||||
status.color === 'red' ? 'bg-rose-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
<span className="text-[9px] font-bold text-gray-600">{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,10 +16,10 @@ const getMostLikedPhoto = (photos: any[]) => {
|
||||
export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedTourIdForPhoto, setSelectedTourIdForPhoto] = useState<string | 'all'>('all'); // Changed from filterTourId
|
||||
const [selectedTourIdForPhoto, setSelectedTourIdForPhoto] = useState<string | 'all'>('all');
|
||||
const [filterDate, setFilterDate] = useState<string>('');
|
||||
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
|
||||
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest'); // 'newest' by default
|
||||
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest');
|
||||
const notify = useNotification();
|
||||
const confirm = useConfirm();
|
||||
|
||||
@@ -100,9 +100,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
const updatedPhoto = await response.json();
|
||||
notify({ title: 'Thành công', message: 'Thông tin ảnh đã được cập nhật.', type: 'success' });
|
||||
|
||||
// Update photos list
|
||||
setPhotos(prev => prev.map(p => p.id === updatedPhoto.id ? { ...p, metadata: updatedPhoto.metadata } : p));
|
||||
// Update selectedPhotoForDisplay
|
||||
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
@@ -171,7 +169,6 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
fetchPhotos();
|
||||
}, []);
|
||||
|
||||
// Lấy danh sách các Tour duy nhất để hiển thị trong bộ lọc
|
||||
const toursWithPhotos = useMemo(() => {
|
||||
const tourMap = new Map();
|
||||
photos.forEach(p => {
|
||||
@@ -182,36 +179,27 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
return Array.from(tourMap.values());
|
||||
}, [photos]);
|
||||
|
||||
// Logic lọc ảnh tại Frontend
|
||||
const filteredPhotos = useMemo(() => {
|
||||
let photosToFilter = photos.filter(p => {
|
||||
const matchTour = selectedTourIdForPhoto === 'all' || p.tourId === selectedTourIdForPhoto;
|
||||
// So sánh ngày định dạng YYYY-MM-DD
|
||||
const photoDate = p.capturedAt ? p.capturedAt.split('T')[0] : '';
|
||||
const matchDate = !filterDate || photoDate === filterDate;
|
||||
return matchTour && matchDate;
|
||||
});
|
||||
let sortedPhotos = photosToFilter;
|
||||
// Sắp xếp ảnh
|
||||
if (sortOrder === 'newest') {
|
||||
sortedPhotos.sort((a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime());
|
||||
} else { // 'oldest'
|
||||
} else {
|
||||
sortedPhotos.sort((a, b) => new Date(a.capturedAt).getTime() - new Date(b.capturedAt).getTime());
|
||||
}
|
||||
return sortedPhotos;
|
||||
}, [photos, selectedTourIdForPhoto, filterDate, sortOrder]);
|
||||
|
||||
// Effect để thiết lập ảnh được chọn hiển thị hoặc reset nếu ảnh hiện tại không còn trong danh sách lọc
|
||||
useEffect(() => {
|
||||
if (filteredPhotos.length > 0 && (!selectedPhotoForDisplay || !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id))) {
|
||||
setSelectedPhotoForDisplay(getMostLikedPhoto(filteredPhotos));
|
||||
} else if (filteredPhotos.length === 0) {
|
||||
setSelectedPhotoForDisplay(null);
|
||||
} else if (selectedPhotoForDisplay) {
|
||||
// Nếu ảnh đang chọn vẫn còn trong danh sách lọc, không làm gì cả
|
||||
} else {
|
||||
// Nếu không có ảnh nào để hiển thị
|
||||
setSelectedPhotoForDisplay(null); // No photos to display
|
||||
}
|
||||
}, [filteredPhotos, selectedPhotoForDisplay]);
|
||||
|
||||
@@ -234,73 +222,66 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
if (!response.ok) throw new Error('Failed to delete photo');
|
||||
|
||||
notify({ title: 'Thành công', message: 'Ảnh đã được xóa.', type: 'success' });
|
||||
// Cập nhật lại danh sách ảnh sau khi xóa
|
||||
setPhotos(prev => prev.filter(p => p.id !== photoId));
|
||||
setSelectedPhotoForDisplay(null); // Reset ảnh đang hiển thị
|
||||
setSelectedPhotoForDisplay(null);
|
||||
} catch (error) {
|
||||
notify({ title: 'Lỗi', message: 'Không thể xóa ảnh. Vui lòng thử lại.', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<div className="w-full flex flex-col text-slate-100 bg-transparent font-sans">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-4 flex items-center gap-4">
|
||||
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
||||
<div className="bg-slate-900/60 backdrop-blur-md border-b border-slate-800/80 px-6 py-4 flex items-center gap-4 rounded-t-3xl">
|
||||
<button onClick={onBack} className="p-2 hover:bg-slate-800 rounded-full transition-colors">
|
||||
<ChevronLeft className="w-6 h-6 text-slate-400 hover:text-white" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-black text-gray-900">Ảnh của tôi</h1>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Kho lưu trữ ảnh gốc cá nhân</p>
|
||||
<h1 className="text-xl font-black text-white">Ảnh của tôi</h1>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Kho lưu trữ ảnh gốc cá nhân</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar - Thanh công cụ lọc */}
|
||||
<div className="bg-white border-b border-gray-100 px-6 py-4 flex flex-wrap items-center gap-4 sticky top-[73px] z-20 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
{/* Filter Bar */}
|
||||
<div className="bg-slate-900/40 border-b border-slate-800/60 px-6 py-4 flex flex-wrap items-center gap-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-gray-400">Bộ lọc:</span>
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Bộ lọc:</span>
|
||||
</div>
|
||||
|
||||
{/* Chỉ báo Tour hiện tại */}
|
||||
<div className="relative min-w-[150px]">
|
||||
<div className="px-3 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold border border-blue-100 truncate max-w-[200px]">
|
||||
<div className="px-3 py-2 bg-slate-800 text-indigo-300 rounded-xl text-xs font-bold border border-slate-700/60 truncate max-w-[200px]">
|
||||
{selectedTourIdForPhoto === 'all' ? 'Tất cả hành trình' : toursWithPhotos.find(t => t.id === selectedTourIdForPhoto)?.title || 'Tour đã chọn'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lọc theo Thời gian */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="date"
|
||||
value={filterDate}
|
||||
onChange={(e) => setFilterDate(e.target.value)}
|
||||
className="pl-3 pr-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500 transition-all text-gray-700 cursor-pointer"
|
||||
className="pl-3 pr-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-indigo-500/80 transition-all text-slate-200 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lọc theo Sắp xếp */}
|
||||
<div className="relative min-w-[120px]">
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value as 'newest' | 'oldest')}
|
||||
className="w-full pl-3 pr-8 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500 transition-all appearance-none cursor-pointer text-gray-700"
|
||||
className="w-full pl-3 pr-8 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-indigo-500/80 transition-all appearance-none cursor-pointer text-slate-200"
|
||||
>
|
||||
<option value="newest">Mới nhất</option>
|
||||
<option value="oldest">Cũ nhất</option>
|
||||
</select>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-slate-500">
|
||||
<ChevronLeft className="w-3 h-3 -rotate-90" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Reset Filters - Nút xóa nhanh lọc */}
|
||||
{(selectedTourIdForPhoto !== 'all' || filterDate) && (
|
||||
<button
|
||||
onClick={() => { setSelectedTourIdForPhoto('all'); setFilterDate(''); }}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-bold text-red-500 hover:bg-red-50 rounded-xl transition-all"
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-bold text-rose-400 hover:bg-rose-950/20 rounded-xl transition-all"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Xóa lọc
|
||||
@@ -308,29 +289,35 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
)}
|
||||
|
||||
<div className="ml-auto">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-tighter">
|
||||
Kết quả: <span className="text-blue-600">{filteredPhotos.length}</span> / {photos.length} ảnh
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-tighter">
|
||||
Kết quả: <span className="text-indigo-400">{filteredPhotos.length}</span> / {photos.length} ảnh
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
|
||||
<div className="flex-1 p-6 w-full max-w-7xl mx-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-4" />
|
||||
<p className="font-bold">Đang tải kho ảnh...</p>
|
||||
<div className="flex flex-col items-center justify-center py-20 text-slate-400">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-4 text-indigo-500" />
|
||||
<p className="font-bold text-sm">Đang tải kho ảnh...</p>
|
||||
</div>
|
||||
) : photos.length === 0 ? (
|
||||
<div className="py-24 text-center bg-slate-900/30 rounded-[40px] border-2 border-dashed border-slate-800/80">
|
||||
<ImageIcon className="w-16 h-16 text-slate-700 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-slate-400">Chưa có ảnh nào</h3>
|
||||
<p className="text-sm text-slate-500">Hãy tham gia các chuyến đi và lưu lại khoảnh khắc nhé!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-in fade-in">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Left Column: Tour List */}
|
||||
<div className="md:w-1/4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex-shrink-0">
|
||||
<h3 className="text-sm font-bold text-gray-800 mb-3">Tour của bạn</h3>
|
||||
<div className="md:w-1/4 bg-slate-900/50 rounded-2xl border border-slate-800/60 p-4 flex-shrink-0">
|
||||
<h3 className="text-xs font-black uppercase text-slate-400 tracking-wider mb-3">Hành trình của bạn</h3>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => { setSelectedTourIdForPhoto('all'); setSelectedPhotoForDisplay(null); }}
|
||||
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
selectedTourIdForPhoto === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
className={`w-full text-left px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all ${
|
||||
selectedTourIdForPhoto === 'all' ? 'bg-indigo-650 text-white shadow-md' : 'bg-slate-800/60 text-slate-300 hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
Tất cả ảnh
|
||||
@@ -339,9 +326,10 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
<button
|
||||
key={tour.id}
|
||||
onClick={() => { setSelectedTourIdForPhoto(tour.id); setSelectedPhotoForDisplay(null); }}
|
||||
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
selectedTourIdForPhoto === tour.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
className={`w-full text-left px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all truncate ${
|
||||
selectedTourIdForPhoto === tour.id ? 'bg-indigo-650 text-white shadow-md' : 'bg-slate-800/60 text-slate-300 hover:bg-slate-800'
|
||||
}`}
|
||||
title={tour.title}
|
||||
>
|
||||
{tour.title}
|
||||
</button>
|
||||
@@ -350,87 +338,84 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
</div>
|
||||
|
||||
{/* Right Column: Large Photo Display */}
|
||||
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
|
||||
<div className="md:flex-1 bg-slate-900/50 rounded-2xl border border-slate-800/60 p-4 flex flex-col items-center justify-center min-h-[350px]">
|
||||
{selectedPhotoForDisplay ? (
|
||||
<div className="relative w-full h-full flex flex-col items-center justify-center">
|
||||
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
|
||||
<div className="relative overflow-hidden rounded-xl shadow-lg max-w-full max-h-[calc(100vh-380px)] group">
|
||||
<img
|
||||
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
|
||||
alt="Selected Photo"
|
||||
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
|
||||
alt="Selected"
|
||||
className="max-w-full max-h-[calc(100vh-380px)] object-contain cursor-zoom-in"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
/>
|
||||
|
||||
{/* Overlays (Only show when not editing) */}
|
||||
{!isEditing && (
|
||||
<>
|
||||
{/* Like (Heart) button overlay */}
|
||||
{/* Like Button */}
|
||||
<button
|
||||
onClick={handleToggleLike}
|
||||
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-black/60 hover:bg-black/75 border border-white/10 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
|
||||
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-slate-950/80 hover:bg-slate-950 border border-slate-800/60 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
|
||||
title={isLiked ? "Bỏ thích" : "Thích"}
|
||||
>
|
||||
<Heart className={`w-4 h-4 transition-colors ${
|
||||
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-gray-300 hover:text-rose-450'
|
||||
isLiked ? 'text-rose-500 fill-rose-500' : 'text-gray-300'
|
||||
}`} />
|
||||
<span>{likeCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Delete button overlay */}
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeletePhoto(selectedPhotoForDisplay.id);
|
||||
}}
|
||||
className="absolute top-4 right-4 z-10 p-2 bg-black/60 hover:bg-red-600 border border-white/10 text-white rounded-full transition-all active:scale-95 backdrop-blur-md"
|
||||
className="absolute top-4 right-4 z-10 p-2 bg-slate-950/80 hover:bg-rose-650 border border-slate-800/60 text-white rounded-full transition-all active:scale-95 backdrop-blur-md"
|
||||
title="Xóa ảnh này"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Bottom metadata details gradient panel overlay */}
|
||||
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
|
||||
{/* Metadata Overlay */}
|
||||
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-slate-950 via-slate-950/45 to-transparent p-6 text-white flex flex-col gap-2 text-left">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{selectedPhotoForDisplay.metadata?.title ? (
|
||||
<h3 className="text-base font-extrabold text-white break-words drop-shadow-md">
|
||||
<h3 className="text-sm font-extrabold text-white break-words drop-shadow-md">
|
||||
{selectedPhotoForDisplay.metadata.title}
|
||||
</h3>
|
||||
) : (
|
||||
<span className="text-xs text-gray-350 italic block mb-1 drop-shadow-md">Chưa có tiêu đề</span>
|
||||
<span className="text-xs text-slate-400 italic block mb-1">Chưa có tiêu đề</span>
|
||||
)}
|
||||
{selectedPhotoForDisplay.metadata?.description ? (
|
||||
<p className="text-xs text-gray-250 leading-relaxed mt-1 break-words drop-shadow-sm max-h-16 overflow-y-auto no-scrollbar">
|
||||
<p className="text-xs text-slate-300 leading-relaxed mt-1 break-words max-h-16 overflow-y-auto no-scrollbar">
|
||||
{selectedPhotoForDisplay.metadata.description}
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-[11px] text-gray-350 italic block mt-1 drop-shadow-sm">Chưa có mô tả</span>
|
||||
<span className="text-[11px] text-slate-400 italic block mt-1">Chưa có mô tả</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Button Overlay */}
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 border border-white/15 rounded-xl text-white hover:text-gray-200 transition-all shrink-0 backdrop-blur-sm"
|
||||
className="p-2 bg-slate-800 hover:bg-slate-800 border border-slate-700/50 rounded-xl text-white transition-all shrink-0"
|
||||
title="Chỉnh sửa thông tin"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
<Edit className="w-4 h-4 text-indigo-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Additional metadata info inside bottom overlay */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-3 text-xs text-gray-200">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-800/80 pt-3 text-xs text-slate-300">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-gray-300 text-[10px] font-bold uppercase tracking-wider">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
<div className="flex items-center gap-2 text-slate-450 text-[10px] font-bold uppercase tracking-wider">
|
||||
<Calendar className="w-3.5 h-3.5 text-indigo-450" />
|
||||
Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 font-bold" title={selectedPhotoForDisplay.metadata?.lat && selectedPhotoForDisplay.metadata?.lng ? `${selectedPhotoForDisplay.metadata.lat.toFixed(6)}, ${selectedPhotoForDisplay.metadata.lng.toFixed(6)}` : ''}>
|
||||
<div className="flex items-center gap-2 font-bold text-slate-205">
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||
Địa điểm: {resolvedAddress}
|
||||
</div>
|
||||
{selectedPhotoForDisplay.tour?.title && (
|
||||
<div className="text-[10px] text-gray-400">
|
||||
<div className="text-[10px] text-slate-400">
|
||||
Hành trình: {selectedPhotoForDisplay.tour.title}
|
||||
</div>
|
||||
)}
|
||||
@@ -440,9 +425,9 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
<a
|
||||
href={selectedPhotoForDisplay.originalUrl}
|
||||
download
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold uppercase tracking-widest text-[9px] transition-all shadow-md active:scale-95 shrink-0"
|
||||
className="flex items-center gap-2 px-3.5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-bold uppercase tracking-wider text-[9px] transition-all shadow-md active:scale-95 shrink-0"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 animate-pulse" /> Tải ảnh gốc
|
||||
<Download className="w-3.5 h-3.5" /> Tải ảnh gốc
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -453,53 +438,53 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
|
||||
{isEditing && (
|
||||
<div className="mt-6 w-full flex flex-col gap-4 px-2">
|
||||
<div className="space-y-3 bg-gray-50 border border-gray-150 p-4 rounded-2xl w-full">
|
||||
<h4 className="text-xs font-black uppercase tracking-wider text-blue-605">Chỉnh sửa thông tin ảnh</h4>
|
||||
<div className="space-y-3 bg-slate-950/40 border border-slate-800/80 p-4 rounded-2xl w-full">
|
||||
<h4 className="text-xs font-black uppercase tracking-wider text-indigo-400">Chỉnh sửa thông tin ảnh</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Tiêu đề</label>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-slate-450 mb-1">Tiêu đề</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="Nhập tiêu đề..."
|
||||
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Mô tả</label>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-slate-455 mb-1">Mô tả</label>
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Nhập mô tả..."
|
||||
rows={2}
|
||||
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Vĩ độ</label>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-slate-455 mb-1">Vĩ độ</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={editLat}
|
||||
onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
||||
placeholder="Vĩ độ..."
|
||||
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2 text-xs focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Kinh độ</label>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-slate-455 mb-1">Kinh độ</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={editLng}
|
||||
onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
||||
placeholder="Kinh độ..."
|
||||
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2 text-xs focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -509,7 +494,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMapOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 hover:text-gray-900 rounded-xl text-[10px] font-bold transition-all"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-200 rounded-xl text-[10px] font-bold transition-all"
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||
Chọn trên bản đồ
|
||||
@@ -520,14 +505,14 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
<button
|
||||
onClick={() => setIsEditing(false)}
|
||||
disabled={isSavingEdit}
|
||||
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-xs font-bold transition-all"
|
||||
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-750 text-slate-300 rounded-lg text-xs font-bold transition-all"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={isSavingEdit}
|
||||
className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
|
||||
className="flex items-center gap-1 px-4 py-1.5 bg-indigo-650 hover:bg-indigo-600 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
|
||||
>
|
||||
{isSavingEdit ? (
|
||||
<>
|
||||
@@ -544,26 +529,26 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-20">
|
||||
<div className="text-center text-slate-500 py-20">
|
||||
<ImageIcon className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
||||
<p className="text-lg font-bold">Chọn một tấm ảnh để xem</p>
|
||||
<p className="text-base font-bold">Chọn một tấm ảnh để xem</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Thumbnails */}
|
||||
<div className="mt-4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4">
|
||||
<div className="mt-6 bg-slate-900/50 rounded-2xl border border-slate-800/60 p-4">
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h3 className="text-xs font-black text-gray-400 uppercase tracking-widest">Kho ảnh ({filteredPhotos.length})</h3>
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest">Kho ảnh ({filteredPhotos.length})</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-3 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-3 max-h-[250px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{filteredPhotos.map((photo: any) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
onClick={() => setSelectedPhotoForDisplay(photo)}
|
||||
className={`aspect-square bg-gray-100 rounded-xl overflow-hidden relative group border-2 transition-all cursor-pointer ${
|
||||
selectedPhotoForDisplay?.id === photo.id ? 'border-blue-500 scale-[0.98]' : 'border-transparent hover:border-blue-200'
|
||||
className={`aspect-square bg-slate-950 rounded-xl overflow-hidden relative group border-2 transition-all cursor-pointer ${
|
||||
selectedPhotoForDisplay?.id === photo.id ? 'border-indigo-505 border-indigo-500 scale-[0.98]' : 'border-transparent hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
@@ -576,14 +561,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) }
|
||||
<div className="h-4"></div>
|
||||
<div className="py-32 text-center bg-white rounded-[40px] border-2 border-dashed border-gray-100">
|
||||
<ImageIcon className="w-16 h-16 text-gray-200 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-400">Chưa có ảnh nào</h3>
|
||||
<p className="text-sm text-gray-300">Hãy tham gia các chuyến đi và lưu lại khoảnh khắc nhé!</p>
|
||||
</div>
|
||||
{}
|
||||
)}
|
||||
</div>
|
||||
<CoordinateSelectModal
|
||||
isOpen={isMapOpen}
|
||||
@@ -609,7 +587,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
</button>
|
||||
<img
|
||||
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
|
||||
alt="Fullscreen photo"
|
||||
alt="Fullscreen"
|
||||
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { CommentModal } from '@/components/CommentModal';
|
||||
import { AddPhotoModal } from '@/components/AddPhotoModal';
|
||||
import { TourChat } from '../components/TourChat';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap, Tooltip } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||
@@ -361,8 +362,14 @@ export const TourDetailPage = ({
|
||||
const [userSpeed, setUserSpeed] = useState<number | null>(null); // Tốc độ di chuyển từ GPS
|
||||
|
||||
// Di chuyển khai báo state lên trên useEffect để tránh lỗi "before initialization"
|
||||
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings' | 'members'>('plan');
|
||||
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings' | 'members' | 'chat'>(() => {
|
||||
const defaultTab = localStorage.getItem('tour_detail_default_tab');
|
||||
localStorage.removeItem('tour_detail_default_tab');
|
||||
if (defaultTab === 'chat') return 'chat';
|
||||
return 'plan';
|
||||
});
|
||||
const [mergingId, setMergingId] = useState<string | null>(null);
|
||||
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
||||
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
||||
const [isHeadingMode, setIsHeadingMode] = useState(false);
|
||||
const [mapRotation, setMapRotation] = useState(0);
|
||||
@@ -1137,8 +1144,14 @@ export const TourDetailPage = ({
|
||||
handleCommentIncrement(data.locationId);
|
||||
});
|
||||
|
||||
socket.on('tourMessageReceived', (data: any) => {
|
||||
if (data.tourId === currentTour.id && activeTab !== 'chat') {
|
||||
setUnreadChatCount(prev => prev + 1);
|
||||
}
|
||||
});
|
||||
|
||||
return () => { socket.disconnect(); };
|
||||
}, [currentTour?.id]);
|
||||
}, [currentTour?.id, activeTab]);
|
||||
|
||||
// This useEffect is for initial demo loading, might not be needed if tourId is always passed
|
||||
// useEffect(() => {
|
||||
@@ -1400,6 +1413,7 @@ export const TourDetailPage = ({
|
||||
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
|
||||
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
|
||||
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
|
||||
{ id: 'chat', label: 'Trò chuyện', icon: MessageSquare, visible: !!userRole, hasBadge: unreadChatCount > 0, badgeCount: unreadChatCount },
|
||||
{ id: 'members', label: 'Thành viên', icon: Users, visible: canManage },
|
||||
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
|
||||
].filter(t => t.visible);
|
||||
@@ -1744,8 +1758,11 @@ export const TourDetailPage = ({
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
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
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id as any);
|
||||
if (tab.id === 'chat') setUnreadChatCount(0);
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all relative ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||
@@ -1753,6 +1770,11 @@ export const TourDetailPage = ({
|
||||
>
|
||||
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}`} />
|
||||
{tab.label}
|
||||
{tab.hasBadge && tab.badgeCount !== undefined && tab.badgeCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1.5 bg-red-500 text-white text-[9px] font-black rounded-full px-1.5 py-0.5 animate-bounce shadow-md">
|
||||
{tab.badgeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -2667,6 +2689,11 @@ export const TourDetailPage = ({
|
||||
onOpenAddMember={() => setIsAddMemberOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'chat' && currentTour && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2">
|
||||
<TourChat tourId={currentTour.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user