fix: guest and admin logic
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
server/node_modules/
|
||||
|
||||
# Bỏ qua cấu hình hệ thống và Git
|
||||
.git/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Bỏ qua các file log và build
|
||||
*.log
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Photo" ADD COLUMN "flaggedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "flaggedReason" TEXT,
|
||||
ADD COLUMN "isFlagged" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -209,6 +209,9 @@ model Photo {
|
||||
capturedAt DateTime @default(now())
|
||||
metadata Json?
|
||||
privacy PrivacyLevel @default(TOUR_ONLY)
|
||||
isFlagged Boolean @default(false)
|
||||
flaggedReason String?
|
||||
flaggedAt DateTime?
|
||||
|
||||
tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
|
||||
// Guard that rejects anonymous/guest users - used for dashboard and sensitive endpoints
|
||||
@Injectable()
|
||||
export class JwtAuthGuardNoAnonymous extends AuthGuard('jwt') {
|
||||
handleRequest(err: any, user: any, info: any) {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
|
||||
}
|
||||
|
||||
// Reject anonymous/temporary users
|
||||
if (user.isAnonymous) {
|
||||
throw new UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
|
||||
}
|
||||
|
||||
// Note: We allow anonymous users to pass JWT validation
|
||||
// Individual endpoints decide whether to accept anonymous users based on their guard
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { AdminGuard } from './auth/admin.guard';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { JwtAuthGuardNoAnonymous } from './auth/jwt-auth.guard';
|
||||
import { JwtStrategy } from './auth/jwt.strategy';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { SetMetadata, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
@@ -787,6 +788,37 @@ class TourController {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-create default note template for new tour
|
||||
const noteContent = `<h2>${filteredTitle} - Initial Planning</h2>
|
||||
<p><strong>Start Date:</strong> ${startDate ? new Date(startDate).toLocaleDateString() : 'TBD'}</p>
|
||||
<p><strong>End Date:</strong> ${endDate ? new Date(endDate).toLocaleDateString() : 'TBD'}</p>
|
||||
<p><strong>Adult Participants:</strong> ${adultCount || 1}</p>
|
||||
<p><strong>Child Participants:</strong> ${childCount || 0}</p>
|
||||
<h3>Key Items to Plan:</h3>
|
||||
<ul>
|
||||
<li>Accommodations</li>
|
||||
<li>Transportation</li>
|
||||
<li>Activities & Attractions</li>
|
||||
<li>Budget & Expenses</li>
|
||||
<li>Important Contact Numbers</li>
|
||||
<li>Special Requirements & Notes</li>
|
||||
</ul>
|
||||
<p><em>Add your planning notes here...</em></p>`;
|
||||
|
||||
try {
|
||||
await this.prisma.tourNote.create({
|
||||
data: {
|
||||
tourId: tour.id,
|
||||
userId: req.user.id,
|
||||
title: `[${filteredTitle}] - Initial Planning`,
|
||||
content: noteContent
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`Failed to auto-create note for tour ${tour.id}:`, err.message);
|
||||
}
|
||||
|
||||
await this.cacheManager.del(`/api/v1/tours/explore`);
|
||||
return tour;
|
||||
}
|
||||
@@ -1117,7 +1149,7 @@ class TourController {
|
||||
}
|
||||
|
||||
@UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuardNoAnonymous)
|
||||
@Get('explore')
|
||||
async getPublicTours(@Req() req: any) {
|
||||
// Trả về tất cả các tour trong hệ thống để hiển thị trên bản đồ cộng đồng
|
||||
@@ -1596,18 +1628,42 @@ class TourController {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure /uploads/tours/ directory exists
|
||||
const uploadsDir = path.dirname(displayFilePath);
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
try {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
console.log(`[Photo] Created directory: ${uploadsDir}`);
|
||||
} catch (e) {
|
||||
console.error(`[Photo] Failed to create directory ${uploadsDir}:`, e);
|
||||
throw new BadRequestException(`Lỗi tạo thư mục: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Sử dụng Sharp để resize và tối ưu dung lượng ảnh
|
||||
await sharp(processBuffer)
|
||||
.rotate()
|
||||
.resize(2560, 2560, {
|
||||
fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K
|
||||
withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh
|
||||
})
|
||||
.jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng
|
||||
.toFile(displayFilePath);
|
||||
try {
|
||||
await sharp(processBuffer)
|
||||
.rotate()
|
||||
.resize(2560, 2560, {
|
||||
fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K
|
||||
withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh
|
||||
})
|
||||
.jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng
|
||||
.toFile(displayFilePath);
|
||||
console.log(`[Photo] Display image saved successfully: ${displayFilePath}`);
|
||||
} catch (e) {
|
||||
console.error(`[Photo] Failed to save display image to ${displayFilePath}:`, e);
|
||||
throw new BadRequestException(`Lỗi lưu hình ảnh: ${e.message}`);
|
||||
}
|
||||
|
||||
// Verify file was created
|
||||
if (!fs.existsSync(displayFilePath)) {
|
||||
console.error(`[Photo] File verification failed at ${displayFilePath}`);
|
||||
throw new BadRequestException('Lỗi: Hình ảnh không được lưu thành công. Vui lòng kiểm tra quyền lưu trữ.');
|
||||
}
|
||||
|
||||
// 6. Lưu thông tin vào Database (Lưu cả 2 đường dẫn và metadata GPS)
|
||||
return this.prisma.photo.create({
|
||||
const photoRecord = await this.prisma.photo.create({
|
||||
data: {
|
||||
tourId: tourId,
|
||||
uploaderId: uploaderId,
|
||||
@@ -1620,6 +1676,8 @@ class TourController {
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log(`[Photo] Database record created: ID=${photoRecord.id}, imageUrl=${photoRecord.imageUrl}`);
|
||||
return photoRecord;
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1680,20 +1738,39 @@ class TourController {
|
||||
@Post('join-by-token')
|
||||
async joinByToken(@Body() body: { token: string }, @Req() req: any) {
|
||||
const { token } = body;
|
||||
console.log('[joinByToken] Request received, token length:', token ? token.length : 0);
|
||||
|
||||
if (!token) {
|
||||
console.error('[joinByToken] No token provided');
|
||||
throw new BadRequestException('Vui lòng cung cấp token lời mời.');
|
||||
}
|
||||
|
||||
console.log('[joinByToken] Searching for invitation with token:', token.substring(0, 20) + '...');
|
||||
const invitation = await this.prisma.tourInvitation.findUnique({
|
||||
where: { token },
|
||||
include: { tour: { select: { title: true } } }
|
||||
include: { tour: { select: { id: true, title: true } } }
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
console.error('[joinByToken] Invitation not found for token:', token.substring(0, 20) + '...');
|
||||
// Check how many invitations exist in database for debugging
|
||||
const totalInvitations = await this.prisma.tourInvitation.count();
|
||||
console.log('[joinByToken] Total invitations in database:', totalInvitations);
|
||||
throw new NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.');
|
||||
}
|
||||
|
||||
// Verify the invitation email matches the logged-in user's email
|
||||
const userEmail = req.user.email;
|
||||
console.log('[joinByToken] Checking email - User:', userEmail, 'Invitation:', invitation.email);
|
||||
if (invitation.email && userEmail && invitation.email !== userEmail) {
|
||||
console.error('[joinByToken] Email mismatch - User email:', userEmail, 'Invitation email:', invitation.email);
|
||||
throw new BadRequestException('Lời mời này được gửi tới một địa chỉ email khác. Vui lòng đăng nhập bằng tài khoản đúng.');
|
||||
}
|
||||
|
||||
console.log('[joinByToken] Invitation found, tourId:', invitation.tourId, 'expired:', invitation.expiredAt < new Date());
|
||||
|
||||
if (invitation.expiredAt < new Date()) {
|
||||
console.error('[joinByToken] Invitation expired at:', invitation.expiredAt);
|
||||
try {
|
||||
await this.prisma.tourInvitation.delete({ where: { id: invitation.id } });
|
||||
} catch (e) {}
|
||||
@@ -1701,12 +1778,14 @@ class TourController {
|
||||
}
|
||||
|
||||
const userId = req.user.id;
|
||||
console.log('[joinByToken] User attempting to join - userId:', userId, 'tourId:', invitation.tourId);
|
||||
|
||||
const existingParticipation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId: invitation.tourId, userId } }
|
||||
});
|
||||
|
||||
if (existingParticipation) {
|
||||
console.log('[joinByToken] User is already a participant');
|
||||
try {
|
||||
await this.prisma.tourInvitation.delete({ where: { id: invitation.id } });
|
||||
} catch (e) {}
|
||||
@@ -1743,6 +1822,7 @@ class TourController {
|
||||
})
|
||||
]);
|
||||
|
||||
console.log('[joinByToken] Successfully joined tour:', invitation.tourId, 'userId:', userId);
|
||||
return { success: true, tourId: invitation.tourId, message: `Bạn đã gia nhập hành trình "${invitation.tour.title}"!`, shouldRefresh: true };
|
||||
}
|
||||
|
||||
@@ -2112,13 +2192,17 @@ class PhotoController {
|
||||
const displayFilename = `${uniqueSuffix}.jpg`;
|
||||
|
||||
const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals');
|
||||
const displayFilePath = path.join(UPLOAD_ROOT, 'tours', displayFilename);
|
||||
const tourDisplayPath = path.join(UPLOAD_ROOT, 'tours');
|
||||
const displayFilePath = path.join(tourDisplayPath, displayFilename);
|
||||
const originalFilePath = path.join(memberOriginalDir, originalFilename);
|
||||
|
||||
// Đảm bảo thư mục lưu trữ ảnh gốc tồn tại
|
||||
// Đảm bảo thư mục lưu trữ ảnh gốc và ảnh hiển thị tồn tại
|
||||
if (!fs.existsSync(memberOriginalDir)) {
|
||||
fs.mkdirSync(memberOriginalDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(tourDisplayPath)) {
|
||||
fs.mkdirSync(tourDisplayPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của khách
|
||||
await fs.promises.writeFile(originalFilePath, file.buffer);
|
||||
@@ -3014,6 +3098,33 @@ class PublicPhotoController {
|
||||
|
||||
res.type('text/html').send(htmlContent);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post(':photoId/flag')
|
||||
async flagPhoto(
|
||||
@Param('photoId', ParseUUIDPipe) photoId: string,
|
||||
@Body() body: { reason: string },
|
||||
@Req() req: any
|
||||
) {
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
where: { id: photoId }
|
||||
});
|
||||
if (!photo) {
|
||||
throw new NotFoundException('Ảnh không tồn tại');
|
||||
}
|
||||
|
||||
// Update photo as flagged
|
||||
const updatedPhoto = await this.prisma.photo.update({
|
||||
where: { id: photoId },
|
||||
data: {
|
||||
isFlagged: true,
|
||||
flaggedReason: body.reason || 'Không xác định',
|
||||
flaggedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, message: 'Đã báo cáo ảnh thành công', photo: updatedPhoto };
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -3536,6 +3647,67 @@ class AdminModerationController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/photos')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
class AdminPhotosController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Get('flagged')
|
||||
async getFlaggedPhotos() {
|
||||
return this.prisma.photo.findMany({
|
||||
where: { isFlagged: true },
|
||||
include: {
|
||||
uploader: { select: { id: true, name: true, email: true } },
|
||||
tour: { select: { id: true, title: true } }
|
||||
},
|
||||
orderBy: { flaggedAt: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':photoId/approve')
|
||||
async approvePhoto(@Param('photoId', ParseUUIDPipe) photoId: string) {
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
where: { id: photoId }
|
||||
});
|
||||
if (!photo) {
|
||||
throw new NotFoundException('Ảnh không tồn tại');
|
||||
}
|
||||
|
||||
// Unflag the photo
|
||||
const updatedPhoto = await this.prisma.photo.update({
|
||||
where: { id: photoId },
|
||||
data: {
|
||||
isFlagged: false,
|
||||
flaggedReason: null,
|
||||
flaggedAt: null
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, message: 'Đã duyệt ảnh thành công', photo: updatedPhoto };
|
||||
}
|
||||
|
||||
@Delete(':photoId')
|
||||
async deletePhoto(@Param('photoId', ParseUUIDPipe) photoId: string) {
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
where: { id: photoId }
|
||||
});
|
||||
if (!photo) {
|
||||
throw new NotFoundException('Ảnh không tồn tại');
|
||||
}
|
||||
|
||||
// Soft delete the photo
|
||||
const deletedPhoto = await this.prisma.photo.update({
|
||||
where: { id: photoId },
|
||||
data: {
|
||||
isDeleted: true,
|
||||
deletedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, message: 'Đã xóa ảnh thành công', photo: deletedPhoto };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('reports')
|
||||
class ReportsController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -4141,54 +4313,105 @@ class AdminTrashController {
|
||||
@Post('delete-permanent')
|
||||
async deletePermanentItems(@Body() body: { type: 'tour' | 'photo' | 'note'; ids: string[] }) {
|
||||
const { type, ids } = body;
|
||||
console.log('[deletePermanent] Request received - type:', type, 'ids count:', ids.length);
|
||||
|
||||
if (!type || !ids || !Array.isArray(ids)) {
|
||||
throw new BadRequestException('Tham số không hợp lệ.');
|
||||
}
|
||||
|
||||
if (type === 'tour') {
|
||||
for (const tourId of ids) {
|
||||
console.log('[deletePermanent] Deleting tour:', tourId);
|
||||
const photos = await this.prisma.photo.findMany({ where: { tourId } });
|
||||
console.log('[deletePermanent] Found', photos.length, 'photos for tour');
|
||||
|
||||
for (const p of photos) {
|
||||
if (p.imageUrl) {
|
||||
const displayFilePath = path.join(process.cwd(), p.imageUrl.replace(/^\//, ''));
|
||||
console.log('[deletePermanent] Checking display file:', displayFilePath);
|
||||
if (fs.existsSync(displayFilePath)) {
|
||||
try { fs.unlinkSync(displayFilePath); } catch (e) {}
|
||||
try {
|
||||
fs.unlinkSync(displayFilePath);
|
||||
console.log('[deletePermanent] Deleted display file:', displayFilePath);
|
||||
} catch (e) {
|
||||
console.error('[deletePermanent] Error deleting display file:', e);
|
||||
}
|
||||
} else {
|
||||
console.log('[deletePermanent] Display file not found (OK):', displayFilePath);
|
||||
}
|
||||
}
|
||||
if (p.originalUrl) {
|
||||
const originalFilePath = path.join(process.cwd(), p.originalUrl.replace(/^\//, ''));
|
||||
console.log('[deletePermanent] Checking original file:', originalFilePath);
|
||||
if (fs.existsSync(originalFilePath)) {
|
||||
try { fs.unlinkSync(originalFilePath); } catch (e) {}
|
||||
try {
|
||||
fs.unlinkSync(originalFilePath);
|
||||
console.log('[deletePermanent] Deleted original file:', originalFilePath);
|
||||
} catch (e) {
|
||||
console.error('[deletePermanent] Error deleting original file:', e);
|
||||
}
|
||||
} else {
|
||||
console.log('[deletePermanent] Original file not found (OK):', originalFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.prisma.tour.delete({ where: { id: tourId } });
|
||||
console.log('[deletePermanent] Deleted tour from database:', tourId);
|
||||
}
|
||||
} else if (type === 'photo') {
|
||||
console.log('[deletePermanent] Deleting', ids.length, 'photos');
|
||||
for (const photoId of ids) {
|
||||
console.log('[deletePermanent] Processing photo:', photoId);
|
||||
const photo = await this.prisma.photo.findUnique({ where: { id: photoId } });
|
||||
if (photo) {
|
||||
if (photo.imageUrl) {
|
||||
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(displayFilePath)) {
|
||||
try { fs.unlinkSync(displayFilePath); } catch (e) {}
|
||||
}
|
||||
}
|
||||
if (photo.originalUrl) {
|
||||
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(originalFilePath)) {
|
||||
try { fs.unlinkSync(originalFilePath); } catch (e) {}
|
||||
}
|
||||
}
|
||||
await this.prisma.photo.delete({ where: { id: photoId } });
|
||||
if (!photo) {
|
||||
console.warn('[deletePermanent] Photo not found:', photoId);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('[deletePermanent] Photo found, imageUrl:', photo.imageUrl);
|
||||
|
||||
if (photo.imageUrl) {
|
||||
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
|
||||
console.log('[deletePermanent] Checking display file:', displayFilePath);
|
||||
if (fs.existsSync(displayFilePath)) {
|
||||
try {
|
||||
fs.unlinkSync(displayFilePath);
|
||||
console.log('[deletePermanent] Deleted display file');
|
||||
} catch (e) {
|
||||
console.error('[deletePermanent] Error deleting display file:', e);
|
||||
}
|
||||
} else {
|
||||
console.log('[deletePermanent] Display file not found (OK)');
|
||||
}
|
||||
}
|
||||
|
||||
if (photo.originalUrl) {
|
||||
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
|
||||
console.log('[deletePermanent] Checking original file:', originalFilePath);
|
||||
if (fs.existsSync(originalFilePath)) {
|
||||
try {
|
||||
fs.unlinkSync(originalFilePath);
|
||||
console.log('[deletePermanent] Deleted original file');
|
||||
} catch (e) {
|
||||
console.error('[deletePermanent] Error deleting original file:', e);
|
||||
}
|
||||
} else {
|
||||
console.log('[deletePermanent] Original file not found (OK)');
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.prisma.photo.delete({ where: { id: photoId } });
|
||||
console.log('[deletePermanent] Deleted photo from database:', photoId);
|
||||
}
|
||||
} else if (type === 'note') {
|
||||
await this.prisma.tourNote.deleteMany({
|
||||
console.log('[deletePermanent] Deleting', ids.length, 'notes');
|
||||
const result = await this.prisma.tourNote.deleteMany({
|
||||
where: { id: { in: ids } }
|
||||
});
|
||||
console.log('[deletePermanent] Deleted notes from database, count:', result.count);
|
||||
}
|
||||
|
||||
console.log('[deletePermanent] Deletion complete, type:', type);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 456 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 756 KiB |
|
After Width: | Height: | Size: 1003 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 808 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 518 KiB |
|
After Width: | Height: | Size: 889 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 681 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 994 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 806 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 516 KiB |
|
After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 887 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 681 KiB |
|
After Width: | Height: | Size: 359 KiB |
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
@@ -32,6 +30,8 @@ services:
|
||||
sh -c "npx prisma migrate deploy --schema=prisma/schema.prisma && node dist/src/main.js"
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ./backend/uploads:/usr/src/app/uploads
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://postgres:password@postgres:5432/travel_db?schema=public"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
server_name yotrip.labz.io.vn localhost;
|
||||
client_max_body_size 50M;
|
||||
|
||||
location / {
|
||||
@@ -9,7 +9,7 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy requests to backend for deployment on same server/domain (optional but good practice)
|
||||
# Proxy API requests to backend
|
||||
location /api/v1/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
@@ -19,6 +19,14 @@ server {
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Serve uploaded files from backend
|
||||
location /uploads/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Proxy WebSocket connection
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend:3001;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MyPhotosPage } from './pages/MyPhotosPage';
|
||||
import { MyNotePage } from './pages/MyNotePage';
|
||||
import { JoinTourPage } from './pages/JoinTourPage';
|
||||
import { MemberDashboard } from './pages/MemberDashboard';
|
||||
import { AdminDashboard } from './pages/AdminDashboard';
|
||||
import { ShareJourneyPage } from './pages/ShareJourneyPage';
|
||||
import { ConfirmProvider } from './hooks/useConfirm';
|
||||
import { NotificationProvider } from './hooks/useNotification';
|
||||
@@ -20,7 +21,7 @@ function App() {
|
||||
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [shareJourneyToken, setShareJourneyToken] = useState<string | null>(journeyTokenVal);
|
||||
const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney'>(
|
||||
const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'admin' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour' | 'shareJourney'>(
|
||||
journeyTokenVal ? 'shareJourney' : (viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing'))
|
||||
);
|
||||
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||
@@ -34,9 +35,20 @@ function App() {
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const journeyToken = pathParts[1] === 'journey' && pathParts[2] ? pathParts[2] : null;
|
||||
|
||||
// Check if user just finished uploading from a public tour
|
||||
const fromPublicUpload = localStorage.getItem('fromPublicUpload');
|
||||
if (fromPublicUpload) {
|
||||
localStorage.removeItem('fromPublicUpload');
|
||||
setCurrentPage('landing');
|
||||
return;
|
||||
}
|
||||
|
||||
// Khôi phục thông tin đăng nhập nếu có
|
||||
const token = localStorage.getItem('token');
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
const storedGuestUser = localStorage.getItem('guest_user');
|
||||
|
||||
let loggedInUser = null;
|
||||
if (token && storedUser) {
|
||||
try {
|
||||
@@ -56,8 +68,18 @@ function App() {
|
||||
} else if (viewTourId) {
|
||||
setCurrentPage('tourDetail');
|
||||
} else {
|
||||
if (loggedInUser) {
|
||||
setCurrentPage('dashboard');
|
||||
// Check if only guest token exists (no real login)
|
||||
const isGuestOnly = guestToken && !token;
|
||||
if (isGuestOnly) {
|
||||
// Guest user trying to access dashboard - redirect to landing
|
||||
setCurrentPage('landing');
|
||||
} else if (loggedInUser) {
|
||||
// Real authenticated user
|
||||
if (loggedInUser.isAdmin) {
|
||||
setCurrentPage('admin');
|
||||
} else {
|
||||
setCurrentPage('dashboard');
|
||||
}
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
@@ -71,8 +93,18 @@ function App() {
|
||||
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
|
||||
if (pendingInviteToken) {
|
||||
setCurrentPage('joinTour');
|
||||
} else if (loggedInUser.isAdmin) {
|
||||
// Nếu là admin, chuyển đến admin dashboard
|
||||
setCurrentPage('admin');
|
||||
} else {
|
||||
setCurrentPage('dashboard');
|
||||
// Only set to dashboard if this is a real user (has token), not a guest
|
||||
const token = localStorage.getItem('token');
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
if (token && !guestToken) {
|
||||
setCurrentPage('dashboard');
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,12 +123,22 @@ function App() {
|
||||
};
|
||||
|
||||
const handleBackFromTourDetail = () => {
|
||||
// Check if this was a public view BEFORE clearing the flag
|
||||
const wasPublicView = isPublicTourView;
|
||||
|
||||
setCurrentTourId(null);
|
||||
setIsPublicTourView(false);
|
||||
// Quay về trang cũ nếu đã đăng nhập, ngược lại quay về Landing
|
||||
if (user) {
|
||||
|
||||
// If user was viewing a public tour, redirect to index/landing page
|
||||
// Otherwise redirect based on authentication and previous page
|
||||
if (wasPublicView) {
|
||||
// Public tour view - always redirect to index/landing
|
||||
setCurrentPage('landing');
|
||||
} else if (user) {
|
||||
// Authenticated user viewing their own tour - go back to previous page
|
||||
setCurrentPage(previousPage);
|
||||
} else {
|
||||
// Not authenticated and not public view - go to landing
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
};
|
||||
@@ -137,7 +179,26 @@ function App() {
|
||||
};
|
||||
|
||||
const handleBackFromExplore = () => {
|
||||
if (user) {
|
||||
// Only allow real users (with token, not guest_token)
|
||||
const token = localStorage.getItem('token');
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
const isRealUser = token && !guestToken;
|
||||
|
||||
if (user && isRealUser) {
|
||||
setCurrentPage('dashboard');
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoToDashboard = () => {
|
||||
// Only allow real users (with token, not guest_token)
|
||||
const token = localStorage.getItem('token');
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
const isRealUser = token && !guestToken;
|
||||
|
||||
if (user && isRealUser) {
|
||||
setPreviousPage('explore');
|
||||
setCurrentPage('dashboard');
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
@@ -147,14 +208,26 @@ function App() {
|
||||
const handleGoToHome = () => {
|
||||
window.history.pushState({}, '', '/');
|
||||
setShareJourneyToken(null);
|
||||
const loggedIn = !!localStorage.getItem('token');
|
||||
setCurrentPage(loggedIn ? 'dashboard' : 'landing');
|
||||
const token = localStorage.getItem('token');
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
// Only real authenticated users can access dashboard, not guests
|
||||
const isRealUser = token && !guestToken;
|
||||
setCurrentPage(isRealUser ? 'dashboard' : 'landing');
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmProvider>
|
||||
<NotificationProvider>
|
||||
{(() => {
|
||||
if (currentPage === 'admin') {
|
||||
return (
|
||||
<AdminDashboard
|
||||
user={user}
|
||||
onNavigate={setCurrentPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPage === 'dashboard') {
|
||||
return (
|
||||
<MemberDashboard
|
||||
@@ -191,6 +264,7 @@ function App() {
|
||||
onViewTour={handleViewTour}
|
||||
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
|
||||
onLoginSuccess={handleLoginSuccess}
|
||||
onGoToDashboard={handleGoToDashboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ interface AddPhotoModalProps {
|
||||
onClose: () => void;
|
||||
tourId: string;
|
||||
onSuccess?: () => void;
|
||||
isPublicView?: boolean;
|
||||
}
|
||||
|
||||
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess }) => {
|
||||
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess, isPublicView }) => {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -136,13 +137,26 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
fetchTour(tourId);
|
||||
if (onSuccess) onSuccess();
|
||||
onClose();
|
||||
// Giải phóng bộ nhớ sau khi hoàn tất
|
||||
previews.forEach(url => URL.revokeObjectURL(url));
|
||||
setSelectedFiles([]);
|
||||
setPreviews([]);
|
||||
|
||||
// For public users: redirect to landing page after upload
|
||||
// For authenticated users: refresh tour data and close modal
|
||||
if (isPublicView) {
|
||||
onClose();
|
||||
// Give time for notification to show before redirecting
|
||||
// Set flag so App.tsx knows to redirect to landing even if user is logged in
|
||||
setTimeout(() => {
|
||||
localStorage.setItem('fromPublicUpload', 'true');
|
||||
window.location.href = '/';
|
||||
}, 1000);
|
||||
} else {
|
||||
fetchTour(tourId);
|
||||
if (onSuccess) onSuccess();
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Mail, Lock, ArrowRight, Loader2, LogIn } from 'lucide-react';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
|
||||
interface JoinTourLoginModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
inviteToken: string;
|
||||
onJoinSuccess?: (user: any, tourData: any) => void;
|
||||
onSwitchToSignup?: () => void;
|
||||
}
|
||||
|
||||
export const JoinTourLoginModal: React.FC<JoinTourLoginModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
inviteToken,
|
||||
onJoinSuccess,
|
||||
onSwitchToSignup
|
||||
}) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const notify = useNotification();
|
||||
|
||||
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));
|
||||
|
||||
// Attempt to join tour with the token
|
||||
try {
|
||||
console.log('[JoinTourLogin] Attempting to join with token:', inviteToken.substring(0, 10) + '...');
|
||||
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: inviteToken }),
|
||||
});
|
||||
|
||||
console.log('[JoinTourLogin] Join response status:', joinRes.status);
|
||||
const joinData = await joinRes.json().catch(() => ({}));
|
||||
|
||||
if (joinRes.ok) {
|
||||
console.log('[JoinTourLogin] Join successful');
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: joinData.message || 'Bạn đã gia nhập tour!',
|
||||
type: 'success'
|
||||
});
|
||||
localStorage.removeItem('pendingInviteToken');
|
||||
if (onJoinSuccess) {
|
||||
onJoinSuccess(data.user, joinData);
|
||||
}
|
||||
onClose();
|
||||
} else {
|
||||
// Email mismatch or other error
|
||||
const errorMessage = joinData.message || `Không thể gia nhập tour (HTTP ${joinRes.status})`;
|
||||
console.error('[JoinTourLogin] Join failed:', errorMessage);
|
||||
setError(`Lỗi gia nhập tour: ${errorMessage}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[JoinTourLogin] Join exception:', e);
|
||||
setError(`Lỗi khi gia nhập: ${e.message}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[JoinTourLogin] Google login error:', err);
|
||||
setError(err.message);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailPasswordJoin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Đăng nhập thất bại');
|
||||
}
|
||||
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
// Attempt to join tour
|
||||
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: inviteToken }),
|
||||
});
|
||||
|
||||
const joinData = await joinRes.json().catch(() => ({}));
|
||||
|
||||
if (joinRes.ok) {
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: joinData.message || 'Bạn đã gia nhập tour!',
|
||||
type: 'success'
|
||||
});
|
||||
localStorage.removeItem('pendingInviteToken');
|
||||
if (onJoinSuccess) {
|
||||
onJoinSuccess(data.user, joinData);
|
||||
}
|
||||
onClose();
|
||||
} else {
|
||||
const errorMessage = joinData.message || 'Không thể gia nhập tour';
|
||||
setError(`Lỗi gia nhập tour: ${errorMessage}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(`Lỗi khi gia nhập: ${e.message}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[JoinTourLogin] Login error:', err);
|
||||
setError(err.message);
|
||||
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-join-tour'),
|
||||
{ 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;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/50">
|
||||
<div className="w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="relative h-32 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_50%,rgba(255,255,255,.3)_0%,transparent_50%)]" />
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-10 p-2 hover:bg-white/20 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LogIn className="w-12 h-12 text-white opacity-80" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2 text-center">
|
||||
Gia nhập tour
|
||||
</h2>
|
||||
<p className="text-center text-gray-600 mb-6">
|
||||
Đăng nhập để tham gia chuyến du lịch này
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Google OAuth Button */}
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div id="google-signin-btn-join-tour" className="w-full" />
|
||||
</div>
|
||||
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Hoặc</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email/Password Form */}
|
||||
<form onSubmit={handleEmailPasswordJoin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3.5 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Mật khẩu
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3.5 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Nhập mật khẩu"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-gradient-to-r from-blue-500 to-purple-500 text-white font-semibold py-3 rounded-lg hover:shadow-lg transition-all disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Đang gia nhập...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
Gia nhập tour
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Signup Link */}
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
Chưa có tài khoản?{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
if (onSwitchToSignup) onSwitchToSignup();
|
||||
}}
|
||||
className="font-semibold text-blue-500 hover:text-blue-600 transition"
|
||||
>
|
||||
Đăng ký tại đây
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -31,34 +31,16 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
||||
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
console.log('[OAuth] Google login successful');
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Regular login - no auto-join for tour
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(data.user);
|
||||
}
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
console.error('[OAuth] Login error:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -109,27 +91,9 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
||||
// Lưu phiên đăng nhập
|
||||
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);
|
||||
}
|
||||
}
|
||||
console.log('[LoginModal] Email/password login successful');
|
||||
|
||||
// Regular login - no auto-join for tour
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(data.user);
|
||||
}
|
||||
|
||||
@@ -390,15 +390,24 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300"
|
||||
onClick={onClose}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Container */}
|
||||
<div className="fixed inset-0 overflow-y-auto flex flex-col bg-slate-900 text-slate-100 md:relative md:inset-auto md:overflow-hidden md:rounded-[32px] md:shadow-2xl md:border md:border-slate-800 md:w-full md:max-w-5xl md:h-[85vh] md:flex-row animate-in zoom-in-95 duration-300">
|
||||
<div
|
||||
className="fixed inset-0 overflow-y-auto flex flex-col bg-slate-900 text-slate-100 md:relative md:inset-auto md:overflow-hidden md:rounded-[32px] md:shadow-2xl md:border md:border-slate-800 md:w-full md:max-w-5xl md:h-[85vh] md:flex-row animate-in zoom-in-95 duration-300"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
{/* Close Button Mobile/Desktop */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="fixed md:absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 md:top-4 md:right-4 z-50 p-2 bg-slate-950/60 hover:bg-slate-800/80 border border-slate-700/50 rounded-full text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
@@ -421,7 +430,10 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
|
||||
{/* Like Button Overlay */}
|
||||
<button
|
||||
onClick={handleToggleLike}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleLike();
|
||||
}}
|
||||
className="absolute bottom-4 right-4 z-40 flex items-center gap-1.5 bg-slate-950/70 hover:bg-slate-900/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md md:absolute md:top-[calc(1rem+env(safe-area-inset-top,0px))] md:left-4 md:bottom-auto md:right-auto"
|
||||
title={isLiked ? "Bỏ thích" : "Thích"}
|
||||
>
|
||||
@@ -457,22 +469,25 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Info & Timeline overlay inside photo panel */}
|
||||
<div className="relative p-6 bg-slate-900 border-b border-slate-800/60 md:absolute md:bottom-0 md:left-0 md:right-0 md:bg-gradient-to-t md:from-black/60 md:via-black/20 md:to-transparent md:border-b-0 flex flex-col gap-4">
|
||||
<div className="relative z-20 p-6 bg-slate-900 border-b border-slate-800/60 md:absolute md:bottom-0 md:left-0 md:right-0 md:bg-gradient-to-t md:from-black/60 md:via-black/20 md:to-transparent md:border-b-0 flex flex-col gap-4">
|
||||
|
||||
{/* Timeline scroll */}
|
||||
{photoGroup && photoGroup.length > 1 && (
|
||||
<div className="flex flex-col gap-2 border-b border-slate-800/80 pb-3">
|
||||
<div className="flex flex-col gap-2 border-b border-slate-800/80 pb-3 relative z-20">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
|
||||
Lịch sử ảnh tại vị trí này ({photoGroup.length})
|
||||
</span>
|
||||
<div className="flex gap-3 overflow-x-auto no-scrollbar py-1">
|
||||
<div className="flex gap-3 overflow-x-auto no-scrollbar py-1 relative z-20">
|
||||
{photoGroup.map((p) => {
|
||||
const isActive = p.id === photo.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onSelectPhoto?.(p)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectPhoto?.(p);
|
||||
}}
|
||||
className={`relative w-12 h-12 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
|
||||
isActive ? 'border-emerald-500 scale-110 shadow-lg' : 'border-slate-700 hover:border-slate-500'
|
||||
}`}
|
||||
@@ -551,7 +566,10 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
<div className="flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMapOpen(true)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsMapOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all"
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||
@@ -562,14 +580,20 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => setIsEditing(false)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(false);
|
||||
}}
|
||||
disabled={isSavingEdit}
|
||||
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-bold transition-all"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSaveEdit();
|
||||
}}
|
||||
disabled={isSavingEdit}
|
||||
className="flex items-center gap-1 px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
|
||||
>
|
||||
@@ -606,7 +630,10 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
</div>
|
||||
{isAuthorized && (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="p-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700/50 rounded-xl text-slate-300 hover:text-white transition-all shrink-0"
|
||||
title="Chỉnh sửa thông tin"
|
||||
>
|
||||
@@ -713,7 +740,10 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
currentUser?.id === photo.uploaderId ||
|
||||
currentUser?.id === photo.uploader?.id) && (
|
||||
<button
|
||||
onClick={() => handleDeleteComment(c.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteComment(c.id);
|
||||
}}
|
||||
className="text-slate-500 hover:text-rose-500 transition-colors p-0.5"
|
||||
title={t('delete') || "Xóa"}
|
||||
>
|
||||
@@ -746,7 +776,10 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
disabled={isSending}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSend();
|
||||
}}
|
||||
disabled={!newComment.trim() || isSending}
|
||||
className="p-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-40 text-white rounded-2xl transition-all active:scale-95 shadow-lg shadow-emerald-950/30 flex items-center justify-center"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, Image as ImageIcon, Map, FileText, CheckCircle, Star, Settings } from 'lucide-react';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
|
||||
interface UserManagementModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -7,6 +8,7 @@ interface UserManagementModalProps {
|
||||
}
|
||||
|
||||
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
|
||||
const confirm = useConfirm();
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'tours' | 'photos' | 'notes' | 'recommendations' | 'trash' | 'filters' | 'reports'>('users');
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
@@ -68,7 +70,10 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
};
|
||||
|
||||
const handleDeleteReport = async (id: string) => {
|
||||
if (!confirm('Bạn có chắc muốn xóa báo cáo này?')) return;
|
||||
if (!await confirm({
|
||||
title: 'Xóa báo cáo',
|
||||
message: 'Bạn có chắc muốn xóa báo cáo này?'
|
||||
})) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/admin/reports/${id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -492,8 +497,13 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
const handleDeletePermanentTrash = async () => {
|
||||
if (selectedTrashIds.length === 0) return;
|
||||
if (!confirm(`CẢNH BÁO: Bạn có chắc muốn xóa VĨNH VIỄN ${selectedTrashIds.length} mục đã chọn? Thao tác này không thể hoàn tác.`)) return;
|
||||
|
||||
setTrashLoading(true);
|
||||
const idsToDelete = [...selectedTrashIds];
|
||||
setSelectedTrashIds([]);
|
||||
|
||||
try {
|
||||
console.log('[Trash] Starting permanent delete for', idsToDelete.length, 'items');
|
||||
const res = await fetch('/api/v1/admin/trash/delete-permanent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -502,16 +512,42 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: trashSubTab,
|
||||
ids: selectedTrashIds
|
||||
ids: idsToDelete
|
||||
})
|
||||
});
|
||||
|
||||
console.log('[Trash] Delete response status:', res.status);
|
||||
|
||||
if (res.ok) {
|
||||
setSelectedTrashIds([]);
|
||||
console.log('[Trash] Delete successful, clearing state immediately');
|
||||
setError('');
|
||||
alert('Đã xóa vĩnh viễn thành công!');
|
||||
|
||||
// Small delay to let alert close, then refresh data
|
||||
setTimeout(() => {
|
||||
console.log('[Trash] Refreshing trash data after delete');
|
||||
fetchTrashData();
|
||||
setTrashLoading(false);
|
||||
}, 500);
|
||||
return; // Important: exit early so finally doesn't run again
|
||||
} else {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
const errorMsg = errorData.message || `HTTP ${res.status}`;
|
||||
console.error('[Trash] Delete failed:', errorMsg);
|
||||
setError(errorMsg);
|
||||
alert(`Xóa thất bại: ${errorMsg}`);
|
||||
|
||||
// Refresh on error
|
||||
fetchTrashData();
|
||||
setTrashLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
} catch (e: any) {
|
||||
console.error('[Trash] Delete error:', e);
|
||||
setError(e.message || 'Lỗi khi xóa các mục');
|
||||
alert(`Lỗi: ${e.message}`);
|
||||
|
||||
// Refresh on error
|
||||
fetchTrashData();
|
||||
setTrashLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,46 +1,61 @@
|
||||
const loadScript = (src: string, fallbackSrc?: string): Promise<void> => {
|
||||
const loadScript = (src: string, fallbackSrcs?: string[]): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (
|
||||
document.querySelector(`script[src="${src}"]`) ||
|
||||
(fallbackSrc && document.querySelector(`script[src="${fallbackSrc}"]`))
|
||||
) {
|
||||
const allSrcs = [src, ...(fallbackSrcs || [])];
|
||||
|
||||
// Check if any of the scripts are already loaded
|
||||
if (allSrcs.some(s => document.querySelector(`script[src="${s}"]`))) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
if (fallbackSrc) {
|
||||
console.warn(`Failed to load script ${src}. Trying fallback: ${fallbackSrc}`);
|
||||
const fallbackScript = document.createElement('script');
|
||||
fallbackScript.src = fallbackSrc;
|
||||
fallbackScript.onload = () => resolve();
|
||||
fallbackScript.onerror = () => reject(new Error(`Failed to load script ${fallbackSrc}`));
|
||||
document.head.appendChild(fallbackScript);
|
||||
} else {
|
||||
reject(new Error(`Failed to load script ${src}`));
|
||||
|
||||
const tryLoadScript = (index: number) => {
|
||||
if (index >= allSrcs.length) {
|
||||
reject(new Error(`Failed to load script from any source: ${allSrcs.join(', ')}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSrc = allSrcs[index];
|
||||
const script = document.createElement('script');
|
||||
script.src = currentSrc;
|
||||
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
console.warn(`Failed to load script ${currentSrc}. Trying next fallback...`);
|
||||
const nextIndex = index + 1;
|
||||
if (nextIndex < allSrcs.length) {
|
||||
tryLoadScript(nextIndex);
|
||||
} else {
|
||||
reject(new Error(`Failed to load script from all sources: ${allSrcs.join(', ')}`));
|
||||
}
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
|
||||
tryLoadScript(0);
|
||||
});
|
||||
};
|
||||
|
||||
const loadModerationLibraries = async () => {
|
||||
// Load TensorFlow first with fallback
|
||||
// Load TensorFlow first with fallbacks
|
||||
await loadScript(
|
||||
'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs',
|
||||
'https://unpkg.com/@tensorflow/tfjs'
|
||||
['https://unpkg.com/@tensorflow/tfjs', 'https://esm.sh/@tensorflow/tfjs']
|
||||
);
|
||||
// Load models after tfjs is available, with fallbacks
|
||||
|
||||
// Load models after tfjs is available, with multiple fallbacks
|
||||
await Promise.all([
|
||||
loadScript(
|
||||
'https://cdn.jsdelivr.net/npm/@tensorflow-models/blazeface',
|
||||
'https://unpkg.com/@tensorflow-models/blazeface'
|
||||
['https://unpkg.com/@tensorflow-models/blazeface', 'https://esm.sh/@tensorflow-models/blazeface']
|
||||
),
|
||||
// NSFWJS with 3 CDN fallbacks
|
||||
loadScript(
|
||||
'https://cdn.jsdelivr.net/npm/nsfwjs@2.4.0/dist/bundle.js',
|
||||
'https://unpkg.com/nsfwjs@2.4.0/dist/bundle.js'
|
||||
[
|
||||
'https://unpkg.com/nsfwjs@2.4.0/dist/bundle.js',
|
||||
'https://esm.sh/nsfwjs@2.4.0/dist/bundle.js'
|
||||
]
|
||||
)
|
||||
]);
|
||||
};
|
||||
@@ -56,7 +71,13 @@ export const processImageModeration = async (file: File): Promise<{ file: File;
|
||||
return { file, blocked: false };
|
||||
}
|
||||
|
||||
await loadModerationLibraries();
|
||||
// Try to load moderation libraries, but don't fail if they're unavailable
|
||||
try {
|
||||
await loadModerationLibraries();
|
||||
} catch (libLoadErr) {
|
||||
console.warn('Moderation libraries failed to load, proceeding without NSFW/Face blur checks:', libLoadErr);
|
||||
return { file, blocked: false };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
@@ -73,48 +94,53 @@ export const processImageModeration = async (file: File): Promise<{ file: File;
|
||||
|
||||
if (blockNsfw) {
|
||||
try {
|
||||
const nsfwModel = await (window as any).nsfwjs.load();
|
||||
const predictions = await nsfwModel.classify(canvas);
|
||||
const pornOrHentai = predictions.find((p: any) => p.className === 'Porn' || p.className === 'Hentai');
|
||||
const pornProb = pornOrHentai ? pornOrHentai.probability : 0;
|
||||
if (pornProb > 0.5) {
|
||||
resolve({ file, blocked: true });
|
||||
return;
|
||||
const nsfwModel = await (window as any).nsfwjs?.load();
|
||||
if (nsfwModel) {
|
||||
const predictions = await nsfwModel.classify(canvas);
|
||||
const pornOrHentai = predictions.find((p: any) => p.className === 'Porn' || p.className === 'Hentai');
|
||||
const pornProb = pornOrHentai ? pornOrHentai.probability : 0;
|
||||
if (pornProb > 0.5) {
|
||||
console.warn(`Image blocked by NSFW filter (probability: ${pornProb})`);
|
||||
resolve({ file, blocked: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('NSFW validation error:', e);
|
||||
console.warn('NSFW validation error (will allow upload):', e);
|
||||
}
|
||||
}
|
||||
|
||||
let modified = false;
|
||||
if (blurFaces) {
|
||||
try {
|
||||
const blazefaceModel = await (window as any).blazeface.load();
|
||||
const predictions = await blazefaceModel.estimateFaces(canvas, false);
|
||||
if (predictions && predictions.length > 0) {
|
||||
modified = true;
|
||||
predictions.forEach((prediction: any) => {
|
||||
const startX = prediction.topLeft[0];
|
||||
const startY = prediction.topLeft[1];
|
||||
const endX = prediction.bottomRight[0];
|
||||
const endY = prediction.bottomRight[1];
|
||||
const width = endX - startX;
|
||||
const height = endY - startY;
|
||||
const blazefaceModel = await (window as any).blazeface?.load();
|
||||
if (blazefaceModel) {
|
||||
const predictions = await blazefaceModel.estimateFaces(canvas, false);
|
||||
if (predictions && predictions.length > 0) {
|
||||
modified = true;
|
||||
predictions.forEach((prediction: any) => {
|
||||
const startX = prediction.topLeft[0];
|
||||
const startY = prediction.topLeft[1];
|
||||
const endX = prediction.bottomRight[0];
|
||||
const endY = prediction.bottomRight[1];
|
||||
const width = endX - startX;
|
||||
const height = endY - startY;
|
||||
|
||||
const faceCanvas = document.createElement('canvas');
|
||||
faceCanvas.width = width;
|
||||
faceCanvas.height = height;
|
||||
const faceCtx = faceCanvas.getContext('2d');
|
||||
if (faceCtx) {
|
||||
faceCtx.drawImage(canvas, startX, startY, width, height, 0, 0, width, height);
|
||||
ctx.filter = 'blur(15px)';
|
||||
ctx.drawImage(faceCanvas, startX, startY, width, height);
|
||||
ctx.filter = 'none';
|
||||
}
|
||||
});
|
||||
const faceCanvas = document.createElement('canvas');
|
||||
faceCanvas.width = width;
|
||||
faceCanvas.height = height;
|
||||
const faceCtx = faceCanvas.getContext('2d');
|
||||
if (faceCtx) {
|
||||
faceCtx.drawImage(canvas, startX, startY, width, height, 0, 0, width, height);
|
||||
ctx.filter = 'blur(15px)';
|
||||
ctx.drawImage(faceCanvas, startX, startY, width, height);
|
||||
ctx.filter = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Face blur error:', e);
|
||||
console.warn('Face blur error (will skip face detection):', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +163,7 @@ export const processImageModeration = async (file: File): Promise<{ file: File;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Image moderation failed:', err);
|
||||
console.error('Image moderation process failed:', err);
|
||||
return { file, blocked: false };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,6 +18,10 @@ const translations: Record<string, Record<Language, string>> = {
|
||||
info: { vi: 'Thông tin', en: 'Info', zh: '信息' },
|
||||
delete: { vi: 'Xóa', en: 'Delete', zh: '删除' },
|
||||
edit: { vi: 'Chỉnh sửa', en: 'Edit', zh: '编辑' },
|
||||
yes: { vi: 'Có', en: 'Yes', zh: '是' },
|
||||
no: { vi: 'Không', en: 'No', zh: '否' },
|
||||
close: { vi: 'Đóng', en: 'Close', zh: '关闭' },
|
||||
ok: { vi: 'Đồng ý', en: 'OK', zh: '确定' },
|
||||
|
||||
// Landing Page
|
||||
welcomeBack: { vi: 'Chào mừng bạn quay trở lại!', en: 'Welcome back!', zh: '欢迎回来!' },
|
||||
@@ -31,6 +35,7 @@ const translations: Record<string, Record<Language, string>> = {
|
||||
momentsTitle: { vi: 'Khoảnh khắc cộng đồng', en: 'Community Moments', zh: '社区精彩瞬间' },
|
||||
trustedMembers: { vi: 'Thành viên uy tín', en: 'Trusted Members', zh: '信用会员' },
|
||||
exploreToursBtn: { vi: 'Khám phá các hành trình du lịch', en: 'Explore Travel Itineraries', zh: '探索旅行行程' },
|
||||
exploreTourMap: { vi: 'Khám phá Bản đồ Tour', en: 'Explore Tour Map', zh: '探索旅游地图' },
|
||||
|
||||
// Explore Map
|
||||
systemBtn: { vi: 'Hệ thống', en: 'System', zh: '系统管理' },
|
||||
@@ -38,6 +43,9 @@ const translations: Record<string, Record<Language, string>> = {
|
||||
chooseLocationMap: { vi: 'Chọn vị trí trên bản đồ', en: 'Choose location on map', zh: '在地图上选择位置' },
|
||||
clickMapSelectCoords: { vi: 'Click lên bản đồ để chọn tọa độ', en: 'Click on map to select coordinates', zh: '在地图上点击以选择坐标' },
|
||||
coordsLabel: { vi: 'Tọa độ', en: 'Coordinates', zh: '坐标' },
|
||||
businessRestaurant: { vi: 'Nhà hàng', en: 'Restaurant', zh: '餐厅' },
|
||||
businessHotel: { vi: 'Khách sạn', en: 'Hotel', zh: '酒店' },
|
||||
businessHomestay: { vi: 'Homestay', en: 'Homestay', zh: 'Homestay' },
|
||||
noCoordsSelected: { vi: 'Chưa chọn vị trí', en: 'No location selected', zh: '未选择位置' },
|
||||
searchPlaceholder: { vi: 'Tìm kiếm địa điểm...', en: 'Search places...', zh: '搜索地点...' },
|
||||
|
||||
@@ -134,7 +142,155 @@ const translations: Record<string, Record<Language, string>> = {
|
||||
submitReport: { vi: 'Gửi báo cáo', en: 'Submit Report', zh: '提交举报' },
|
||||
reportSuccess: { vi: 'Gửi báo cáo thành công. Ban quản trị sẽ kiểm duyệt thông tin.', en: 'Report submitted successfully. The admin will review it.', zh: '提交成功。管理员将审核' },
|
||||
emptyBlacklist: { vi: 'Chưa có cơ sở nào trong danh sách đen.', en: 'No businesses in blacklist yet.', zh: '黑名单中暂无商家。' },
|
||||
tabReports: { vi: 'Blacklist', en: 'Blacklist', zh: '黑名单' }
|
||||
tabReports: { vi: 'Blacklist', en: 'Blacklist', zh: '黑名单' },
|
||||
|
||||
// Modal titles and messages
|
||||
areYouSure: { vi: 'Bạn có chắc chắn không?', en: 'Are you sure?', zh: '你确定吗?' },
|
||||
processing: { vi: 'Đang xử lý...', en: 'Processing...', zh: '处理中...' },
|
||||
checkingImages: { vi: 'Đang kiểm tra và lọc hình ảnh của bạn...', en: 'Checking and filtering your images...', zh: '正在检查和过滤您的图片...' },
|
||||
uploadFailed: { vi: 'Tải ảnh thất bại.', en: 'Image upload failed.', zh: '图片上传失败。' },
|
||||
uploadSuccess: { vi: 'Đã tải lên thành công.', en: 'Upload successful.', zh: '上传成功。' },
|
||||
imageBlocked: { vi: 'Ảnh chứa nội dung không phù hợp và bị chặn.', en: 'Image contains inappropriate content and was blocked.', zh: '图片包含不当内容并已被屏蔽。' },
|
||||
selectImage: { vi: 'Nhấn để chọn ảnh', en: 'Click to select images', zh: '点击选择图片' },
|
||||
uploadPhotos: { vi: 'Tải ảnh lên', en: 'Upload Photos', zh: '上传照片' },
|
||||
selectedFiles: { vi: 'Đã chọn', en: 'Selected', zh: '已选择' },
|
||||
|
||||
// Photo modals
|
||||
imageModeration: { vi: 'Ảnh bị từ chối', en: 'Image Rejected', zh: '图片被拒' },
|
||||
imageModerationBlocked: { vi: 'ảnh chứa nội dung không phù hợp và bị chặn.', en: 'image contains inappropriate content and was blocked.', zh: '图片包含不当内容并已被屏蔽。' },
|
||||
imageCheck: { vi: 'Lỗi ảnh', en: 'Image Error', zh: '图片错误' },
|
||||
imageLoadFailed: { vi: 'Không thể đọc nội dung ảnh: ', en: 'Cannot read image content: ', zh: '无法读取图片内容:' },
|
||||
pleaseTryAgain: { vi: 'Vui lòng kiểm tra lại file.', en: 'Please check the file.', zh: '请检查文件。' },
|
||||
fileCheckingError: { vi: 'Lỗi trong quá trình kiểm duyệt ảnh.', en: 'Error during image review.', zh: '图片审核过程中出错。' },
|
||||
|
||||
// Create Tour Modal
|
||||
createTourModalTitle: { vi: 'Tạo Tour mới', en: 'Create New Tour', zh: '创建新行程' },
|
||||
creatingTour: { vi: 'Đang tạo...', en: 'Creating...', zh: '创建中...' },
|
||||
confirmCreateTour: { vi: 'Xác nhận tạo Tour', en: 'Confirm Tour Creation', zh: '确认创建行程' },
|
||||
enterTourName: { vi: 'Nhập tên tour...', en: 'Enter tour name...', zh: '输入行程名称...' },
|
||||
tourNameRequired: { vi: 'Tên tour không được để trống', en: 'Tour name cannot be empty', zh: '行程名称不能为空' },
|
||||
|
||||
// Add Location Modal
|
||||
confirmStartPoint: { vi: 'Xác nhận Điểm xuất phát', en: 'Confirm Starting Point', zh: '确认起点' },
|
||||
confirmEndPoint: { vi: 'Xác nhận Điểm kết thúc', en: 'Confirm Ending Point', zh: '确认终点' },
|
||||
location: { vi: 'Vị trí', en: 'Location', zh: '位置' },
|
||||
|
||||
// Expense Manager
|
||||
expenseReportTitle: { vi: 'BÁO CÁO CHI PHÍ TOUR', en: 'TOUR EXPENSE REPORT', zh: '行程费用报告' },
|
||||
expenseSplitTable: { vi: 'Bảng phân chia chi phí', en: 'Expense Split Table', zh: '费用分割表' },
|
||||
member: { vi: 'Thành viên', en: 'Member', zh: '成员' },
|
||||
shouldPay: { vi: 'Cần trả', en: 'Should Pay', zh: '应付' },
|
||||
paid: { vi: 'Đã trả', en: 'Paid', zh: '已付' },
|
||||
balance: { vi: 'Số dư', en: 'Balance', zh: '余额' },
|
||||
|
||||
// Members Management
|
||||
mergeMembers: { vi: 'Hợp nhất thành viên', en: 'Merge Members', zh: '合并成员' },
|
||||
mergeSuccess: { vi: 'Hợp nhất thành công.', en: 'Merge successful.', zh: '合并成功。' },
|
||||
mergeFailed: { vi: 'Hợp nhất thất bại.', en: 'Merge failed.', zh: '合并失败。' },
|
||||
|
||||
// Dashboard / User interactions
|
||||
disconnect: { vi: 'Hủy kết nối', en: 'Disconnect', zh: '断开连接' },
|
||||
notificationsMuted: { vi: 'Đã tắt thông báo', en: 'Notifications muted', zh: '通知已关闭' },
|
||||
searchContent: { vi: 'Tìm kiếm nội dung...', en: 'Search content...', zh: '搜索内容...' },
|
||||
|
||||
// Notes Page
|
||||
noNotesYet: { vi: 'Chưa có ghi chú nào', en: 'No notes yet', zh: '还没有笔记' },
|
||||
createNewNote: { vi: 'Tạo ghi chú mới', en: 'Create New Note', zh: '创建新笔记' },
|
||||
noteTitle: { vi: 'Tiêu đề', en: 'Title', zh: '标题' },
|
||||
noteContent: { vi: 'Nội dung', en: 'Content', zh: '内容' },
|
||||
deleteNote: { vi: 'Xóa ghi chú', en: 'Delete Note', zh: '删除笔记' },
|
||||
deleteNoteConfirm: { vi: 'Bạn có chắc chắn muốn xóa ghi chú này?', en: 'Are you sure you want to delete this note?', zh: '确定要删除此笔记吗?' },
|
||||
|
||||
// Signup Page
|
||||
createAccount: { vi: 'Tạo tài khoản mới', en: 'Create New Account', zh: '创建新账户' },
|
||||
verifyAccount: { vi: 'Xác thực tài khoản', en: 'Verify Account', zh: '验证账户' },
|
||||
confirmPassword: { vi: 'Xác nhận mật khẩu', en: 'Confirm Password', zh: '确认密码' },
|
||||
passwordMismatch: { vi: 'Mật khẩu không khớp', en: 'Passwords do not match', zh: '密码不匹配' },
|
||||
firstName: { vi: 'Tên', en: 'First Name', zh: '名字' },
|
||||
lastName: { vi: 'Họ', en: 'Last Name', zh: '姓氏' },
|
||||
|
||||
// Tour Detail Page
|
||||
tourMembers: { vi: 'Thành viên', en: 'Members', zh: '成员' },
|
||||
expenses: { vi: 'Chi phí', en: 'Expenses', zh: '费用' },
|
||||
errorLoadingTour: { vi: 'Lỗi khi tải thông tin tour.', en: 'Error loading tour information.', zh: '加载行程信息出错。' },
|
||||
|
||||
// Explore Map
|
||||
restaurant: { vi: 'Nhà hàng', en: 'Restaurant', zh: '餐厅' },
|
||||
hotel: { vi: 'Khách sạn', en: 'Hotel', zh: '酒店' },
|
||||
homestay: { vi: 'Homestay', en: 'Homestay', zh: '民宿' },
|
||||
|
||||
// User Management Modal
|
||||
businessTypeLabel: { vi: 'Loại hình kinh doanh', en: 'Business Type', zh: '商家类型' },
|
||||
|
||||
// Add Member Modal
|
||||
searchMember: { vi: 'Tìm kiếm thành viên...', en: 'Search members...', zh: '搜索成员...' },
|
||||
addMemberTitle: { vi: 'Thêm thành viên', en: 'Add Member', zh: '添加成员' },
|
||||
|
||||
// Comment Modal
|
||||
comments: { vi: 'Bình luận', en: 'Comments', zh: '评论' },
|
||||
addComment: { vi: 'Thêm bình luận', en: 'Add comment', zh: '添加评论' },
|
||||
writeComment: { vi: 'Viết bình luận...', en: 'Write a comment...', zh: '写评论...' },
|
||||
noComments: { vi: 'Chưa có bình luận nào', en: 'No comments yet', zh: '还没有评论' },
|
||||
deleteComment: { vi: 'Xóa bình luận', en: 'Delete comment', zh: '删除评论' },
|
||||
editComment: { vi: 'Chỉnh sửa bình luận', en: 'Edit comment', zh: '编辑评论' },
|
||||
|
||||
// General messages
|
||||
loading_msg: { vi: 'Đang tải...', en: 'Loading...', zh: '加载中...' },
|
||||
noData: { vi: 'Không có dữ liệu', en: 'No data', zh: '无数据' },
|
||||
retry: { vi: 'Thử lại', en: 'Retry', zh: '重试' },
|
||||
back: { vi: 'Quay lại', en: 'Back', zh: '返回' },
|
||||
next: { vi: 'Tiếp theo', en: 'Next', zh: '下一步' },
|
||||
previous: { vi: 'Trước đó', en: 'Previous', zh: '上一步' },
|
||||
done: { vi: 'Xong', en: 'Done', zh: '完成' },
|
||||
finish: { vi: 'Kết thúc', en: 'Finish', zh: '完成' },
|
||||
submit: { vi: 'Gửi', en: 'Submit', zh: '提交' },
|
||||
update: { vi: 'Cập nhật', en: 'Update', zh: '更新' },
|
||||
create: { vi: 'Tạo', en: 'Create', zh: '创建' },
|
||||
new: { vi: 'Mới', en: 'New', zh: '新建' },
|
||||
|
||||
// Dashboard - Connections & Friends
|
||||
friendsList: { vi: 'Danh sách bạn bè', en: 'Friends List', zh: '好友列表' },
|
||||
familyGroup: { vi: 'Gia đình', en: 'Family', zh: '家庭' },
|
||||
friends: { vi: 'Bạn bè', en: 'Friends', zh: '朋友' },
|
||||
manageFriendsDesc: { vi: 'Bạn bè, gia đình, yêu cầu chờ duyệt', en: 'Friends, family, pending requests', zh: '朋友、家人、待决批准' },
|
||||
noConnections: { vi: 'Bạn chưa kết nối với ai. Hãy chuyển sang tìm kiếm để gửi lời mời.', en: 'You have no connections yet. Search to send invitations.', zh: '你还没有任何连接。搜索以发送邀请。' },
|
||||
searchMembers: { vi: 'Tìm kiếm theo Tên hoặc Email (nhập tối thiểu 2 ký tự)...', en: 'Search by Name or Email (min 2 characters)...', zh: '按名称或电子邮件搜索(最少2个字符)...' },
|
||||
searching: { vi: 'Đang tìm kiếm...', en: 'Searching...', zh: '搜索中...' },
|
||||
minCharsRequired: { vi: 'Vui lòng nhập tối thiểu 2 ký tự để tìm kiếm thành viên.', en: 'Please enter at least 2 characters to search.', zh: '请输入至少2个字符进行搜索。' },
|
||||
disconnectTitle: { vi: 'Hủy kết nối', en: 'Disconnect', zh: '断开连接' },
|
||||
disconnectConfirm: { vi: 'Bạn có chắc chắn muốn hủy kết nối với', en: 'Are you sure you want to disconnect with', zh: '你确定要与...断开连接吗' },
|
||||
disconnectSuccess: { vi: 'Đã hủy kết nối thành công.', en: 'Disconnected successfully.', zh: '断开连接成功。' },
|
||||
searchError: { vi: 'Lỗi tìm kiếm thành viên:', en: 'Error searching members:', zh: '搜索成员时出错:' },
|
||||
disconnectError: { vi: 'Lỗi hủy kết nối:', en: 'Error disconnecting:', zh: '断开连接出错:' },
|
||||
|
||||
// Dashboard - Photo Gallery
|
||||
photoGallery: { vi: 'Thư viện ảnh', en: 'Photo Gallery', zh: '相册' },
|
||||
photoGalleryDesc: { vi: 'Kho ảnh gốc của bạn từ các tour', en: 'Your original photos from all tours', zh: '您来自所有行程的原始照片' },
|
||||
flagPhoto: { vi: 'Báo cáo ảnh', en: 'Report Photo', zh: '举报照片' },
|
||||
flagPhotoReason: { vi: 'Lý do báo cáo', en: 'Report Reason', zh: '举报原因' },
|
||||
flagPhotoSuccess: { vi: 'Đã báo cáo ảnh', en: 'Photo reported', zh: '已举报照片' },
|
||||
flagPhotoError: { vi: 'Lỗi báo cáo ảnh', en: 'Error reporting photo', zh: '举报照片出错' },
|
||||
inappropriate: { vi: 'Nội dung không phù hợp', en: 'Inappropriate content', zh: '不恰当的内容' },
|
||||
spam: { vi: 'Spam', en: 'Spam', zh: '垃圾邮件' },
|
||||
copyright: { vi: 'Vi phạm bản quyền', en: 'Copyright violation', zh: '侵犯版权' },
|
||||
other: { vi: 'Khác', en: 'Other', zh: '其他' },
|
||||
|
||||
// Dashboard - Tours Management
|
||||
toursManagement: { vi: 'Quản lý hành trình', en: 'Manage Tours', zh: '管理行程' },
|
||||
toursManagementDesc: { vi: 'Xem và quản lý các chuyến đi', en: 'View and manage your travels', zh: '查看和管理您的旅行' },
|
||||
|
||||
// Dashboard - Notifications
|
||||
notificationsEnabled: { vi: 'Đã bật thông báo', en: 'Notifications enabled', zh: '已启用通知' },
|
||||
notificationsDisabled: { vi: 'Đã tắt thông báo', en: 'Notifications disabled', zh: '已禁用通知' },
|
||||
emergencySharingEnabled: { vi: 'Đã bật chia sẻ hành trình cứu hộ.', en: 'Emergency sharing enabled.', zh: '已启用紧急分享。' },
|
||||
emergencySharingDisabled: { vi: 'Đã tắt chia sẻ.', en: 'Sharing disabled.', zh: '已禁用分享。' },
|
||||
|
||||
// Dashboard - Connection Types
|
||||
changeConnectionType: { vi: 'Mối quan hệ đã được chuyển sang nhóm: ', en: 'Relationship changed to group: ', zh: '关系已更改为组:' },
|
||||
|
||||
// Dashboard - Sections
|
||||
settingsSection: { vi: 'Cài đặt', en: 'Settings', zh: '设置' },
|
||||
manageRelations: { vi: 'Quản lý các mối quan hệ bạn bè, gia đình, duyệt các yêu cầu kết nối từ thành viên khác.', en: 'Manage friends, family relationships, and review connection requests from other members.', zh: '管理朋友和家人关系,审查来自其他成员的连接请求。' }
|
||||
};
|
||||
|
||||
export const useTranslation = () => {
|
||||
|
||||
@@ -157,4 +157,65 @@ html.light .bg-rose-950\/40 {
|
||||
|
||||
html.light .border-rose-900\/50 {
|
||||
border-color: #fecaca !important;
|
||||
}
|
||||
|
||||
/* Additional light mode overrides for MemberDashboard and complex classes */
|
||||
html.light [class*="bg-slate-800"] {
|
||||
background-color: #f0f1f5 !important;
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
html.light [class*="bg-slate-900"] {
|
||||
background-color: #f1f5f9 !important;
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
html.light [class*="text-slate-400"],
|
||||
html.light [class*="text-slate-350"],
|
||||
html.light [class*="text-slate-300"] {
|
||||
color: #475569 !important;
|
||||
}
|
||||
|
||||
html.light [class*="border-slate-900"],
|
||||
html.light [class*="border-slate-800"] {
|
||||
border-color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
html.light .hover\:bg-slate-900:hover {
|
||||
background-color: #e2e8f0 !important;
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
html.light .hover\:bg-white:hover {
|
||||
background-color: #f8fafc !important;
|
||||
}
|
||||
|
||||
/* Ensure text contrast in light mode */
|
||||
html.light .text-white {
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
html.light .text-gray-50 {
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
html.light .text-gray-100 {
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
/* Tailwind Dark Mode - ensure dark classes work when in dark theme */
|
||||
html.dark .dark\:bg-slate-800 {
|
||||
background-color: #1e293b !important;
|
||||
}
|
||||
|
||||
html.dark .dark\:text-slate-200 {
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
html.dark .dark\:border-slate-700 {
|
||||
border-color: #334155 !important;
|
||||
}
|
||||
|
||||
html.dark .dark\:hover\:bg-slate-700:hover {
|
||||
background-color: #334155 !important;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { UserManagementModal } from '../components/UserManagementModal';
|
||||
|
||||
interface AdminDashboardProps {
|
||||
user: any;
|
||||
onNavigate: (page: string) => void;
|
||||
}
|
||||
|
||||
export const AdminDashboard: React.FC<AdminDashboardProps> = ({ user, onNavigate }) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(true);
|
||||
|
||||
if (!user?.isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-black text-white mb-4">Quyền Truy Cập Bị Từ Chối</h1>
|
||||
<p className="text-slate-400 mb-6">Bạn không có quyền truy cập trang admin này.</p>
|
||||
<button
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
className="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-bold transition-all"
|
||||
>
|
||||
Quay lại Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// When admin closes the modal, navigate back to user dashboard
|
||||
const handleCloseModal = () => {
|
||||
onNavigate('dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
{/* Header with toggle button */}
|
||||
<div className="fixed top-0 left-0 right-0 z-[60] bg-slate-900/95 backdrop-blur-md border-b border-slate-800 px-4 md:px-6 py-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleCloseModal}
|
||||
className="p-2 hover:bg-slate-800 rounded-xl transition-colors"
|
||||
title="Quay lại User Dashboard"
|
||||
>
|
||||
<ArrowLeft className="w-6 h-6 text-slate-300" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-black text-white">
|
||||
🛡️ Admin Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCloseModal}
|
||||
className="px-4 py-2 text-sm font-bold text-slate-300 hover:text-white hover:bg-slate-800 rounded-xl transition-all"
|
||||
>
|
||||
Switch to User Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal shown full screen */}
|
||||
<div className="pt-20">
|
||||
<UserManagementModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -61,7 +61,11 @@ function MapTracker() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos, onLoginSuccess }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => void }) => {
|
||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos, onLoginSuccess, onGoToDashboard }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => void, onGoToDashboard?: () => void }) => {
|
||||
// Check if user is logged in (real user) or is a guest
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
const isLoggedInOrGuest = user || guestToken;
|
||||
|
||||
// Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa
|
||||
const publicTours = useTourStore(state => state.publicTours);
|
||||
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||
@@ -701,7 +705,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
{/* Nhóm bên phải: Các thao tác người dùng */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto">
|
||||
{/* Nút Ảnh của tôi */}
|
||||
{user && (
|
||||
{isLoggedInOrGuest && (
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log("Đang mở Ảnh của tôi...");
|
||||
@@ -716,7 +720,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
)}
|
||||
|
||||
{/* Nút tạo Tour mới */}
|
||||
{user && (
|
||||
{user && !localStorage.getItem('guest_token') && (
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="w-11 h-11 md:w-auto bg-green-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
|
||||
@@ -770,28 +774,29 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</div>
|
||||
|
||||
{/* Nút quản lý người dùng cho Admin */}
|
||||
{user && (
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (user.isAdmin) {
|
||||
setIsAdminModalOpen(true);
|
||||
} else {
|
||||
const key = window.prompt(t('enterSecretKey'));
|
||||
if (key) {
|
||||
handlePromoteAdmin(key);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`w-11 h-11 md:w-auto p-0 md:px-4 md:py-3 rounded-2xl shadow-xl transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 ${
|
||||
user.isAdmin ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-slate-700 hover:bg-slate-800 text-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-750 dark:border-slate-700 border border-slate-600'
|
||||
}`}
|
||||
onClick={() => setIsAdminModalOpen(true)}
|
||||
className="w-11 h-11 md:w-auto p-0 md:px-4 md:py-3 rounded-2xl shadow-xl transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
title={t('systemBtn')}
|
||||
>
|
||||
{user.isAdmin ? <Settings className="w-5 h-5" /> : <Lock className="w-5 h-5" />}
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">{t('systemBtn')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút Bảng điều khiển của tôi - chỉ hiển thị cho người dùng đã đăng nhập (không phải khách) */}
|
||||
{user && !localStorage.getItem('guest_token') && onGoToDashboard && (
|
||||
<button
|
||||
onClick={onGoToDashboard}
|
||||
className="w-11 h-11 md:w-auto p-0 md:px-4 md:py-3 rounded-2xl shadow-xl transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
title="Bảng điều khiển của tôi"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Bảng điều khiển của tôi</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút đăng xuất */}
|
||||
{onLogout && (
|
||||
<button
|
||||
@@ -1059,7 +1064,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[10px] bg-emerald-50 text-emerald-700 px-1.5 py-0.5 rounded font-black uppercase tracking-wider mb-1 w-max">
|
||||
{item.type === 'RESTAURANT' ? 'Nhà hàng' : item.type === 'HOTEL' ? 'Khách sạn' : 'Homestay'}
|
||||
{item.type === 'RESTAURANT' ? t('businessRestaurant') : item.type === 'HOTEL' ? t('businessHotel') : t('businessHomestay')}
|
||||
</div>
|
||||
{item.address && (
|
||||
<div className="text-[10px] text-gray-500 mb-1 font-semibold">{item.address}</div>
|
||||
@@ -1291,7 +1296,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
<div className="flex items-start justify-between gap-1.5">
|
||||
<span className="text-xs font-bold text-gray-800 dark:text-slate-200 truncate">{item.name}</span>
|
||||
<span className="text-[8px] bg-emerald-50 dark:bg-emerald-950/40 text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 rounded font-black shrink-0">
|
||||
{item.type === 'RESTAURANT' ? 'Nhà hàng' : item.type === 'HOTEL' ? 'Khách sạn' : 'Homestay'}
|
||||
{item.type === 'RESTAURANT' ? t('businessRestaurant') : item.type === 'HOTEL' ? t('businessHotel') : t('businessHomestay')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1446,9 +1451,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
onChange={(e) => setProposeForm(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
|
||||
>
|
||||
<option value="RESTAURANT">Nhà hàng</option>
|
||||
<option value="HOTEL">Khách sạn</option>
|
||||
<option value="HOMESTAY">Homestay</option>
|
||||
<option value="RESTAURANT">{t('businessRestaurant')}</option>
|
||||
<option value="HOTEL">{t('businessHotel')}</option>
|
||||
<option value="HOMESTAY">{t('businessHomestay')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Compass, Loader2, LogIn, UserPlus, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { LoginModal } from '../components/LoginModal';
|
||||
import { JoinTourLoginModal } from '../components/JoinTourLoginModal';
|
||||
|
||||
interface JoinTourPageProps {
|
||||
onLoginSuccess: (user: any) => void;
|
||||
@@ -43,6 +43,7 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
console.log('[JoinTour] Attempting to join with token');
|
||||
const res = await fetch('/api/v1/tours/join-by-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -54,28 +55,31 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || 'Không thể gia nhập tour.');
|
||||
console.error('[JoinTour] Join failed with status', res.status, 'message:', data.message);
|
||||
// Show real backend error message
|
||||
throw new Error(data.message || `Không thể gia nhập tour (${res.status}). Vui lòng kiểm tra lại mã lời mời.`);
|
||||
}
|
||||
|
||||
console.log('[JoinTour] Join successful, message:', data.message);
|
||||
setSuccessMsg(data.message || 'Bạn đã tham gia tour thành công!');
|
||||
setTourId(data.tourId);
|
||||
// Đợi 500ms để đảm bảo các thay đổi cache ở backend hoàn tất trước khi tiếp tục
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
localStorage.removeItem('pendingInviteToken');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Đã xảy ra lỗi.');
|
||||
console.error('[JoinTour] Error:', err.message);
|
||||
setError(err.message || 'Đã xảy ra lỗi khi gia nhập tour.');
|
||||
// Keep the pendingInviteToken in localStorage so user can retry
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccessLogin = (user: any) => {
|
||||
const handleJoinSuccess = (user: any, tourData: any) => {
|
||||
console.log('[JoinTour] Join successful via modal, tourId:', tourData.tourId);
|
||||
setTourId(tourData.tourId);
|
||||
setSuccessMsg(tourData.message || 'Bạn đã tham gia tour thành công!');
|
||||
onLoginSuccess(user);
|
||||
const token = localStorage.getItem('token');
|
||||
const pendingToken = localStorage.getItem('pendingInviteToken') || inviteToken;
|
||||
if (token && pendingToken) {
|
||||
handleJoinTour(pendingToken, token);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoggedIn = !!localStorage.getItem('token');
|
||||
@@ -175,11 +179,12 @@ export const JoinTourPage: React.FC<JoinTourPageProps> = ({ onLoginSuccess, onGo
|
||||
|
||||
</div>
|
||||
|
||||
<LoginModal
|
||||
<JoinTourLoginModal
|
||||
isOpen={isLoginOpen}
|
||||
onClose={() => setIsLoginOpen(false)}
|
||||
inviteToken={inviteToken || ''}
|
||||
onSwitchToSignup={onGoToSignup}
|
||||
onLoginSuccess={handleSuccessLogin}
|
||||
onJoinSuccess={handleJoinSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,6 +45,21 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
onExploreTours,
|
||||
onViewTour
|
||||
}) => {
|
||||
// GUARD: Prevent guest users from accessing dashboard
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
const token = localStorage.getItem('token');
|
||||
if (guestToken && !token) {
|
||||
// This is a guest user - redirect them by triggering onLogout
|
||||
// which will clear everything and redirect to landing
|
||||
console.warn('Guest user attempted to access MemberDashboard - redirecting to home');
|
||||
// Clear guest tokens
|
||||
localStorage.removeItem('guest_token');
|
||||
localStorage.removeItem('guest_user');
|
||||
// Redirect to home
|
||||
window.location.href = '/';
|
||||
return null;
|
||||
}
|
||||
|
||||
const notify = useNotification();
|
||||
const confirm = useConfirm();
|
||||
const { t, lang, changeLanguage } = useTranslation();
|
||||
@@ -130,7 +145,7 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
setShareStatus(data);
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: isEnabled ? 'Đã bật chia sẻ hành trình cứu hộ.' : 'Đã tắt chia sẻ.',
|
||||
message: isEnabled ? t('emergencySharingEnabled') : t('emergencySharingDisabled'),
|
||||
type: 'success'
|
||||
});
|
||||
}
|
||||
@@ -337,9 +352,15 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
|
||||
// Fetch connections, tours, and photos on mount
|
||||
useEffect(() => {
|
||||
fetchPublicTours();
|
||||
fetchConnections();
|
||||
fetchPhotos();
|
||||
Promise.all([
|
||||
fetchPublicTours().catch(err => {
|
||||
console.error('[MemberDashboard] fetchPublicTours failed:', err.message);
|
||||
// If fetch fails due to anonymous user rejection, don't crash
|
||||
// The page is already loaded
|
||||
}),
|
||||
fetchConnections(),
|
||||
fetchPhotos()
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const fetchConnectionsRef = useRef<() => Promise<void>>(null as any);
|
||||
@@ -560,7 +581,7 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
if (res.ok) {
|
||||
notify({
|
||||
title: 'Đã cập nhật',
|
||||
message: `Mối quan hệ đã được chuyển sang nhóm: ${type === 'FAMILY' ? 'Gia đình' : 'Bạn bè'}.`,
|
||||
message: `${t('changeConnectionType')}${type === 'FAMILY' ? t('familyGroup') : t('friends')}.`,
|
||||
type: 'success'
|
||||
});
|
||||
fetchConnections();
|
||||
@@ -573,8 +594,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
// Remove connection
|
||||
const handleRemoveConnection = async (connId: string, targetName: string) => {
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Hủy kết nối',
|
||||
message: `Bạn có chắc chắn muốn hủy kết nối với ${targetName}?`
|
||||
title: t('disconnectTitle'),
|
||||
message: `${t('disconnectConfirm')} ${targetName}?`
|
||||
});
|
||||
|
||||
if (isConfirmed) {
|
||||
@@ -586,7 +607,7 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
if (res.ok) {
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã hủy kết nối thành công.',
|
||||
message: t('disconnectSuccess'),
|
||||
type: 'success'
|
||||
});
|
||||
fetchConnections();
|
||||
@@ -595,7 +616,7 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Lỗi hủy kết nối:', err);
|
||||
console.error(t('disconnectError'), err);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -684,7 +705,7 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
const getConnectionStatusText = (targetId: string) => {
|
||||
const isConnected = connections.find(c => c.targetUser?.id === targetId);
|
||||
if (isConnected) {
|
||||
return isConnected.type === 'FAMILY' ? 'Gia đình' : 'Bạn bè';
|
||||
return isConnected.type === 'FAMILY' ? t('familyGroup') : t('friends');
|
||||
}
|
||||
const isPendingReceived = receivedRequests.find(r => r.requester?.id === targetId);
|
||||
if (isPendingReceived) return 'Chờ bạn duyệt';
|
||||
@@ -805,7 +826,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
className="w-full py-4 px-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-bold flex items-center justify-center gap-2 shadow-lg active:scale-98 transition-all"
|
||||
>
|
||||
<Compass className="w-5 h-5 animate-spin-slow" />
|
||||
Khám phá Bản đồ Tour
|
||||
{t('exploreTourMap')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -823,12 +844,12 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
<Compass className="w-5 h-5 text-indigo-400" />
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="text-sm flex items-center gap-1.5">
|
||||
Hành trình của tôi
|
||||
{t('myItineraries')}
|
||||
{(unreadTourSenders.length > 0 || unreadTourChats.length > 0) && (
|
||||
<span className="w-2 h-2 rounded-full bg-indigo-500 animate-pulse" title="Có tin nhắn mới"></span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-slate-400">Xem và quản lý các chuyến đi</span>
|
||||
<span className="text-[10px] font-medium text-slate-400">{t('toursManagementDesc')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -850,8 +871,8 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageIcon className="w-5 h-5 text-indigo-400" />
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="text-sm">Thư viện ảnh</span>
|
||||
<span className="text-[10px] font-medium text-slate-400">Kho ảnh gốc của bạn từ các tour</span>
|
||||
<span className="text-sm">{t('photoGallery')}</span>
|
||||
<span className="text-[10px] font-medium text-slate-400">{t('photoGalleryDesc')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -876,8 +897,8 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="w-5 h-5 text-indigo-400" />
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="text-sm">Danh sách bạn bè</span>
|
||||
<span className="text-[10px] font-medium text-slate-400">Bạn bè, gia đình, yêu cầu chờ duyệt</span>
|
||||
<span className="text-sm">{t('friendsList')}</span>
|
||||
<span className="text-[10px] font-medium text-slate-400">{t('manageFriendsDesc')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -962,8 +983,8 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="text-sm font-black uppercase text-white tracking-wider">
|
||||
{activeTab === 'tours' && 'Hành trình của tôi'}
|
||||
{activeTab === 'connections' && 'Danh sách bạn bè'}
|
||||
{activeTab === 'tours' && t('myItineraries')}
|
||||
{activeTab === 'connections' && t('friendsList')}
|
||||
{activeTab === 'chats' && 'Trò chuyện trực tiếp'}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -1096,7 +1117,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
<span>Thư viện ảnh</span>
|
||||
<span>{t('photoGallery')}</span>
|
||||
</div>
|
||||
<span className="bg-slate-800/80 px-2 py-0.5 text-xs rounded-full text-slate-400 font-semibold border border-slate-700">
|
||||
{photos.length}
|
||||
@@ -1135,7 +1156,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>Danh sách bạn bè</span>
|
||||
<span>{t('friendsList')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{receivedRequests.length > 0 && (
|
||||
@@ -1188,7 +1209,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black uppercase tracking-tight flex items-center gap-2 text-white">
|
||||
<Compass className="w-6 h-6 text-indigo-500" />
|
||||
Hành trình của tôi
|
||||
{t('myItineraries')}
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">Danh sách các chuyến đi bạn tham gia (với vai trò chủ sở hữu, quản lý hoặc thành viên).</p>
|
||||
</div>
|
||||
@@ -1331,9 +1352,9 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-black uppercase tracking-tight flex items-center gap-2 text-white">
|
||||
<Users className="w-6 h-6 text-indigo-500" />
|
||||
Danh sách bạn bè
|
||||
{t('friendsList')}
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">Quản lý các mối quan hệ bạn bè, gia đình, duyệt các yêu cầu kết nối từ thành viên khác.</p>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('manageRelations')}</p>
|
||||
</div>
|
||||
|
||||
{/* Sub-tabs for connections */}
|
||||
@@ -1382,7 +1403,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
<div className="bg-slate-900/40 border border-slate-800/60 rounded-3xl p-12 text-center max-w-md mx-auto my-6">
|
||||
<Users className="w-12 h-12 text-indigo-500 mx-auto mb-4" />
|
||||
<h3 className="text-base font-bold mb-1">Chưa có kết nối nào</h3>
|
||||
<p className="text-xs text-slate-400 mb-6">Bạn chưa kết nối với ai. Hãy chuyển sang tìm kiếm để gửi lời mời.</p>
|
||||
<p className="text-xs text-slate-400 mb-6">{t('noConnections')}</p>
|
||||
<button
|
||||
onClick={() => setConnectionSubTab('search')}
|
||||
className="py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs font-bold transition-all"
|
||||
@@ -1418,7 +1439,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
? 'bg-rose-950/40 text-rose-300 border-rose-900/50'
|
||||
: 'bg-indigo-950/40 text-indigo-300 border-indigo-900/50'
|
||||
}`}>
|
||||
{conn.type === 'FAMILY' ? 'Gia đình' : 'Bạn bè'}
|
||||
{conn.type === 'FAMILY' ? t('familyGroup') : t('friends')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1431,7 +1452,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
onChange={(e) => handleChangeConnectionType(conn.id, e.target.value as any)}
|
||||
className="bg-slate-800 text-slate-200 border border-slate-700/80 rounded-md text-[10px] px-2 py-1 outline-none font-bold cursor-pointer hover:bg-slate-750 transition-colors"
|
||||
>
|
||||
<option value="FRIEND">Bạn bè</option>
|
||||
<option value="FRIEND">{t('friends')}</option>
|
||||
<option value="FAMILY">Gia đình</option>
|
||||
</select>
|
||||
|
||||
@@ -1452,7 +1473,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
<button
|
||||
onClick={() => handleRemoveConnection(conn.id, connUser.name)}
|
||||
className="p-1.5 bg-rose-950/50 hover:bg-rose-600 text-rose-300 hover:text-white border border-rose-800/40 hover:border-rose-500 rounded-lg text-xs font-bold transition-all"
|
||||
title="Hủy kết nối"
|
||||
title={t('disconnectTitle')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -1474,7 +1495,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
<Search className="absolute left-4 top-3.5 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tìm kiếm theo Tên hoặc Email (nhập tối thiểu 2 ký tự)..."
|
||||
placeholder={t('searchMembers')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800/80 rounded-2xl py-3 pl-11 pr-4 text-sm text-white placeholder-slate-500 outline-none focus:border-indigo-500/80 transition-all duration-150"
|
||||
@@ -1483,11 +1504,11 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
|
||||
{searchingUsers ? (
|
||||
<div className="text-center py-8 text-slate-400 text-xs font-bold flex items-center justify-center gap-1.5">
|
||||
<Clock className="w-4 h-4 animate-spin text-indigo-500" /> Đang tìm kiếm...
|
||||
<Clock className="w-4 h-4 animate-spin text-indigo-500" /> {t('searching')}...
|
||||
</div>
|
||||
) : searchQuery.trim().length < 2 ? (
|
||||
<div className="text-center py-12 text-slate-500 text-xs italic">
|
||||
Vui lòng nhập tối thiểu 2 ký tự để tìm kiếm thành viên.
|
||||
{t('minCharsRequired')}
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400 text-xs">
|
||||
@@ -1522,7 +1543,7 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
<div>
|
||||
{statusText ? (
|
||||
<span className={`px-3 py-1 rounded-lg text-xs font-bold border ${
|
||||
statusText === 'Bạn bè' || statusText === 'Gia đình'
|
||||
statusText === t('friends') || statusText === t('familyGroup')
|
||||
? 'bg-emerald-950/30 border-emerald-900/50 text-emerald-300'
|
||||
: 'bg-slate-800/80 border-slate-700 text-slate-400'
|
||||
}`}>
|
||||
|
||||
@@ -3170,6 +3170,7 @@ export const TourDetailPage = ({
|
||||
onClose={() => setIsAddPhotoOpen(false)}
|
||||
tourId={currentTour.id}
|
||||
onSuccess={() => fetchTour(currentTour.id)}
|
||||
isPublicView={isPublicView}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -92,15 +92,30 @@ export const useTourStore = create<TourState>((set, get) => ({
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const response = await fetch(`/api/v1/tours/explore`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
try {
|
||||
const response = await fetch(`/api/v1/tours/explore`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// If response is 401 or 403, the user is likely anonymous and rejected by backend
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Tài khoản khách không có quyền truy cập');
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
set({ publicTours: data });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
set({ publicTours: data });
|
||||
} catch (error: any) {
|
||||
console.error('[fetchPublicTours] Error:', error.message);
|
||||
// Re-throw the error so MemberDashboard can catch it and redirect
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
fetchPublicTourDetails: async (tourId: string) => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
|
||||
@@ -31,15 +31,15 @@ export default defineConfig(({ mode }) => {
|
||||
https: httpsConfig,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
target: env.VITE_BACKEND_URL || 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3001',
|
||||
target: env.VITE_BACKEND_URL || 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3001',
|
||||
target: env.VITE_BACKEND_URL || 'http://localhost:3001',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
|
||||