feat: tính năng chia sẻ khẩn cấp

This commit is contained in:
2026-06-21 12:52:47 +07:00
parent 8d79cd76f6
commit 403c169ddd
21 changed files with 2939 additions and 132 deletions
+457 -18
View File
@@ -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() {
const user = await this.prisma.user.create({
data: {
@@ -601,6 +614,15 @@ __decorate([
__metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise)
], 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([
(0, common_1.Post)('create-guest'),
__metadata("design:type", Function),
@@ -720,10 +742,12 @@ let TourController = class TourController {
}
async createTour(body, req) {
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({
data: {
title,
description,
title: filteredTitle,
description: filteredDesc,
startDate: startDate ? new Date(startDate) : null,
endDate: endDate ? new Date(endDate) : null,
tags: tags || [],
@@ -939,18 +963,23 @@ let TourController = class TourController {
});
}
async updateTour(id, body) {
const updateData = {
startDate: body.startDate ? new Date(body.startDate) : undefined,
tags: body.tags,
endDate: body.endDate ? new Date(body.endDate) : undefined,
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
childCount: body.childCount !== undefined ? Number(body.childCount) : 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: {
title: body.title,
description: body.description,
startDate: body.startDate ? new Date(body.startDate) : undefined,
tags: body.tags,
endDate: body.endDate ? new Date(body.endDate) : undefined,
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined,
},
data: updateData,
}).then(async (tour) => {
await Promise.all([
this.cacheManager.del(id),
@@ -2445,9 +2474,10 @@ let CommentController = class CommentController {
});
}
async addComment(locationId, body, req) {
const filteredContent = await filterText(this.prisma, body.content);
const comment = await this.prisma.comment.create({
data: {
content: body.content,
content: filteredContent,
locationId,
userId: req.user.id
},
@@ -2848,9 +2878,10 @@ let PublicPhotoController = class PublicPhotoController {
if (!photo) {
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({
data: {
content: content.trim(),
content: filteredContent,
photoId,
userId: req.user.id
},
@@ -2863,6 +2894,26 @@ let PublicPhotoController = class PublicPhotoController {
this.commentGateway.notifyNewPhotoComment(photoId, 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) {
const photo = await this.prisma.photo.findUnique({
where: { id: photoId }
@@ -2977,6 +3028,15 @@ __decorate([
__metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise)
], 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([
(0, common_1.Get)(':photoId/share'),
__param(0, (0, common_1.Param)('photoId', common_1.ParseUUIDPipe)),
@@ -3224,11 +3284,12 @@ let DirectMessageController = class DirectMessageController {
if (!conn) {
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({
data: {
senderId,
receiverId,
content: content || '',
content: filteredContent,
attachmentUrl,
latitude,
longitude
@@ -3330,11 +3391,12 @@ let TourMessageController = class TourMessageController {
if (!participant) {
throw new common_1.ForbiddenException('Bạn không phải là thành viên của hành trình này.');
}
const filteredContent = content ? await filterText(this.prisma, content) : '';
const tourMessage = await this.prisma.tourMessage.create({
data: {
tourId,
senderId,
content: content || '',
content: filteredContent,
attachmentUrl,
latitude,
longitude
@@ -3365,13 +3427,14 @@ let TourMessageController = class TourMessageController {
});
for (const p of otherParticipants) {
if (p.userId) {
const filteredContent = content ? await filterText(this.prisma, content) : '';
const isTagged = hasTags && taggedUserIds.includes(p.userId);
console.log(`[sendTourMessage] Emitting tourMessageNotification to user_${p.userId}, isTagged: ${isTagged}`);
this.commentGateway.server.to(`user_${p.userId}`).emit('tourMessageNotification', {
tourId,
senderName: req.user.name || 'Thành viên',
tourTitle: tour?.title || 'Hành trình',
content: isTagged ? `@Bạn: ${content || ''}` : content || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'),
content: isTagged ? `@Bạn: ${filteredContent || ''}` : filteredContent || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'),
isTagged
});
}
@@ -3402,6 +3465,382 @@ TourMessageController = __decorate([
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
CommentGateway])
], 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 {
};
AppModule = __decorate([
@@ -3426,7 +3865,7 @@ AppModule = __decorate([
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],
exports: [prisma_service_1.PrismaService]
})
+1 -1
View File
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;
+51
View File
@@ -80,6 +80,8 @@ model User {
sentMessages DirectMessage[] @relation("MessageSender")
receivedMessages DirectMessage[] @relation("MessageReceiver")
tourMessages TourMessage[]
receivedRatings TourRating[] @relation("RatedUser")
sentRatings TourRating[] @relation("RatingUser")
}
model Tour {
@@ -105,6 +107,8 @@ model Tour {
photos Photo[]
invitations TourInvitation[]
tourMessages TourMessage[]
ratings TourRating[]
share TourShare?
}
model JoinRequest {
@@ -296,3 +300,50 @@ model TourMessage {
@@index([tourId])
@@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)
}
+397 -19
View File
@@ -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')
async createGuestUser() {
const user = await this.prisma.user.create({
@@ -687,10 +703,12 @@ class TourController {
@Post()
async createTour(@Body() body: any, @Req() req: any) {
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({
data: {
title,
description,
title: filteredTitle,
description: filteredDesc,
startDate: startDate ? new Date(startDate) : null,
endDate: endDate ? new Date(endDate) : null,
tags: tags || [],
@@ -959,19 +977,24 @@ class TourController {
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Patch(':id')
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 = {
startDate: body.startDate ? new Date(body.startDate) : undefined,
tags: body.tags,
endDate: body.endDate ? new Date(body.endDate) : undefined,
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
childCount: body.childCount !== undefined ? Number(body.childCount) : 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: {
title: body.title,
description: body.description,
startDate: body.startDate ? new Date(body.startDate) : undefined,
tags: body.tags,
endDate: body.endDate ? new Date(body.endDate) : undefined,
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined,
},
data: updateData,
}).then(async (tour) => {
await Promise.all([
this.cacheManager.del(id),
@@ -2410,9 +2433,10 @@ class CommentController {
@Body() body: { content: string },
@Req() req: any
) {
const filteredContent = await filterText(this.prisma, body.content);
const comment = await this.prisma.comment.create({
data: {
content: body.content,
content: filteredContent,
locationId,
userId: req.user.id
},
@@ -2800,9 +2824,10 @@ class PublicPhotoController {
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({
data: {
content: content.trim(),
content: filteredContent,
photoId,
userId: req.user.id
},
@@ -2819,6 +2844,35 @@ class PublicPhotoController {
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')
async sharePhoto(
@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.');
}
const filteredContent = content ? await filterText(this.prisma, content) : '';
const message = await this.prisma.directMessage.create({
data: {
senderId,
receiverId,
content: content || '',
content: filteredContent,
attachmentUrl,
latitude,
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.');
}
const filteredContent = content ? await filterText(this.prisma, content) : '';
const tourMessage = await this.prisma.tourMessage.create({
data: {
tourId,
senderId,
content: content || '',
content: filteredContent,
attachmentUrl,
latitude,
longitude
@@ -3333,13 +3389,14 @@ class TourMessageController {
for (const p of otherParticipants) {
if (p.userId) {
const filteredContent = content ? await filterText(this.prisma, content) : '';
const isTagged = hasTags && taggedUserIds.includes(p.userId);
console.log(`[sendTourMessage] Emitting tourMessageNotification to user_${p.userId}, isTagged: ${isTagged}`);
this.commentGateway.server.to(`user_${p.userId}`).emit('tourMessageNotification', {
tourId,
senderName: req.user.name || 'Thành viên',
tourTitle: tour?.title || 'Hành trình',
content: isTagged ? `@Bạn: ${content || ''}` : content || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'),
content: isTagged ? `@Bạn: ${filteredContent || ''}` : filteredContent || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'),
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({
imports: [
ConfigModule.forRoot({
@@ -3372,7 +3750,7 @@ class TourMessageController {
signOptions: { expiresIn: '1d' },
}) 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],
exports: [PrismaService]
})
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
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { LandingPage } from './pages/LandingPage';
import { ExploreMap } from './pages/ExploreMap';
import { TourDetailPage } from './pages/TourDetailPage';
@@ -7,30 +7,32 @@ import { MyPhotosPage } from './pages/MyPhotosPage';
import { MyNotePage } from './pages/MyNotePage';
import { JoinTourPage } from './pages/JoinTourPage';
import { MemberDashboard } from './pages/MemberDashboard';
import { useTourStore } from './store/useTourStore';
import { ShareJourneyPage } from './pages/ShareJourneyPage';
import { ConfirmProvider } from './hooks/useConfirm';
import { NotificationProvider } from './hooks/useNotification';
function App() {
const params = new URLSearchParams(window.location.search);
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 [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour'>(
viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing')
const [shareJourneyToken, setShareJourneyToken] = useState<string | null>(journeyTokenVal);
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 [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
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(() => {
const params = new URLSearchParams(window.location.search);
const viewTourId = params.get('viewTour');
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ó
const token = localStorage.getItem('token');
@@ -46,7 +48,10 @@ function App() {
}
}
if (isJoinTour) {
if (journeyToken) {
setShareJourneyToken(journeyToken);
setCurrentPage('shareJourney');
} else if (isJoinTour) {
setCurrentPage('joinTour');
} else if (viewTourId) {
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 (
<ConfirmProvider>
<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} />;
})()}
</NotificationProvider>
+57 -27
View File
@@ -2,6 +2,7 @@ import React, { useState, useRef } from 'react';
import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useTourStore } from '@/store/useTourStore';
import { processImageModeration } from '@/hooks/useImageModeration';
interface AddPhotoModalProps {
isOpen: boolean;
@@ -14,6 +15,7 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
const fetchTour = useTourStore(state => state.fetchTour);
@@ -26,34 +28,52 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
const newValidFiles: File[] = [];
const newValidPreviews: string[] = [];
for (const file of files) {
// 1. Kiểm tra cơ bản: Tệp có dung lượng hay không
if (!file || file.size === 0) {
notify({ title: 'Lỗi tệp', message: `Tệp ${file.name} trống hoặc không khả dụng.`, type: 'error' });
continue;
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) {
// 1. Kiểm tra cơ bản: Tệp có dung lượng hay không
if (!file || file.size === 0) {
notify({ title: 'Lỗi tệp', message: `Tệp ${file.name} trống hoặc không khả dụng.`, type: 'error' });
continue;
}
// 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;
}
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 img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = previewUrl;
});
if (isValidImage) {
newValidFiles.push(processedFile);
newValidPreviews.push(previewUrl);
} else {
URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ
notify({ title: 'Lỗi ảnh', message: `Không thể đọc nội dung ảnh: ${file.name}. Vui lòng kiểm tra lại file.`, type: 'error' });
}
}
const previewUrl = URL.createObjectURL(file);
// 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 isValidImage = await new Promise<boolean>((resolve) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = previewUrl;
});
if (isValidImage) {
newValidFiles.push(file);
newValidPreviews.push(previewUrl);
} else {
URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ
notify({ title: 'Lỗi ảnh', message: `Không thể đọc nội dung ảnh: ${file.name}. Vui lòng kiểm tra lại file.`, type: 'error' });
}
setSelectedFiles(prev => [...prev, ...newValidFiles]);
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);
}
setSelectedFiles(prev => [...prev, ...newValidFiles]);
setPreviews(prev => [...prev, ...newValidPreviews]);
}
};
@@ -130,6 +150,7 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
}
};
return (
<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} />
@@ -177,10 +198,19 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
)}
<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"
>
{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ử nh...
</>
) : (
'Xác nhận tải lên'
)}
</button>
</form>
</div>
@@ -1,8 +1,9 @@
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 L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTranslation } from '../hooks/useTranslation';
// Fix Leaflet default marker icon bug
const DefaultIcon = L.icon({
@@ -55,10 +56,16 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
initialLng,
onSelect
}) => {
const { t } = useTranslation();
const defaultCenter: [number, number] = [10.7769, 106.7009]; // TP.HCM default
const [position, setPosition] = useState<[number, number]>(defaultCenter);
const [hasSelected, setHasSelected] = useState(false);
// Search States
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
if (isOpen) {
if (typeof initialLat === 'number' && typeof initialLng === 'number' && !isNaN(initialLat) && !isNaN(initialLng)) {
@@ -68,6 +75,8 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
setPosition(defaultCenter);
setHasSelected(false);
}
setSearchQuery('');
setSearchResults([]);
}
}, [isOpen, initialLat, initialLng]);
@@ -83,6 +92,31 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
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 (
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4 animate-in fade-in duration-200">
{/* Backdrop */}
@@ -92,33 +126,75 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
/>
{/* 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 */}
<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">
<MapPin className="w-5 h-5 text-blue-500" />
<div className="text-left">
<h3 className="font-extrabold text-sm text-gray-900">Chọn vị trí trên bản đ</h3>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Click lên bản đ đ chọn tọa đ</p>
<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">{t('clickMapSelectCoords')}</p>
</div>
</div>
<button
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" />
</button>
</div>
{/* 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
center={position}
zoom={13}
attributionControl={false}
style={{ width: '100%', height: '100%', zIndex: 1 }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapClickEvents onClick={handleMapClick} />
@@ -131,29 +207,29 @@ export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-100 flex items-center justify-between bg-white shrink-0">
<div className="text-xs text-gray-500">
<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 dark:text-slate-400">
{hasSelected ? (
<span className="font-semibold text-gray-700">
Tọa đ: {position[0].toFixed(6)}, {position[1].toFixed(6)}
<span className="font-semibold text-gray-700 dark:text-slate-200">
{t('coordsLabel')}: {position[0].toFixed(6)}, {position[1].toFixed(6)}
</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 className="flex gap-2">
<button
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
onClick={handleConfirm}
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>
</div>
</div>
@@ -201,7 +201,7 @@ export const ItineraryTimeline = ({
}, [legs]);
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">
{legs.length === 0 ? (
<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 && (
<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}
</h3>
<div className="flex items-center text-sm text-gray-500 mt-1">
+51 -6
View File
@@ -1,7 +1,8 @@
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 { CoordinateSelectModal } from './CoordinateSelectModal';
import { useTranslation } from '../hooks/useTranslation';
interface Comment {
id: string;
@@ -46,6 +47,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
onLoginSuccess,
onUpdatePhoto
}) => {
const { t } = useTranslation();
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
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 () => {
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 ||
(currentUser && photo.uploader && currentUser.id === photo.uploader.id) ||
(currentUser && photo.uploaderId && currentUser.id === photo.uploaderId);
@@ -630,7 +660,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<div>
<h3 className="text-lg font-black tracking-tight text-white flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-emerald-500" />
Bình luận cộng đng
{t('commentSectionTitle')}
</h3>
<p className="text-xs text-slate-400 mt-1">nh chia sẻ công khai trên bản đ</p>
</div>
@@ -641,7 +671,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
{isLoading ? (
<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" />
<span className="text-xs font-semibold">Đang tải bình luận...</span>
<span className="text-xs font-semibold">{t('loading')}</span>
</div>
) : comments.length === 0 ? (
<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="flex justify-between items-center mb-1">
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
<span className="text-[9px] font-medium text-slate-500">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
<div className="flex items-center gap-2">
<span className="text-[9px] font-medium text-slate-500">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</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>
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
</div>
@@ -672,6 +716,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
);
})
)}
<div ref={commentsEndRef} />
</div>
+210 -1
View File
@@ -7,10 +7,14 @@ interface UserManagementModalProps {
}
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 [photos, setPhotos] = 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 [photosLoading, setPhotosLoading] = 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(() => {
if (isOpen) {
if (activeTab === 'users') {
@@ -113,6 +195,9 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
fetchPhotos();
} else if (activeTab === 'trash') {
fetchTrashPhotos();
} else if (activeTab === 'filters') {
fetchModerationSettings();
fetchWordFilters();
}
}
}, [isOpen, activeTab]);
@@ -212,6 +297,15 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
<Trash2 className="w-4 h-4" />
nh rác
</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>
{/* Content Body */}
@@ -409,6 +503,121 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
</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 chặn tải lên các hình nh 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>
+120
View File
@@ -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 };
}
};
+68
View File
@@ -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 };
};
+147
View File
@@ -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 };
};
+140 -7
View File
@@ -5,11 +5,13 @@ const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerCluste
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTourStore } from '@/store/useTourStore';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, 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 { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
import { CreateTourModal } from '../components/CreateTourModal';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { PublicPhotoModal } from '../components/PublicPhotoModal';
// 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 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
const touchTimerRef = React.useRef<any>(null);
@@ -193,6 +225,24 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
};
const [isAdminModalOpen, setIsAdminModalOpen] = 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 [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 }[]>([]);
@@ -515,15 +565,58 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</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 */}
{user?.isAdmin && (
{user && (
<button
onClick={() => setIsAdminModalOpen(true)}
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"
title="Quản lý hệ thống"
onClick={() => {
if (user.isAdmin) {
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" />
<span className="hidden md:inline text-sm">Hệ thống</span>
{user.isAdmin ? <Settings className="w-5 h-5" /> : <Lock className="w-5 h-5" />}
<span className="hidden md:inline text-sm">{t('systemBtn')}</span>
</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 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>
);
};
+135 -17
View File
@@ -2,6 +2,9 @@ import React, { useState, useRef, useEffect } from 'react';
import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-react';
import { LoginModal } from '../components/LoginModal';
import { useNotification } from '@/hooks/useNotification';
import { processImageModeration } from '../hooks/useImageModeration';
import { useTranslation } from '../hooks/useTranslation';
import { useTheme } from '../hooks/useTheme';
interface LandingPageProps {
onContinue?: () => void;
@@ -41,8 +44,11 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
const { t, lang, changeLanguage } = useTranslation();
const { theme, changeTheme } = useTheme();
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
const [currentBgIndex, setCurrentBgIndex] = useState(0);
const [bg1, setBg1] = useState('/background.avif');
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(() => {
fetchPublicPhotos();
fetchTrustedUsers();
}, []);
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' });
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)
const location = await Promise.race([
new Promise<GeolocationPosition | null>((resolve) => {
@@ -138,7 +165,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
// 2. Tải ảnh lên
const formData = new FormData();
formData.append('images', file);
formData.append('images', processedFile);
if (location) {
formData.append('latitude', location.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString());
@@ -247,20 +274,99 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
`}</style>
{/* 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">
<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 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
onClick={() => setIsLoginModalOpen(true)}
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" />
<span className="text-sm">{t('login')}</span>
</button>
</div>
<button
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"
>
<LogIn className="w-4 h-4" />
<span className="text-sm">Đăng nhập</span>
</button>
</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 đá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 */}
<div className="absolute inset-0 flex items-center justify-center z-10" >
{/* Kích hoạt click vào file input để chọn hoặc chụp ảnh */}
@@ -279,20 +385,24 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
{/* Community Gallery Previews */}
{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">
Khoảnh khắc từ cộng đng ({publicPhotos.length})
{t('momentsTitle')} ({publicPhotos.length})
</span>
<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) => (
<button
key={photo.id}
onClick={() => setCurrentBgIndex(index)}
className={`relative w-14 h-14 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
currentBgIndex === index ? 'border-emerald-500 scale-110 shadow-lg' : 'border-white/20 hover:border-white/50'
className={`relative w-14 h-14 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 shadow-inner bg-slate-800 ${
currentBgIndex === index ? 'scale-110 shadow-lg' : 'hover:scale-105'
}`}
>
<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>
))}
</div>
@@ -310,13 +420,21 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
)}
{/* 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
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" />
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>
</div>
+542
View File
@@ -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>
);
};
+362 -8
View File
@@ -3,6 +3,7 @@ import { io } from 'socket.io-client';
import { ItineraryTimeline } from '../components/ItineraryTimeline';
import { ExpenseManager } from '../components/ExpenseManager';
import { useTourStore } from '@/store/useTourStore';
import { useTranslation } from '../hooks/useTranslation';
import { AddLocationModal } from '@/components/AddLocationModal';
import { AddMemberModal } from '../components/AddMemberModal';
import { MembersTab } from '../components/MembersTab';
@@ -253,7 +254,7 @@ const MapHoverTip = ({ canEdit }: { canEdit: boolean }) => {
);
};
// 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 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}`);
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
if (onOpen) onOpen();
},
moveend: (e) => {
@@ -352,6 +354,9 @@ export const TourDetailPage = ({
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
const { t } = useTranslation();
const notify = useNotification();
const confirm = useConfirm();
const currentTour = useTourStore(state => state.currentTour);
const legs = useTourStore(state => state.legs);
const publicTours = useTourStore(state => state.publicTours);
@@ -378,6 +383,187 @@ export const TourDetailPage = ({
const [isAddMemberOpen, setIsAddMemberOpen] = 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
@@ -834,8 +1020,6 @@ export const TourDetailPage = ({
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
const deleteTour = useTourStore(state => state.deleteTour);
const confirm = useConfirm();
const notify = useNotification();
// Khôi phục vị trí và mức zoom từ localStorage
const [initialViewState] = useState(() => {
@@ -1071,8 +1255,8 @@ export const TourDetailPage = ({
const handleNavigateToLocation = (location: any) => {
setMapCenter([location.latitude, location.longitude]);
setViewMode('map');
setLocateTrigger(prev => prev + 1);
setIsMapFullscreen(true);
notify({
title: 'Bắt đầu chỉ đường',
@@ -1784,21 +1968,30 @@ export const TourDetailPage = ({
{activeTab === 'plan' && (
<div className="animate-in fade-in slide-in-from-bottom-2">
{/* View Mode Toggle */}
<div className="flex justify-center mb-6">
<div className="bg-gray-100 p-1 rounded-2xl flex gap-1">
<div className="flex justify-between items-center mb-6">
<div className="flex-1" />
<div className="bg-gray-100 dark:bg-slate-800 p-1 rounded-2xl flex gap-1">
<button
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
</button>
<button
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 đ
</button>
</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>
{viewMode === 'timeline' ? (
@@ -2538,6 +2731,54 @@ export const TourDetailPage = ({
</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 && (
<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">
@@ -2930,6 +3171,20 @@ export const TourDetailPage = ({
)}
<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>
{!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' && (
<button
onClick={async () => {
@@ -2961,6 +3216,105 @@ export const TourDetailPage = ({
</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
isOpen={isCommentModalOpen}
onClose={() => setIsCommentModalOpen(false)}