feat: tính năng chia sẻ khẩn cấp
This commit is contained in:
Vendored
+453
-14
@@ -420,6 +420,19 @@ let AuthController = class AuthController {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async promoteAdmin(body, req) {
|
||||||
|
const { secretKey } = body;
|
||||||
|
const adminSecret = this.configService.get('ADMIN_SECRET_KEY') || 'yotrip_secret_admin_key';
|
||||||
|
if (secretKey !== adminSecret) {
|
||||||
|
throw new common_1.BadRequestException('Mã Secret Key không hợp lệ.');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
data: { isAdmin: true },
|
||||||
|
select: { id: true, email: true, name: true, isAdmin: true }
|
||||||
|
});
|
||||||
|
return { success: true, user: updated };
|
||||||
|
}
|
||||||
async createGuestUser() {
|
async createGuestUser() {
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -601,6 +614,15 @@ __decorate([
|
|||||||
__metadata("design:paramtypes", [Object]),
|
__metadata("design:paramtypes", [Object]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], AuthController.prototype, "convertGuestToOfficial", null);
|
], AuthController.prototype, "convertGuestToOfficial", null);
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||||
|
(0, common_1.Post)('promote-admin'),
|
||||||
|
__param(0, (0, common_1.Body)()),
|
||||||
|
__param(1, (0, common_1.Req)()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [Object, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], AuthController.prototype, "promoteAdmin", null);
|
||||||
__decorate([
|
__decorate([
|
||||||
(0, common_1.Post)('create-guest'),
|
(0, common_1.Post)('create-guest'),
|
||||||
__metadata("design:type", Function),
|
__metadata("design:type", Function),
|
||||||
@@ -720,10 +742,12 @@ let TourController = class TourController {
|
|||||||
}
|
}
|
||||||
async createTour(body, req) {
|
async createTour(body, req) {
|
||||||
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags, members } = body;
|
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags, members } = body;
|
||||||
|
const filteredTitle = await filterText(this.prisma, title || '');
|
||||||
|
const filteredDesc = await filterText(this.prisma, description || '');
|
||||||
const tour = await this.prisma.tour.create({
|
const tour = await this.prisma.tour.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title: filteredTitle,
|
||||||
description,
|
description: filteredDesc,
|
||||||
startDate: startDate ? new Date(startDate) : null,
|
startDate: startDate ? new Date(startDate) : null,
|
||||||
endDate: endDate ? new Date(endDate) : null,
|
endDate: endDate ? new Date(endDate) : null,
|
||||||
tags: tags || [],
|
tags: tags || [],
|
||||||
@@ -939,18 +963,23 @@ let TourController = class TourController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
async updateTour(id, body) {
|
async updateTour(id, body) {
|
||||||
return this.prisma.tour.update({
|
const updateData = {
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
title: body.title,
|
|
||||||
description: body.description,
|
|
||||||
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
||||||
tags: body.tags,
|
tags: body.tags,
|
||||||
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||||
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
|
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
|
||||||
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
||||||
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined,
|
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined,
|
||||||
},
|
};
|
||||||
|
if (body.title !== undefined) {
|
||||||
|
updateData.title = await filterText(this.prisma, body.title);
|
||||||
|
}
|
||||||
|
if (body.description !== undefined) {
|
||||||
|
updateData.description = await filterText(this.prisma, body.description);
|
||||||
|
}
|
||||||
|
return this.prisma.tour.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
}).then(async (tour) => {
|
}).then(async (tour) => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.cacheManager.del(id),
|
this.cacheManager.del(id),
|
||||||
@@ -2445,9 +2474,10 @@ let CommentController = class CommentController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
async addComment(locationId, body, req) {
|
async addComment(locationId, body, req) {
|
||||||
|
const filteredContent = await filterText(this.prisma, body.content);
|
||||||
const comment = await this.prisma.comment.create({
|
const comment = await this.prisma.comment.create({
|
||||||
data: {
|
data: {
|
||||||
content: body.content,
|
content: filteredContent,
|
||||||
locationId,
|
locationId,
|
||||||
userId: req.user.id
|
userId: req.user.id
|
||||||
},
|
},
|
||||||
@@ -2848,9 +2878,10 @@ let PublicPhotoController = class PublicPhotoController {
|
|||||||
if (!photo) {
|
if (!photo) {
|
||||||
throw new common_1.NotFoundException('Không tìm thấy ảnh.');
|
throw new common_1.NotFoundException('Không tìm thấy ảnh.');
|
||||||
}
|
}
|
||||||
|
const filteredContent = await filterText(this.prisma, content.trim());
|
||||||
const comment = await this.prisma.comment.create({
|
const comment = await this.prisma.comment.create({
|
||||||
data: {
|
data: {
|
||||||
content: content.trim(),
|
content: filteredContent,
|
||||||
photoId,
|
photoId,
|
||||||
userId: req.user.id
|
userId: req.user.id
|
||||||
},
|
},
|
||||||
@@ -2863,6 +2894,26 @@ let PublicPhotoController = class PublicPhotoController {
|
|||||||
this.commentGateway.notifyNewPhotoComment(photoId, comment);
|
this.commentGateway.notifyNewPhotoComment(photoId, comment);
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
async deletePhotoComment(id, req) {
|
||||||
|
const comment = await this.prisma.comment.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { photo: true }
|
||||||
|
});
|
||||||
|
if (!comment) {
|
||||||
|
throw new common_1.NotFoundException('Không tìm thấy bình luận.');
|
||||||
|
}
|
||||||
|
const isAuthorized = req.user.isAdmin ||
|
||||||
|
comment.userId === req.user.id ||
|
||||||
|
(comment.photo && comment.photo.uploaderId === req.user.id);
|
||||||
|
if (!isAuthorized) {
|
||||||
|
throw new common_1.ForbiddenException('Bạn không có quyền xóa bình luận này.');
|
||||||
|
}
|
||||||
|
await this.prisma.comment.delete({ where: { id } });
|
||||||
|
if (comment.photoId) {
|
||||||
|
this.commentGateway.server.to(`photo_${comment.photoId}`).emit('photoCommentDeleted', { id, photoId: comment.photoId });
|
||||||
|
}
|
||||||
|
return { success: true, message: 'Đã xóa bình luận thành công.' };
|
||||||
|
}
|
||||||
async sharePhoto(photoId, req, res) {
|
async sharePhoto(photoId, req, res) {
|
||||||
const photo = await this.prisma.photo.findUnique({
|
const photo = await this.prisma.photo.findUnique({
|
||||||
where: { id: photoId }
|
where: { id: photoId }
|
||||||
@@ -2977,6 +3028,15 @@ __decorate([
|
|||||||
__metadata("design:paramtypes", [String, Object, Object]),
|
__metadata("design:paramtypes", [String, Object, Object]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], PublicPhotoController.prototype, "addPhotoComment", null);
|
], PublicPhotoController.prototype, "addPhotoComment", null);
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||||
|
(0, common_1.Delete)('comments/: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)
|
||||||
|
], PublicPhotoController.prototype, "deletePhotoComment", null);
|
||||||
__decorate([
|
__decorate([
|
||||||
(0, common_1.Get)(':photoId/share'),
|
(0, common_1.Get)(':photoId/share'),
|
||||||
__param(0, (0, common_1.Param)('photoId', common_1.ParseUUIDPipe)),
|
__param(0, (0, common_1.Param)('photoId', common_1.ParseUUIDPipe)),
|
||||||
@@ -3224,11 +3284,12 @@ let DirectMessageController = class DirectMessageController {
|
|||||||
if (!conn) {
|
if (!conn) {
|
||||||
throw new common_1.ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.');
|
throw new common_1.ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.');
|
||||||
}
|
}
|
||||||
|
const filteredContent = content ? await filterText(this.prisma, content) : '';
|
||||||
const message = await this.prisma.directMessage.create({
|
const message = await this.prisma.directMessage.create({
|
||||||
data: {
|
data: {
|
||||||
senderId,
|
senderId,
|
||||||
receiverId,
|
receiverId,
|
||||||
content: content || '',
|
content: filteredContent,
|
||||||
attachmentUrl,
|
attachmentUrl,
|
||||||
latitude,
|
latitude,
|
||||||
longitude
|
longitude
|
||||||
@@ -3330,11 +3391,12 @@ let TourMessageController = class TourMessageController {
|
|||||||
if (!participant) {
|
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.');
|
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 filteredContent = content ? await filterText(this.prisma, content) : '';
|
||||||
const tourMessage = await this.prisma.tourMessage.create({
|
const tourMessage = await this.prisma.tourMessage.create({
|
||||||
data: {
|
data: {
|
||||||
tourId,
|
tourId,
|
||||||
senderId,
|
senderId,
|
||||||
content: content || '',
|
content: filteredContent,
|
||||||
attachmentUrl,
|
attachmentUrl,
|
||||||
latitude,
|
latitude,
|
||||||
longitude
|
longitude
|
||||||
@@ -3365,13 +3427,14 @@ let TourMessageController = class TourMessageController {
|
|||||||
});
|
});
|
||||||
for (const p of otherParticipants) {
|
for (const p of otherParticipants) {
|
||||||
if (p.userId) {
|
if (p.userId) {
|
||||||
|
const filteredContent = content ? await filterText(this.prisma, content) : '';
|
||||||
const isTagged = hasTags && taggedUserIds.includes(p.userId);
|
const isTagged = hasTags && taggedUserIds.includes(p.userId);
|
||||||
console.log(`[sendTourMessage] Emitting tourMessageNotification to user_${p.userId}, isTagged: ${isTagged}`);
|
console.log(`[sendTourMessage] Emitting tourMessageNotification to user_${p.userId}, isTagged: ${isTagged}`);
|
||||||
this.commentGateway.server.to(`user_${p.userId}`).emit('tourMessageNotification', {
|
this.commentGateway.server.to(`user_${p.userId}`).emit('tourMessageNotification', {
|
||||||
tourId,
|
tourId,
|
||||||
senderName: req.user.name || 'Thành viên',
|
senderName: req.user.name || 'Thành viên',
|
||||||
tourTitle: tour?.title || 'Hành trình',
|
tourTitle: tour?.title || 'Hành trình',
|
||||||
content: isTagged ? `@Bạn: ${content || ''}` : content || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'),
|
content: isTagged ? `@Bạn: ${filteredContent || ''}` : filteredContent || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'),
|
||||||
isTagged
|
isTagged
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3402,6 +3465,382 @@ TourMessageController = __decorate([
|
|||||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||||
CommentGateway])
|
CommentGateway])
|
||||||
], TourMessageController);
|
], TourMessageController);
|
||||||
|
async function filterText(prisma, text) {
|
||||||
|
if (!text)
|
||||||
|
return text;
|
||||||
|
try {
|
||||||
|
const filters = await prisma.wordFilter.findMany();
|
||||||
|
let result = text;
|
||||||
|
for (const filter of filters) {
|
||||||
|
const escapedWord = filter.word.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||||
|
const regex = new RegExp(escapedWord, 'gi');
|
||||||
|
result = result.replace(regex, filter.replacement);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('Lỗi khi lọc từ cấm:', err);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ModerationController = class ModerationController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async getSettings() {
|
||||||
|
let settings = await this.prisma.moderationSetting.findFirst();
|
||||||
|
if (!settings) {
|
||||||
|
settings = await this.prisma.moderationSetting.create({
|
||||||
|
data: { blockNsfw: false, blurFaces: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Get)('settings'),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", []),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], ModerationController.prototype, "getSettings", null);
|
||||||
|
ModerationController = __decorate([
|
||||||
|
(0, common_1.Controller)('moderation'),
|
||||||
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||||
|
], ModerationController);
|
||||||
|
let AdminModerationController = class AdminModerationController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async getSettings() {
|
||||||
|
let settings = await this.prisma.moderationSetting.findFirst();
|
||||||
|
if (!settings) {
|
||||||
|
settings = await this.prisma.moderationSetting.create({
|
||||||
|
data: { blockNsfw: false, blurFaces: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
async updateSettings(body) {
|
||||||
|
let settings = await this.prisma.moderationSetting.findFirst();
|
||||||
|
if (!settings) {
|
||||||
|
return this.prisma.moderationSetting.create({
|
||||||
|
data: {
|
||||||
|
blockNsfw: body.blockNsfw ?? false,
|
||||||
|
blurFaces: body.blurFaces ?? false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.prisma.moderationSetting.update({
|
||||||
|
where: { id: settings.id },
|
||||||
|
data: {
|
||||||
|
blockNsfw: body.blockNsfw,
|
||||||
|
blurFaces: body.blurFaces
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async getWordFilters() {
|
||||||
|
return this.prisma.wordFilter.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async addWordFilter(body) {
|
||||||
|
const { word, replacement } = body;
|
||||||
|
if (!word || replacement === undefined) {
|
||||||
|
throw new common_1.BadRequestException('Từ khóa và từ thay thế không được để trống.');
|
||||||
|
}
|
||||||
|
return this.prisma.wordFilter.create({
|
||||||
|
data: {
|
||||||
|
word: word.trim(),
|
||||||
|
replacement: replacement.trim()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async deleteWordFilter(id) {
|
||||||
|
await this.prisma.wordFilter.delete({ where: { id } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Get)(),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", []),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], AdminModerationController.prototype, "getSettings", null);
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Post)(),
|
||||||
|
__param(0, (0, common_1.Body)()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], AdminModerationController.prototype, "updateSettings", null);
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Get)('word-filters'),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", []),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], AdminModerationController.prototype, "getWordFilters", null);
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Post)('word-filters'),
|
||||||
|
__param(0, (0, common_1.Body)()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], AdminModerationController.prototype, "addWordFilter", null);
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Delete)('word-filters/:id'),
|
||||||
|
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], AdminModerationController.prototype, "deleteWordFilter", null);
|
||||||
|
AdminModerationController = __decorate([
|
||||||
|
(0, common_1.Controller)('admin/moderation'),
|
||||||
|
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard),
|
||||||
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||||
|
], AdminModerationController);
|
||||||
|
let TrustedUsersController = class TrustedUsersController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async getTrustedUsers() {
|
||||||
|
const ratings = await this.prisma.tourRating.groupBy({
|
||||||
|
by: ['targetUserId'],
|
||||||
|
_avg: { averageScore: true },
|
||||||
|
_count: { id: true }
|
||||||
|
});
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: ratings.map(r => r.targetUserId) } },
|
||||||
|
select: { id: true, name: true, avatar: true }
|
||||||
|
});
|
||||||
|
const result = ratings.map(r => {
|
||||||
|
const u = users.find(user => user.id === r.targetUserId);
|
||||||
|
return {
|
||||||
|
id: r.targetUserId,
|
||||||
|
name: u?.name || 'Ẩn danh',
|
||||||
|
avatar: u?.avatar || null,
|
||||||
|
averageScore: Math.round((r._avg.averageScore || 0) * 10) / 10,
|
||||||
|
ratingCount: r._count.id
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return result.sort((a, b) => b.averageScore - a.averageScore || b.ratingCount - a.ratingCount);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Get)(),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", []),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TrustedUsersController.prototype, "getTrustedUsers", null);
|
||||||
|
TrustedUsersController = __decorate([
|
||||||
|
(0, common_1.Controller)('users/trusted'),
|
||||||
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||||
|
], TrustedUsersController);
|
||||||
|
let TourRatingController = class TourRatingController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async getTourRatings(tourId) {
|
||||||
|
return this.prisma.tourRating.findMany({
|
||||||
|
where: { tourId },
|
||||||
|
include: {
|
||||||
|
raterUser: { select: { id: true, name: true, avatar: true } },
|
||||||
|
targetUser: { select: { id: true, name: true, avatar: true } }
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async createTourRating(tourId, body, req) {
|
||||||
|
const raterUserId = req.user.id;
|
||||||
|
const { targetUserId, honesty, transparency, enthusiasm, cheerfulness, seriousness, planning, survival, comment } = body;
|
||||||
|
if (!targetUserId || honesty < 1 || honesty > 5 || transparency < 1 || transparency > 5 || enthusiasm < 1 || enthusiasm > 5 || cheerfulness < 1 || cheerfulness > 5 || seriousness < 1 || seriousness > 5 || planning < 1 || planning > 5 || survival < 1 || survival > 5) {
|
||||||
|
throw new common_1.BadRequestException('Dữ liệu đánh giá không hợp lệ.');
|
||||||
|
}
|
||||||
|
const participant = await this.prisma.tourParticipant.findFirst({
|
||||||
|
where: { tourId, userId: raterUserId }
|
||||||
|
});
|
||||||
|
if (!participant) {
|
||||||
|
throw new common_1.ForbiddenException('Bạn không phải là thành viên của tour này.');
|
||||||
|
}
|
||||||
|
const targetParticipant = await this.prisma.tourParticipant.findFirst({
|
||||||
|
where: {
|
||||||
|
tourId,
|
||||||
|
userId: targetUserId,
|
||||||
|
role: { in: ['OWNER', 'MANAGER'] }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!targetParticipant) {
|
||||||
|
throw new common_1.BadRequestException('Chỉ có thể đánh giá người tạo tour hoặc người quản lý.');
|
||||||
|
}
|
||||||
|
const averageScore = (honesty + transparency + enthusiasm + cheerfulness + seriousness + planning + survival) / 7;
|
||||||
|
const existingRating = await this.prisma.tourRating.findUnique({
|
||||||
|
where: {
|
||||||
|
tourId_targetUserId_raterUserId: {
|
||||||
|
tourId,
|
||||||
|
targetUserId,
|
||||||
|
raterUserId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (existingRating) {
|
||||||
|
return this.prisma.tourRating.update({
|
||||||
|
where: { id: existingRating.id },
|
||||||
|
data: {
|
||||||
|
honesty,
|
||||||
|
transparency,
|
||||||
|
enthusiasm,
|
||||||
|
cheerfulness,
|
||||||
|
seriousness,
|
||||||
|
planning,
|
||||||
|
survival,
|
||||||
|
averageScore,
|
||||||
|
comment,
|
||||||
|
createdAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.prisma.tourRating.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
targetUserId,
|
||||||
|
raterUserId,
|
||||||
|
honesty,
|
||||||
|
transparency,
|
||||||
|
enthusiasm,
|
||||||
|
cheerfulness,
|
||||||
|
seriousness,
|
||||||
|
planning,
|
||||||
|
survival,
|
||||||
|
averageScore,
|
||||||
|
comment
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Get)(),
|
||||||
|
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourRatingController.prototype, "getTourRatings", null);
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Post)(),
|
||||||
|
__param(0, (0, common_1.Param)('tourId', 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)
|
||||||
|
], TourRatingController.prototype, "createTourRating", null);
|
||||||
|
TourRatingController = __decorate([
|
||||||
|
(0, common_1.Controller)('tours/:tourId/ratings'),
|
||||||
|
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||||
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||||
|
], TourRatingController);
|
||||||
|
let PublicTourShareController = class PublicTourShareController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async getSharedJourney(token) {
|
||||||
|
const tourShare = await this.prisma.tourShare.findUnique({
|
||||||
|
where: { token, isEnabled: true },
|
||||||
|
include: {
|
||||||
|
tour: {
|
||||||
|
include: {
|
||||||
|
creator: { select: { id: true, name: true, phone: true } },
|
||||||
|
participants: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
displayName: true,
|
||||||
|
user: { select: { id: true, name: true, phone: true } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legs: {
|
||||||
|
include: {
|
||||||
|
locations: {
|
||||||
|
orderBy: { plannedStart: 'asc' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { sequence: 'asc' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!tourShare || !tourShare.tour) {
|
||||||
|
throw new common_1.NotFoundException('Không tìm thấy hành trình chia sẻ hoặc liên kết đã bị vô hiệu hóa.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tour: tourShare.tour,
|
||||||
|
isEnabled: tourShare.isEnabled
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Get)('share/:token'),
|
||||||
|
__param(0, (0, common_1.Param)('token')),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], PublicTourShareController.prototype, "getSharedJourney", null);
|
||||||
|
PublicTourShareController = __decorate([
|
||||||
|
(0, common_1.Controller)('tours'),
|
||||||
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||||
|
], PublicTourShareController);
|
||||||
|
let TourShareController = class TourShareController {
|
||||||
|
constructor(prisma) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
}
|
||||||
|
async getShareStatus(tourId) {
|
||||||
|
let share = await this.prisma.tourShare.findUnique({
|
||||||
|
where: { tourId }
|
||||||
|
});
|
||||||
|
if (!share) {
|
||||||
|
share = await this.prisma.tourShare.create({
|
||||||
|
data: { tourId, isEnabled: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
async toggleShare(tourId, body) {
|
||||||
|
let share = await this.prisma.tourShare.findUnique({
|
||||||
|
where: { tourId }
|
||||||
|
});
|
||||||
|
if (!share) {
|
||||||
|
return this.prisma.tourShare.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
isEnabled: body.isEnabled
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.prisma.tourShare.update({
|
||||||
|
where: { id: share.id },
|
||||||
|
data: { isEnabled: body.isEnabled }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Get)(),
|
||||||
|
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourShareController.prototype, "getShareStatus", null);
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Post)(),
|
||||||
|
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||||
|
__param(1, (0, common_1.Body)()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], TourShareController.prototype, "toggleShare", null);
|
||||||
|
TourShareController = __decorate([
|
||||||
|
(0, common_1.Controller)('tours/:tourId/share'),
|
||||||
|
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||||
|
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||||
|
], TourShareController);
|
||||||
let AppModule = class AppModule {
|
let AppModule = class AppModule {
|
||||||
};
|
};
|
||||||
AppModule = __decorate([
|
AppModule = __decorate([
|
||||||
@@ -3426,7 +3865,7 @@ AppModule = __decorate([
|
|||||||
signOptions: { expiresIn: '1d' },
|
signOptions: { expiresIn: '1d' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController, ConnectionController, DirectMessageController, UploadController, TourMessageController],
|
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController, ConnectionController, DirectMessageController, UploadController, TourMessageController, ModerationController, AdminModerationController, TrustedUsersController, TourRatingController, PublicTourShareController, TourShareController],
|
||||||
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],
|
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]
|
exports: [prisma_service_1.PrismaService]
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,73 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WordFilter" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"word" TEXT NOT NULL,
|
||||||
|
"replacement" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WordFilter_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ModerationSetting" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"blockNsfw" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"blurFaces" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "ModerationSetting_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TourRating" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourId" TEXT NOT NULL,
|
||||||
|
"targetUserId" TEXT NOT NULL,
|
||||||
|
"raterUserId" TEXT NOT NULL,
|
||||||
|
"honesty" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"transparency" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"enthusiasm" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"cheerfulness" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"seriousness" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"planning" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"survival" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"averageScore" DOUBLE PRECISION NOT NULL DEFAULT 5.0,
|
||||||
|
"comment" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TourRating_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TourShare" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tourId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TourShare_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WordFilter_word_key" ON "WordFilter"("word");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TourRating_tourId_targetUserId_raterUserId_key" ON "TourRating"("tourId", "targetUserId", "raterUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TourShare_tourId_key" ON "TourShare"("tourId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TourShare_token_key" ON "TourShare"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourRating" ADD CONSTRAINT "TourRating_raterUserId_fkey" FOREIGN KEY ("raterUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TourShare" ADD CONSTRAINT "TourShare_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -80,6 +80,8 @@ model User {
|
|||||||
sentMessages DirectMessage[] @relation("MessageSender")
|
sentMessages DirectMessage[] @relation("MessageSender")
|
||||||
receivedMessages DirectMessage[] @relation("MessageReceiver")
|
receivedMessages DirectMessage[] @relation("MessageReceiver")
|
||||||
tourMessages TourMessage[]
|
tourMessages TourMessage[]
|
||||||
|
receivedRatings TourRating[] @relation("RatedUser")
|
||||||
|
sentRatings TourRating[] @relation("RatingUser")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Tour {
|
model Tour {
|
||||||
@@ -105,6 +107,8 @@ model Tour {
|
|||||||
photos Photo[]
|
photos Photo[]
|
||||||
invitations TourInvitation[]
|
invitations TourInvitation[]
|
||||||
tourMessages TourMessage[]
|
tourMessages TourMessage[]
|
||||||
|
ratings TourRating[]
|
||||||
|
share TourShare?
|
||||||
}
|
}
|
||||||
|
|
||||||
model JoinRequest {
|
model JoinRequest {
|
||||||
@@ -296,3 +300,50 @@ model TourMessage {
|
|||||||
@@index([tourId])
|
@@index([tourId])
|
||||||
@@index([senderId])
|
@@index([senderId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model WordFilter {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
word String @unique
|
||||||
|
replacement String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model ModerationSetting {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
blockNsfw Boolean @default(false)
|
||||||
|
blurFaces Boolean @default(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
model TourRating {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tourId String
|
||||||
|
targetUserId String
|
||||||
|
raterUserId String
|
||||||
|
honesty Int @default(5)
|
||||||
|
transparency Int @default(5)
|
||||||
|
enthusiasm Int @default(5)
|
||||||
|
cheerfulness Int @default(5)
|
||||||
|
seriousness Int @default(5)
|
||||||
|
planning Int @default(5)
|
||||||
|
survival Int @default(5)
|
||||||
|
averageScore Float @default(5.0)
|
||||||
|
comment String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
|
targetUser User @relation("RatedUser", fields: [targetUserId], references: [id], onDelete: Cascade)
|
||||||
|
raterUser User @relation("RatingUser", fields: [raterUserId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([tourId, targetUserId, raterUserId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TourShare {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tourId String @unique
|
||||||
|
token String @unique @default(uuid())
|
||||||
|
isEnabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+393
-15
@@ -409,6 +409,22 @@ class AuthController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('promote-admin')
|
||||||
|
async promoteAdmin(@Body() body: { secretKey: string }, @Req() req: any) {
|
||||||
|
const { secretKey } = body;
|
||||||
|
const adminSecret = this.configService.get<string>('ADMIN_SECRET_KEY') || 'yotrip_secret_admin_key';
|
||||||
|
if (secretKey !== adminSecret) {
|
||||||
|
throw new BadRequestException('Mã Secret Key không hợp lệ.');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
data: { isAdmin: true },
|
||||||
|
select: { id: true, email: true, name: true, isAdmin: true }
|
||||||
|
});
|
||||||
|
return { success: true, user: updated };
|
||||||
|
}
|
||||||
|
|
||||||
@Post('create-guest')
|
@Post('create-guest')
|
||||||
async createGuestUser() {
|
async createGuestUser() {
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
@@ -687,10 +703,12 @@ class TourController {
|
|||||||
@Post()
|
@Post()
|
||||||
async createTour(@Body() body: any, @Req() req: any) {
|
async createTour(@Body() body: any, @Req() req: any) {
|
||||||
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags, members } = body;
|
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags, members } = body;
|
||||||
|
const filteredTitle = await filterText(this.prisma, title || '');
|
||||||
|
const filteredDesc = await filterText(this.prisma, description || '');
|
||||||
const tour = await this.prisma.tour.create({
|
const tour = await this.prisma.tour.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title: filteredTitle,
|
||||||
description,
|
description: filteredDesc,
|
||||||
startDate: startDate ? new Date(startDate) : null,
|
startDate: startDate ? new Date(startDate) : null,
|
||||||
endDate: endDate ? new Date(endDate) : null,
|
endDate: endDate ? new Date(endDate) : null,
|
||||||
tags: tags || [],
|
tags: tags || [],
|
||||||
@@ -959,19 +977,24 @@ class TourController {
|
|||||||
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
@UseGuards(JwtAuthGuard, TourRoleGuard)
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
async updateTour(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
|
async updateTour(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
|
||||||
// Lưu ý: Trong thực tế nên kiểm tra xem user có phải là OWNER không
|
const updateData: any = {
|
||||||
return this.prisma.tour.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
title: body.title,
|
|
||||||
description: body.description,
|
|
||||||
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
||||||
tags: body.tags,
|
tags: body.tags,
|
||||||
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||||
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
|
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
|
||||||
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
||||||
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined,
|
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined,
|
||||||
},
|
};
|
||||||
|
if (body.title !== undefined) {
|
||||||
|
updateData.title = await filterText(this.prisma, body.title);
|
||||||
|
}
|
||||||
|
if (body.description !== undefined) {
|
||||||
|
updateData.description = await filterText(this.prisma, body.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.tour.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
}).then(async (tour) => {
|
}).then(async (tour) => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.cacheManager.del(id),
|
this.cacheManager.del(id),
|
||||||
@@ -2410,9 +2433,10 @@ class CommentController {
|
|||||||
@Body() body: { content: string },
|
@Body() body: { content: string },
|
||||||
@Req() req: any
|
@Req() req: any
|
||||||
) {
|
) {
|
||||||
|
const filteredContent = await filterText(this.prisma, body.content);
|
||||||
const comment = await this.prisma.comment.create({
|
const comment = await this.prisma.comment.create({
|
||||||
data: {
|
data: {
|
||||||
content: body.content,
|
content: filteredContent,
|
||||||
locationId,
|
locationId,
|
||||||
userId: req.user.id
|
userId: req.user.id
|
||||||
},
|
},
|
||||||
@@ -2800,9 +2824,10 @@ class PublicPhotoController {
|
|||||||
throw new NotFoundException('Không tìm thấy ảnh.');
|
throw new NotFoundException('Không tìm thấy ảnh.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredContent = await filterText(this.prisma, content.trim());
|
||||||
const comment = await this.prisma.comment.create({
|
const comment = await this.prisma.comment.create({
|
||||||
data: {
|
data: {
|
||||||
content: content.trim(),
|
content: filteredContent,
|
||||||
photoId,
|
photoId,
|
||||||
userId: req.user.id
|
userId: req.user.id
|
||||||
},
|
},
|
||||||
@@ -2819,6 +2844,35 @@ class PublicPhotoController {
|
|||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Delete('comments/:id')
|
||||||
|
async deletePhotoComment(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||||
|
const comment = await this.prisma.comment.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { photo: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Không tìm thấy bình luận.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthorized = req.user.isAdmin ||
|
||||||
|
comment.userId === req.user.id ||
|
||||||
|
(comment.photo && comment.photo.uploaderId === req.user.id);
|
||||||
|
|
||||||
|
if (!isAuthorized) {
|
||||||
|
throw new ForbiddenException('Bạn không có quyền xóa bình luận này.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.comment.delete({ where: { id } });
|
||||||
|
|
||||||
|
if (comment.photoId) {
|
||||||
|
this.commentGateway.server.to(`photo_${comment.photoId}`).emit('photoCommentDeleted', { id, photoId: comment.photoId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'Đã xóa bình luận thành công.' };
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':photoId/share')
|
@Get(':photoId/share')
|
||||||
async sharePhoto(
|
async sharePhoto(
|
||||||
@Param('photoId', ParseUUIDPipe) photoId: string,
|
@Param('photoId', ParseUUIDPipe) photoId: string,
|
||||||
@@ -3185,11 +3239,12 @@ class DirectMessageController {
|
|||||||
throw new ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.');
|
throw new ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredContent = content ? await filterText(this.prisma, content) : '';
|
||||||
const message = await this.prisma.directMessage.create({
|
const message = await this.prisma.directMessage.create({
|
||||||
data: {
|
data: {
|
||||||
senderId,
|
senderId,
|
||||||
receiverId,
|
receiverId,
|
||||||
content: content || '',
|
content: filteredContent,
|
||||||
attachmentUrl,
|
attachmentUrl,
|
||||||
latitude,
|
latitude,
|
||||||
longitude
|
longitude
|
||||||
@@ -3289,11 +3344,12 @@ class TourMessageController {
|
|||||||
throw new ForbiddenException('Bạn không phải là thành viên của hành trình này.');
|
throw new ForbiddenException('Bạn không phải là thành viên của hành trình này.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredContent = content ? await filterText(this.prisma, content) : '';
|
||||||
const tourMessage = await this.prisma.tourMessage.create({
|
const tourMessage = await this.prisma.tourMessage.create({
|
||||||
data: {
|
data: {
|
||||||
tourId,
|
tourId,
|
||||||
senderId,
|
senderId,
|
||||||
content: content || '',
|
content: filteredContent,
|
||||||
attachmentUrl,
|
attachmentUrl,
|
||||||
latitude,
|
latitude,
|
||||||
longitude
|
longitude
|
||||||
@@ -3333,13 +3389,14 @@ class TourMessageController {
|
|||||||
|
|
||||||
for (const p of otherParticipants) {
|
for (const p of otherParticipants) {
|
||||||
if (p.userId) {
|
if (p.userId) {
|
||||||
|
const filteredContent = content ? await filterText(this.prisma, content) : '';
|
||||||
const isTagged = hasTags && taggedUserIds.includes(p.userId);
|
const isTagged = hasTags && taggedUserIds.includes(p.userId);
|
||||||
console.log(`[sendTourMessage] Emitting tourMessageNotification to user_${p.userId}, isTagged: ${isTagged}`);
|
console.log(`[sendTourMessage] Emitting tourMessageNotification to user_${p.userId}, isTagged: ${isTagged}`);
|
||||||
this.commentGateway.server.to(`user_${p.userId}`).emit('tourMessageNotification', {
|
this.commentGateway.server.to(`user_${p.userId}`).emit('tourMessageNotification', {
|
||||||
tourId,
|
tourId,
|
||||||
senderName: req.user.name || 'Thành viên',
|
senderName: req.user.name || 'Thành viên',
|
||||||
tourTitle: tour?.title || 'Hành trình',
|
tourTitle: tour?.title || 'Hành trình',
|
||||||
content: isTagged ? `@Bạn: ${content || ''}` : content || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'),
|
content: isTagged ? `@Bạn: ${filteredContent || ''}` : filteredContent || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'),
|
||||||
isTagged
|
isTagged
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3349,6 +3406,327 @@ class TourMessageController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function filterText(prisma: PrismaService, text: string): Promise<string> {
|
||||||
|
if (!text) return text;
|
||||||
|
try {
|
||||||
|
const filters = await prisma.wordFilter.findMany();
|
||||||
|
let result = text;
|
||||||
|
for (const filter of filters) {
|
||||||
|
const escapedWord = filter.word.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||||
|
const regex = new RegExp(escapedWord, 'gi');
|
||||||
|
result = result.replace(regex, filter.replacement);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Lỗi khi lọc từ cấm:', err);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('moderation')
|
||||||
|
class ModerationController {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get('settings')
|
||||||
|
async getSettings() {
|
||||||
|
let settings = await this.prisma.moderationSetting.findFirst();
|
||||||
|
if (!settings) {
|
||||||
|
settings = await this.prisma.moderationSetting.create({
|
||||||
|
data: { blockNsfw: false, blurFaces: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('admin/moderation')
|
||||||
|
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||||
|
class AdminModerationController {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getSettings() {
|
||||||
|
let settings = await this.prisma.moderationSetting.findFirst();
|
||||||
|
if (!settings) {
|
||||||
|
settings = await this.prisma.moderationSetting.create({
|
||||||
|
data: { blockNsfw: false, blurFaces: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async updateSettings(@Body() body: { blockNsfw?: boolean, blurFaces?: boolean }) {
|
||||||
|
let settings = await this.prisma.moderationSetting.findFirst();
|
||||||
|
if (!settings) {
|
||||||
|
return this.prisma.moderationSetting.create({
|
||||||
|
data: {
|
||||||
|
blockNsfw: body.blockNsfw ?? false,
|
||||||
|
blurFaces: body.blurFaces ?? false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.prisma.moderationSetting.update({
|
||||||
|
where: { id: settings.id },
|
||||||
|
data: {
|
||||||
|
blockNsfw: body.blockNsfw,
|
||||||
|
blurFaces: body.blurFaces
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('word-filters')
|
||||||
|
async getWordFilters() {
|
||||||
|
return this.prisma.wordFilter.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('word-filters')
|
||||||
|
async addWordFilter(@Body() body: { word: string, replacement: string }) {
|
||||||
|
const { word, replacement } = body;
|
||||||
|
if (!word || replacement === undefined) {
|
||||||
|
throw new BadRequestException('Từ khóa và từ thay thế không được để trống.');
|
||||||
|
}
|
||||||
|
return this.prisma.wordFilter.create({
|
||||||
|
data: {
|
||||||
|
word: word.trim(),
|
||||||
|
replacement: replacement.trim()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('word-filters/:id')
|
||||||
|
async deleteWordFilter(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
await this.prisma.wordFilter.delete({ where: { id } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('users/trusted')
|
||||||
|
class TrustedUsersController {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getTrustedUsers() {
|
||||||
|
const ratings = await this.prisma.tourRating.groupBy({
|
||||||
|
by: ['targetUserId'],
|
||||||
|
_avg: { averageScore: true },
|
||||||
|
_count: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: ratings.map(r => r.targetUserId) } },
|
||||||
|
select: { id: true, name: true, avatar: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = ratings.map(r => {
|
||||||
|
const u = users.find(user => user.id === r.targetUserId);
|
||||||
|
return {
|
||||||
|
id: r.targetUserId,
|
||||||
|
name: u?.name || 'Ẩn danh',
|
||||||
|
avatar: u?.avatar || null,
|
||||||
|
averageScore: Math.round((r._avg.averageScore || 0) * 10) / 10,
|
||||||
|
ratingCount: r._count.id
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.sort((a, b) => b.averageScore - a.averageScore || b.ratingCount - a.ratingCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('tours/:tourId/ratings')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
class TourRatingController {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getTourRatings(@Param('tourId', ParseUUIDPipe) tourId: string) {
|
||||||
|
return this.prisma.tourRating.findMany({
|
||||||
|
where: { tourId },
|
||||||
|
include: {
|
||||||
|
raterUser: { select: { id: true, name: true, avatar: true } },
|
||||||
|
targetUser: { select: { id: true, name: true, avatar: true } }
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createTourRating(
|
||||||
|
@Param('tourId', ParseUUIDPipe) tourId: string,
|
||||||
|
@Body() body: {
|
||||||
|
targetUserId: string;
|
||||||
|
honesty: number;
|
||||||
|
transparency: number;
|
||||||
|
enthusiasm: number;
|
||||||
|
cheerfulness: number;
|
||||||
|
seriousness: number;
|
||||||
|
planning: number;
|
||||||
|
survival: number;
|
||||||
|
comment?: string;
|
||||||
|
},
|
||||||
|
@Req() req: any
|
||||||
|
) {
|
||||||
|
const raterUserId = req.user.id;
|
||||||
|
const { targetUserId, honesty, transparency, enthusiasm, cheerfulness, seriousness, planning, survival, comment } = body;
|
||||||
|
|
||||||
|
if (!targetUserId || honesty < 1 || honesty > 5 || transparency < 1 || transparency > 5 || enthusiasm < 1 || enthusiasm > 5 || cheerfulness < 1 || cheerfulness > 5 || seriousness < 1 || seriousness > 5 || planning < 1 || planning > 5 || survival < 1 || survival > 5) {
|
||||||
|
throw new BadRequestException('Dữ liệu đánh giá không hợp lệ.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const participant = await this.prisma.tourParticipant.findFirst({
|
||||||
|
where: { tourId, userId: raterUserId }
|
||||||
|
});
|
||||||
|
if (!participant) {
|
||||||
|
throw new ForbiddenException('Bạn không phải là thành viên của tour này.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetParticipant = await this.prisma.tourParticipant.findFirst({
|
||||||
|
where: {
|
||||||
|
tourId,
|
||||||
|
userId: targetUserId,
|
||||||
|
role: { in: ['OWNER', 'MANAGER'] }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!targetParticipant) {
|
||||||
|
throw new BadRequestException('Chỉ có thể đánh giá người tạo tour hoặc người quản lý.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const averageScore = (honesty + transparency + enthusiasm + cheerfulness + seriousness + planning + survival) / 7;
|
||||||
|
|
||||||
|
const existingRating = await this.prisma.tourRating.findUnique({
|
||||||
|
where: {
|
||||||
|
tourId_targetUserId_raterUserId: {
|
||||||
|
tourId,
|
||||||
|
targetUserId,
|
||||||
|
raterUserId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingRating) {
|
||||||
|
return this.prisma.tourRating.update({
|
||||||
|
where: { id: existingRating.id },
|
||||||
|
data: {
|
||||||
|
honesty,
|
||||||
|
transparency,
|
||||||
|
enthusiasm,
|
||||||
|
cheerfulness,
|
||||||
|
seriousness,
|
||||||
|
planning,
|
||||||
|
survival,
|
||||||
|
averageScore,
|
||||||
|
comment,
|
||||||
|
createdAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.tourRating.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
targetUserId,
|
||||||
|
raterUserId,
|
||||||
|
honesty,
|
||||||
|
transparency,
|
||||||
|
enthusiasm,
|
||||||
|
cheerfulness,
|
||||||
|
seriousness,
|
||||||
|
planning,
|
||||||
|
survival,
|
||||||
|
averageScore,
|
||||||
|
comment
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('tours')
|
||||||
|
class PublicTourShareController {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get('share/:token')
|
||||||
|
async getSharedJourney(@Param('token') token: string) {
|
||||||
|
const tourShare = await this.prisma.tourShare.findUnique({
|
||||||
|
where: { token, isEnabled: true },
|
||||||
|
include: {
|
||||||
|
tour: {
|
||||||
|
include: {
|
||||||
|
creator: { select: { id: true, name: true, phone: true } },
|
||||||
|
participants: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
displayName: true,
|
||||||
|
user: { select: { id: true, name: true, phone: true } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legs: {
|
||||||
|
include: {
|
||||||
|
locations: {
|
||||||
|
orderBy: { plannedStart: 'asc' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { sequence: 'asc' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tourShare || !tourShare.tour) {
|
||||||
|
throw new NotFoundException('Không tìm thấy hành trình chia sẻ hoặc liên kết đã bị vô hiệu hóa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tour: tourShare.tour,
|
||||||
|
isEnabled: tourShare.isEnabled
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('tours/:tourId/share')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
class TourShareController {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getShareStatus(@Param('tourId', ParseUUIDPipe) tourId: string) {
|
||||||
|
let share = await this.prisma.tourShare.findUnique({
|
||||||
|
where: { tourId }
|
||||||
|
});
|
||||||
|
if (!share) {
|
||||||
|
share = await this.prisma.tourShare.create({
|
||||||
|
data: { tourId, isEnabled: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async toggleShare(
|
||||||
|
@Param('tourId', ParseUUIDPipe) tourId: string,
|
||||||
|
@Body() body: { isEnabled: boolean }
|
||||||
|
) {
|
||||||
|
let share = await this.prisma.tourShare.findUnique({
|
||||||
|
where: { tourId }
|
||||||
|
});
|
||||||
|
if (!share) {
|
||||||
|
return this.prisma.tourShare.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
isEnabled: body.isEnabled
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.prisma.tourShare.update({
|
||||||
|
where: { id: share.id },
|
||||||
|
data: { isEnabled: body.isEnabled }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
@@ -3372,7 +3750,7 @@ class TourMessageController {
|
|||||||
signOptions: { expiresIn: '1d' },
|
signOptions: { expiresIn: '1d' },
|
||||||
}) as any,
|
}) as any,
|
||||||
],
|
],
|
||||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController, ConnectionController, DirectMessageController, UploadController, TourMessageController],
|
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController, ConnectionController, DirectMessageController, UploadController, TourMessageController, ModerationController, AdminModerationController, TrustedUsersController, TourRatingController, PublicTourShareController, TourShareController],
|
||||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
|
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
|
||||||
exports: [PrismaService]
|
exports: [PrismaService]
|
||||||
})
|
})
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 224 KiB |
+30
-9
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { LandingPage } from './pages/LandingPage';
|
import { LandingPage } from './pages/LandingPage';
|
||||||
import { ExploreMap } from './pages/ExploreMap';
|
import { ExploreMap } from './pages/ExploreMap';
|
||||||
import { TourDetailPage } from './pages/TourDetailPage';
|
import { TourDetailPage } from './pages/TourDetailPage';
|
||||||
@@ -7,30 +7,32 @@ import { MyPhotosPage } from './pages/MyPhotosPage';
|
|||||||
import { MyNotePage } from './pages/MyNotePage';
|
import { MyNotePage } from './pages/MyNotePage';
|
||||||
import { JoinTourPage } from './pages/JoinTourPage';
|
import { JoinTourPage } from './pages/JoinTourPage';
|
||||||
import { MemberDashboard } from './pages/MemberDashboard';
|
import { MemberDashboard } from './pages/MemberDashboard';
|
||||||
import { useTourStore } from './store/useTourStore';
|
import { ShareJourneyPage } from './pages/ShareJourneyPage';
|
||||||
import { ConfirmProvider } from './hooks/useConfirm';
|
import { ConfirmProvider } from './hooks/useConfirm';
|
||||||
import { NotificationProvider } from './hooks/useNotification';
|
import { NotificationProvider } from './hooks/useNotification';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const viewTourId = params.get('viewTour');
|
const viewTourId = params.get('viewTour');
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
const isJourneyShare = pathParts[1] === 'journey' && pathParts[2];
|
||||||
|
const journeyTokenVal = isJourneyShare ? pathParts[2] : null;
|
||||||
|
|
||||||
const [user, setUser] = useState<any>(null);
|
const [user, setUser] = useState<any>(null);
|
||||||
const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour'>(
|
const [shareJourneyToken, setShareJourneyToken] = useState<string | null>(journeyTokenVal);
|
||||||
viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing')
|
const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney'>(
|
||||||
|
journeyTokenVal ? 'shareJourney' : (viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing'))
|
||||||
);
|
);
|
||||||
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||||
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
||||||
const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore');
|
const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore');
|
||||||
|
|
||||||
// Lấy action từ store
|
|
||||||
const fetchTour = useTourStore(state => state.fetchTour);
|
|
||||||
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const viewTourId = params.get('viewTour');
|
const viewTourId = params.get('viewTour');
|
||||||
const isJoinTour = window.location.pathname === '/join-tour' || params.has('token');
|
const isJoinTour = window.location.pathname === '/join-tour' || params.has('token');
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
const journeyToken = pathParts[1] === 'journey' && pathParts[2] ? pathParts[2] : null;
|
||||||
|
|
||||||
// Khôi phục thông tin đăng nhập nếu có
|
// Khôi phục thông tin đăng nhập nếu có
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
@@ -46,7 +48,10 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isJoinTour) {
|
if (journeyToken) {
|
||||||
|
setShareJourneyToken(journeyToken);
|
||||||
|
setCurrentPage('shareJourney');
|
||||||
|
} else if (isJoinTour) {
|
||||||
setCurrentPage('joinTour');
|
setCurrentPage('joinTour');
|
||||||
} else if (viewTourId) {
|
} else if (viewTourId) {
|
||||||
setCurrentPage('tourDetail');
|
setCurrentPage('tourDetail');
|
||||||
@@ -139,6 +144,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGoToHome = () => {
|
||||||
|
window.history.pushState({}, '', '/');
|
||||||
|
setShareJourneyToken(null);
|
||||||
|
const loggedIn = !!localStorage.getItem('token');
|
||||||
|
setCurrentPage(loggedIn ? 'dashboard' : 'landing');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
@@ -211,6 +223,15 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentPage === 'shareJourney') {
|
||||||
|
return (
|
||||||
|
<ShareJourneyPage
|
||||||
|
token={shareJourneyToken!}
|
||||||
|
onGoToHome={handleGoToHome}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
|
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
|
||||||
})()}
|
})()}
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useRef } from 'react';
|
|||||||
import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
|
import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||||
import { useNotification } from '@/hooks/useNotification';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
|
import { processImageModeration } from '@/hooks/useImageModeration';
|
||||||
|
|
||||||
interface AddPhotoModalProps {
|
interface AddPhotoModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -14,6 +15,7 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
|||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
const [previews, setPreviews] = useState<string[]>([]);
|
const [previews, setPreviews] = useState<string[]>([]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const notify = useNotification();
|
const notify = useNotification();
|
||||||
const fetchTour = useTourStore(state => state.fetchTour);
|
const fetchTour = useTourStore(state => state.fetchTour);
|
||||||
@@ -26,6 +28,10 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
|||||||
const newValidFiles: File[] = [];
|
const newValidFiles: File[] = [];
|
||||||
const newValidPreviews: string[] = [];
|
const newValidPreviews: string[] = [];
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
notify({ title: 'Đang kiểm duyệt...', message: 'Đang kiểm tra và lọc hình ảnh của bạn...', type: 'info' });
|
||||||
|
|
||||||
|
try {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// 1. Kiểm tra cơ bản: Tệp có dung lượng hay không
|
// 1. Kiểm tra cơ bản: Tệp có dung lượng hay không
|
||||||
if (!file || file.size === 0) {
|
if (!file || file.size === 0) {
|
||||||
@@ -33,9 +39,17 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewUrl = URL.createObjectURL(file);
|
// 2. Chạy kiểm duyệt hình ảnh
|
||||||
|
const moderationResult = await processImageModeration(file);
|
||||||
|
if (moderationResult.blocked) {
|
||||||
|
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Kiểm tra tính toàn vẹn: Thử load ảnh vào bộ nhớ để xác nhận file "tồn tại" và đọc được
|
const processedFile = moderationResult.file;
|
||||||
|
const previewUrl = URL.createObjectURL(processedFile);
|
||||||
|
|
||||||
|
// 3. Kiểm tra tính toàn vẹn: Thử load ảnh vào bộ nhớ để xác nhận file "tồn tại" và đọc được
|
||||||
const isValidImage = await new Promise<boolean>((resolve) => {
|
const isValidImage = await new Promise<boolean>((resolve) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => resolve(true);
|
img.onload = () => resolve(true);
|
||||||
@@ -44,7 +58,7 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isValidImage) {
|
if (isValidImage) {
|
||||||
newValidFiles.push(file);
|
newValidFiles.push(processedFile);
|
||||||
newValidPreviews.push(previewUrl);
|
newValidPreviews.push(previewUrl);
|
||||||
} else {
|
} else {
|
||||||
URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ
|
URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ
|
||||||
@@ -54,6 +68,12 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
|||||||
|
|
||||||
setSelectedFiles(prev => [...prev, ...newValidFiles]);
|
setSelectedFiles(prev => [...prev, ...newValidFiles]);
|
||||||
setPreviews(prev => [...prev, ...newValidPreviews]);
|
setPreviews(prev => [...prev, ...newValidPreviews]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('File checking error:', err);
|
||||||
|
notify({ title: 'Lỗi', message: 'Lỗi trong quá trình kiểm duyệt ảnh.', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,6 +150,7 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
|
||||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
@@ -177,10 +198,19 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
disabled={isUploading || selectedFiles.length === 0}
|
disabled={isUploading || isProcessing || selectedFiles.length === 0}
|
||||||
className="w-full py-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-black uppercase tracking-widest rounded-2xl flex items-center justify-center gap-2 shadow-lg shadow-blue-100 transition-all active:scale-95"
|
className="w-full py-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-black uppercase tracking-widest rounded-2xl flex items-center justify-center gap-2 shadow-lg shadow-blue-100 transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
{isUploading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Xác nhận tải lên'}
|
{isUploading ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : isProcessing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Đang xử lý ảnh...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Xác nhận tải lên'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, MapPin } from 'lucide-react';
|
import { X, MapPin, Search, Loader2 } from 'lucide-react';
|
||||||
import { MapContainer, TileLayer, Marker, useMap, useMapEvents } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, useMap, useMapEvents } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
|
||||||
// Fix Leaflet default marker icon bug
|
// Fix Leaflet default marker icon bug
|
||||||
const DefaultIcon = L.icon({
|
const DefaultIcon = L.icon({
|
||||||
@@ -55,10 +56,16 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
|
|||||||
initialLng,
|
initialLng,
|
||||||
onSelect
|
onSelect
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const defaultCenter: [number, number] = [10.7769, 106.7009]; // TP.HCM default
|
const defaultCenter: [number, number] = [10.7769, 106.7009]; // TP.HCM default
|
||||||
const [position, setPosition] = useState<[number, number]>(defaultCenter);
|
const [position, setPosition] = useState<[number, number]>(defaultCenter);
|
||||||
const [hasSelected, setHasSelected] = useState(false);
|
const [hasSelected, setHasSelected] = useState(false);
|
||||||
|
|
||||||
|
// Search States
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (typeof initialLat === 'number' && typeof initialLng === 'number' && !isNaN(initialLat) && !isNaN(initialLng)) {
|
if (typeof initialLat === 'number' && typeof initialLng === 'number' && !isNaN(initialLat) && !isNaN(initialLng)) {
|
||||||
@@ -68,6 +75,8 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
|
|||||||
setPosition(defaultCenter);
|
setPosition(defaultCenter);
|
||||||
setHasSelected(false);
|
setHasSelected(false);
|
||||||
}
|
}
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
}
|
}
|
||||||
}, [isOpen, initialLat, initialLng]);
|
}, [isOpen, initialLat, initialLng]);
|
||||||
|
|
||||||
@@ -83,6 +92,31 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!searchQuery.trim()) return;
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&accept-language=vi&limit=5`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSearchResults(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error during Nominatim search:', e);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectResult = (place: any) => {
|
||||||
|
const lat = parseFloat(place.lat);
|
||||||
|
const lng = parseFloat(place.lon);
|
||||||
|
setPosition([lat, lng]);
|
||||||
|
setHasSelected(true);
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearchQuery(place.display_name);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4 animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4 animate-in fade-in duration-200">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
@@ -92,33 +126,75 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="relative w-full max-w-2xl h-[550px] bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col border border-gray-100 animate-in zoom-in-95 duration-200">
|
<div className="relative w-full max-w-2xl h-[550px] bg-white dark:bg-slate-900 rounded-3xl shadow-2xl overflow-hidden flex flex-col border border-gray-100 dark:border-slate-800 animate-in zoom-in-95 duration-200">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-gray-100 flex items-center justify-between bg-white shrink-0">
|
<div className="p-4 border-b border-gray-100 dark:border-slate-800/80 flex items-center justify-between bg-white dark:bg-slate-900 shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MapPin className="w-5 h-5 text-blue-500" />
|
<MapPin className="w-5 h-5 text-blue-500" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="font-extrabold text-sm text-gray-900">Chọn vị trí trên bản đồ</h3>
|
<h3 className="font-extrabold text-sm text-gray-900 dark:text-white">{t('chooseLocationMap')}</h3>
|
||||||
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Click lên bản đồ để chọn tọa độ</p>
|
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">{t('clickMapSelectCoords')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1.5 hover:bg-gray-100 rounded-full transition-all text-gray-400 hover:text-gray-600"
|
className="p-1.5 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full transition-all text-gray-400 hover:text-gray-650"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map Body */}
|
{/* Map Body */}
|
||||||
<div className="flex-1 bg-gray-50 relative min-h-[300px]" style={{ zIndex: 10 }}>
|
<div className="flex-1 bg-gray-50 dark:bg-slate-950 relative min-h-[300px]" style={{ zIndex: 10 }}>
|
||||||
|
|
||||||
|
{/* Floating Search Panel */}
|
||||||
|
<div className="absolute top-4 left-4 right-4 sm:right-auto z-[1000] sm:w-80 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md rounded-2xl border border-slate-150 dark:border-slate-800 shadow-xl p-2 flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 relative flex items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
placeholder={t('searchPlaceholder')}
|
||||||
|
className="w-full bg-slate-50 dark:bg-slate-800 border-0 outline-none rounded-xl pl-8 pr-3 py-2 text-xs text-slate-800 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={isSearching}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-bold px-3 py-2 rounded-xl text-xs transition-all active:scale-95 shrink-0 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{isSearching ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : t('confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="max-h-48 overflow-y-auto divide-y divide-gray-100 dark:divide-slate-800/50 bg-white dark:bg-slate-900 rounded-xl border border-slate-150 dark:border-slate-800 shadow-inner">
|
||||||
|
{searchResults.map((r, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectResult(r)}
|
||||||
|
className="w-full text-left px-3 py-2.5 text-[10px] text-gray-700 dark:text-slate-350 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors truncate block"
|
||||||
|
title={r.display_name}
|
||||||
|
>
|
||||||
|
{r.display_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={position}
|
center={position}
|
||||||
zoom={13}
|
zoom={13}
|
||||||
|
attributionControl={false}
|
||||||
style={{ width: '100%', height: '100%', zIndex: 1 }}
|
style={{ width: '100%', height: '100%', zIndex: 1 }}
|
||||||
>
|
>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
<MapClickEvents onClick={handleMapClick} />
|
<MapClickEvents onClick={handleMapClick} />
|
||||||
@@ -131,29 +207,29 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-gray-100 flex items-center justify-between bg-white shrink-0">
|
<div className="p-4 border-t border-gray-100 dark:border-slate-800/80 flex items-center justify-between bg-white dark:bg-slate-900 shrink-0">
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500 dark:text-slate-400">
|
||||||
{hasSelected ? (
|
{hasSelected ? (
|
||||||
<span className="font-semibold text-gray-700">
|
<span className="font-semibold text-gray-700 dark:text-slate-200">
|
||||||
Tọa độ: {position[0].toFixed(6)}, {position[1].toFixed(6)}
|
{t('coordsLabel')}: {position[0].toFixed(6)}, {position[1].toFixed(6)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="italic text-gray-400">Chưa chọn vị trí</span>
|
<span className="italic text-gray-400 dark:text-slate-500">{t('noCoordsSelected')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 border border-gray-200 hover:bg-gray-50 text-gray-700 rounded-xl text-xs font-bold transition-all"
|
className="px-4 py-2 border border-gray-200 dark:border-slate-800 hover:bg-gray-50 dark:hover:bg-slate-800 text-gray-700 dark:text-slate-300 rounded-xl text-xs font-bold transition-all animate-fade-in"
|
||||||
>
|
>
|
||||||
Hủy
|
{t('cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={!hasSelected}
|
disabled={!hasSelected}
|
||||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-blue-500/10"
|
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-200 disabled:text-gray-400 dark:disabled:bg-slate-800 dark:disabled:text-slate-650 disabled:cursor-not-allowed text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-blue-500/10"
|
||||||
>
|
>
|
||||||
Xác nhận
|
{t('confirm')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export const ItineraryTimeline = ({
|
|||||||
}, [legs]);
|
}, [legs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
<div id="itinerary-timeline-print-zone" className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
||||||
<div className="px-2 pt-4">
|
<div className="px-2 pt-4">
|
||||||
{legs.length === 0 ? (
|
{legs.length === 0 ? (
|
||||||
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
|
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
|
||||||
@@ -397,7 +397,10 @@ export const ItineraryTimeline = ({
|
|||||||
{isEndPoint && (
|
{isEndPoint && (
|
||||||
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
||||||
)}
|
)}
|
||||||
<h3 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
|
<h3
|
||||||
|
onClick={() => onNavigate?.(location)}
|
||||||
|
className={`font-semibold text-lg cursor-pointer hover:text-blue-600 transition-colors select-none ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}
|
||||||
|
>
|
||||||
{location.name}
|
{location.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center text-sm text-gray-500 mt-1">
|
<div className="flex items-center text-sm text-gray-500 mt-1">
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart } from 'lucide-react';
|
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart, Trash2 } from 'lucide-react';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import { CoordinateSelectModal } from './CoordinateSelectModal';
|
import { CoordinateSelectModal } from './CoordinateSelectModal';
|
||||||
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,6 +47,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
onLoginSuccess,
|
onLoginSuccess,
|
||||||
onUpdatePhoto
|
onUpdatePhoto
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [newComment, setNewComment] = useState('');
|
const [newComment, setNewComment] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -260,6 +262,12 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('photoCommentDeleted', (deleted: any) => {
|
||||||
|
if (deleted.photoId === photo.id) {
|
||||||
|
setComments(prev => prev.filter(c => c.id !== deleted.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
};
|
};
|
||||||
@@ -348,6 +356,28 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteComment = async (commentId: string) => {
|
||||||
|
if (!confirm(t('confirmDeleteComment') || 'Bạn có chắc chắn muốn xóa bình luận này không?')) return;
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
||||||
|
const res = await fetch(`/api/v1/public-photos/comments/${commentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setComments(prev => prev.filter(c => c.id !== commentId));
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.message || 'Lỗi khi xóa bình luận.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi khi xóa bình luận:', error);
|
||||||
|
alert('Không thể kết nối đến máy chủ.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isAuthorized = currentUser?.isAdmin ||
|
const isAuthorized = currentUser?.isAdmin ||
|
||||||
(currentUser && photo.uploader && currentUser.id === photo.uploader.id) ||
|
(currentUser && photo.uploader && currentUser.id === photo.uploader.id) ||
|
||||||
(currentUser && photo.uploaderId && currentUser.id === photo.uploaderId);
|
(currentUser && photo.uploaderId && currentUser.id === photo.uploaderId);
|
||||||
@@ -630,7 +660,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-black tracking-tight text-white flex items-center gap-2">
|
<h3 className="text-lg font-black tracking-tight text-white flex items-center gap-2">
|
||||||
<MessageSquare className="w-5 h-5 text-emerald-500" />
|
<MessageSquare className="w-5 h-5 text-emerald-500" />
|
||||||
Bình luận cộng đồng
|
{t('commentSectionTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-400 mt-1">Ảnh chia sẻ công khai trên bản đồ</p>
|
<p className="text-xs text-slate-400 mt-1">Ảnh chia sẻ công khai trên bản đồ</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -641,7 +671,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2">
|
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-emerald-500" />
|
<Loader2 className="w-8 h-8 animate-spin text-emerald-500" />
|
||||||
<span className="text-xs font-semibold">Đang tải bình luận...</span>
|
<span className="text-xs font-semibold">{t('loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : comments.length === 0 ? (
|
) : comments.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500 gap-3">
|
<div className="flex flex-col items-center justify-center py-16 text-slate-500 gap-3">
|
||||||
@@ -661,9 +691,23 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
|
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
|
||||||
<div className="flex justify-between items-center mb-1">
|
<div className="flex justify-between items-center mb-1">
|
||||||
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
|
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[9px] font-medium text-slate-500">
|
<span className="text-[9px] font-medium text-slate-500">
|
||||||
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
|
{(currentUser?.isAdmin ||
|
||||||
|
currentUser?.id === c.userId ||
|
||||||
|
currentUser?.id === photo.uploaderId ||
|
||||||
|
currentUser?.id === photo.uploader?.id) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteComment(c.id)}
|
||||||
|
className="text-slate-500 hover:text-rose-500 transition-colors p-0.5"
|
||||||
|
title={t('delete') || "Xóa"}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
|
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -672,6 +716,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={commentsEndRef} />
|
<div ref={commentsEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ interface UserManagementModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
|
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'photos' | 'trash'>('users');
|
const [activeTab, setActiveTab] = useState<'users' | 'photos' | 'trash' | 'filters'>('users');
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
const [photos, setPhotos] = useState<any[]>([]);
|
const [photos, setPhotos] = useState<any[]>([]);
|
||||||
const [trashPhotos, setTrashPhotos] = useState<any[]>([]);
|
const [trashPhotos, setTrashPhotos] = useState<any[]>([]);
|
||||||
|
const [moderationSetting, setModerationSetting] = useState({ blockNsfw: false, blurFaces: false });
|
||||||
|
const [wordFilters, setWordFilters] = useState<any[]>([]);
|
||||||
|
const [newWord, setNewWord] = useState('');
|
||||||
|
const [newReplacement, setNewReplacement] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [photosLoading, setPhotosLoading] = useState(false);
|
const [photosLoading, setPhotosLoading] = useState(false);
|
||||||
const [trashLoading, setTrashLoading] = useState(false);
|
const [trashLoading, setTrashLoading] = useState(false);
|
||||||
@@ -105,6 +109,84 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchModerationSettings = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/moderation/settings');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setModerationSetting({ blockNsfw: data.blockNsfw, blurFaces: data.blurFaces });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchWordFilters = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/admin/moderation/word-filters', {
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setWordFilters(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateModeration = async (field: 'blockNsfw' | 'blurFaces', value: boolean) => {
|
||||||
|
const updated = { ...moderationSetting, [field]: value };
|
||||||
|
setModerationSetting(updated);
|
||||||
|
try {
|
||||||
|
await fetch('/api/v1/admin/moderation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updated)
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddWordFilter = async () => {
|
||||||
|
if (!newWord.trim()) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/admin/moderation/word-filters', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ word: newWord, replacement: newReplacement })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setNewWord('');
|
||||||
|
setNewReplacement('');
|
||||||
|
fetchWordFilters();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteWordFilter = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/admin/moderation/word-filters/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchWordFilters();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (activeTab === 'users') {
|
if (activeTab === 'users') {
|
||||||
@@ -113,6 +195,9 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
|||||||
fetchPhotos();
|
fetchPhotos();
|
||||||
} else if (activeTab === 'trash') {
|
} else if (activeTab === 'trash') {
|
||||||
fetchTrashPhotos();
|
fetchTrashPhotos();
|
||||||
|
} else if (activeTab === 'filters') {
|
||||||
|
fetchModerationSettings();
|
||||||
|
fetchWordFilters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, activeTab]);
|
}, [isOpen, activeTab]);
|
||||||
@@ -212,6 +297,15 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
|||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
Ảnh rác
|
Ảnh rác
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('filters')}
|
||||||
|
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
|
||||||
|
activeTab === 'filters' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
Bộ lọc
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Body */}
|
{/* Content Body */}
|
||||||
@@ -409,6 +503,121 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'filters' && (
|
||||||
|
<div className="space-y-6 text-gray-800">
|
||||||
|
<div className="bg-gray-50/50 p-6 rounded-2xl border border-gray-100/80 space-y-4 text-left">
|
||||||
|
<h3 className="text-sm font-black uppercase text-blue-600 tracking-wider flex items-center gap-1.5">
|
||||||
|
⚙️ Cấu hình kiểm duyệt hình ảnh
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 bg-white rounded-xl border border-gray-100 shadow-sm">
|
||||||
|
<div className="flex flex-col text-left">
|
||||||
|
<span className="text-sm font-bold text-gray-800">Lọc hình ảnh khiêu dâm (NSFW)</span>
|
||||||
|
<span className="text-xs text-gray-400 mt-0.5">Tự động phát hiện và chặn tải lên các hình ảnh có nội dung người lớn nhạy cảm.</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateModeration('blockNsfw', !moderationSetting.blockNsfw)}
|
||||||
|
className={`w-14 h-8 rounded-full transition-all relative p-1 cursor-pointer shrink-0 ${
|
||||||
|
moderationSetting.blockNsfw ? 'bg-blue-600' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-6 h-6 bg-white rounded-full shadow-md transition-all absolute top-1 ${
|
||||||
|
moderationSetting.blockNsfw ? 'right-1' : 'left-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 bg-white rounded-xl border border-gray-100 shadow-sm">
|
||||||
|
<div className="flex flex-col text-left">
|
||||||
|
<span className="text-sm font-bold text-gray-800">Tự động làm mờ khuôn mặt</span>
|
||||||
|
<span className="text-xs text-gray-400 mt-0.5">Tự động nhận diện khuôn mặt người trong ảnh để làm mờ bảo mật trước khi lưu trữ.</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateModeration('blurFaces', !moderationSetting.blurFaces)}
|
||||||
|
className={`w-14 h-8 rounded-full transition-all relative p-1 cursor-pointer shrink-0 ${
|
||||||
|
moderationSetting.blurFaces ? 'bg-blue-600' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-6 h-6 bg-white rounded-full shadow-md transition-all absolute top-1 ${
|
||||||
|
moderationSetting.blurFaces ? 'right-1' : 'left-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50/50 p-6 rounded-2xl border border-gray-100/80 space-y-4 text-left">
|
||||||
|
<h3 className="text-sm font-black uppercase text-blue-600 tracking-wider flex items-center gap-1.5">
|
||||||
|
📝 Cấu hình bộ lọc văn bản
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Add Word Filter Form */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 bg-white p-4 rounded-xl border border-gray-100 shadow-sm">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-[10px] uppercase font-black text-gray-400 mb-1">Từ cấm</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nhập từ cấm..."
|
||||||
|
value={newWord}
|
||||||
|
onChange={(e) => setNewWord(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 bg-gray-50/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-[10px] uppercase font-black text-gray-400 mb-1">Từ thay thế</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nhập từ thay thế..."
|
||||||
|
value={newReplacement}
|
||||||
|
onChange={(e) => setNewReplacement(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 bg-gray-50/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
onClick={handleAddWordFilter}
|
||||||
|
className="w-full sm:w-auto px-5 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-xl text-xs font-bold transition-all shadow-md active:scale-95 cursor-pointer h-[38px] flex items-center justify-center shrink-0"
|
||||||
|
>
|
||||||
|
Thêm bộ lọc
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Word Filter List */}
|
||||||
|
{wordFilters.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-400 italic text-xs">Chưa cấu hình bộ lọc từ khóa nào.</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-gray-100 rounded-2xl overflow-hidden shadow-sm bg-white">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-400 text-xs uppercase border-b border-gray-100 bg-gray-50/30">
|
||||||
|
<th className="py-2.5 px-4 font-bold">Từ cấm</th>
|
||||||
|
<th className="py-2.5 px-4 font-bold">Từ thay thế</th>
|
||||||
|
<th className="py-2.5 px-4 font-bold text-right">Thao tác</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{wordFilters.map((wf) => (
|
||||||
|
<tr key={wf.id} className="hover:bg-gray-50/30">
|
||||||
|
<td className="py-2.5 px-4 text-xs font-bold text-red-500">{wf.word}</td>
|
||||||
|
<td className="py-2.5 px-4 text-xs font-semibold text-green-600">{wf.replacement}</td>
|
||||||
|
<td className="py-2.5 px-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteWordFilter(wf.id)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
const loadScript = (src: string): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (document.querySelector(`script[src="${src}"]`)) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = src;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => reject(new Error(`Failed to load script ${src}`));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadModerationLibraries = async () => {
|
||||||
|
// Load TensorFlow first
|
||||||
|
await loadScript('https://cdn.jsdelivr.net/npm/@tensorflow/tfjs');
|
||||||
|
// Load models after tfjs is available
|
||||||
|
await Promise.all([
|
||||||
|
loadScript('https://cdn.jsdelivr.net/npm/@tensorflow-models/blazeface'),
|
||||||
|
loadScript('https://cdn.jsdelivr.net/npm/nsfwjs@2.4.0/dist/bundle.js')
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const processImageModeration = async (file: File): Promise<{ file: File; blocked: boolean }> => {
|
||||||
|
try {
|
||||||
|
const settingsRes = await fetch('/api/v1/moderation/settings');
|
||||||
|
if (!settingsRes.ok) return { file, blocked: false };
|
||||||
|
const settings = await settingsRes.json();
|
||||||
|
const { blockNsfw, blurFaces } = settings;
|
||||||
|
|
||||||
|
if (!blockNsfw && !blurFaces) {
|
||||||
|
return { file, blocked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadModerationLibraries();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
resolve({ file, blocked: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
if (blockNsfw) {
|
||||||
|
try {
|
||||||
|
const nsfwModel = await (window as any).nsfwjs.load();
|
||||||
|
const predictions = await nsfwModel.classify(canvas);
|
||||||
|
const pornOrHentai = predictions.find((p: any) => p.className === 'Porn' || p.className === 'Hentai');
|
||||||
|
const pornProb = pornOrHentai ? pornOrHentai.probability : 0;
|
||||||
|
if (pornProb > 0.5) {
|
||||||
|
resolve({ file, blocked: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('NSFW validation error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let modified = false;
|
||||||
|
if (blurFaces) {
|
||||||
|
try {
|
||||||
|
const blazefaceModel = await (window as any).blazeface.load();
|
||||||
|
const predictions = await blazefaceModel.estimateFaces(canvas, false);
|
||||||
|
if (predictions && predictions.length > 0) {
|
||||||
|
modified = true;
|
||||||
|
predictions.forEach((prediction: any) => {
|
||||||
|
const startX = prediction.topLeft[0];
|
||||||
|
const startY = prediction.topLeft[1];
|
||||||
|
const endX = prediction.bottomRight[0];
|
||||||
|
const endY = prediction.bottomRight[1];
|
||||||
|
const width = endX - startX;
|
||||||
|
const height = endY - startY;
|
||||||
|
|
||||||
|
const faceCanvas = document.createElement('canvas');
|
||||||
|
faceCanvas.width = width;
|
||||||
|
faceCanvas.height = height;
|
||||||
|
const faceCtx = faceCanvas.getContext('2d');
|
||||||
|
if (faceCtx) {
|
||||||
|
faceCtx.drawImage(canvas, startX, startY, width, height, 0, 0, width, height);
|
||||||
|
ctx.filter = 'blur(15px)';
|
||||||
|
ctx.drawImage(faceCanvas, startX, startY, width, height);
|
||||||
|
ctx.filter = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Face blur error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
const processedFile = new File([blob], file.name, { type: file.type });
|
||||||
|
resolve({ file: processedFile, blocked: false });
|
||||||
|
} else {
|
||||||
|
resolve({ file, blocked: false });
|
||||||
|
}
|
||||||
|
}, file.type);
|
||||||
|
} else {
|
||||||
|
resolve({ file, blocked: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
resolve({ file, blocked: false });
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Image moderation failed:', err);
|
||||||
|
return { file, blocked: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
return (localStorage.getItem('theme') as Theme) || 'system';
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyTheme = (currentTheme: Theme) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
|
||||||
|
if (currentTheme === 'dark') {
|
||||||
|
root.classList.add('dark');
|
||||||
|
root.style.colorScheme = 'dark';
|
||||||
|
} else if (currentTheme === 'light') {
|
||||||
|
root.classList.add('light');
|
||||||
|
root.style.colorScheme = 'light';
|
||||||
|
} else {
|
||||||
|
// System
|
||||||
|
const systemIsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
if (systemIsDark) {
|
||||||
|
root.classList.add('dark');
|
||||||
|
root.style.colorScheme = 'dark';
|
||||||
|
} else {
|
||||||
|
root.classList.add('light');
|
||||||
|
root.style.colorScheme = 'light';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeTheme = (newTheme: Theme) => {
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
setTheme(newTheme);
|
||||||
|
applyTheme(newTheme);
|
||||||
|
window.dispatchEvent(new Event('themeChange'));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme);
|
||||||
|
|
||||||
|
// Listen for system theme changes if theme is set to 'system'
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handleSystemThemeChange = () => {
|
||||||
|
if (localStorage.getItem('theme') === 'system' || !localStorage.getItem('theme')) {
|
||||||
|
applyTheme('system');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handleSystemThemeChange);
|
||||||
|
|
||||||
|
const handleStorageChange = () => {
|
||||||
|
const storedTheme = (localStorage.getItem('theme') as Theme) || 'system';
|
||||||
|
setTheme(storedTheme);
|
||||||
|
applyTheme(storedTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('themeChange', handleStorageChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener('change', handleSystemThemeChange);
|
||||||
|
window.removeEventListener('themeChange', handleStorageChange);
|
||||||
|
};
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return { theme, changeTheme };
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export type Language = 'vi' | 'en' | 'zh';
|
||||||
|
|
||||||
|
const translations: Record<string, Record<Language, string>> = {
|
||||||
|
// Common
|
||||||
|
appName: { vi: 'Travel Planner', en: 'Travel Planner', zh: '旅行规划' },
|
||||||
|
login: { vi: 'Đăng nhập', en: 'Log In', zh: '登录' },
|
||||||
|
signup: { vi: 'Đăng ký', en: 'Sign Up', zh: '注册' },
|
||||||
|
logout: { vi: 'Đăng xuất', en: 'Log Out', zh: '登出' },
|
||||||
|
cancel: { vi: 'Hủy', en: 'Cancel', zh: '取消' },
|
||||||
|
confirm: { vi: 'Xác nhận', en: 'Confirm', zh: '确认' },
|
||||||
|
save: { vi: 'Lưu', en: 'Save', zh: '保存' },
|
||||||
|
saving: { vi: 'Đang lưu...', en: 'Saving...', zh: '保存中...' },
|
||||||
|
loading: { vi: 'Đang tải...', en: 'Loading...', zh: '加载中...' },
|
||||||
|
success: { vi: 'Thành công', en: 'Success', zh: '成功' },
|
||||||
|
error: { vi: 'Lỗi', en: 'Error', zh: '错误' },
|
||||||
|
info: { vi: 'Thông tin', en: 'Info', zh: '信息' },
|
||||||
|
delete: { vi: 'Xóa', en: 'Delete', zh: '删除' },
|
||||||
|
edit: { vi: 'Chỉnh sửa', en: 'Edit', zh: '编辑' },
|
||||||
|
|
||||||
|
// Landing Page
|
||||||
|
welcomeBack: { vi: 'Chào mừng bạn quay trở lại!', en: 'Welcome back!', zh: '欢迎回来!' },
|
||||||
|
emailLabel: { vi: 'Email', en: 'Email', zh: '邮箱' },
|
||||||
|
passwordLabel: { vi: 'Mật khẩu', en: 'Password', zh: '密码' },
|
||||||
|
forgotPassword: { vi: 'Quên mật khẩu?', en: 'Forgot password?', zh: '忘记密码?' },
|
||||||
|
orLabel: { vi: 'Hoặc', en: 'Or', zh: '或' },
|
||||||
|
noAccount: { vi: 'Chưa có tài khoản?', en: "Don't have an account?", zh: '还没有账号?' },
|
||||||
|
createAccountNow: { vi: 'Tạo tài khoản ngay', en: 'Create one now', zh: '立即注册' },
|
||||||
|
quickCamera: { vi: 'Chụp ảnh nhanh', en: 'Quick Camera', zh: '快速相机' },
|
||||||
|
momentsTitle: { vi: 'Khoảnh khắc cộng đồng', en: 'Community Moments', zh: '社区精彩瞬间' },
|
||||||
|
trustedMembers: { vi: 'Thành viên uy tín', en: 'Trusted Members', zh: '信用会员' },
|
||||||
|
exploreToursBtn: { vi: 'Khám phá các hành trình du lịch', en: 'Explore Travel Itineraries', zh: '探索旅行行程' },
|
||||||
|
|
||||||
|
// Explore Map
|
||||||
|
systemBtn: { vi: 'Hệ thống', en: 'System', zh: '系统管理' },
|
||||||
|
createTourBtn: { vi: 'Tạo Tour', en: 'Create Tour', zh: '创建行程' },
|
||||||
|
chooseLocationMap: { vi: 'Chọn vị trí trên bản đồ', en: 'Choose location on map', zh: '在地图上选择位置' },
|
||||||
|
clickMapSelectCoords: { vi: 'Click lên bản đồ để chọn tọa độ', en: 'Click on map to select coordinates', zh: '在地图上点击以选择坐标' },
|
||||||
|
coordsLabel: { vi: 'Tọa độ', en: 'Coordinates', zh: '坐标' },
|
||||||
|
noCoordsSelected: { vi: 'Chưa chọn vị trí', en: 'No location selected', zh: '未选择位置' },
|
||||||
|
searchPlaceholder: { vi: 'Tìm kiếm địa điểm...', en: 'Search places...', zh: '搜索地点...' },
|
||||||
|
|
||||||
|
// Itinerary Timeline
|
||||||
|
legLabel: { vi: 'Chặng', en: 'Leg', zh: '航段' },
|
||||||
|
legSequence: { vi: 'Chi tiết Chặng', en: 'Leg Details', zh: '航段详情' },
|
||||||
|
startDate: { vi: 'Bắt đầu', en: 'Start Date', zh: '开始日期' },
|
||||||
|
endDate: { vi: 'Kết thúc', en: 'End Date', zh: '结束日期' },
|
||||||
|
addLocation: { vi: 'Thêm địa điểm', en: 'Add Location', zh: '添加地点' },
|
||||||
|
dwellTime: { vi: 'Thời gian dừng', en: 'Dwell Time', zh: '停留时间' },
|
||||||
|
expenseLabel: { vi: 'Chi phí', en: 'Expense', zh: '费用' },
|
||||||
|
paidByLabel: { vi: 'Người chi trả', en: 'Paid By', zh: '付款人' },
|
||||||
|
optimizeBtn: { vi: 'Tối ưu', en: 'Optimize', zh: '优化' },
|
||||||
|
startPoint: { vi: 'Điểm bắt đầu', en: 'Start Point', zh: '起点' },
|
||||||
|
endPoint: { vi: 'Điểm kết thúc', en: 'End Point', zh: '终点' },
|
||||||
|
pinStartHelper: { vi: 'Nhấn để ghim điểm bắt đầu cho Tour...', en: 'Click to pin starting point...', zh: '点击锁定行程起点...' },
|
||||||
|
pinEndHelper: { vi: 'Nhấn để ghim điểm kết thúc cho Tour...', en: 'Click to pin ending point...', zh: '点击锁定行程终点...' },
|
||||||
|
noLegs: { vi: 'Chưa có chặng nào trong lộ trình.', en: 'No legs in the itinerary yet.', zh: '行程中暂无航段。' },
|
||||||
|
declareLegsBtn: { vi: 'Khai báo số chặng', en: 'Declare Leg Count', zh: '申报航段数' },
|
||||||
|
addSingleLeg: { vi: 'Thêm chặng lẻ vào cuối', en: 'Add leg to end', zh: '末尾添加单个航段' },
|
||||||
|
exportPDF: { vi: 'Xuất PDF', en: 'Export PDF', zh: '导出 PDF' },
|
||||||
|
|
||||||
|
// Tour Detail / Organizer Rating
|
||||||
|
rateOrganizer: { vi: 'Đánh giá ban tổ chức', en: 'Rate Organizer', zh: '评估组织者' },
|
||||||
|
honesty: { vi: 'Trung thực', en: 'Honesty', zh: '诚实度' },
|
||||||
|
transparency: { vi: 'Minh bạch', en: 'Transparency', zh: '透明度' },
|
||||||
|
enthusiasm: { vi: 'Nhiệt tình', en: 'Enthusiasm', zh: '热情度' },
|
||||||
|
cheerfulness: { vi: 'Vui vẻ', en: 'Cheerfulness', zh: '愉快度' },
|
||||||
|
seriousness: { vi: 'Nhiêm túc', en: 'Seriousness', zh: '认真度' },
|
||||||
|
planning: { vi: 'Có kế hoạch', en: 'Planning Skills', zh: '计划性' },
|
||||||
|
survival: { vi: 'Kỹ năng sinh tồn', en: 'Survival Skills', zh: '生存技能' },
|
||||||
|
rateTitle: { vi: 'Đánh giá Người tạo Tour', en: 'Rate Tour Creator', zh: '评价行程发起人' },
|
||||||
|
rateCommentPlaceholder: { vi: 'Nhập ý kiến đánh giá khác...', en: 'Enter other comments...', zh: '输入其他评价...' },
|
||||||
|
emergencyShare: { vi: 'Chia sẻ khẩn cấp', en: 'Emergency Share', zh: '紧急分享' },
|
||||||
|
emergencyShareTooltip: { vi: 'Bật chia sẻ để người thân có thể định vị bạn khi khẩn cấp', en: 'Enable sharing so family can locate you in emergencies', zh: '开启分享以便家人在紧急情况下定位您' },
|
||||||
|
copyShareLink: { vi: 'Sao chép liên kết chia sẻ', en: 'Copy share link', zh: '复制分享链接' },
|
||||||
|
|
||||||
|
// User Management / Moderation
|
||||||
|
tabUsers: { vi: 'Người dùng', en: 'Users', zh: '用户管理' },
|
||||||
|
tabPhotos: { vi: 'Ảnh công khai', en: 'Public Photos', zh: '公开照片' },
|
||||||
|
tabTrash: { vi: 'Ảnh rác', en: 'Trash Photos', zh: '垃圾照片' },
|
||||||
|
tabFilters: { vi: 'Bộ lọc', en: 'Filters', zh: '过滤器' },
|
||||||
|
filterNsfwToggle: { vi: 'Lọc hình ảnh khiêu dâm', en: 'Block NSFW Images', zh: '过滤淫秽图片' },
|
||||||
|
filterFaceBlurToggle: { vi: 'Làm mờ khuôn mặt', en: 'Automatic Face Blur', zh: '自动模糊人脸' },
|
||||||
|
wordFiltersTitle: { vi: 'Từ khóa cấm & Thay thế', en: 'Banned Words & Replacements', zh: '禁用词及替换词' },
|
||||||
|
addWordBtn: { vi: 'Thêm từ khóa', en: 'Add Word', zh: '添加词汇' },
|
||||||
|
wordLabel: { vi: 'Từ cấm', en: 'Banned Word', zh: '敏感词' },
|
||||||
|
replacementLabel: { vi: 'Từ thay thế', en: 'Replacement', zh: '替换词' },
|
||||||
|
commentSectionTitle: { vi: 'Bình luận cộng đồng', en: 'Community Comments', zh: '社区评论' },
|
||||||
|
|
||||||
|
// Dashboard / General Settings
|
||||||
|
myItineraries: { vi: 'Hành trình của tôi', en: 'My Itineraries', zh: '我的行程' },
|
||||||
|
chatMenu: { vi: 'Trò chuyện', en: 'Chat', zh: '聊天' },
|
||||||
|
friendsMenu: { vi: 'Danh sách bạn bè', en: 'Friends List', zh: '好友列表' },
|
||||||
|
muteNotifications: { vi: 'Tắt thông báo đẩy', en: 'Mute Notifications', zh: '关闭推送通知' },
|
||||||
|
unmuteNotifications: { vi: 'Bật thông báo đẩy', en: 'Unmute Notifications', zh: '开启推送通知' },
|
||||||
|
enterSecretKey: { vi: 'Nhập Admin Secret Key để mở khóa', en: 'Enter Admin Secret Key to unlock', zh: '输入管理员密钥解锁' },
|
||||||
|
invalidSecretKey: { vi: 'Mã Secret Key không hợp lệ', en: 'Invalid Secret Key', zh: '密钥无效' },
|
||||||
|
languageSelect: { vi: 'Ngôn ngữ', en: 'Language', zh: '语言' },
|
||||||
|
themeSelect: { vi: 'Giao diện', en: 'Theme', zh: '主题' },
|
||||||
|
themeLight: { vi: 'Sáng', en: 'Light', zh: '浅色' },
|
||||||
|
themeDark: { vi: 'Tối', en: 'Dark', zh: '深色' },
|
||||||
|
themeSystem: { vi: 'Hệ thống', en: 'System', zh: '跟随系统' },
|
||||||
|
|
||||||
|
// Emergency Share Journey Page
|
||||||
|
emergencyContacts: { vi: 'Liên hệ khẩn cấp', en: 'Emergency Contacts', zh: '紧急联系人' },
|
||||||
|
tourOwner: { vi: 'Người tạo Tour (Owner)', en: 'Tour Owner', zh: '发起人' },
|
||||||
|
tourManager: { vi: 'Người quản lý (Manager)', en: 'Tour Manager', zh: '管理员' },
|
||||||
|
phoneNumber: { vi: 'Số điện thoại', en: 'Phone Number', zh: '电话号码' },
|
||||||
|
emergencyJourney: { vi: 'Hành trình Cứu hộ Khẩn cấp', en: 'Emergency Rescue Journey', zh: '紧急救援行程' },
|
||||||
|
noPhone: { vi: 'Không có số điện thoại', en: 'No phone number', zh: '暂无电话' },
|
||||||
|
noStops: { vi: 'Chưa có điểm dừng nào', en: 'No stops declared', zh: '暂无停留点' },
|
||||||
|
viewMap: { vi: 'Bản đồ', en: 'Map', zh: '地图' },
|
||||||
|
viewTimeline: { vi: 'Lịch trình', en: 'Itinerary', zh: '行程表' },
|
||||||
|
sharedJourneyTitle: { vi: 'Hành trình chia sẻ khẩn cấp', en: 'Emergency Shared Journey', zh: '紧急分享行程' },
|
||||||
|
linkExpired: { vi: 'Liên kết không tồn tại hoặc đã bị vô hiệu hóa.', en: 'Link does not exist or has been disabled.', zh: '链接不存在或已被禁用。' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTranslation = () => {
|
||||||
|
const [lang, setLang] = useState<Language>(() => {
|
||||||
|
return (localStorage.getItem('language') as Language) || 'vi';
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeLanguage = (newLang: Language) => {
|
||||||
|
localStorage.setItem('language', newLang);
|
||||||
|
setLang(newLang);
|
||||||
|
// Dispatch custom event to sync across components
|
||||||
|
window.dispatchEvent(new Event('languageChange'));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleLangChange = () => {
|
||||||
|
setLang((localStorage.getItem('language') as Language) || 'vi');
|
||||||
|
};
|
||||||
|
window.addEventListener('languageChange', handleLangChange);
|
||||||
|
return () => window.removeEventListener('languageChange', handleLangChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const t = (key: string): string => {
|
||||||
|
if (!translations[key]) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
return translations[key][lang];
|
||||||
|
};
|
||||||
|
|
||||||
|
return { t, lang, changeLanguage };
|
||||||
|
};
|
||||||
@@ -5,11 +5,13 @@ const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerCluste
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft } from 'lucide-react';
|
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft, Lock, Globe, Sun, Moon, Laptop, Users } from 'lucide-react';
|
||||||
import { UserManagementModal } from '@/components/UserManagementModal';
|
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||||
import { useNotification } from '@/hooks/useNotification';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { useConfirm } from '@/hooks/useConfirm';
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
import { CreateTourModal } from '../components/CreateTourModal';
|
import { CreateTourModal } from '../components/CreateTourModal';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { PublicPhotoModal } from '../components/PublicPhotoModal';
|
import { PublicPhotoModal } from '../components/PublicPhotoModal';
|
||||||
|
|
||||||
// Fix lỗi icon mặc định của Leaflet
|
// Fix lỗi icon mặc định của Leaflet
|
||||||
@@ -67,6 +69,36 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
|
|
||||||
const notify = useNotification();
|
const notify = useNotification();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
const { t, lang, changeLanguage } = useTranslation();
|
||||||
|
const { theme, changeTheme } = useTheme();
|
||||||
|
|
||||||
|
const handlePromoteAdmin = async (secretKey: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/promote-admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ secretKey })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
const updatedUser = { ...user, isAdmin: true };
|
||||||
|
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||||
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess(updatedUser);
|
||||||
|
}
|
||||||
|
notify({ title: t('success'), message: 'Đã kích hoạt quyền quản trị thành công!', type: 'success' });
|
||||||
|
setIsAdminModalOpen(true);
|
||||||
|
} else {
|
||||||
|
notify({ title: t('error'), message: data.message || t('invalidSecretKey'), type: 'error' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
notify({ title: t('error'), message: 'Lỗi mạng khi kích hoạt Admin.', type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Refs for mobile long-press detection
|
// Refs for mobile long-press detection
|
||||||
const touchTimerRef = React.useRef<any>(null);
|
const touchTimerRef = React.useRef<any>(null);
|
||||||
@@ -193,6 +225,24 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
};
|
};
|
||||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||||||
|
const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false);
|
||||||
|
|
||||||
|
const fetchTrustedUsers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/users/trusted');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setTrustedUsers(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Lỗi khi tải thành viên uy tín:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTrustedUsers();
|
||||||
|
}, []);
|
||||||
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
|
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
|
||||||
const [searchQuery, setSearchQuery] = useState(''); // State cho giá trị tìm kiếm
|
const [searchQuery, setSearchQuery] = useState(''); // State cho giá trị tìm kiếm
|
||||||
const [suggestions, setSuggestions] = useState<{ type: 'tour' | 'location', id: string, name: string, lat?: number, lon?: number }[]>([]);
|
const [suggestions, setSuggestions] = useState<{ type: 'tour' | 'location', id: string, name: string, lat?: number, lon?: number }[]>([]);
|
||||||
@@ -515,15 +565,58 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Lựa chọn Ngôn ngữ */}
|
||||||
|
<div className="relative group shrink-0">
|
||||||
|
<button className="w-11 h-11 bg-white dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 transition-all flex items-center justify-center border border-gray-100 dark:border-slate-700">
|
||||||
|
<Globe className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-white dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
|
||||||
|
<button onClick={() => changeLanguage('vi')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'vi' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>Tiếng Việt</button>
|
||||||
|
<button onClick={() => changeLanguage('en')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'en' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>English</button>
|
||||||
|
<button onClick={() => changeLanguage('zh')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'zh' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>中文</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lựa chọn Giao diện */}
|
||||||
|
<div className="relative group shrink-0">
|
||||||
|
<button className="w-11 h-11 bg-white dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 transition-all flex items-center justify-center border border-gray-100 dark:border-slate-700">
|
||||||
|
{theme === 'light' && <Sun className="w-5 h-5 text-amber-500" />}
|
||||||
|
{theme === 'dark' && <Moon className="w-5 h-5 text-indigo-400" />}
|
||||||
|
{theme === 'system' && <Laptop className="w-5 h-5 animate-pulse" />}
|
||||||
|
</button>
|
||||||
|
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-white dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
|
||||||
|
<button onClick={() => changeTheme('light')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'light' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>
|
||||||
|
<Sun className="w-3.5 h-3.5 text-amber-500" /> {t('themeLight')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => changeTheme('dark')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'dark' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>
|
||||||
|
<Moon className="w-3.5 h-3.5 text-indigo-400" /> {t('themeDark')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => changeTheme('system')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'system' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>
|
||||||
|
<Laptop className="w-3.5 h-3.5" /> {t('themeSystem')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Nút quản lý người dùng cho Admin */}
|
{/* Nút quản lý người dùng cho Admin */}
|
||||||
{user?.isAdmin && (
|
{user && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsAdminModalOpen(true)}
|
onClick={() => {
|
||||||
className="w-11 h-11 md:w-auto bg-blue-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
|
if (user.isAdmin) {
|
||||||
title="Quản lý hệ thống"
|
setIsAdminModalOpen(true);
|
||||||
|
} else {
|
||||||
|
const key = window.prompt(t('enterSecretKey'));
|
||||||
|
if (key) {
|
||||||
|
handlePromoteAdmin(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`w-11 h-11 md:w-auto p-0 md:px-4 md:py-3 rounded-2xl shadow-xl transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 ${
|
||||||
|
user.isAdmin ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-slate-700 hover:bg-slate-800 text-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-750 dark:border-slate-700 border border-slate-600'
|
||||||
|
}`}
|
||||||
|
title={t('systemBtn')}
|
||||||
>
|
>
|
||||||
<Settings className="w-5 h-5" />
|
{user.isAdmin ? <Settings className="w-5 h-5" /> : <Lock className="w-5 h-5" />}
|
||||||
<span className="hidden md:inline text-sm">Hệ thống</span>
|
<span className="hidden md:inline text-sm">{t('systemBtn')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -832,6 +925,46 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Floating Trusted Leaderboard Panel (Bottom Left) */}
|
||||||
|
<div className="absolute bottom-6 left-6 z-[1002] pointer-events-auto flex flex-col items-start gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsLeaderboardOpen(prev => !prev)}
|
||||||
|
className="bg-white dark:bg-slate-900 border border-gray-150 dark:border-slate-800 text-slate-800 dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95"
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4 text-amber-500" />
|
||||||
|
<span>{t('trustedMembers')} ({trustedUsers.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isLeaderboardOpen && (
|
||||||
|
<div className="bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-gray-100 dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
|
||||||
|
<h4 className="text-xs font-black uppercase text-amber-600 dark:text-amber-500 tracking-widest mb-3 flex items-center gap-2">
|
||||||
|
🏆 {t('trustedMembers')}
|
||||||
|
</h4>
|
||||||
|
{trustedUsers.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400 italic">Chưa có thành viên nào được đánh giá.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{trustedUsers.map((u, idx) => (
|
||||||
|
<div key={u.id} className="flex items-center justify-between gap-3 p-2 bg-gray-50/50 dark:bg-slate-850/50 rounded-xl border border-gray-100/50 dark:border-slate-800/50">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="w-5 h-5 font-bold text-[10px] text-gray-500 flex items-center justify-center bg-gray-100 dark:bg-slate-800 rounded-lg">
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-gray-800 dark:text-slate-200 truncate">{u.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[11px] font-bold text-amber-500 shrink-0">
|
||||||
|
<span>★</span>
|
||||||
|
<span>{u.averageScore}</span>
|
||||||
|
<span className="text-[9px] text-gray-400 font-medium">({u.ratingCount})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,9 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-react';
|
import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-react';
|
||||||
import { LoginModal } from '../components/LoginModal';
|
import { LoginModal } from '../components/LoginModal';
|
||||||
import { useNotification } from '@/hooks/useNotification';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
|
import { processImageModeration } from '../hooks/useImageModeration';
|
||||||
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
|
||||||
interface LandingPageProps {
|
interface LandingPageProps {
|
||||||
onContinue?: () => void;
|
onContinue?: () => void;
|
||||||
@@ -41,8 +44,11 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
|||||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const notify = useNotification();
|
const notify = useNotification();
|
||||||
|
const { t, lang, changeLanguage } = useTranslation();
|
||||||
|
const { theme, changeTheme } = useTheme();
|
||||||
|
|
||||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||||
|
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||||||
const [currentBgIndex, setCurrentBgIndex] = useState(0);
|
const [currentBgIndex, setCurrentBgIndex] = useState(0);
|
||||||
const [bg1, setBg1] = useState('/background.avif');
|
const [bg1, setBg1] = useState('/background.avif');
|
||||||
const [bg2, setBg2] = useState('');
|
const [bg2, setBg2] = useState('');
|
||||||
@@ -87,8 +93,21 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchTrustedUsers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/users/trusted');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setTrustedUsers(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Lỗi khi tải thành viên uy tín:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPublicPhotos();
|
fetchPublicPhotos();
|
||||||
|
fetchTrustedUsers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,6 +125,14 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
|||||||
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 0. Kiểm duyệt ảnh
|
||||||
|
const moderationResult = await processImageModeration(file);
|
||||||
|
if (moderationResult.blocked) {
|
||||||
|
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const processedFile = moderationResult.file;
|
||||||
|
|
||||||
// Lấy tọa độ hiện tại của người dùng với cơ chế chống treo (Promise.race)
|
// Lấy tọa độ hiện tại của người dùng với cơ chế chống treo (Promise.race)
|
||||||
const location = await Promise.race([
|
const location = await Promise.race([
|
||||||
new Promise<GeolocationPosition | null>((resolve) => {
|
new Promise<GeolocationPosition | null>((resolve) => {
|
||||||
@@ -138,7 +165,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
|||||||
|
|
||||||
// 2. Tải ảnh lên
|
// 2. Tải ảnh lên
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('images', file);
|
formData.append('images', processedFile);
|
||||||
if (location) {
|
if (location) {
|
||||||
formData.append('latitude', location.coords.latitude.toString());
|
formData.append('latitude', location.coords.latitude.toString());
|
||||||
formData.append('longitude', location.coords.longitude.toString());
|
formData.append('longitude', location.coords.longitude.toString());
|
||||||
@@ -247,19 +274,98 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
|||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* Top Bar - Thanh điều hướng trên cùng */}
|
{/* Top Bar - Thanh điều hướng trên cùng */}
|
||||||
<div className="absolute top-0 left-0 right-0 z-20 p-4 pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center">
|
<div className="absolute top-0 left-0 right-0 z-20 p-4 pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center bg-gradient-to-b from-slate-950/40 to-transparent">
|
||||||
<div className="flex items-center gap-2 text-white drop-shadow-lg">
|
<div className="flex items-center gap-2 text-white drop-shadow-lg">
|
||||||
<Compass className="w-8 h-8" />
|
<Compass className="w-8 h-8" />
|
||||||
<span className="text-xl font-black tracking-tighter uppercase hidden sm:block">Travel Planner</span>
|
<span className="text-xl font-black tracking-tighter uppercase hidden sm:block">YoTrip</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Language Selector */}
|
||||||
|
<select
|
||||||
|
value={lang}
|
||||||
|
onChange={(e) => changeLanguage(e.target.value as any)}
|
||||||
|
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-3 py-1.5 text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="vi" className="text-black">Tiếng Việt</option>
|
||||||
|
<option value="en" className="text-black">English</option>
|
||||||
|
<option value="zh" className="text-black">中文</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Theme Selector */}
|
||||||
|
<select
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => changeTheme(e.target.value as any)}
|
||||||
|
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-3 py-1.5 text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="light" className="text-black">{t('themeLight') || 'Sáng'}</option>
|
||||||
|
<option value="dark" className="text-black">{t('themeDark') || 'Tối'}</option>
|
||||||
|
<option value="system" className="text-black">{t('themeSystem') || 'Hệ thống'}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsLoginModalOpen(true)}
|
onClick={() => setIsLoginModalOpen(true)}
|
||||||
className="flex items-center justify-center gap-2 bg-white/10 backdrop-blur-md text-white font-bold py-2 px-4 rounded-full border border-white/20 hover:bg-white/20 transition-all"
|
className="flex items-center justify-center gap-2 bg-white/15 backdrop-blur-md text-white font-bold py-2 px-4 rounded-full border border-white/25 hover:bg-white/25 transition-all shadow-md active:scale-95"
|
||||||
>
|
>
|
||||||
<LogIn className="w-4 h-4" />
|
<LogIn className="w-4 h-4" />
|
||||||
<span className="text-sm">Đăng nhập</span>
|
<span className="text-sm">{t('login')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Trusted Leaderboard Panel (Left Side on Desktop) */}
|
||||||
|
<div className="absolute left-6 top-24 bottom-36 z-20 hidden md:flex flex-col w-72 bg-slate-900/60 backdrop-blur-md border border-white/10 rounded-[32px] p-5 text-white overflow-hidden shadow-2xl animate-in slide-in-from-left duration-500">
|
||||||
|
<h3 className="text-base font-black flex items-center gap-2 mb-3 border-b border-white/10 pb-2">
|
||||||
|
🏆 {t('trustedMembers')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3 pr-1 no-scrollbar">
|
||||||
|
{trustedUsers.length === 0 ? (
|
||||||
|
<div className="text-xs text-white/50 italic py-4">Chưa có đánh giá nào.</div>
|
||||||
|
) : (
|
||||||
|
trustedUsers.map((u, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-3 bg-white/5 hover:bg-white/10 p-2.5 rounded-2xl border border-white/5 transition-all">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-slate-800 border border-white/10 flex items-center justify-center font-bold overflow-hidden">
|
||||||
|
{u.avatar ? (
|
||||||
|
<img src={u.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span>{u.name.charAt(0)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="absolute -top-1 -left-1 bg-amber-500 text-[10px] text-white font-black px-1.5 py-0.5 rounded-full border border-slate-950">
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-bold truncate">{u.name}</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="text-[10px] text-amber-400 font-bold">★ {u.averageScore}</span>
|
||||||
|
<span className="text-[9px] text-white/40 font-semibold">({u.ratingCount})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Only: Trusted Members Top Bar */}
|
||||||
|
{trustedUsers.length > 0 && (
|
||||||
|
<div className="absolute top-24 left-4 right-4 z-20 md:hidden flex flex-col gap-1 bg-slate-950/45 backdrop-blur-sm p-2 rounded-2xl border border-white/5">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-wider text-white/70 px-1">
|
||||||
|
🏆 {t('trustedMembers')}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2 overflow-x-auto no-scrollbar py-0.5">
|
||||||
|
{trustedUsers.slice(0, 5).map((u, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-1.5 bg-slate-950/60 border border-white/10 px-2.5 py-1 rounded-full shrink-0">
|
||||||
|
<span className="text-[9px] font-bold text-amber-400">#{idx + 1}</span>
|
||||||
|
<span className="text-[10px] font-bold text-white truncate max-w-[70px]">{u.name}</span>
|
||||||
|
<span className="text-[9px] text-amber-400 font-bold">★ {u.averageScore}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Body: Animation người lữ hành */}
|
{/* Body: Animation người lữ hành */}
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-10" >
|
<div className="absolute inset-0 flex items-center justify-center z-10" >
|
||||||
@@ -279,20 +385,24 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
|||||||
|
|
||||||
{/* Community Gallery Previews */}
|
{/* Community Gallery Previews */}
|
||||||
{publicPhotos.length > 0 && (
|
{publicPhotos.length > 0 && (
|
||||||
<div className="absolute bottom-[calc(7rem+env(safe-area-inset-bottom,0px))] left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
|
<div className="absolute bottom-[calc(7.5rem+env(safe-area-inset-bottom,0px))] left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-white/70 drop-shadow-md">
|
<span className="text-xs font-bold uppercase tracking-wider text-white/70 drop-shadow-md">
|
||||||
Khoảnh khắc từ cộng đồng ({publicPhotos.length})
|
{t('momentsTitle')} ({publicPhotos.length})
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-2 px-4 justify-center">
|
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-2 px-4 justify-center">
|
||||||
{publicPhotos.slice(0, 8).map((photo, index) => (
|
{publicPhotos.slice(0, 8).map((photo, index) => (
|
||||||
<button
|
<button
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
onClick={() => setCurrentBgIndex(index)}
|
onClick={() => setCurrentBgIndex(index)}
|
||||||
className={`relative w-14 h-14 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
|
className={`relative w-14 h-14 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 shadow-inner bg-slate-800 ${
|
||||||
currentBgIndex === index ? 'border-emerald-500 scale-110 shadow-lg' : 'border-white/20 hover:border-white/50'
|
currentBgIndex === index ? 'scale-110 shadow-lg' : 'hover:scale-105'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<img src={photo.imageUrl} alt="Community thumbnail" className="w-full h-full object-cover" />
|
<img src={photo.imageUrl} alt="Community thumbnail" className="w-full h-full object-cover" />
|
||||||
|
{/* Border Overlay absolute to prevent border clipping or corner overlap */}
|
||||||
|
<div className={`absolute inset-0 rounded-xl border-2 pointer-events-none transition-colors ${
|
||||||
|
currentBgIndex === index ? 'border-emerald-500' : 'border-white/20'
|
||||||
|
}`} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -310,13 +420,21 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom Bar - Nút hành động chính */}
|
{/* Bottom Bar - Nút hành động chính */}
|
||||||
<div className="absolute bottom-[calc(2rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4">
|
<div className="absolute bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4 flex flex-col sm:flex-row gap-3 items-center justify-center max-w-lg">
|
||||||
<button
|
<button
|
||||||
onClick={onGoToMap}
|
onClick={onGoToMap}
|
||||||
className="w-full max-w-md mx-auto flex items-center justify-center gap-3 bg-white/20 backdrop-blur-lg text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-2xl border border-white/30 hover:bg-white/30 active:scale-95"
|
className="w-full flex items-center justify-center gap-3 bg-emerald-600/90 text-white font-bold py-3.5 px-6 rounded-2xl transition-all shadow-2xl border border-emerald-500/30 hover:bg-emerald-500 hover:scale-[1.02] active:scale-95 text-sm"
|
||||||
>
|
>
|
||||||
<MapIcon className="w-5 h-5" />
|
<MapIcon className="w-5 h-5" />
|
||||||
Khám phá các hành trình du lịch
|
{t('exploreToursBtn')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="w-full flex items-center justify-center gap-3 bg-white/20 backdrop-blur-lg text-white font-bold py-3.5 px-6 rounded-2xl transition-all shadow-2xl border border-white/30 hover:bg-white/30 hover:scale-[1.02] active:scale-95 text-sm"
|
||||||
|
>
|
||||||
|
<Camera className="w-5 h-5" />
|
||||||
|
{t('quickCamera')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,542 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import {
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
ShieldAlert,
|
||||||
|
ChevronLeft,
|
||||||
|
LocateFixed,
|
||||||
|
Loader2,
|
||||||
|
PhoneCall,
|
||||||
|
Compass,
|
||||||
|
Map as MapIcon,
|
||||||
|
List
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParticipantInfo {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
displayName: string | null;
|
||||||
|
user: UserInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
plannedStart: string | null;
|
||||||
|
plannedEnd: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegInfo {
|
||||||
|
id: string;
|
||||||
|
sequence: number;
|
||||||
|
note: string | null;
|
||||||
|
description: string | null;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
locations: LocationInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TourInfo {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
creator: UserInfo;
|
||||||
|
participants: ParticipantInfo[];
|
||||||
|
legs: LegInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShareJourneyPageProps {
|
||||||
|
token: string;
|
||||||
|
onGoToHome: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component to dynamically recenter map to a location coordinate
|
||||||
|
const MapRecenter = ({ position }: { position: [number, number] | null }) => {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (position) {
|
||||||
|
map.setView(position, 15, { animate: true });
|
||||||
|
}
|
||||||
|
}, [position, map]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component to adjust bounds to fit all stop locations
|
||||||
|
const MapBoundsFit = ({ locations, trigger }: { locations: LocationInfo[]; trigger: number }) => {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (locations.length > 0) {
|
||||||
|
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
|
||||||
|
if (locations.length === 1) {
|
||||||
|
map.setView([locations[0].latitude, locations[0].longitude], 14, { animate: true });
|
||||||
|
} else {
|
||||||
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [locations, trigger, map]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoToHome }) => {
|
||||||
|
const { t, lang, changeLanguage } = useTranslation();
|
||||||
|
const { theme, changeTheme } = useTheme();
|
||||||
|
|
||||||
|
const [tour, setTour] = useState<TourInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [focusedLocation, setFocusedLocation] = useState<[number, number] | null>(null);
|
||||||
|
const [fitBoundsTrigger, setFitBoundsTrigger] = useState(0);
|
||||||
|
const [activeTab, setActiveTab] = useState<'map' | 'itinerary'>('map');
|
||||||
|
|
||||||
|
// Viewer's own GPS tracking
|
||||||
|
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
|
||||||
|
const [isLocating, setIsLocating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSharedTour = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch(`/api/v1/tours/share/${token}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Not found or disabled');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setTour(data.tour);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
setError('expired_or_not_found');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
fetchSharedTour();
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const allLocations = useMemo(() => {
|
||||||
|
if (!tour) return [];
|
||||||
|
const locs: LocationInfo[] = [];
|
||||||
|
tour.legs.forEach(leg => {
|
||||||
|
leg.locations.forEach(loc => {
|
||||||
|
locs.push(loc);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return locs;
|
||||||
|
}, [tour]);
|
||||||
|
|
||||||
|
const ownerContact = tour?.creator;
|
||||||
|
const managerContacts = useMemo(() => {
|
||||||
|
if (!tour) return [];
|
||||||
|
return tour.participants
|
||||||
|
.filter(p => p.role === 'MANAGER' && p.user)
|
||||||
|
.map(p => ({
|
||||||
|
name: p.displayName || p.user?.name || '',
|
||||||
|
phone: p.user?.phone || ''
|
||||||
|
}))
|
||||||
|
.filter(c => c.phone);
|
||||||
|
}, [tour]);
|
||||||
|
|
||||||
|
const handleLocateMe = () => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
alert('Trình duyệt của bạn không hỗ trợ định vị.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLocating(true);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
const coords: [number, number] = [position.coords.latitude, position.coords.longitude];
|
||||||
|
setUserLocation(coords);
|
||||||
|
setFocusedLocation(coords);
|
||||||
|
setIsLocating(false);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
console.error(err);
|
||||||
|
setIsLocating(false);
|
||||||
|
alert('Không thể lấy vị trí hiện tại. Vui lòng bật định vị GPS.');
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom Map Markers
|
||||||
|
const mapIcons = useMemo(() => {
|
||||||
|
if (typeof window === 'undefined') return {};
|
||||||
|
return {
|
||||||
|
start: L.divIcon({
|
||||||
|
className: '!bg-transparent !border-none',
|
||||||
|
html: `<div class="w-8 h-8 bg-rose-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-xs font-black text-white animate-in zoom-in duration-300">S</div>`,
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 16]
|
||||||
|
}),
|
||||||
|
end: L.divIcon({
|
||||||
|
className: '!bg-transparent !border-none',
|
||||||
|
html: `<div class="w-8 h-8 bg-emerald-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-xs font-black text-white animate-in zoom-in duration-300">E</div>`,
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 16]
|
||||||
|
}),
|
||||||
|
visit: L.divIcon({
|
||||||
|
className: '!bg-transparent !border-none',
|
||||||
|
html: `<div class="w-6 h-6 bg-violet-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center"><div class="w-1.5 h-1.5 bg-white rounded-full opacity-60"></div></div>`,
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12]
|
||||||
|
}),
|
||||||
|
user: L.divIcon({
|
||||||
|
className: '!bg-transparent !border-none',
|
||||||
|
html: `
|
||||||
|
<div class="relative flex items-center justify-center">
|
||||||
|
<div class="absolute w-8 h-8 bg-blue-400 rounded-full opacity-35 animate-ping"></div>
|
||||||
|
<div class="w-5 h-5 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center z-10"></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
iconSize: [32, 32],
|
||||||
|
iconAnchor: [16, 16]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 transition-colors">
|
||||||
|
<Loader2 className="w-12 h-12 text-rose-500 animate-spin mb-4" />
|
||||||
|
<p className="font-bold text-sm">{t('loading')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !tour) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 p-4 transition-colors">
|
||||||
|
<div className="bg-white dark:bg-slate-900 border border-gray-100 dark:border-slate-800 shadow-2xl rounded-[32px] p-8 max-w-md w-full text-center flex flex-col items-center gap-5">
|
||||||
|
<div className="w-16 h-16 bg-rose-50 dark:bg-rose-950/20 border border-rose-100 dark:border-rose-900/30 rounded-full flex items-center justify-center text-rose-500">
|
||||||
|
<ShieldAlert className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-black tracking-tight">{t('error')}</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||||
|
{t('linkExpired')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onGoToHome}
|
||||||
|
className="w-full bg-slate-900 dark:bg-slate-100 hover:bg-slate-800 dark:hover:bg-white text-white dark:text-slate-900 font-bold py-3 px-6 rounded-2xl transition-all active:scale-95 text-sm"
|
||||||
|
>
|
||||||
|
{t('appName')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen flex flex-col bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 transition-colors overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="h-16 shrink-0 bg-white/70 dark:bg-slate-900/70 backdrop-blur-md border-b border-gray-100 dark:border-slate-800 px-4 md:px-6 flex items-center justify-between z-20">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onGoToHome}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-xl transition-all active:scale-95 text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2.5 h-2.5 bg-rose-500 rounded-full animate-pulse"></span>
|
||||||
|
<h1 className="text-sm md:text-base font-black uppercase tracking-tight">{t('emergencyJourney')}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-500 dark:text-slate-400 truncate max-w-[200px] sm:max-w-xs">{tour.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 md:gap-3">
|
||||||
|
{/* Language Selector */}
|
||||||
|
<select
|
||||||
|
value={lang}
|
||||||
|
onChange={(e) => changeLanguage(e.target.value as any)}
|
||||||
|
className="bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 text-slate-800 dark:text-slate-100 rounded-xl px-2 py-1.5 text-xs font-bold focus:outline-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="vi">VI</option>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="zh">ZH</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Theme Selector */}
|
||||||
|
<select
|
||||||
|
value={theme}
|
||||||
|
onChange={(e) => changeTheme(e.target.value as any)}
|
||||||
|
className="bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 text-slate-800 dark:text-slate-100 rounded-xl px-2 py-1.5 text-xs font-bold focus:outline-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="light">{t('themeLight')}</option>
|
||||||
|
<option value="dark">{t('themeDark')}</option>
|
||||||
|
<option value="system">{t('themeSystem')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile Tab Switcher */}
|
||||||
|
<div className="lg:hidden h-12 shrink-0 bg-white dark:bg-slate-900 border-b border-gray-100 dark:border-slate-800 flex items-center z-10 p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('map')}
|
||||||
|
className={`flex-1 h-full flex items-center justify-center gap-2 text-xs font-bold rounded-lg transition-all ${activeTab === 'map' ? 'bg-rose-500 text-white shadow-md' : 'text-slate-500 dark:text-slate-400'}`}
|
||||||
|
>
|
||||||
|
<MapIcon className="w-4 h-4" />
|
||||||
|
{t('viewMap')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('itinerary')}
|
||||||
|
className={`flex-1 h-full flex items-center justify-center gap-2 text-xs font-bold rounded-lg transition-all ${activeTab === 'itinerary' ? 'bg-rose-500 text-white shadow-md' : 'text-slate-500 dark:text-slate-400'}`}
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
{t('viewTimeline')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Layout Area */}
|
||||||
|
<div className="flex-1 flex flex-col lg:grid lg:grid-cols-12 overflow-hidden">
|
||||||
|
{/* Left Side Pane (Itinerary and Contacts) */}
|
||||||
|
<div className={`flex-col lg:col-span-4 bg-white dark:bg-slate-900 border-r border-gray-100 dark:border-slate-800 overflow-y-auto no-scrollbar ${activeTab === 'itinerary' ? 'flex h-full' : 'hidden lg:flex'}`}>
|
||||||
|
<div className="p-4 md:p-6 space-y-6">
|
||||||
|
{/* Tour Meta Info */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-lg md:text-xl font-black text-slate-900 dark:text-white leading-tight">{tour.title}</h2>
|
||||||
|
{tour.startDate && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<Calendar className="w-4 h-4 text-rose-500" />
|
||||||
|
<span>
|
||||||
|
{new Date(tour.startDate).toLocaleDateString()}
|
||||||
|
{tour.endDate && ` - ${new Date(tour.endDate).toLocaleDateString()}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tour.description && (
|
||||||
|
<p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed bg-slate-50 dark:bg-slate-950 p-3 rounded-2xl border border-gray-100 dark:border-slate-800/40">
|
||||||
|
{tour.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emergency Contacts */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-xs font-black text-rose-600 dark:text-rose-400 uppercase tracking-widest flex items-center gap-2">
|
||||||
|
<Phone className="w-4 h-4" />
|
||||||
|
{t('emergencyContacts')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Creator/Owner contact */}
|
||||||
|
{ownerContact && (
|
||||||
|
<div className="bg-rose-50/50 dark:bg-rose-950/10 border border-rose-100/50 dark:border-rose-950/20 p-4 rounded-2xl flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs text-rose-600 dark:text-rose-400 font-bold uppercase tracking-wider">{t('tourOwner')}</div>
|
||||||
|
<div className="text-sm font-black text-slate-900 dark:text-white truncate mt-0.5">{ownerContact.name || 'Anonymous'}</div>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{ownerContact.phone || t('noPhone')}</div>
|
||||||
|
</div>
|
||||||
|
{ownerContact.phone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${ownerContact.phone}`}
|
||||||
|
className="w-10 h-10 bg-rose-500 hover:bg-rose-600 text-white rounded-xl shadow-lg shadow-rose-500/20 flex items-center justify-center shrink-0 active:scale-95 transition-all"
|
||||||
|
>
|
||||||
|
<PhoneCall className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manager contacts */}
|
||||||
|
{managerContacts.map((mgr, idx) => (
|
||||||
|
<div key={idx} className="bg-slate-50 dark:bg-slate-950 border border-gray-100 dark:border-slate-800 p-4 rounded-2xl flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400 font-bold uppercase tracking-wider">{t('tourManager')}</div>
|
||||||
|
<div className="text-sm font-black text-slate-900 dark:text-white truncate mt-0.5">{mgr.name}</div>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{mgr.phone}</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`tel:${mgr.phone}`}
|
||||||
|
className="w-10 h-10 bg-slate-900 dark:bg-slate-100 hover:bg-slate-800 dark:hover:bg-white text-white dark:text-slate-900 rounded-xl shadow-md flex items-center justify-center shrink-0 active:scale-95 transition-all"
|
||||||
|
>
|
||||||
|
<PhoneCall className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!ownerContact?.phone && managerContacts.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 italic">{t('noPhone')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stops Hierarchy List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">
|
||||||
|
{t('viewTimeline')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4 relative before:absolute before:left-3 before:top-2 before:bottom-2 before:w-[2px] before:bg-gray-100 dark:before:bg-slate-800">
|
||||||
|
{allLocations.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 italic pl-6">{t('noStops')}</p>
|
||||||
|
) : (
|
||||||
|
allLocations.map((loc, idx) => {
|
||||||
|
const isStart = idx === 0;
|
||||||
|
const isEnd = idx === allLocations.length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={loc.id}
|
||||||
|
onClick={() => {
|
||||||
|
setFocusedLocation([loc.latitude, loc.longitude]);
|
||||||
|
// Auto switch to map tab on mobile when clicking a location
|
||||||
|
setActiveTab('map');
|
||||||
|
}}
|
||||||
|
className="group flex items-start gap-4 cursor-pointer pl-1 transition-all"
|
||||||
|
>
|
||||||
|
{/* Dot indicator */}
|
||||||
|
<div className="relative mt-1 shrink-0 z-10">
|
||||||
|
{isStart ? (
|
||||||
|
<div className="w-5 h-5 bg-rose-600 rounded-full border-4 border-white dark:border-slate-950 shadow-md flex items-center justify-center text-[8px] font-black text-white">S</div>
|
||||||
|
) : isEnd ? (
|
||||||
|
<div className="w-5 h-5 bg-emerald-600 rounded-full border-4 border-white dark:border-slate-950 shadow-md flex items-center justify-center text-[8px] font-black text-white">E</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-4 h-4 bg-violet-600 rounded-full border-4 border-white dark:border-slate-950 shadow-sm ml-0.5"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="flex-1 bg-slate-50/50 hover:bg-slate-50 dark:bg-slate-950/40 dark:hover:bg-slate-950/80 p-3 rounded-2xl border border-gray-100/50 dark:border-slate-800/40 transition-all select-none">
|
||||||
|
<h4 className="text-xs font-black text-slate-800 dark:text-slate-200 group-hover:text-rose-500 dark:group-hover:text-rose-400 transition-colors">
|
||||||
|
{loc.name}
|
||||||
|
</h4>
|
||||||
|
{loc.address && (
|
||||||
|
<p className="text-[10px] text-slate-500 dark:text-slate-400 truncate mt-0.5">{loc.address}</p>
|
||||||
|
)}
|
||||||
|
{loc.plannedStart && (
|
||||||
|
<p className="text-[9px] text-slate-400 dark:text-slate-500 mt-1">
|
||||||
|
{new Date(loc.plannedStart).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
{loc.plannedEnd && ` - ${new Date(loc.plannedEnd).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side Map Pane */}
|
||||||
|
<div className={`relative flex-1 lg:col-span-8 h-full bg-slate-100 dark:bg-slate-950 ${activeTab === 'map' ? 'flex' : 'hidden lg:flex'}`}>
|
||||||
|
{allLocations.length > 0 ? (
|
||||||
|
<MapContainer
|
||||||
|
center={[allLocations[0].latitude, allLocations[0].longitude]}
|
||||||
|
zoom={13}
|
||||||
|
preferCanvas={true}
|
||||||
|
className="h-full w-full z-0"
|
||||||
|
attributionControl={false}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
attribution=""
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fit map bounds helper */}
|
||||||
|
<MapBoundsFit locations={allLocations} trigger={fitBoundsTrigger} />
|
||||||
|
|
||||||
|
{/* Recenter helper */}
|
||||||
|
<MapRecenter position={focusedLocation} />
|
||||||
|
|
||||||
|
{/* Polyline Route */}
|
||||||
|
{allLocations.length > 1 && (
|
||||||
|
<Polyline
|
||||||
|
positions={allLocations.map(l => [l.latitude, l.longitude])}
|
||||||
|
color="#ec4899"
|
||||||
|
weight={4}
|
||||||
|
dashArray="6, 12"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location Stop Markers */}
|
||||||
|
{allLocations.map((loc, idx) => {
|
||||||
|
const isStart = idx === 0;
|
||||||
|
const isEnd = idx === allLocations.length - 1;
|
||||||
|
const markerIcon = isStart ? mapIcons.start : isEnd ? mapIcons.end : mapIcons.visit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={loc.id}
|
||||||
|
position={[loc.latitude, loc.longitude]}
|
||||||
|
icon={markerIcon}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="p-1">
|
||||||
|
<div className="font-black text-xs text-slate-900">{loc.name}</div>
|
||||||
|
{loc.address && <div className="text-[10px] text-gray-500 mt-0.5">{loc.address}</div>}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Viewer's GPS tracking marker */}
|
||||||
|
{userLocation && (
|
||||||
|
<Marker position={userLocation} icon={mapIcons.user}>
|
||||||
|
<Popup>
|
||||||
|
<div className="font-bold text-xs">{t('myCoords') || 'Vị trí của tôi'}</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-slate-400">
|
||||||
|
<MapPin className="w-12 h-12 stroke-1 mb-2 animate-bounce" />
|
||||||
|
<p className="text-xs">{t('noStops')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Floating Actions on Map */}
|
||||||
|
<div className="absolute bottom-6 right-6 z-[1000] flex flex-col gap-3">
|
||||||
|
{/* Locate Me button */}
|
||||||
|
<button
|
||||||
|
onClick={handleLocateMe}
|
||||||
|
className="w-12 h-12 bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-800 rounded-2xl shadow-xl flex items-center justify-center text-slate-700 dark:text-slate-200 hover:bg-gray-50 dark:hover:bg-slate-800 transition-all active:scale-95"
|
||||||
|
title="Định vị của tôi"
|
||||||
|
>
|
||||||
|
{isLocating ? (
|
||||||
|
<Loader2 className="w-5 h-5 text-rose-500 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LocateFixed className={`w-5 h-5 ${userLocation ? 'text-rose-500' : ''}`} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Fit bounds button */}
|
||||||
|
{allLocations.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFitBoundsTrigger(prev => prev + 1)}
|
||||||
|
className="w-12 h-12 bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-800 rounded-2xl shadow-xl flex items-center justify-center text-slate-700 dark:text-slate-200 hover:bg-gray-50 dark:hover:bg-slate-800 transition-all active:scale-95"
|
||||||
|
title="Bao phủ toàn bộ"
|
||||||
|
>
|
||||||
|
<Compass className="w-5 h-5 text-violet-500" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { io } from 'socket.io-client';
|
|||||||
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
||||||
import { ExpenseManager } from '../components/ExpenseManager';
|
import { ExpenseManager } from '../components/ExpenseManager';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
import { AddLocationModal } from '@/components/AddLocationModal';
|
import { AddLocationModal } from '@/components/AddLocationModal';
|
||||||
import { AddMemberModal } from '../components/AddMemberModal';
|
import { AddMemberModal } from '../components/AddMemberModal';
|
||||||
import { MembersTab } from '../components/MembersTab';
|
import { MembersTab } from '../components/MembersTab';
|
||||||
@@ -253,7 +254,7 @@ const MapHoverTip = ({ canEdit }: { canEdit: boolean }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
// Menu ngữ cảnh cho bản đồ
|
// Menu ngữ cảnh cho bản đồ
|
||||||
const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.LatLng) => void }) => {
|
const MapContextMenu = ({ onAction, onOpen }: { onAction: (action: string, latlng: L.LatLng) => void; onOpen?: () => void }) => {
|
||||||
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
|
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
|
||||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -271,6 +272,7 @@ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.Lat
|
|||||||
|
|
||||||
console.log(`[MAP] Context menu triggered at: ${e.latlng.lat}, ${e.latlng.lng}`);
|
console.log(`[MAP] Context menu triggered at: ${e.latlng.lat}, ${e.latlng.lng}`);
|
||||||
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
|
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
|
||||||
|
if (onOpen) onOpen();
|
||||||
},
|
},
|
||||||
|
|
||||||
moveend: (e) => {
|
moveend: (e) => {
|
||||||
@@ -352,6 +354,9 @@ export const TourDetailPage = ({
|
|||||||
onOpenNotes?: () => void
|
onOpenNotes?: () => void
|
||||||
}) => {
|
}) => {
|
||||||
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const notify = useNotification();
|
||||||
|
const confirm = useConfirm();
|
||||||
const currentTour = useTourStore(state => state.currentTour);
|
const currentTour = useTourStore(state => state.currentTour);
|
||||||
const legs = useTourStore(state => state.legs);
|
const legs = useTourStore(state => state.legs);
|
||||||
const publicTours = useTourStore(state => state.publicTours);
|
const publicTours = useTourStore(state => state.publicTours);
|
||||||
@@ -378,6 +383,187 @@ export const TourDetailPage = ({
|
|||||||
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||||
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
||||||
|
|
||||||
|
const [isRatingModalOpen, setIsRatingModalOpen] = useState(false);
|
||||||
|
const [ratingTargetUser, setRatingTargetUser] = useState<any>(null);
|
||||||
|
const [ratingScores, setRatingScores] = useState({
|
||||||
|
honesty: 5,
|
||||||
|
transparency: 5,
|
||||||
|
enthusiasm: 5,
|
||||||
|
cheerfulness: 5,
|
||||||
|
seriousness: 5,
|
||||||
|
planning: 5,
|
||||||
|
survival: 5
|
||||||
|
});
|
||||||
|
const [ratingComment, setRatingComment] = useState('');
|
||||||
|
const [isSubmittingRating, setIsSubmittingRating] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmitRating = async () => {
|
||||||
|
if (!ratingTargetUser) return;
|
||||||
|
setIsSubmittingRating(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const res = await fetch(`/api/v1/tours/${tourId}/ratings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
targetUserId: ratingTargetUser.userId || ratingTargetUser.user?.id,
|
||||||
|
...ratingScores,
|
||||||
|
comment: ratingComment
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
notify({ title: 'Thành công', message: 'Cảm ơn bạn đã gửi đánh giá!', type: 'success' });
|
||||||
|
setIsRatingModalOpen(false);
|
||||||
|
setRatingComment('');
|
||||||
|
setRatingScores({
|
||||||
|
honesty: 5,
|
||||||
|
transparency: 5,
|
||||||
|
enthusiasm: 5,
|
||||||
|
cheerfulness: 5,
|
||||||
|
seriousness: 5,
|
||||||
|
planning: 5,
|
||||||
|
survival: 5
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
notify({ title: 'Lỗi', message: err.message || 'Lỗi khi gửi đánh giá.', type: 'error' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
notify({ title: 'Lỗi', message: 'Lỗi mạng khi gửi đánh giá.', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingRating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [shareStatus, setShareStatus] = useState<{ isEnabled: boolean; token: string } | null>(null);
|
||||||
|
|
||||||
|
const fetchShareStatus = async () => {
|
||||||
|
if (isPublicView) return;
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const res = await fetch(`/api/v1/tours/${tourId}/share`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setShareStatus(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching share status:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleShare = async (isEnabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const res = await fetch(`/api/v1/tours/${tourId}/share`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ isEnabled })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setShareStatus(data);
|
||||||
|
notify({
|
||||||
|
title: 'Thành công',
|
||||||
|
message: isEnabled ? 'Đã bật chia sẻ hành trình cứu hộ.' : 'Đã tắt chia sẻ.',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportPDF = async () => {
|
||||||
|
if (!(window as any).html2pdf) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js';
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => reject(new Error('Failed to load html2pdf'));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.innerHTML = `
|
||||||
|
.pdf-exclude { display: none !important; }
|
||||||
|
.pdf-container { padding: 40px !important; color: #000 !important; background: #fff !important; }
|
||||||
|
.pdf-title { font-size: 24px !important; font-weight: bold !important; margin-bottom: 20px !important; text-align: center !important; }
|
||||||
|
.pdf-timeline { margin-top: 20px; }
|
||||||
|
.pdf-location-card { border: 1px solid #e5e7eb; padding: 15px; border-radius: 12px; margin-bottom: 15px; background: #fafafa; }
|
||||||
|
.pdf-leg-header { font-size: 16px; font-weight: bold; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #3b82f6; padding-bottom: 5px; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = 'pdf-container font-sans text-black bg-white';
|
||||||
|
|
||||||
|
const titleEl = document.createElement('h1');
|
||||||
|
titleEl.className = 'pdf-title';
|
||||||
|
titleEl.innerText = `Hành Trình: ${currentTour?.title || 'Tour Itinerary'}`;
|
||||||
|
element.appendChild(titleEl);
|
||||||
|
|
||||||
|
const subEl = document.createElement('div');
|
||||||
|
subEl.style.textAlign = 'center';
|
||||||
|
subEl.style.marginBottom = '30px';
|
||||||
|
subEl.style.fontSize = '12px';
|
||||||
|
subEl.style.color = '#555';
|
||||||
|
subEl.innerText = `Thời gian: ${currentTour?.startDate ? new Date(currentTour.startDate).toLocaleDateString('vi-VN') : ''} - ${currentTour?.endDate ? new Date(currentTour.endDate).toLocaleDateString('vi-VN') : ''}`;
|
||||||
|
element.appendChild(subEl);
|
||||||
|
|
||||||
|
const printDom = document.getElementById('itinerary-timeline-print-zone');
|
||||||
|
if (printDom) {
|
||||||
|
const clone = printDom.cloneNode(true) as HTMLElement;
|
||||||
|
|
||||||
|
// Clean clone layout by removing action buttons and interactive inputs, expenses
|
||||||
|
clone.querySelectorAll('button, input, textarea, .pdf-exclude, .expense-badge, .paid-by-badge, .comment-section, .location-actions, .mt-2.text-indigo-650, .flex.gap-2.mt-2').forEach(el => {
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear styles or apply simple standard styles so PDF generation is clean
|
||||||
|
clone.style.background = 'white';
|
||||||
|
clone.style.color = 'black';
|
||||||
|
|
||||||
|
element.appendChild(clone);
|
||||||
|
} else {
|
||||||
|
notify({ title: 'Lỗi', message: 'Không tìm thấy vùng hiển thị lịch trình để xuất PDF.', type: 'error' });
|
||||||
|
document.head.removeChild(style);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opt = {
|
||||||
|
margin: 10,
|
||||||
|
filename: `Lich_trinh_${currentTour?.title || 'tour'}.pdf`,
|
||||||
|
image: { type: 'jpeg', quality: 0.98 },
|
||||||
|
html2canvas: { scale: 2, useCORS: true },
|
||||||
|
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
notify({ title: 'Đang tạo PDF...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
||||||
|
await (window as any).html2pdf().from(element).set(opt).save();
|
||||||
|
notify({ title: 'Thành công', message: 'Lịch trình đã được xuất ra tập tin PDF thành công.', type: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
notify({ title: 'Lỗi', message: 'Không thể xuất PDF lịch trình.', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
document.head.removeChild(style);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchShareStatus();
|
||||||
|
}, [tourId]);
|
||||||
|
|
||||||
// State cho vị trí và hướng của người dùng
|
// State cho vị trí và hướng của người dùng
|
||||||
|
|
||||||
|
|
||||||
@@ -834,8 +1020,6 @@ export const TourDetailPage = ({
|
|||||||
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
|
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
|
||||||
const deleteTour = useTourStore(state => state.deleteTour);
|
const deleteTour = useTourStore(state => state.deleteTour);
|
||||||
|
|
||||||
const confirm = useConfirm();
|
|
||||||
const notify = useNotification();
|
|
||||||
|
|
||||||
// Khôi phục vị trí và mức zoom từ localStorage
|
// Khôi phục vị trí và mức zoom từ localStorage
|
||||||
const [initialViewState] = useState(() => {
|
const [initialViewState] = useState(() => {
|
||||||
@@ -1071,8 +1255,8 @@ export const TourDetailPage = ({
|
|||||||
|
|
||||||
const handleNavigateToLocation = (location: any) => {
|
const handleNavigateToLocation = (location: any) => {
|
||||||
setMapCenter([location.latitude, location.longitude]);
|
setMapCenter([location.latitude, location.longitude]);
|
||||||
|
setViewMode('map');
|
||||||
setLocateTrigger(prev => prev + 1);
|
setLocateTrigger(prev => prev + 1);
|
||||||
setIsMapFullscreen(true);
|
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
title: 'Bắt đầu chỉ đường',
|
title: 'Bắt đầu chỉ đường',
|
||||||
@@ -1784,21 +1968,30 @@ export const TourDetailPage = ({
|
|||||||
{activeTab === 'plan' && (
|
{activeTab === 'plan' && (
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-2">
|
<div className="animate-in fade-in slide-in-from-bottom-2">
|
||||||
{/* View Mode Toggle */}
|
{/* View Mode Toggle */}
|
||||||
<div className="flex justify-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div className="bg-gray-100 p-1 rounded-2xl flex gap-1">
|
<div className="flex-1" />
|
||||||
|
<div className="bg-gray-100 dark:bg-slate-800 p-1 rounded-2xl flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('timeline')}
|
onClick={() => setViewMode('timeline')}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
|
||||||
>
|
>
|
||||||
<List className="w-3.5 h-3.5" /> Danh sách
|
<List className="w-3.5 h-3.5" /> Danh sách
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('map')}
|
onClick={() => setViewMode('map')}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
|
||||||
>
|
>
|
||||||
<MapIconLucide className="w-3.5 h-3.5" /> Bản đồ
|
<MapIconLucide className="w-3.5 h-3.5" /> Bản đồ
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleExportPDF}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs font-bold shadow-md transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
📥 {t('exportPDF') || 'Xuất PDF'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'timeline' ? (
|
{viewMode === 'timeline' ? (
|
||||||
@@ -2538,6 +2731,54 @@ export const TourDetailPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Emergency Sharing Settings */}
|
||||||
|
<div className="p-6 bg-white dark:bg-slate-900 rounded-3xl border border-dashed border-rose-200 dark:border-rose-950/40 shadow-sm animate-in zoom-in-95">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">🚨</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{t('emergencyShare')}</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-slate-400">{t('emergencyShareTooltip')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{shareStatus && (
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={shareStatus.isEnabled}
|
||||||
|
onChange={(e) => handleToggleShare(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-rose-500"></div>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shareStatus?.isEnabled && (
|
||||||
|
<div className="mt-4 bg-rose-50 dark:bg-rose-950/20 p-4 rounded-2xl border border-rose-100 dark:border-rose-950/30 flex flex-col gap-2">
|
||||||
|
<div className="text-xs font-bold text-rose-700 dark:text-rose-400 uppercase tracking-widest">Đường dẫn chia sẻ khẩn cấp:</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={`${window.location.origin}/journey/${shareStatus.token}`}
|
||||||
|
className="flex-1 bg-white dark:bg-slate-800 border dark:border-slate-700 rounded-xl px-3 py-2 text-xs text-slate-800 dark:text-slate-100 select-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(`${window.location.origin}/journey/${shareStatus.token}`);
|
||||||
|
notify({ title: 'Thành công', message: 'Đã sao chép liên kết!', type: 'success' });
|
||||||
|
}}
|
||||||
|
className="bg-rose-600 hover:bg-rose-700 text-white font-bold px-3 py-2 rounded-xl text-xs transition-all active:scale-95 shrink-0"
|
||||||
|
>
|
||||||
|
{t('copyShareLink')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className="p-6 bg-white rounded-3xl border border-gray-100 shadow-sm animate-in zoom-in-95">
|
<div className="p-6 bg-white rounded-3xl border border-gray-100 shadow-sm animate-in zoom-in-95">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
@@ -2930,6 +3171,20 @@ export const TourDetailPage = ({
|
|||||||
)}
|
)}
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<button onClick={() => setIsMemberDetailOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100">Đóng</button>
|
<button onClick={() => setIsMemberDetailOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100">Đóng</button>
|
||||||
|
|
||||||
|
{!isPublicView && currentUser && selectedMember && (selectedMember.userId || selectedMember.user?.id) && currentUser.id !== (selectedMember.userId || selectedMember.user?.id) && (selectedMember.role === 'OWNER' || selectedMember.role === 'MANAGER') && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsMemberDetailOpen(false);
|
||||||
|
setRatingTargetUser(selectedMember);
|
||||||
|
setIsRatingModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-xl text-sm font-bold flex items-center gap-1 shadow-md transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
⭐ {t('rateOrganizer') || 'Đánh giá'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{canEdit && selectedMember.role !== 'OWNER' && (
|
{canEdit && selectedMember.role !== 'OWNER' && (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -2961,6 +3216,105 @@ export const TourDetailPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Organizer Rating Modal */}
|
||||||
|
{isRatingModalOpen && ratingTargetUser && (
|
||||||
|
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in" onClick={() => setIsRatingModalOpen(false)} />
|
||||||
|
<div className="relative w-full max-w-lg bg-white dark:bg-slate-900 rounded-[32px] shadow-2xl p-8 max-h-[90vh] overflow-y-auto flex flex-col animate-in zoom-in-95 duration-250 text-slate-800 dark:text-slate-100 border dark:border-slate-800">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-xl font-black text-slate-950 dark:text-white flex items-center gap-2">
|
||||||
|
⭐ {t('rateTitle') || 'Đánh giá Người tạo Tour'}
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => setIsRatingModalOpen(false)} className="p-2 hover:bg-gray-150 dark:hover:bg-slate-850 rounded-full transition-colors">
|
||||||
|
<X className="w-5 h-5 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-6 bg-slate-50 dark:bg-slate-850 p-4 rounded-2xl border border-slate-100 dark:border-slate-800">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold overflow-hidden">
|
||||||
|
{ratingTargetUser.avatar ? (
|
||||||
|
<img src={ratingTargetUser.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span>{ratingTargetUser.user?.name?.charAt(0) || ratingTargetUser.displayName?.charAt(0) || '?'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-black dark:text-white">{ratingTargetUser.user?.name || ratingTargetUser.displayName}</div>
|
||||||
|
<div className="text-xs text-gray-450 uppercase tracking-widest font-bold">{ratingTargetUser.role}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 flex-1">
|
||||||
|
{[
|
||||||
|
{ key: 'honesty', label: t('honesty') || 'Trung thực' },
|
||||||
|
{ key: 'transparency', label: t('transparency') || 'Minh bạch' },
|
||||||
|
{ key: 'enthusiasm', label: t('enthusiasm') || 'Nhiệt tình' },
|
||||||
|
{ key: 'cheerfulness', label: t('cheerfulness') || 'Vui vẻ' },
|
||||||
|
{ key: 'seriousness', label: t('seriousness') || 'Nghiêm túc' },
|
||||||
|
{ key: 'planning', label: t('planning') || 'Có kế hoạch' },
|
||||||
|
{ key: 'survival', label: t('survival') || 'Kỹ năng sinh tồn' }
|
||||||
|
].map((c) => (
|
||||||
|
<div key={c.key} className="flex items-center justify-between border-b border-slate-100 dark:border-slate-850 pb-2">
|
||||||
|
<span className="text-xs font-bold text-slate-700 dark:text-slate-350">{c.label}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => {
|
||||||
|
const currentVal = (ratingScores as any)[c.key];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRatingScores(prev => ({ ...prev, [c.key]: star }))}
|
||||||
|
className={`w-6 h-6 text-xl transition-all active:scale-95 ${
|
||||||
|
star <= currentVal ? 'text-amber-450' : 'text-gray-300 dark:text-gray-700 hover:text-amber-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Nhận xét khác</label>
|
||||||
|
<textarea
|
||||||
|
value={ratingComment}
|
||||||
|
onChange={(e) => setRatingComment(e.target.value)}
|
||||||
|
placeholder={t('rateCommentPlaceholder') || 'Nhập ý kiến đánh giá khác...'}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-slate-850 border border-gray-200 dark:border-slate-800 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-xs resize-none text-slate-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsRatingModalOpen(false)}
|
||||||
|
className="py-3.5 bg-gray-100 dark:bg-slate-800 hover:bg-gray-250 dark:hover:bg-slate-700 text-gray-600 dark:text-slate-300 font-bold rounded-2xl transition-all active:scale-95 text-xs"
|
||||||
|
>
|
||||||
|
{t('cancel') || 'Hủy'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmitRating}
|
||||||
|
disabled={isSubmittingRating}
|
||||||
|
className="py-3.5 bg-amber-500 hover:bg-amber-600 disabled:opacity-50 text-white font-bold rounded-2xl shadow-lg transition-all active:scale-95 text-xs flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
{isSubmittingRating ? (
|
||||||
|
<Loader2 className="w-4.5 h-4.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{t('save') || 'Gửi đánh giá'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<CommentModal
|
<CommentModal
|
||||||
isOpen={isCommentModalOpen}
|
isOpen={isCommentModalOpen}
|
||||||
onClose={() => setIsCommentModalOpen(false)}
|
onClose={() => setIsCommentModalOpen(false)}
|
||||||
|
|||||||
Reference in New Issue
Block a user