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:
Vendored
+1
@@ -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;
|
||||
|
||||
Vendored
+283
-15
@@ -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) {
|
||||
|
||||
Vendored
+1
-1
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;
|
||||
@@ -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
@@ -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 hiện tại là thành viên (Participant)
|
||||
// Trả về tất cả các tour trong hệ thống để hiển 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')
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
BIN
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 |
@@ -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>
|
||||
|
||||
+57
-12
@@ -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
|
||||
// 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 {
|
||||
setUser(JSON.parse(storedUser));
|
||||
setCurrentPage('explore'); // Chuyển đến bản đồ khám phá nếu đã đăng nhập
|
||||
loggedInUser = JSON.parse(storedUser);
|
||||
setUser(loggedInUser);
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
if (isJoinTour) {
|
||||
setCurrentPage('joinTour');
|
||||
} else if (viewTourId) {
|
||||
setCurrentPage('tourDetail');
|
||||
} else {
|
||||
setCurrentPage('landing'); // Quay về trang Landing nếu chưa đăng nhập
|
||||
if (loggedInUser) {
|
||||
setCurrentPage('explore');
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
}
|
||||
}, []); // Chỉ chạy một lần khi component mount
|
||||
}, []);
|
||||
|
||||
const handleLoginSuccess = (loggedInUser: any) => {
|
||||
setUser(loggedInUser);
|
||||
|
||||
// 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 = () => {
|
||||
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>
|
||||
|
||||
@@ -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,11 +340,15 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className="border-gray-100" />
|
||||
|
||||
{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</label>
|
||||
<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"
|
||||
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)}
|
||||
>
|
||||
@@ -320,7 +395,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
{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">
|
||||
<div className="space-y-2 max-h-[30vh] overflow-y-auto pr-1">
|
||||
{query.trim() && canCreateDirectly && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -359,14 +434,8 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
<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>
|
||||
@@ -378,9 +447,7 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 border-t border-gray-100 flex justify-end gap-2">
|
||||
<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>
|
||||
@@ -393,6 +460,72 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
</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={onClose}
|
||||
className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Đó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>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 có tài khoản?{' '}
|
||||
|
||||
@@ -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,7 +609,8 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
style={{ top: shareMenu.y, left: shareMenu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{shareMenu.canShare ? (
|
||||
{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"
|
||||
@@ -583,7 +618,22 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
<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">Bạn không có quyền chia sẻ tour này</div>
|
||||
<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"
|
||||
>
|
||||
<Clock className="w-4 h-4 text-gray-400" /> Đang chờ duyệt...
|
||||
</button>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -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ử lý 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 để có thể join và 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 ký tài khoản mới
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<LoginModal
|
||||
isOpen={isLoginOpen}
|
||||
onClose={() => setIsLoginOpen(false)}
|
||||
onSwitchToSignup={onGoToSignup}
|
||||
onLoginSuccess={handleSuccessLogin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user