fix: guest and admin logic

This commit is contained in:
2026-06-22 11:41:34 +07:00
parent b1a539235b
commit 860395cb14
61 changed files with 1291 additions and 244 deletions
+13
View File
@@ -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;
+3
View File
@@ -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)
+19 -1
View File
@@ -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;
}
}
+2
View File
@@ -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;
}
}
+253 -30
View File
@@ -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 };
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

+2 -2
View File
@@ -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"
+10 -2
View File
@@ -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;
+83 -9
View File
@@ -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}
/>
);
}
+18 -4
View File
@@ -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 tài khoản?{' '}
<button
onClick={() => {
onClose();
if (onSwitchToSignup) onSwitchToSignup();
}}
className="font-semibold text-blue-500 hover:text-blue-600 transition"
>
Đăng tại đây
</button>
</div>
</div>
</div>
</div>
);
};
+5 -41
View File
@@ -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);
}
+47 -14
View File
@@ -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);
}
};
+83 -57
View File
@@ -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 };
}
};
+157 -1
View File
@@ -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 = () => {
+61
View File
@@ -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;
}
+68
View File
@@ -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 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>
);
};
+28 -23
View File
@@ -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>
+16 -11
View File
@@ -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>
);
+53 -32
View File
@@ -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 quản 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 </span>
<span className="text-[10px] font-medium text-slate-400">Bạn , 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 </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 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
{t('friendsList')}
</h1>
<p className="text-xs text-slate-400 mt-1">Quản các mối quan hệ bạn , 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 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 </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 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'
}`}>
+1
View File
@@ -3170,6 +3170,7 @@ export const TourDetailPage = ({
onClose={() => setIsAddPhotoOpen(false)}
tourId={currentTour.id}
onSuccess={() => fetchTour(currentTour.id)}
isPublicView={isPublicView}
/>
)}
+23 -8
View File
@@ -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
View File
@@ -1,6 +1,7 @@
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: 'class',
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
+3 -3
View File
@@ -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,
},