bugs: lỗi khi người dùng link để tham gia tour nhưng không xuất hiện trong danh sách thành viên

This commit is contained in:
2026-06-20 20:59:01 +07:00
parent 52706cab7d
commit 36157bd53b
18 changed files with 1419 additions and 188 deletions
+1
View File
@@ -19,6 +19,7 @@ export declare class EmailService {
private transporter;
constructor();
sendOTP(email: string, otp: string): Promise<any>;
sendTourInvitation(email: string, tourTitle: string, inviteLink: string): Promise<any>;
}
export declare class CommentGateway implements OnGatewayConnection {
server: Server;
+283 -15
View File
@@ -51,6 +51,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.CommentGateway = exports.EmailService = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = void 0;
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
const crypto = __importStar(require("crypto"));
const config_1 = require("@nestjs/config");
require("reflect-metadata");
const zlib = __importStar(require("zlib"));
@@ -258,6 +259,40 @@ let EmailService = class EmailService {
throw error;
}
}
async sendTourInvitation(email, tourTitle, inviteLink) {
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
throw new Error('Thiếu cấu hình SMTP_USER hoặc SMTP_PASS trong file .env');
}
const mailOptions = {
from: `"Travel Planning Support" <${process.env.SMTP_USER}>`,
to: email,
subject: `Lời mời tham gia hành trình: ${tourTitle}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 10px;">
<h2 style="color: #2563eb; text-align: center;">Lời Mời Tham Gia Hành Trình</h2>
<p>Xin chào,</p>
<p>Bạn đã được mời tham gia hành trình du lịch <strong>"${tourTitle}"</strong>.</p>
<p>Vui lòng nhấp vào nút dưới đây để chấp nhận lời mời và gia nhập hành trình của chúng tôi:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${inviteLink}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; font-weight: bold; border-radius: 8px; display: inline-block;">
Tham Gia Hành Trình Ngay
</a>
</div>
<p style="font-size: 13px; color: #4b5563;">Hoặc bạn có thể sao chép liên kết dưới đây và dán vào trình duyệt:</p>
<p style="font-size: 13px; color: #2563eb; word-break: break-all;">${inviteLink}</p>
<p>Lời mời này có hiệu lực trong vòng 7 ngày. Hãy nhanh tay đăng ký nhé!</p>
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;" />
<p style="font-size: 12px; color: #6b7280; text-align: center;">Đây là email tự động từ ứng dụng hệ thống Travel Planning.</p>
</div>
`,
};
try {
return await this.transporter.sendMail(mailOptions);
}
catch (error) {
throw error;
}
}
};
exports.EmailService = EmailService;
exports.EmailService = EmailService = __decorate([
@@ -352,7 +387,7 @@ let AuthController = class AuthController {
async login(body) {
const { email, password } = body;
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) {
throw new common_1.UnauthorizedException('Email hoặc mật khẩu không chính xác');
}
if (user.isBlocked) {
@@ -369,6 +404,67 @@ let AuthController = class AuthController {
},
};
}
async googleLogin(body) {
const { credential } = body;
if (!credential) {
throw new common_1.BadRequestException('Vui lòng cung cấp Google credential.');
}
try {
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`);
if (!response.ok) {
throw new common_1.UnauthorizedException('Token Google không hợp lệ hoặc đã hết hạn.');
}
const payload = await response.json();
const email = payload.email;
const name = payload.name || email.split('@')[0];
const avatar = payload.picture || null;
if (!email) {
throw new common_1.BadRequestException('Không tìm thấy địa chỉ email từ Google.');
}
let user = await this.prisma.user.findUnique({ where: { email } });
if (!user) {
user = await this.prisma.user.create({
data: {
email,
name,
avatar,
isAnonymous: false,
},
});
}
else if (user.isBlocked) {
throw new common_1.UnauthorizedException('Tài khoản của bạn đã bị khóa. Vui lòng liên hệ quản trị viên.');
}
else if (user.isAnonymous) {
user = await this.prisma.user.update({
where: { id: user.id },
data: {
isAnonymous: false,
name: user.name || name,
avatar: user.avatar || avatar,
},
});
}
const jwtPayload = { email: user.email, sub: user.id };
return {
access_token: this.jwtService.sign(jwtPayload),
user: {
id: user.id,
email: user.email,
name: user.name,
avatar: user.avatar,
isAdmin: user.isAdmin,
},
};
}
catch (error) {
console.error('[Google Auth Error]:', error);
if (error instanceof common_1.UnauthorizedException || error instanceof common_1.BadRequestException) {
throw error;
}
throw new common_1.BadRequestException('Đã xảy ra lỗi khi đăng nhập bằng Google.');
}
}
async signupRequest(body) {
const { email, password, name, phone, address } = body;
const existingUser = await this.prisma.user.findUnique({ where: { email } });
@@ -465,6 +561,13 @@ __decorate([
__metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise)
], AuthController.prototype, "login", null);
__decorate([
(0, common_1.Post)('google'),
__param(0, (0, common_1.Body)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise)
], AuthController.prototype, "googleLogin", null);
__decorate([
(0, common_1.Post)('signup/request'),
__param(0, (0, common_1.Body)()),
@@ -550,8 +653,9 @@ PublicTourController = __decorate([
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], PublicTourController);
let TourController = class TourController {
constructor(prisma, cacheManager) {
constructor(prisma, emailService, cacheManager) {
this.prisma = prisma;
this.emailService = emailService;
this.cacheManager = cacheManager;
}
async createTour(body, req) {
@@ -844,16 +948,14 @@ let TourController = class TourController {
}
async getPublicTours(req) {
return this.prisma.tour.findMany({
where: {
participants: {
some: { userId: req.user.id }
}
},
take: 20,
take: 50,
include: {
participants: {
where: { userId: req.user.id },
select: { role: true }
select: { userId: true, role: true, displayName: true }
},
joinRequests: {
where: { userId: req.user.id, status: 'PENDING' },
select: { id: true }
},
photos: { take: 1 },
legs: {
@@ -1245,6 +1347,142 @@ let TourController = class TourController {
});
}));
}
async createInvitation(tourId, body, req) {
const { email, role = client_1.ParticipantRole.MEMBER } = body;
if (!email) {
throw new common_1.BadRequestException('Vui lòng cung cấp email mời.');
}
const tour = await this.prisma.tour.findUnique({
where: { id: tourId },
select: { title: true }
});
if (!tour) {
throw new common_1.NotFoundException('Không tìm thấy hành trình.');
}
const invitedUser = await this.prisma.user.findUnique({ where: { email } });
if (invitedUser) {
const existingParticipant = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: invitedUser.id } }
});
if (existingParticipant) {
throw new common_1.BadRequestException('Người dùng này đã là thành viên của tour.');
}
}
const token = crypto.randomBytes(32).toString('hex');
const expiredAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const invitation = await this.prisma.tourInvitation.upsert({
where: { tourId_email: { tourId, email } },
update: { token, expiredAt, role },
create: { tourId, email, token, expiredAt, role }
});
const appUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
const inviteLink = `${appUrl}/join-tour?token=${token}`;
try {
await this.emailService.sendTourInvitation(email, tour.title, inviteLink);
}
catch (e) {
console.error('[Invitation Email Error]:', e);
throw new common_1.BadRequestException('Không thể gửi email lời mời. Vui lòng kiểm tra cấu hình SMTP.');
}
return { success: true, message: 'Lời mời đã được gửi thành công!' };
}
async joinByToken(body, req) {
const { token } = body;
if (!token) {
throw new common_1.BadRequestException('Vui lòng cung cấp token lời mời.');
}
const invitation = await this.prisma.tourInvitation.findUnique({
where: { token },
include: { tour: { select: { title: true } } }
});
if (!invitation) {
throw new common_1.NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.');
}
if (invitation.expiredAt < new Date()) {
try {
await this.prisma.tourInvitation.delete({ where: { id: invitation.id } });
}
catch (e) { }
throw new common_1.BadRequestException('Lời mời đã hết hạn.');
}
const userId = req.user.id;
const existingParticipation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId: invitation.tourId, userId } }
});
if (existingParticipation) {
try {
await this.prisma.tourInvitation.delete({ where: { id: invitation.id } });
}
catch (e) { }
await Promise.all([
this.cacheManager.del(invitation.tourId),
this.cacheManager.del(`/api/v1/tours/${invitation.tourId}`),
this.cacheManager.del(`/api/v1/tours/${invitation.tourId}/public`),
this.cacheManager.del(`/api/v1/tours/explore`),
]);
return { success: true, tourId: invitation.tourId, message: 'Bạn đã là thành viên của tour này.' };
}
await this.prisma.$transaction([
this.prisma.tourParticipant.create({
data: {
tourId: invitation.tourId,
userId,
role: invitation.role
}
}),
this.prisma.tourInvitation.delete({
where: { id: invitation.id }
})
]);
await Promise.all([
this.cacheManager.del(invitation.tourId),
this.cacheManager.del(`/api/v1/tours/${invitation.tourId}`),
this.cacheManager.del(`/api/v1/tours/${invitation.tourId}/public`),
this.cacheManager.del(`/api/v1/tours/explore`),
this.cacheManager.del(`user-role:${userId}:${invitation.tourId}`),
]);
return { success: true, tourId: invitation.tourId, message: `Bạn đã gia nhập hành trình "${invitation.tour.title}"!` };
}
async mergeMember(tourId, body, req) {
const { manualParticipantId, systemUserId } = body;
if (!manualParticipantId || !systemUserId) {
throw new common_1.BadRequestException('Thiếu thông tin manualParticipantId hoặc systemUserId.');
}
const manualParticipant = await this.prisma.tourParticipant.findFirst({
where: { id: manualParticipantId, tourId, userId: null }
});
if (!manualParticipant) {
throw new common_1.NotFoundException('Không tìm thấy thành viên ngoài hệ thống cần gán.');
}
const systemParticipant = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: systemUserId } }
});
if (!systemParticipant) {
throw new common_1.NotFoundException('Không tìm thấy thành viên hệ thống trong hành trình này.');
}
const nextAdult = Math.max(systemParticipant.adultCount, manualParticipant.adultCount);
const nextChild = Math.max(systemParticipant.childCount, manualParticipant.childCount);
await this.prisma.$transaction([
this.prisma.tourParticipant.update({
where: { id: systemParticipant.id },
data: {
adultCount: nextAdult,
childCount: nextChild,
role: manualParticipant.role !== client_1.ParticipantRole.MEMBER ? manualParticipant.role : systemParticipant.role
}
}),
this.prisma.tourParticipant.delete({
where: { id: manualParticipant.id }
})
]);
await this.cacheManager.del(`user-role:${systemUserId}:${tourId}`);
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/explore`)
]);
return { success: true, message: 'Đã gán và hợp nhất thông tin thành viên thành công.' };
}
};
__decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
@@ -1337,7 +1575,6 @@ __decorate([
__metadata("design:returntype", Promise)
], TourController.prototype, "getPublicTours", null);
__decorate([
(0, common_1.UseInterceptors)(compress_cache_interceptor_1.CompressCacheInterceptor),
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE, client_1.ParticipantRole.VIEWER_ONLY),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Get)(':id'),
@@ -1380,8 +1617,7 @@ __decorate([
__metadata("design:returntype", Promise)
], TourController.prototype, "getJoinRequests", null);
__decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE, client_1.ParticipantRole.VIEWER_ONLY),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
(0, common_1.Post)(':tourId/join-requests'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Body)()),
@@ -1434,10 +1670,42 @@ __decorate([
__metadata("design:paramtypes", [String, Array, Object]),
__metadata("design:returntype", Promise)
], TourController.prototype, "uploadPhotos", null);
__decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':tourId/invitations'),
__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)
], TourController.prototype, "createInvitation", null);
__decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
(0, common_1.Post)('join-by-token'),
__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)
], TourController.prototype, "joinByToken", null);
__decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Post)(':tourId/members/merge'),
__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)
], TourController.prototype, "mergeMember", null);
TourController = __decorate([
(0, common_1.Controller)('tours'),
__param(1, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [prisma_service_1.PrismaService, Object])
__param(2, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
EmailService, Object])
], TourController);
let LocationController = class LocationController {
constructor(prisma, cacheManager) {
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "TourInvitation" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiredAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TourInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TourInvitation_token_key" ON "TourInvitation"("token");
-- CreateIndex
CREATE UNIQUE INDEX "TourInvitation_tourId_email_key" ON "TourInvitation"("tourId", "email");
-- AddForeignKey
ALTER TABLE "TourInvitation" ADD CONSTRAINT "TourInvitation_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+14
View File
@@ -99,6 +99,7 @@ model Tour {
joinRequests JoinRequest[]
legs Leg[]
photos Photo[]
invitations TourInvitation[]
}
model JoinRequest {
@@ -213,3 +214,16 @@ model Comment {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model TourInvitation {
id String @id @default(uuid())
tourId String
email String
role ParticipantRole @default(MEMBER)
token String @unique
createdAt DateTime @default(now())
expiredAt DateTime
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
@@unique([tourId, email])
}
+288 -14
View File
@@ -1,5 +1,6 @@
import * as path from 'path';
import * as fs from 'fs';
import * as crypto from 'crypto';
import { ConfigModule, ConfigService } from '@nestjs/config';
import 'reflect-metadata';
@@ -244,6 +245,42 @@ export class EmailService {
throw error;
}
}
async sendTourInvitation(email: string, tourTitle: string, inviteLink: string) {
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
throw new Error('Thiếu cấu hình SMTP_USER hoặc SMTP_PASS trong file .env');
}
const mailOptions = {
from: `"Travel Planning Support" <${process.env.SMTP_USER}>`,
to: email,
subject: `Lời mời tham gia hành trình: ${tourTitle}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 10px;">
<h2 style="color: #2563eb; text-align: center;">Lời Mời Tham Gia Hành Trình</h2>
<p>Xin chào,</p>
<p>Bạn đã được mời tham gia hành trình du lịch <strong>"${tourTitle}"</strong>.</p>
<p>Vui lòng nhấp vào nút dưới đây để chấp nhận lời mời và gia nhập hành trình của chúng tôi:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${inviteLink}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; font-weight: bold; border-radius: 8px; display: inline-block;">
Tham Gia Hành Trình Ngay
</a>
</div>
<p style="font-size: 13px; color: #4b5563;">Hoặc bạn có thể sao chép liên kết dưới đây và dán vào trình duyệt:</p>
<p style="font-size: 13px; color: #2563eb; word-break: break-all;">${inviteLink}</p>
<p>Lời mời này có hiệu lực trong vòng 7 ngày. Hãy nhanh tay đăng ký nhé!</p>
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;" />
<p style="font-size: 12px; color: #6b7280; text-align: center;">Đây là email tự động từ ứng dụng hệ thống Travel Planning.</p>
</div>
`,
};
try {
return await this.transporter.sendMail(mailOptions);
} catch (error) {
throw error;
}
}
}
@Controller()
@@ -355,7 +392,7 @@ class AuthController {
const { email, password } = body;
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException('Email hoặc mật khẩu không chính xác');
}
@@ -377,6 +414,72 @@ class AuthController {
};
}
@Post('google')
async googleLogin(@Body() body: { credential: string }) {
const { credential } = body;
if (!credential) {
throw new BadRequestException('Vui lòng cung cấp Google credential.');
}
try {
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`);
if (!response.ok) {
throw new UnauthorizedException('Token Google không hợp lệ hoặc đã hết hạn.');
}
const payload = await response.json();
const email = payload.email;
const name = payload.name || email.split('@')[0];
const avatar = payload.picture || null;
if (!email) {
throw new BadRequestException('Không tìm thấy địa chỉ email từ Google.');
}
let user = await this.prisma.user.findUnique({ where: { email } });
if (!user) {
user = await this.prisma.user.create({
data: {
email,
name,
avatar,
isAnonymous: false,
},
});
} else if (user.isBlocked) {
throw new UnauthorizedException('Tài khoản của bạn đã bị khóa. Vui lòng liên hệ quản trị viên.');
} else if (user.isAnonymous) {
user = await this.prisma.user.update({
where: { id: user.id },
data: {
isAnonymous: false,
name: user.name || name,
avatar: user.avatar || avatar,
},
});
}
const jwtPayload = { email: user.email, sub: user.id };
return {
access_token: this.jwtService.sign(jwtPayload),
user: {
id: user.id,
email: user.email,
name: user.name,
avatar: user.avatar,
isAdmin: user.isAdmin,
},
};
} catch (error) {
console.error('[Google Auth Error]:', error);
if (error instanceof UnauthorizedException || error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException('Đã xảy ra lỗi khi đăng nhập bằng Google.');
}
}
@Post('signup/request')
async signupRequest(@Body() body: any) {
const { email, password, name, phone, address } = body;
@@ -525,6 +628,7 @@ class PublicTourController {
class TourController {
constructor(
private prisma: PrismaService,
private emailService: EmailService,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) {}
@@ -894,23 +998,20 @@ class TourController {
return { success: true };
}
// getPublicTours does not need TourRoleGuard as it's for any logged-in user to see their tours
@UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache
@UseGuards(JwtAuthGuard)
@Get('explore')
async getPublicTours(@Req() req: any) {
// Lọc Tour: Chỉ lấy những tour mà người dùng hin tại là thành viên (Participant)
// Trả về tất cả các tour trong hệ thống để hin thị trên bản đồ cộng đồng
return this.prisma.tour.findMany({
where: {
participants: {
some: { userId: req.user.id }
}
},
take: 20,
take: 50,
include: {
participants: {
where: { userId: req.user.id },
select: { role: true }
select: { userId: true, role: true, displayName: true }
},
joinRequests: {
where: { userId: req.user.id, status: 'PENDING' },
select: { id: true }
},
photos: { take: 1 },
legs: {
@@ -929,7 +1030,6 @@ class TourController {
});
}
@UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY) // Any participant can view tour details
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Get(':id')
@@ -1133,8 +1233,7 @@ class TourController {
return requests;
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY) // Any participant can create a join request for themselves or others (if they have permission)
@UseGuards(JwtAuthGuard, TourRoleGuard)
@UseGuards(JwtAuthGuard)
@Post(':tourId/join-requests')
async createJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId?: string }, @Req() req: any) {
const requestingUserId = body.userId || req.user.id;
@@ -1392,6 +1491,181 @@ class TourController {
});
}));
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER)
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/invitations')
async createInvitation(
@Param('tourId', ParseUUIDPipe) tourId: string,
@Body() body: { email: string; role?: ParticipantRole },
@Req() req: any
) {
const { email, role = ParticipantRole.MEMBER } = body;
if (!email) {
throw new BadRequestException('Vui lòng cung cấp email mời.');
}
const tour = await this.prisma.tour.findUnique({
where: { id: tourId },
select: { title: true }
});
if (!tour) {
throw new NotFoundException('Không tìm thấy hành trình.');
}
const invitedUser = await this.prisma.user.findUnique({ where: { email } });
if (invitedUser) {
const existingParticipant = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: invitedUser.id } }
});
if (existingParticipant) {
throw new BadRequestException('Người dùng này đã là thành viên của tour.');
}
}
const token = crypto.randomBytes(32).toString('hex');
const expiredAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const invitation = await this.prisma.tourInvitation.upsert({
where: { tourId_email: { tourId, email } },
update: { token, expiredAt, role },
create: { tourId, email, token, expiredAt, role }
});
const appUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
const inviteLink = `${appUrl}/join-tour?token=${token}`;
try {
await this.emailService.sendTourInvitation(email, tour.title, inviteLink);
} catch (e) {
console.error('[Invitation Email Error]:', e);
throw new BadRequestException('Không thể gửi email lời mời. Vui lòng kiểm tra cấu hình SMTP.');
}
return { success: true, message: 'Lời mời đã được gửi thành công!' };
}
@UseGuards(JwtAuthGuard)
@Post('join-by-token')
async joinByToken(@Body() body: { token: string }, @Req() req: any) {
const { token } = body;
if (!token) {
throw new BadRequestException('Vui lòng cung cấp token lời mời.');
}
const invitation = await this.prisma.tourInvitation.findUnique({
where: { token },
include: { tour: { select: { title: true } } }
});
if (!invitation) {
throw new NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.');
}
if (invitation.expiredAt < new Date()) {
try {
await this.prisma.tourInvitation.delete({ where: { id: invitation.id } });
} catch (e) {}
throw new BadRequestException('Lời mời đã hết hạn.');
}
const userId = req.user.id;
const existingParticipation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId: invitation.tourId, userId } }
});
if (existingParticipation) {
try {
await this.prisma.tourInvitation.delete({ where: { id: invitation.id } });
} catch (e) {}
// Xóa cache để đảm bảo dữ liệu thành viên luôn mới nhất
await Promise.all([
this.cacheManager.del(invitation.tourId),
this.cacheManager.del(`/api/v1/tours/${invitation.tourId}`),
this.cacheManager.del(`/api/v1/tours/${invitation.tourId}/public`),
this.cacheManager.del(`/api/v1/tours/explore`),
]);
return { success: true, tourId: invitation.tourId, message: 'Bạn đã là thành viên của tour này.' };
}
await this.prisma.$transaction([
this.prisma.tourParticipant.create({
data: {
tourId: invitation.tourId,
userId,
role: invitation.role
}
}),
this.prisma.tourInvitation.delete({
where: { id: invitation.id }
})
]);
await Promise.all([
this.cacheManager.del(invitation.tourId),
this.cacheManager.del(`/api/v1/tours/${invitation.tourId}`),
this.cacheManager.del(`/api/v1/tours/${invitation.tourId}/public`),
this.cacheManager.del(`/api/v1/tours/explore`),
this.cacheManager.del(`user-role:${userId}:${invitation.tourId}`),
]);
return { success: true, tourId: invitation.tourId, message: `Bạn đã gia nhập hành trình "${invitation.tour.title}"!` };
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER)
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/members/merge')
async mergeMember(
@Param('tourId', ParseUUIDPipe) tourId: string,
@Body() body: { manualParticipantId: string; systemUserId: string },
@Req() req: any
) {
const { manualParticipantId, systemUserId } = body;
if (!manualParticipantId || !systemUserId) {
throw new BadRequestException('Thiếu thông tin manualParticipantId hoặc systemUserId.');
}
const manualParticipant = await this.prisma.tourParticipant.findFirst({
where: { id: manualParticipantId, tourId, userId: null }
});
if (!manualParticipant) {
throw new NotFoundException('Không tìm thấy thành viên ngoài hệ thống cần gán.');
}
const systemParticipant = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: systemUserId } }
});
if (!systemParticipant) {
throw new NotFoundException('Không tìm thấy thành viên hệ thống trong hành trình này.');
}
const nextAdult = Math.max(systemParticipant.adultCount, manualParticipant.adultCount);
const nextChild = Math.max(systemParticipant.childCount, manualParticipant.childCount);
await this.prisma.$transaction([
this.prisma.tourParticipant.update({
where: { id: systemParticipant.id },
data: {
adultCount: nextAdult,
childCount: nextChild,
role: manualParticipant.role !== ParticipantRole.MEMBER ? manualParticipant.role : systemParticipant.role
}
}),
this.prisma.tourParticipant.delete({
where: { id: manualParticipant.id }
})
]);
await this.cacheManager.del(`user-role:${systemUserId}:${tourId}`);
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/explore`)
]);
return { success: true, message: 'Đã gán và hợp nhất thông tin thành viên thành công.' };
}
}
@Controller('locations')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script src="https://accounts.google.com/gsi/client" async defer></script>
<title>Travel Planner</title>
</head>
<body>
+68 -23
View File
@@ -5,6 +5,7 @@ import { TourDetailPage } from './pages/TourDetailPage';
import SignupPage from './pages/SignupPage';
import { MyPhotosPage } from './pages/MyPhotosPage';
import { MyNotePage } from './pages/MyNotePage';
import { JoinTourPage } from './pages/JoinTourPage';
import { useTourStore } from './store/useTourStore';
import { ConfirmProvider } from './hooks/useConfirm';
import { NotificationProvider } from './hooks/useNotification';
@@ -14,7 +15,9 @@ function App() {
const viewTourId = params.get('viewTour');
const [user, setUser] = useState<any>(null);
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes'>(viewTourId ? 'tourDetail' : 'landing');
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour'>(
viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing')
);
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
@@ -25,32 +28,45 @@ function App() {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const viewTourId = params.get('viewTour');
const isJoinTour = window.location.pathname === '/join-tour' || params.has('token');
if (viewTourId) {
// Không gọi replaceState ngay để tránh mất ID khi component re-render hoặc refresh
} else {
// Kiểm tra đăng nhập bình thường nếu không có tham số viewTour
const token = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (token && storedUser) {
try {
setUser(JSON.parse(storedUser));
setCurrentPage('explore'); // Chuyển đến bản đồ khám phá nếu đã đăng nhập
} catch (e) {
console.error("Lỗi khi phân tích dữ liệu người dùng từ localStorage", e);
localStorage.removeItem('token');
localStorage.removeItem('user');
setCurrentPage('landing'); // Quay về trang Landing nếu dữ liệu lỗi
}
} else {
setCurrentPage('landing'); // Quay về trang Landing nếu chưa đăng nhập
// Khôi phục thông tin đăng nhập nếu có
const token = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
let loggedInUser = null;
if (token && storedUser) {
try {
loggedInUser = JSON.parse(storedUser);
setUser(loggedInUser);
} catch (e) {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
}
}, []); // Chỉ chạy một lần khi component mount
if (isJoinTour) {
setCurrentPage('joinTour');
} else if (viewTourId) {
setCurrentPage('tourDetail');
} else {
if (loggedInUser) {
setCurrentPage('explore');
} else {
setCurrentPage('landing');
}
}
}, []);
const handleLoginSuccess = (loggedInUser: any) => {
setUser(loggedInUser);
setCurrentPage('explore');
// Nếu có pending token, ta vẫn giữ ở trang joinTour để nó tự động thực hiện join
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else {
setCurrentPage('explore');
}
};
const handleLogout = () => {
@@ -78,11 +94,22 @@ function App() {
};
const handleBackFromSignup = () => {
setCurrentPage('landing');
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else {
setCurrentPage('landing');
}
};
const handleSignupSuccess = () => {
setCurrentPage('landing'); // Quay về trang Landing sau khi đăng ký thành công
// Sau khi đăng ký, ta có thể tự động đăng nhập hoặc quay lại landing/joinTour
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else {
setCurrentPage('landing');
}
};
return (
@@ -127,6 +154,24 @@ function App() {
return <SignupPage onBack={handleBackFromSignup} onSuccess={handleSignupSuccess} />;
}
if (currentPage === 'joinTour') {
return (
<JoinTourPage
onLoginSuccess={handleLoginSuccess}
onGoToSignup={() => setCurrentPage('signup')}
onViewTour={(tourId) => {
setCurrentTourId(tourId);
setIsPublicTourView(false);
setCurrentPage('tourDetail');
}}
onGoToHome={() => {
const loggedIn = !!localStorage.getItem('token');
setCurrentPage(loggedIn ? 'explore' : 'landing');
}}
/>
);
}
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
})()}
</NotificationProvider>
+254 -121
View File
@@ -16,6 +16,7 @@ interface AddMemberModalProps {
}
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole, isPublicView }) => {
const [activeTab, setActiveTab] = useState<'search' | 'email'>('search');
const [query, setQuery] = useState('');
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
@@ -26,6 +27,13 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const [submitError, setSubmitError] = useState('');
const [actionLoading, setActionLoading] = useState<string | null>(null);
// Trạng thái cho tab mời qua email
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY'>('MEMBER');
const [inviteLoading, setInviteLoading] = useState(false);
const [inviteError, setInviteError] = useState('');
const [inviteSuccess, setInviteSuccess] = useState('');
const confirm = useConfirm();
const notify = useNotification();
@@ -34,6 +42,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
const canInviteByEmail = userRole === 'OWNER' || userRole === 'MANAGER';
const handleManualAdd = async () => {
const name = query.trim();
@@ -79,14 +88,44 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
}
};
const handleSendInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim()) return;
setInviteLoading(true);
setInviteError('');
setInviteSuccess('');
try {
const res = await fetch(`/api/v1/tours/${tourId}/invitations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'Gửi lời mời thất bại');
}
setInviteSuccess(`Lời mời đã được gửi thành công đến ${inviteEmail}!`);
setInviteEmail('');
notify({ title: 'Thành công', message: `Lời mời đã gửi tới ${inviteEmail}`, type: 'success' });
await onMemberAdded?.();
} catch (err: any) {
setInviteError(err.message || 'Thao tác thất bại');
} finally {
setInviteLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
if (!isOpen || activeTab !== 'search') return;
const delayDebounceFn = setTimeout(() => {
fetchUsers();
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [query, isOpen]);
}, [query, isOpen, activeTab]);
useEffect(() => {
if (!isOpen) {
@@ -95,6 +134,11 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
setRole('MEMBER');
setFetchError('');
setSubmitError('');
setInviteEmail('');
setInviteRole('MEMBER');
setInviteError('');
setInviteSuccess('');
setActiveTab('search');
}
}, [isOpen]);
@@ -176,10 +220,10 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div>
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
<UserPlus className="w-5 h-5 text-blue-600" /> {canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'}
<UserPlus className="w-5 h-5 text-blue-600" /> {canCreateDirectly ? 'Thành viên hành trình' : 'Mời tham gia tour'}
</h2>
<p className="text-xs text-gray-500">
{canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'}
{canCreateDirectly ? 'Quản lý, thêm thành viên và mời người khác tham gia.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'}
</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
@@ -187,7 +231,33 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</button>
</div>
<div className="p-5 space-y-4">
{canInviteByEmail && (
<div className="flex border-b border-gray-100 bg-gray-50/30">
<button
onClick={() => setActiveTab('search')}
className={`flex-1 py-3 text-center text-sm font-bold border-b-2 transition-all ${
activeTab === 'search'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Tìm thành viên
</button>
<button
onClick={() => setActiveTab('email')}
className={`flex-1 py-3 text-center text-sm font-bold border-b-2 transition-all ${
activeTab === 'email'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Mời qua Email
</button>
</div>
)}
<div className="p-5 space-y-4 overflow-y-auto flex-1">
{/* Danh sách thành viên hiện tại */}
<div>
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.filter(p => p.user || p.displayName).length})</p>
<div className="flex flex-wrap gap-3">
@@ -230,6 +300,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</div>
</div>
{/* Danh sách chờ duyệt */}
{joinRequests.length > 0 && (
<div>
<p className="text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1">
@@ -269,128 +340,190 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
</div>
)}
{canCreateDirectly && (
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền</label>
<select
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={role}
onChange={(e) => setRole(e.target.value as any)}
>
<option value="OWNER">OWNER</option>
<option value="MANAGER">MANAGER</option>
<option value="MEMBER">MEMBER</option>
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE</option>
<option value="VIEWER_ONLY">VIEWER_ONLY</option>
</select>
</div>
)}
<hr className="border-gray-100" />
<div className="space-y-2">
<div className="relative mb-2">
<input
type="text"
placeholder="Tìm kiếm theo tên hoặc địa chỉ email..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-10 pr-10 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-gray-800"
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
{query && (
{activeTab === 'search' ? (
<div className="space-y-4">
{canCreateDirectly && (
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền khi thêm</label>
<select
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm font-bold text-gray-800"
value={role}
onChange={(e) => setRole(e.target.value as any)}
>
<option value="OWNER">OWNER</option>
<option value="MANAGER">MANAGER</option>
<option value="MEMBER">MEMBER</option>
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE</option>
<option value="VIEWER_ONLY">VIEWER_ONLY</option>
</select>
</div>
)}
<div className="space-y-2">
<div className="relative mb-2">
<input
type="text"
placeholder="Tìm kiếm theo tên hoặc địa chỉ email..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-10 pr-10 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-gray-800"
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-200 rounded-full text-gray-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{fetchError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{fetchError}
</div>
)}
{submitError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{submitError}
</div>
)}
{loading ? (
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
) : (
<div className="space-y-2 max-h-[30vh] overflow-y-auto pr-1">
{query.trim() && canCreateDirectly && (
<button
type="button"
onClick={handleManualAdd}
disabled={submitting}
className="w-full flex items-center gap-3 p-3 rounded-2xl border border-dashed border-blue-300 hover:border-blue-400 bg-blue-50/20 text-blue-700 transition-all text-left mb-2"
>
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
+
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold">Thêm thành viên thủ công</div>
<div className="text-[11px] text-gray-500">Thêm "{query.trim()}" trực tiếp vào danh sách thành viên</div>
</div>
{submitting ? (
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
) : (
<span className="px-2.5 py-1 bg-blue-600 text-white rounded-xl text-xs font-bold transition-all">Thêm</span>
)}
</button>
)}
{visibleUsers.map((u) => {
const isSelected = selectedUser === u.id;
return (
<button
key={u.id}
onClick={() => setSelectedUser(u.id)}
disabled={requestUserIds.has(u.id)}
className={`w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${
isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'
} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`}
>
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || '?'}
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
<div className="text-[11px] text-gray-500">{u.email}</div>
</div>
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
{isSelected && <Shield className="w-3 h-3 text-blue-600" />}
</div>
</button>
);
})}
{!loading && visibleUsers.length === 0 && (
<div className="text-center py-8 text-sm text-gray-500">Không tìm thấy người dùng phù hợp</div>
)}
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
Hủy
</button>
<button
disabled={!selectedUser || submitting}
onClick={handleAdd}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all"
>
{submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời'}
</button>
</div>
</div>
) : (
<form onSubmit={handleSendInvite} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 ml-1">Email người nhận</label>
<input
required
type="email"
placeholder="nhap.email@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-gray-800"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 ml-1">Vai trò trong Tour</label>
<select
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm font-bold text-gray-800"
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as any)}
>
<option value="MEMBER">MEMBER (Thành viên tài chính)</option>
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE (Thành viên phi tài chính)</option>
<option value="MANAGER">MANAGER (Đng quản trị viên)</option>
<option value="VIEWER_ONLY">VIEWER_ONLY (Chỉ xem thông tin)</option>
</select>
</div>
{inviteError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{inviteError}
</div>
)}
{inviteSuccess && (
<div className="p-3 bg-green-50 text-green-700 rounded-xl text-xs font-bold border border-green-100">
{inviteSuccess}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-200 rounded-full text-gray-400 transition-colors"
onClick={onClose}
className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors"
>
<X className="w-4 h-4" />
Đóng
</button>
<button
type="submit"
disabled={inviteLoading || !inviteEmail}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all flex items-center gap-2"
>
{inviteLoading ? (
<>
Đang gửi...
<Loader2 className="w-4 h-4 animate-spin" />
</>
) : (
'Gửi thư mời'
)}
</button>
)}
</div>
{fetchError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{fetchError}
</div>
)}
{submitError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{submitError}
</div>
)}
{loading ? (
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
) : (
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1">
{query.trim() && canCreateDirectly && (
<button
type="button"
onClick={handleManualAdd}
disabled={submitting}
className="w-full flex items-center gap-3 p-3 rounded-2xl border border-dashed border-blue-300 hover:border-blue-400 bg-blue-50/20 text-blue-700 transition-all text-left mb-2"
>
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
+
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold">Thêm thành viên thủ công</div>
<div className="text-[11px] text-gray-500">Thêm "{query.trim()}" trực tiếp vào danh sách thành viên</div>
</div>
{submitting ? (
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
) : (
<span className="px-2.5 py-1 bg-blue-600 text-white rounded-xl text-xs font-bold transition-all">Thêm</span>
)}
</button>
)}
{visibleUsers.map((u) => {
const isSelected = selectedUser === u.id;
return (
<button
key={u.id}
onClick={() => setSelectedUser(u.id)}
disabled={requestUserIds.has(u.id)}
className={`w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${
isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'
} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`}
>
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || '?'}
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
<div className="text-[11px] text-gray-500">{u.email}</div>
<div className="text-[11px] text-gray-400">{u.phone || ''} {u.address ? `${u.address}` : ''}</div>
</div>
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100">USER</span>
)}
{isSelected && <Shield className="w-3 h-3 text-blue-600" />}
</div>
</button>
);
})}
{!loading && visibleUsers.length === 0 && (
<div className="text-center py-8 text-sm text-gray-500">Không tìm thấy người dùng phù hợp</div>
)}
</div>
)}
</div>
</div>
<div className="p-5 border-t border-gray-100 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
Hủy
</button>
<button
disabled={!selectedUser || submitting}
onClick={handleAdd}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all"
>
{submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời'}
</button>
</form>
)}
</div>
</div>
</div>
+103 -1
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
interface LoginModalProps {
@@ -14,6 +14,79 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleGoogleLogin = async (googleResponse: any) => {
setError('');
setIsLoading(true);
try {
const response = await fetch(`/api/v1/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: googleResponse.credential }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng nhập Google thất bại');
}
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
// Tự động gia nhập tour nếu có pendingInviteToken
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: pendingInviteToken }),
});
if (joinRes.ok) {
localStorage.removeItem('pendingInviteToken');
}
} catch (e) {
console.error('Lỗi tự động gia nhập:', e);
}
}
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
onClose();
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
const timer = setTimeout(() => {
if (typeof window !== 'undefined' && (window as any).google) {
try {
(window as any).google.accounts.id.initialize({
client_id: (import.meta as any).env.VITE_GOOGLE_CLIENT_ID || '864264639911-dummyid.apps.googleusercontent.com',
callback: handleGoogleLogin,
});
(window as any).google.accounts.id.renderButton(
document.getElementById('google-signin-btn-login'),
{ theme: 'outline', size: 'large', width: '380' }
);
} catch (e) {
console.error('Lỗi khởi tạo Google Sign-in:', e);
}
}
}, 100);
return () => clearTimeout(timer);
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
@@ -37,6 +110,26 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
// Tự động gia nhập tour nếu có pendingInviteToken
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
try {
const joinRes = await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: pendingInviteToken }),
});
if (joinRes.ok) {
localStorage.removeItem('pendingInviteToken');
}
} catch (e) {
console.error('Lỗi tự động gia nhập:', e);
}
}
if (onLoginSuccess) {
onLoginSuccess(data.user);
}
@@ -122,6 +215,15 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
</button>
</form>
<div className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200"></div>
</div>
<span className="relative px-3 bg-white text-xs font-bold text-gray-400 uppercase">Hoặc</span>
</div>
<div id="google-signin-btn-login" className="w-full flex justify-center"></div>
<div className="mt-10 pt-8 border-t border-gray-100 text-center">
<p className="text-gray-500">
Chưa tài khoản?{' '}
+62 -12
View File
@@ -5,7 +5,7 @@ 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 } from 'lucide-react';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock } from 'lucide-react';
import { UserManagementModal } from '@/components/UserManagementModal';
import { useNotification } from '@/hooks/useNotification';
import { CreateTourModal } from '../components/CreateTourModal';
@@ -174,7 +174,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
}, [publicTours]);
// State cho menu chuột phải chia sẻ
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean } | null>(null);
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean, isParticipant: boolean, hasPendingRequest: boolean } | null>(null);
const handleShare = (id: string, title: string) => {
const shareUrl = `${window.location.origin}?viewTour=${id}`;
@@ -211,6 +211,35 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
setShareMenu(null);
};
const handleRequestJoin = async (tourId: string) => {
setShareMenu(null);
try {
const res = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'Gửi yêu cầu tham gia thất bại.');
}
notify({
title: 'Thành công',
message: 'Đã gửi yêu cầu tham gia tour. Vui lòng chờ chủ tour duyệt.',
type: 'success'
});
fetchPublicTours();
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Không thể gửi yêu cầu tham gia.',
type: 'error'
});
}
};
const handleSelectSuggestion = (s: any) => {
if (s.type === 'tour') {
onViewTour(s.id);
@@ -471,9 +500,12 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
eventHandlers={{
click: () => onViewTour(tour.id),
contextmenu: (e) => {
// Kiểm tra quyền chia sẻ (OWNER, MANAGER, MEMBER)
const role = tour.participants?.[0]?.role;
const canShare = ['OWNER', 'MANAGER', 'MEMBER'].includes(role);
const currentUserId = user?.id;
const myParticipant = tour.participants?.find((p: any) => p.userId === currentUserId);
const isParticipant = !!myParticipant;
const myRole = myParticipant?.role;
const canShare = isParticipant && ['OWNER', 'MANAGER', 'MEMBER'].includes(myRole);
const hasPendingRequest = tour.joinRequests && tour.joinRequests.length > 0;
// Hiển thị menu tại vị trí chuột
setShareMenu({
@@ -481,7 +513,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
y: e.containerPoint.y,
id: tour.id,
title: tour.title,
canShare
canShare,
isParticipant,
hasPendingRequest
});
}
}}
@@ -575,15 +609,31 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
style={{ top: shareMenu.y, left: shareMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{shareMenu.canShare ? (
<button
onClick={() => handleShare(shareMenu.id, shareMenu.title)}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2 transition-colors"
{shareMenu.isParticipant ? (
shareMenu.canShare ? (
<button
onClick={() => handleShare(shareMenu.id, shareMenu.title)}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2 transition-colors"
>
<Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết
</button>
) : (
<div className="px-4 py-2 text-xs text-gray-400 italic font-bold">Bạn đã gia nhập tour này</div>
)
) : shareMenu.hasPendingRequest ? (
<button
disabled
className="w-full text-left px-4 py-2 text-sm font-bold text-gray-400 flex items-center gap-2 cursor-not-allowed bg-gray-50/50"
>
<Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết
<Clock className="w-4 h-4 text-gray-400" /> Đang chờ duyệt...
</button>
) : (
<div className="px-4 py-2 text-xs text-gray-400 italic">Bạn không quyền chia sẻ tour này</div>
<button
onClick={() => handleRequestJoin(shareMenu.id)}
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-blue-700 flex items-center gap-2 transition-colors"
>
<UserPlus className="w-4 h-4 text-blue-600" /> Yêu cầu tham gia Tour
</button>
)}
</div>
)}
+184
View File
@@ -0,0 +1,184 @@
import React, { useEffect, useState, useRef } from 'react';
import { Compass, Loader2, LogIn, UserPlus, AlertCircle, CheckCircle } from 'lucide-react';
import { LoginModal } from '../components/LoginModal';
interface JoinTourPageProps {
onLoginSuccess: (user: any) => void;
onGoToSignup: () => void;
onViewTour: (tourId: string) => void;
onGoToHome: () => void;
}
export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGoToSignup, onViewTour, onGoToHome }) => {
const [inviteToken, setInviteToken] = useState<string | null>(null);
const [isLoginOpen, setIsLoginOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [successMsg, setSuccessMsg] = useState('');
const [tourId, setTourId] = useState<string | null>(null);
// Guard chống React StrictMode chạy effect 2 lần trong dev mode
const hasJoinedRef = useRef(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
setInviteToken(token);
if (token) {
localStorage.setItem('pendingInviteToken', token);
// Nếu đã đăng nhập, tự động thực hiện join
const systemToken = localStorage.getItem('token');
if (systemToken && !hasJoinedRef.current) {
hasJoinedRef.current = true;
handleJoinTour(token, systemToken);
}
} else {
setError('Mã lời mời không tồn tại hoặc không hợp lệ.');
}
}, []);
const handleJoinTour = async (token: string, authToken: string) => {
setLoading(true);
setError('');
try {
const res = await fetch('/api/v1/tours/join-by-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`
},
body: JSON.stringify({ token })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'Không thể gia nhập tour.');
}
setSuccessMsg(data.message || 'Bạn đã tham gia tour thành công!');
setTourId(data.tourId);
localStorage.removeItem('pendingInviteToken');
} catch (err: any) {
setError(err.message || 'Đã xảy ra lỗi.');
} finally {
setLoading(false);
}
};
const handleSuccessLogin = (user: any) => {
onLoginSuccess(user);
const token = localStorage.getItem('token');
const pendingToken = localStorage.getItem('pendingInviteToken') || inviteToken;
if (token && pendingToken) {
handleJoinTour(pendingToken, token);
}
};
const isLoggedIn = !!localStorage.getItem('token');
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8 font-sans">
<div className="max-w-md w-full space-y-8 bg-white p-10 rounded-3xl shadow-2xl text-center border border-gray-100">
{/* Logo/Icon */}
<div className="flex justify-center">
<div className="w-16 h-16 bg-blue-50 rounded-2xl flex items-center justify-center text-blue-600 shadow-md">
<Compass className="w-10 h-10 animate-spin-slow" />
</div>
</div>
{loading ? (
<div className="space-y-4 py-6">
<Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto" />
<h2 className="text-xl font-bold text-gray-900">Đang xử tham gia hành trình...</h2>
<p className="text-sm text-gray-500">Vui lòng đi trong giây lát.</p>
</div>
) : error ? (
<div className="space-y-4 py-4">
<div className="w-12 h-12 bg-red-50 text-red-500 rounded-full flex items-center justify-center mx-auto">
<AlertCircle className="w-6 h-6" />
</div>
<h2 className="text-xl font-bold text-gray-900">Gia nhập thất bại</h2>
<p className="text-sm text-red-600 bg-red-50 p-4 rounded-2xl font-semibold border border-red-100">{error}</p>
<div className="pt-4 flex flex-col gap-2">
{isLoggedIn ? (
<button
onClick={onGoToHome}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3.5 rounded-2xl transition-all shadow-lg"
>
Về trang khám phá
</button>
) : (
<>
<button
onClick={() => setIsLoginOpen(true)}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3.5 rounded-2xl transition-all flex items-center justify-center gap-2"
>
<LogIn className="w-5 h-5" /> Thử đăng nhập lại
</button>
<button
onClick={onGoToHome}
className="w-full bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold py-3.5 rounded-2xl transition-all"
>
Về trang chủ
</button>
</>
)}
</div>
</div>
) : successMsg ? (
<div className="space-y-4 py-4">
<div className="w-12 h-12 bg-green-50 text-green-500 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-6 h-6" />
</div>
<h2 className="text-xl font-bold text-gray-900">Thành công!</h2>
<p className="text-sm text-green-700 bg-green-50 p-4 rounded-2xl font-semibold border border-green-100">{successMsg}</p>
<div className="pt-4">
<button
onClick={() => {
if (tourId) onViewTour(tourId);
else onGoToHome();
}}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3.5 rounded-2xl transition-all shadow-lg"
>
Xem chi tiết Tour
</button>
</div>
</div>
) : (
<div className="space-y-6">
<h2 className="text-2xl font-black text-gray-900 tracking-tight">Chào mừng bạn!</h2>
<p className="text-gray-600 text-sm leading-relaxed">
Bạn nhận đưc một lời mời tham gia hành trình du lịch. Vui lòng đăng nhập hoặc tạo tài khoản đ thể join xem các hoạt đng, chi phí của tour.
</p>
<div className="space-y-3 pt-4">
<button
onClick={() => setIsLoginOpen(true)}
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg transition-all active:scale-[0.98]"
>
<LogIn className="w-5 h-5" /> Đăng nhập hệ thống
</button>
<button
onClick={onGoToSignup}
className="w-full flex items-center justify-center gap-2 bg-gray-50 hover:bg-gray-100 text-gray-700 font-bold py-4 rounded-2xl border border-gray-200 transition-all active:scale-[0.98]"
>
<UserPlus className="w-5 h-5" /> Đăng tài khoản mới
</button>
</div>
</div>
)}
</div>
<LoginModal
isOpen={isLoginOpen}
onClose={() => setIsLoginOpen(false)}
onSwitchToSignup={onGoToSignup}
onLoginSuccess={handleSuccessLogin}
/>
</div>
);
};
+79 -1
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { User, Mail, Lock, ArrowRight, ChevronLeft, Phone, MapPin, ShieldCheck } from 'lucide-react';
interface SignupPageProps {
@@ -20,6 +20,71 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleGoogleLogin = async (googleResponse: any) => {
setError('');
setIsLoading(true);
try {
const response = await fetch(`/api/v1/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: googleResponse.credential }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Đăng ký bằng Google thất bại');
}
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
// Tự động gia nhập tour nếu có pendingInviteToken
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
if (pendingInviteToken) {
await fetch(`/api/v1/tours/join-by-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.access_token}`,
},
body: JSON.stringify({ token: pendingInviteToken }),
}).catch((e) => console.error('Lỗi tự động gia nhập:', e));
}
onSuccess();
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (step !== 'form') return;
const timer = setTimeout(() => {
if (typeof window !== 'undefined' && (window as any).google) {
try {
(window as any).google.accounts.id.initialize({
client_id: (import.meta as any).env.VITE_GOOGLE_CLIENT_ID || '864264639911-dummyid.apps.googleusercontent.com',
callback: handleGoogleLogin,
});
(window as any).google.accounts.id.renderButton(
document.getElementById('google-signin-btn-signup'),
{ theme: 'outline', size: 'large', width: '380' }
);
} catch (e) {
console.error('Lỗi khởi tạo Google Sign-in:', e);
}
}
}, 100);
return () => clearTimeout(timer);
}, [step]);
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
@@ -230,6 +295,19 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
{!isLoading && <ArrowRight className="w-5 h-5" />}
</button>
</form>
{step === 'form' && (
<>
<div className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200"></div>
</div>
<span className="relative px-3 bg-white text-xs font-bold text-gray-400 uppercase">Hoặc</span>
</div>
<div id="google-signin-btn-signup" className="w-full flex justify-center"></div>
</>
)}
</div>
</div>
);
+60
View File
@@ -844,6 +844,27 @@ export const TourDetailPage = ({
const canUploadPhoto = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE'].includes(userRole || '');
const isOwner = isPublicView ? false : userRole === 'OWNER';
const canShare = isPublicView || ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
const canManage = isOwner || (!isPublicView && userRole === 'MANAGER');
const duplicateMatches = useMemo(() => {
if (!canManage || !currentTour?.participants) return [];
const manualMembers = currentTour.participants.filter((p: any) => !p.userId && p.displayName);
const systemMembers = currentTour.participants.filter((p: any) => p.userId && p.user?.name);
const matches: Array<{ manual: any; system: any }> = [];
manualMembers.forEach((m: any) => {
const match = systemMembers.find((s: any) => {
return s.user.name.trim().toLowerCase() === m.displayName.trim().toLowerCase();
});
if (match) {
matches.push({ manual: m, system: match });
}
});
return matches;
}, [canManage, currentTour?.participants]);
useEffect(() => {
if (isPublicView) {
@@ -1609,6 +1630,45 @@ export const TourDetailPage = ({
</button>
)}
</div>
{duplicateMatches.map((match) => (
<div key={match.manual.id} className="mt-3 p-3 bg-amber-500/20 backdrop-blur-md border border-amber-500/30 rounded-2xl flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-xs text-amber-100 animate-in fade-in slide-in-from-top-1 shadow-lg">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-amber-400 animate-pulse shrink-0"></span>
<span>
Phát hiện thành viên ngoài hệ thống <strong>"{match.manual.displayName}"</strong> trùng tên với tài khoản <strong>"{match.system.user.name}"</strong> vừa tham gia.
</span>
</div>
<button
onClick={async () => {
try {
const res = await fetch(`/api/v1/tours/${currentTour.id}/members/merge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
manualParticipantId: match.manual.id,
systemUserId: match.system.userId
})
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'Hợp nhất thất bại.');
}
notify({ title: 'Thành công', message: 'Đã gán thành viên ngoài hệ thống thành công!', type: 'success' });
fetchTour(currentTour.id);
} catch (e: any) {
notify({ title: 'Lỗi', message: e.message || 'Không thể hợp nhất', type: 'error' });
}
}}
className="px-3.5 py-2 bg-amber-500 hover:bg-amber-600 active:scale-95 text-white font-black rounded-xl transition-all shrink-0 shadow-md text-[11px]"
>
Gán & Hợp nhất
</button>
</div>
))}
</div>
</div>
</div>