4 Commits

39 changed files with 3510 additions and 452 deletions
+129
View File
@@ -0,0 +1,129 @@
# To AI Agent: Deprecate MemberDashboard Page and Re-architect All User Features into Dedicated Modals on LandingPage
## 1. Architectural Strategy & Goal
We are completely removing the standalone `MemberDashboard` route/page. When a user logs in successfully, they must **remain directly on the `LandingPage`** (or `ExplorerMap`), with their authentication state shifting cleanly to display the member avatar dropdown menu in the top-bar header.
Every core feature previously hosted on the dashboard page must now be converted into a high-performance, responsive **Modal Overlay Component**. Clicking an item in the avatar dropdown menu will toggle the visibility state of its respective modal over the current viewport, ensuring zero page-redirection disruption.
---
## 2. Route Deletion & Auth Redirection Clean-up
### 1. **Route Removal:** Open your router configuration (`App.tsx` or `routes.tsx`) and permanently delete the `<Route path="/dashboard" ... />` node.
### 2. **Auth Hook Modification:** Inside the login handler lifecycle (e.g., `LoginModal.tsx` or `AuthContext.tsx`), replace `Maps('/dashboard')` with a simple state closer that preserves the current page instance:
```typescript
// ❌ OLD: navigate('/dashboard');
// ✅ NEW: Keep user on context page, close login overlay, update profile states
setIsLoginModalOpen(false);
## 3. Technical Specifications for Each Target Modal
Implement the following modal architectures inside frontend/src/components/modals/:
### 3.1. Modal Chat Trực Tiếp (LiveChatModal.tsx)
Layout Architecture: A wide responsive viewport split into two main functional vertical columns (flex flex-col md:flex-row h-[80vh]).
Left Column (w-full md:w-80 border-r border-slate-800): Interactive scrollable contact strip displaying the User's Friend List with online status indicators and latest message snippets.
Right Column (flex-1 flex flex-col bg-slate-950): Active conversation window.
Bottom Input Tray Wrapper: A rich-text typing container bar fixed at the baseline containing:
Text input area field.
Attachment Trigger buttons: Icon for Image uploads (accept="image/*") and an Icon for transmitting spatial GPS Coordinates/Locations directly into the text stream.
### 3.2. Modal Hành Trình Của Tôi (MyToursModal.tsx)
Layout Architecture: A dynamic card explorer view featuring structural chronological timeline tabs at the top:
Tabs Grid: Đang thực hiện (Ongoing), Sắp khởi hành (Upcoming), and Đã hoàn thành (Past).
Core Content Panel: Clicking a tab renders a fluid inner grid loop of compact trip tiles. Each tile houses a banner photo, progress indicators, and quick action links to open the standalone TourNavigationPage or route maps directly.
### 3.3. Modal Thư Viện Ảnh (PhotoGalleryModal.tsx)
Layout Architecture: A dedicated media repository browser displaying all images uploaded by the member.
Filtering Header Matrix: Dual filter select boxes pinned at the top:
Filter 1: Filter by Itinerary/Trip (Theo hành trình).
Filter 2: Filter by Media Hashtags (Theo tags).
Core Workspace: A masonry-style gallery layout grid with hover micro-interactions enabling the user to view full resolution views, edit asset tagging descriptions, or delete pictures directly.
### 3.4. Modal Danh Sách Bạn Bè (FriendsManagerModal.tsx)
Layout Architecture: A centralized social dashboard layout built to handle user connections.
Functional Subsections:
Search engine bar to lookup new profiles via display name or telephone metrics.
Tab views separating Danh sách bạn bè (Active Friends) and Lời mời kết bạn (Pending Requests).
Row items equipped with direct context action triggers: Hủy kết bạn (Unfriend), Chấp nhận (Accept), or Nhắn tin (Quick Message - which bridges states to auto-toggle the Chat Modal).
## 4. Top-Bar Profile Dropdown Structure Integration
Refactor the items list container within MapProfileDropdown.tsx to match the localized modal toggles state logic:
TypeScript
import React, { useState } from 'react';
import { Compass, Map, Image, Settings, ShieldAlert, Users, LogOut } from 'lucide-react';
export const HeaderMemberDropdown = ({ openModal }) => {
return (
<div className="absolute right-0 top-14 w-64 bg-slate-900 border border-slate-800 rounded-xl p-1.5 shadow-2xl flex flex-col space-y-0.5 text-xs text-slate-200 z-[999999]">
{/* 1. Nút "Tạo tour" */}
<button onClick={() => openModal('create_tour')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Compass className="w-4 h-4 text-amber-400" />
<span className="font-medium">Tạo tour</span>
</button>
{/* 2. Nút "Hành trình của tôi" */}
<button onClick={() => openModal('my_tours')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Map className="w-4 h-4 text-blue-400" />
<span className="font-medium">Hành trình của tôi</span>
</button>
{/* 3. Nút "Thư viện ảnh" */}
<button onClick={() => openModal('gallery')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Image className="w-4 h-4 text-emerald-400" />
<span className="font-medium">Thư viện ảnh</span>
</button>
{/* 4. Nút "Cài đặt" */}
<button onClick={() => openModal('settings')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Settings className="w-4 h-4 text-slate-400" />
<span className="font-medium">Cài đặt</span>
</button>
{/* 5. Nút "Báo cáo vi phạm" */}
<button onClick={() => openModal('reports')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<ShieldAlert className="w-4 h-4 text-rose-400" />
<span className="font-medium">Báo cáo vi phạm</span>
</button>
<div className="h-[1px] bg-slate-800/80 my-1 mx-2" />
{/* 6. Nút "Danh sách bạn bè" */}
<button onClick={() => openModal('friends')} className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors">
<Users className="w-4 h-4 text-indigo-400" />
<span className="font-medium">Danh sách bạn bè</span>
</button>
{/* 7. Nút "Đăng xuất" */}
<button onClick={() => openModal('logout')} className="flex items-center gap-3 w-full px-4 py-2.5 text-rose-400 hover:bg-slate-800 rounded-lg text-left font-bold transition-colors">
<LogOut className="w-4 h-4 text-rose-500" />
<span>Đăng xuất</span>
</button>
</div>
);
};
## 5. Automation Checklists for Quality Verification
[ ] Redirection Invalidation Check: Perform a successful user login sweep. Verify the active URL hash parameter remains exactly / or /map, completely dropping dashboard transitions.
[ ] Chat Modal Layout Sizing: Trigger the Live Chat button. Ensure the workspace scales to an explicit split-view pane framework (Left: Contacts / Right: Dialog Thread) with working attachment trays.
[ ] Gallery Filter Interception: Confirm that filtering options inside the Photo modal dynamically re-index asset cards based on itinerary tags or custom upload parameters.
[ ] Global Z-Index Verification: All new modals must enforce a strict z-[999999] utility layer rule to pop up cleanly above the underlying map viewport layer without clip cutting.
+11
View File
@@ -154,6 +154,17 @@ async function bootstrap() {
app.useStaticAssets(UPLOAD_ROOT, {
prefix: '/uploads/',
});
const downloadsDir = path.join(process.cwd(), 'public/downloads');
if (!fs.existsSync(downloadsDir)) {
fs.mkdirSync(downloadsDir, { recursive: true });
}
app.useStaticAssets(downloadsDir, {
prefix: '/downloads/',
setHeaders: (res) => {
res.set('Content-Type', 'application/vnd.android.package-archive');
res.set('Content-Disposition', 'attachment; filename="yotrip-latest.apk"');
}
});
const prisma = app.get(prisma_service_1.PrismaService);
startAutoCleanup(prisma);
await app.listen(3001);
+1 -1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+19 -8
View File
@@ -114,6 +114,21 @@ async function bootstrap() {
prefix: '/uploads/',
});
// Tự động tạo thư mục downloads nếu chưa tồn tại
const downloadsDir = path.join(process.cwd(), 'public/downloads');
if (!fs.existsSync(downloadsDir)) {
fs.mkdirSync(downloadsDir, { recursive: true });
}
// Khai báo thư mục lưu trữ APK tải về
app.useStaticAssets(downloadsDir, {
prefix: '/downloads/',
setHeaders: (res) => {
res.set('Content-Type', 'application/vnd.android.package-archive');
res.set('Content-Disposition', 'attachment; filename="yotrip-latest.apk"');
}
});
const prisma = app.get(PrismaService);
startAutoCleanup(prisma);
@@ -2169,12 +2184,7 @@ class PhotoController {
}
const uploaderId = req.user.id;
const isAnonymous = req.user.isAnonymous;
// Chỉ người dùng ẩn danh mới được dùng endpoint này
if (!isAnonymous) {
throw new ForbiddenException('Chỉ người dùng khách mới có thể sử dụng tính năng này.');
}
// Cả người dùng đã đăng ký và khách ẩn danh đều được dùng endpoint này để chia sẻ ảnh công khai lên bản đồ
const file = files[0];
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
@@ -2309,7 +2319,7 @@ class PhotoController {
@Patch(':id')
async updatePhoto(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { title?: string; description?: string; latitude?: number; longitude?: number },
@Body() body: { title?: string; description?: string; latitude?: number; longitude?: number; tags?: string[] },
@Req() req: any
) {
const photo = await this.prisma.photo.findUnique({
@@ -2332,6 +2342,7 @@ class PhotoController {
lng: body.longitude !== undefined ? body.longitude : currentMetadata.lng,
title: body.title !== undefined ? body.title : currentMetadata.title,
description: body.description !== undefined ? body.description : currentMetadata.description,
tags: body.tags !== undefined ? body.tags : currentMetadata.tags,
};
return this.prisma.photo.update({
@@ -2463,7 +2474,7 @@ class UserController {
return this.prisma.user.update({
where: { id },
data,
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true }
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, avatar: true, phone: true, address: true }
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 KiB

+1 -1
View File
@@ -15,7 +15,7 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
FROM base AS build
ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
RUN npm install
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
+3 -3
View File
@@ -21,16 +21,16 @@
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
<script type="module" crossorigin src="/assets/index-BximWk33.js"></script>
<script type="module" crossorigin src="/assets/index-CRdl2V_E.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-Cyuzqnbw.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-others-VIU5qAPG.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-DOXkNaRS.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-maps-DY-S_hoR.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-react-DQOuwrxr.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BqXL8Z-i.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-pdf-IjT8x9OX.js">
<link rel="stylesheet" crossorigin href="/assets/vendor-maps-CcXJxNtP.css">
<link rel="stylesheet" crossorigin href="/assets/vendor-react-CuSj0z1j.css">
<link rel="stylesheet" crossorigin href="/assets/index-21UejyBs.css">
<link rel="stylesheet" crossorigin href="/assets/index-BsXBn1-b.css">
</head>
<body>
<div id="root"></div>
+9
View File
@@ -12,6 +12,15 @@ server {
add_header Cache-Control "public, max-age=31536000";
}
# Proxy downloaded APK files from backend
location /downloads/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# JavaScript and CSS files - immutable caching
location ~* \.(?:js|css)$ {
root /usr/share/nginx/html;
+30 -65
View File
@@ -6,13 +6,13 @@ import SignupPage from './pages/SignupPage';
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 { TourNavigationPage } from './pages/TourNavigationPage';
import { ConfirmProvider } from './hooks/useConfirm';
import { NotificationProvider, useNotification } from './hooks/useNotification';
import { io } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
interface GlobalNotificationListenerProps {
user: any;
@@ -33,7 +33,9 @@ const GlobalNotificationListener: React.FC<GlobalNotificationListenerProps> = ({
useEffect(() => {
if (!user?.id) return;
const socketInstance = io();
const socketInstance = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
: io();
socketInstance.on('connect', () => {
console.log('[WS] Global notification socket connected:', socketInstance.id);
@@ -136,7 +138,7 @@ function App() {
);
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore');
const [previousPage, setPreviousPage] = useState<'explore' | 'landing'>('explore');
const [navigationPayload, setNavigationPayload] = useState<{ tourId: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string } | null>(null);
useEffect(() => {
@@ -158,7 +160,6 @@ function App() {
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) {
@@ -188,7 +189,7 @@ function App() {
if (loggedInUser.isAdmin) {
setCurrentPage('admin');
} else {
setCurrentPage('dashboard');
setCurrentPage('landing');
}
} else {
setCurrentPage('landing');
@@ -207,14 +208,7 @@ function App() {
// Nếu là admin, chuyển đến admin dashboard
setCurrentPage('admin');
} else {
// 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');
}
// Keep on current page after successful login, modal will close
}
};
@@ -225,10 +219,11 @@ function App() {
setCurrentPage('landing');
};
const handleViewTour = (tourId: string, fromPage?: 'explore' | 'dashboard') => {
const handleViewTour = (tourId: string, fromPage?: 'explore') => {
setCurrentTourId(tourId);
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
setPreviousPage(fromPage || (currentPage === 'dashboard' ? 'dashboard' : 'explore'));
setPreviousPage(fromPage || 'explore');
setCurrentPage('tourDetail');
};
@@ -293,30 +288,11 @@ function App() {
};
const handleBackFromExplore = () => {
// 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');
}
const handleGoToDashboard = (_tab?: 'tours' | 'connections' | 'photos' | 'chats') => {
// Deprecated dashboard redirect: keeping empty function to satisfy interface prop requirements
};
const handleGoToHome = () => {
@@ -344,32 +320,7 @@ function App() {
return (
<AdminDashboard
user={user}
onNavigate={setCurrentPage}
/>
);
}
if (currentPage === 'dashboard') {
// SECURITY: Prevent any guest from accessing dashboard
const guestToken = localStorage.getItem('guest_token');
if (guestToken) {
console.warn('[App] Guest user attempted to access dashboard - forcing redirect to landing');
setCurrentPage('landing');
return (
<LandingPage
onLoginSuccess={handleLoginSuccess}
onGoToSignup={() => setCurrentPage('signup')}
/>
);
}
return (
<MemberDashboard
user={user}
onLogout={handleLogout}
onExploreTours={() => setCurrentPage('explore')}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
onNavigate={(page) => setCurrentPage(page as any)}
/>
);
}
@@ -381,7 +332,10 @@ function App() {
onBack={handleBackFromTourDetail}
isPublicView={isPublicTourView}
onOpenNotes={() => setCurrentPage('notes')}
onOpenNavigationPage={handleOpenNavigationPage}
onOpenNavigationPage={(routeData) => handleOpenNavigationPage({
tourId: currentTourId!,
...routeData
})}
/>
);
}
@@ -410,9 +364,9 @@ function App() {
onLogout={handleLogout}
user={user}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
onLoginSuccess={handleLoginSuccess}
onGoToDashboard={handleGoToDashboard}
onOpenNavigation={handleOpenNavigationPage}
/>
);
}
@@ -454,7 +408,18 @@ function App() {
);
}
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
return (
<LandingPage
onContinue={() => setCurrentPage('explore')}
onGoToSignup={() => setCurrentPage('signup')}
onGoToMap={() => setCurrentPage('explore')}
onLoginSuccess={handleLoginSuccess}
user={user}
onLogout={handleLogout}
onGoToDashboard={handleGoToDashboard}
onOpenNavigation={handleOpenNavigationPage}
/>
);
})()}
</NotificationProvider>
</ConfirmProvider>
@@ -0,0 +1,230 @@
import React, { useState } from 'react';
import { Compass, Map, Image as ImageIcon, Settings, ShieldAlert, Users, LogOut, LogIn, Globe, Sun, Moon } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
interface MapProfileDropdownProps {
user: any;
onLogout?: () => void;
onOpenSettings: () => void;
onOpenCreateTour: () => void;
onOpenReport: () => void;
onOpenLogin: () => void;
onOpenMyPhotos: () => void;
onOpenMyTours: () => void;
onOpenFriends: () => void;
onOpenAdmin?: () => void;
}
export const MapProfileDropdown: React.FC<MapProfileDropdownProps> = ({
user,
onLogout,
onOpenSettings,
onOpenCreateTour,
onOpenReport,
onOpenLogin,
onOpenMyPhotos,
onOpenMyTours,
onOpenFriends,
onOpenAdmin,
}) => {
const [isOpen, setIsOpen] = useState(false);
const { lang, changeLanguage } = useTranslation();
const { theme, changeTheme } = useTheme();
const guestToken = localStorage.getItem('guest_token');
const isAuthenticated = !!user && !guestToken;
const renderInitialsAvatar = (name: string) => {
const initials = name
? name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()
: 'U';
return (
<div className="w-full h-full flex items-center justify-center bg-indigo-600 text-white font-bold text-sm">
{initials}
</div>
);
};
const handleItemClick = (callback: () => void) => {
setIsOpen(false);
callback();
};
return (
<div className="relative inline-block text-left select-none pointer-events-auto">
{/* TRIGGER BUTTON */}
<button
onClick={() => setIsOpen(!isOpen)}
className="w-11 h-11 rounded-full border-2 border-white dark:border-slate-800 bg-slate-800 flex items-center justify-center overflow-hidden shadow-xl active:scale-95 transition-all duration-150 cursor-pointer"
title="Menu cá nhân"
>
{isAuthenticated ? (
user?.avatar ? (
<img src={user.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
renderInitialsAvatar(user?.name || 'User')
)
) : (
<div className="w-full h-full flex items-center justify-center bg-slate-700 text-slate-300 font-bold text-sm">
G
</div>
)}
</button>
{/* DROPDOWN MENU / MOBILE BOTTOM SHEET LAYER */}
{isOpen && (
<>
<div
className="fixed inset-0 z-[999998] bg-black/50 sm:bg-transparent"
onClick={() => setIsOpen(false)}
/>
<div
className="fixed bottom-0 left-0 right-0 sm:absolute sm:bottom-auto sm:top-14 sm:right-0 sm:left-auto z-[999999] w-full sm:w-64 bg-slate-900 border-t sm:border border-slate-800 rounded-t-3xl sm:rounded-2xl p-3 sm:p-2 shadow-2xl animate-in slide-in-from-bottom sm:slide-in-from-top-2 duration-300 text-xs text-slate-200"
>
<div className="w-12 h-1 bg-slate-700 rounded-full mx-auto mb-3 sm:hidden" />
{isAuthenticated ? (
<div className="flex flex-col space-y-1">
{/* User info header */}
<div className="px-4 py-2 border-b border-slate-800/60 mb-1 flex items-center gap-3">
<div className="w-8 h-8 rounded-full overflow-hidden shrink-0 border border-slate-700">
{user?.avatar ? (
<img src={user.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
renderInitialsAvatar(user?.name || 'User')
)}
</div>
<div className="flex flex-col min-w-0">
<span className="font-bold text-white truncate text-sm">{user?.name}</span>
<span className="text-[10px] text-slate-400 truncate">{user?.email}</span>
</div>
</div>
{/* Extra System Admin option */}
{user?.isAdmin && (
<button
onClick={() => handleItemClick(onOpenAdmin || (() => {}))}
className="flex items-center gap-3 w-full px-4 py-2.5 bg-blue-950/20 hover:bg-slate-800 rounded-lg text-left transition-colors font-bold text-blue-400 cursor-pointer"
>
<Settings className="w-4 h-4 shrink-0 text-blue-400" /> Quản trị hệ thống
</button>
)}
{/* 1. Tạo tour */}
<button
onClick={() => handleItemClick(onOpenCreateTour)}
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
>
<Compass className="w-4 h-4 text-amber-400 shrink-0" /> Tạo tour
</button>
{/* 2. Hành trình của tôi */}
<button
onClick={() => handleItemClick(onOpenMyTours)}
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
>
<Map className="w-4 h-4 text-blue-400 shrink-0" /> Hành trình của tôi
</button>
{/* 3. Thư viện ảnh */}
<button
onClick={() => handleItemClick(onOpenMyPhotos)}
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
>
<ImageIcon className="w-4 h-4 text-emerald-400 shrink-0" /> Thư viện nh
</button>
{/* 4. Cài đặt */}
<button
onClick={() => handleItemClick(onOpenSettings)}
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
>
<Settings className="w-4 h-4 text-slate-400 shrink-0" /> Cài đt
</button>
{/* 5. Báo cáo vi phạm */}
<button
onClick={() => handleItemClick(onOpenReport)}
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
>
<ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" /> Báo cáo vi phạm
</button>
{/* STRICT VISUAL DIVIDER LINE */}
<div className="border-b border-slate-800 my-1 mx-2" />
{/* 6. Danh sách bạn bè */}
<button
onClick={() => handleItemClick(onOpenFriends)}
className="flex items-center gap-3 w-full px-4 py-2.5 hover:bg-slate-800 rounded-lg text-left transition-colors font-semibold cursor-pointer"
>
<Users className="w-4 h-4 text-indigo-400 shrink-0" /> Danh sách bạn
</button>
{/* 7. Đăng xuất */}
<button
onClick={() => handleItemClick(onLogout || (() => {}))}
className="flex items-center gap-3 w-full px-4 py-2.5 text-rose-400 hover:bg-slate-800 rounded-lg text-left font-bold transition-colors cursor-pointer"
>
<LogOut className="w-4 h-4 shrink-0 text-rose-500" /> Đăng xuất
</button>
</div>
) : (
<div className="flex flex-col space-y-1">
<div className="px-4 py-1.5 text-[10px] font-black uppercase tracking-wider text-slate-500">Tùy chỉnh nhanh</div>
<div className="px-3 py-2 flex flex-col gap-3 bg-slate-950/40 rounded-xl m-1 border border-slate-850">
<div className="flex justify-between items-center">
<span className="font-semibold flex items-center gap-1.5 text-slate-400">
<Globe className="w-3.5 h-3.5 text-indigo-400" /> Ngôn ngữ:
</span>
<select
value={lang}
onChange={(e) => changeLanguage(e.target.value as any)}
className="bg-slate-800 border border-slate-700 rounded-lg px-2 py-1 text-white text-[11px] font-bold focus:outline-none cursor-pointer"
>
<option value="vi">Tiếng Việt</option>
<option value="en">English</option>
<option value="zh"></option>
</select>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold flex items-center gap-1.5 text-slate-400">
{theme === 'light' ? (
<Sun className="w-3.5 h-3.5 text-amber-500" />
) : (
<Moon className="w-3.5 h-3.5 text-indigo-400" />
)}
Giao diện:
</span>
<select
value={theme}
onChange={(e) => changeTheme(e.target.value as any)}
className="bg-slate-800 border border-slate-700 rounded-lg px-2 py-1 text-white text-[11px] font-bold focus:outline-none cursor-pointer"
>
<option value="dark">Tối</option>
<option value="light">Sáng</option>
<option value="system">Hệ thống</option>
</select>
</div>
</div>
<div className="h-[1px] bg-slate-850 my-1 mx-2" />
<button
onClick={() => handleItemClick(onOpenLogin)}
className="flex items-center gap-3 w-full px-4 py-3 text-blue-400 hover:bg-slate-800 rounded-xl text-left font-black transition-colors cursor-pointer"
>
<LogIn className="w-4 h-4 shrink-0" /> Đăng / Đăng nhập
</button>
</div>
)}
</div>
</>
)}
</div>
);
};
+206
View File
@@ -0,0 +1,206 @@
import React, { useEffect, useState } from 'react';
import { X, Image as ImageIcon, Loader2, Calendar, Download, Eye, MapPin } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
interface MyPhotosModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
}
export const MyPhotosModal: React.FC<MyPhotosModalProps> = ({
isOpen,
onClose,
user,
}) => {
const notify = useNotification();
const [photos, setPhotos] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
useEffect(() => {
if (isOpen && user) {
const loadPhotos = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/v1/users/me/photos', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (res.ok) {
const data = await res.json();
setPhotos(data);
} else {
throw new Error('Không thể tải thư viện ảnh.');
}
} catch (e: any) {
console.error(e);
notify({
title: 'Lỗi',
message: e.message || 'Không thể tải ảnh.',
type: 'error',
});
} finally {
setIsLoading(false);
}
};
loadPhotos();
}
}, [isOpen, user]);
if (!isOpen || !user) return null;
const handleDownload = async (url: string, filename: string) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (e) {
console.error(e);
notify({
title: 'Lỗi tải về',
message: 'Không thể tải trực tiếp ảnh xuống thiết bị.',
type: 'error',
});
}
};
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-3xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-emerald-400" /> Thư viện nh
</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Content body */}
<div className="flex-1 overflow-y-auto p-5">
{isLoading ? (
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
<Loader2 className="w-8 h-8 text-emerald-400 animate-spin" />
<span className="text-slate-400 font-medium">Đang tải thư viện nh của bạn...</span>
</div>
) : photos.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
<ImageIcon className="w-8 h-8 text-slate-500" />
</div>
<div>
<div className="font-bold text-white text-sm">Thư viện nh trống</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn chưa đăng tải bức nh nào trong các chuyến đi của mình.</p>
</div>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{photos.map((photo) => {
const dateStr = photo.capturedAt ? new Date(photo.capturedAt).toLocaleDateString('vi-VN') : '';
return (
<div
key={photo.id}
className="group relative aspect-square rounded-2xl overflow-hidden bg-slate-950 border border-slate-850 hover:border-slate-700 shadow-lg transition-all flex flex-col"
>
<img
src={photo.imageUrl}
alt="Gallery Asset"
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
{/* Hover controls overlay */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex flex-col justify-between p-3.5 transition-opacity duration-250 z-10">
<div className="flex justify-end gap-1.5">
<button
onClick={() => setSelectedPhoto(photo)}
className="p-2 bg-slate-900/85 hover:bg-indigo-650 text-white rounded-xl transition-colors cursor-pointer"
title="Xem phóng to"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleDownload(photo.originalUrl || photo.imageUrl, `yotrip-photo-${photo.id}.jpg`)}
className="p-2 bg-slate-900/85 hover:bg-emerald-650 text-white rounded-xl transition-colors cursor-pointer"
title="Tải về tệp gốc"
>
<Download className="w-4 h-4" />
</button>
</div>
<div className="min-w-0">
{photo.tour?.title && (
<div className="text-[10px] font-bold text-indigo-300 truncate flex items-center gap-1">
<MapPin className="w-3 h-3 shrink-0" />
{photo.tour.title}
</div>
)}
<div className="text-[9px] text-slate-400 mt-0.5 flex items-center gap-1">
<Calendar className="w-3 h-3 shrink-0" />
{dateStr}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Fullscreen Preview overlay */}
{selectedPhoto && (
<div className="fixed inset-0 z-[2000000] bg-black/95 flex flex-col justify-between p-4 pointer-events-auto">
{/* Close trigger top bar */}
<div className="flex justify-between items-center w-full pb-3 border-b border-slate-900">
<div className="text-white font-bold text-xs truncate">
{selectedPhoto.tour?.title || 'Xem ảnh'}
</div>
<button
onClick={() => setSelectedPhoto(null)}
className="p-2 bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white rounded-xl cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Fullscreen Photo view */}
<div className="flex-1 flex items-center justify-center p-4">
<img
src={selectedPhoto.originalUrl || selectedPhoto.imageUrl}
alt="Fullscreen Preview"
className="max-w-full max-h-[75vh] object-contain rounded-xl shadow-2xl"
/>
</div>
{/* Action bottom bar */}
<div className="flex justify-center items-center py-4 border-t border-slate-900">
<button
onClick={() => handleDownload(selectedPhoto.originalUrl || selectedPhoto.imageUrl, `yotrip-photo-${selectedPhoto.id}.jpg`)}
className="flex items-center gap-2 px-5 py-3 bg-emerald-650 hover:bg-emerald-600 font-bold text-white rounded-xl shadow-lg transition-all active:scale-95 cursor-pointer"
>
<Download className="w-4 h-4" /> Tải về tệp gốc (.jpg)
</button>
</div>
</div>
)}
</div>
);
};
+179
View File
@@ -0,0 +1,179 @@
import React, { useEffect, useState } from 'react';
import { X, Calendar, Users, Navigation, Trash2, Loader2, Compass } from 'lucide-react';
import { useTourStore } from '../store/useTourStore';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
interface MyToursModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
onViewTour: (tourId: string) => void;
}
export const MyToursModal: React.FC<MyToursModalProps> = ({
isOpen,
onClose,
user,
onViewTour,
}) => {
const notify = useNotification();
const confirm = useConfirm();
const publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const deleteTour = useTourStore(state => state.deleteTour);
const [isLoading, setIsLoading] = useState(false);
const [isDeletingId, setIsDeletingId] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
const loadTours = async () => {
setIsLoading(true);
try {
await fetchPublicTours();
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
loadTours();
}
}, [isOpen, fetchPublicTours]);
if (!isOpen || !user) return null;
// Filter tours where user is participant
const myTours = publicTours.filter(tour =>
tour.participants?.some((p: any) => p.userId === user?.id)
);
const handleDelete = async (tourId: string, tourTitle: string) => {
const ok = await confirm({
title: 'Xóa chuyến đi?',
message: `Bạn có chắc chắn muốn xóa chuyến đi "${tourTitle}" không? Hành động này không thể hoàn tác.`,
});
if (!ok) return;
setIsDeletingId(tourId);
try {
await deleteTour(tourId);
notify({
title: 'Thành công',
message: 'Đã xóa chuyến đi thành công.',
type: 'success',
});
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Xóa chuyến đi thất bại.',
type: 'error',
});
} finally {
setIsDeletingId(null);
}
};
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white flex items-center gap-2">
<Compass className="w-5 h-5 text-indigo-400" /> Hành trình của tôi
</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Content body */}
<div className="flex-1 overflow-y-auto p-5">
{isLoading ? (
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
<Loader2 className="w-8 h-8 text-indigo-400 animate-spin" />
<span className="text-slate-400 font-medium">Đang tải danh sách chuyến đi...</span>
</div>
) : myTours.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
<Compass className="w-8 h-8 text-slate-500" />
</div>
<div>
<div className="font-bold text-white text-sm">Chưa hành trình nào</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn chưa tham gia chuyến đi nào. Hãy nhấp Tạo Tour mới đ bắt đu hành trình của riêng mình!</p>
</div>
</div>
) : (
<div className="space-y-3.5">
{myTours.map((tour) => {
const isOwner = tour.participants?.some(
(p: any) => p.userId === user?.id && p.role === 'OWNER'
);
const startDateStr = tour.startDate ? new Date(tour.startDate).toLocaleDateString('vi-VN') : '';
const endDateStr = tour.endDate ? new Date(tour.endDate).toLocaleDateString('vi-VN') : '';
return (
<div
key={tour.id}
className="p-4 bg-slate-950/40 border border-slate-850 hover:border-slate-750 rounded-2xl flex flex-col gap-3 transition-all"
>
<div className="flex justify-between items-start gap-4">
<div className="min-w-0">
<h4 className="font-bold text-white text-sm truncate">{tour.title}</h4>
<div className="flex items-center gap-4 text-[10px] text-slate-400 mt-1 font-semibold">
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-slate-500 shrink-0" />
{startDateStr} - {endDateStr}
</span>
<span className="flex items-center gap-1">
<Users className="w-3.5 h-3.5 text-slate-500 shrink-0" />
{tour.participants?.length || 0} thành viên
</span>
</div>
</div>
</div>
<div className="flex justify-end items-center gap-2 border-t border-slate-850 pt-3">
{isOwner && (
<button
onClick={() => handleDelete(tour.id, tour.title)}
disabled={isDeletingId === tour.id}
className="p-2 text-rose-400 hover:bg-rose-500/10 rounded-xl transition-all cursor-pointer shrink-0 disabled:opacity-50"
title="Xóa chuyến đi"
>
{isDeletingId === tour.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
)}
<button
onClick={() => {
onClose();
onViewTour(tour.id);
}}
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-650 hover:bg-indigo-600 font-bold text-white rounded-xl transition-all active:scale-95 cursor-pointer shadow-md shadow-indigo-900/10"
>
<Navigation className="w-3.5 h-3.5" /> Xem hành trình
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,350 @@
import React, { useState, useEffect } from 'react';
import { X, Camera, Loader2, Check } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { useNotification } from '@/hooks/useNotification';
interface ProfileSettingsModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
onSaveSuccess?: (updatedUser: any) => void;
}
export const ProfileSettingsModal: React.FC<ProfileSettingsModalProps> = ({
isOpen,
onClose,
user,
onSaveSuccess,
}) => {
const { lang, changeLanguage } = useTranslation();
const { theme, changeTheme } = useTheme();
const notify = useNotification();
// Form States
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [address, setAddress] = useState('');
const [avatar, setAvatar] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
// Status States
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
if (user) {
setName(user.name || '');
setPhone(user.phone || '');
setAddress(user.address || '');
setAvatar(user.avatar || '');
}
}, [user, isOpen]);
if (!isOpen || !user) return null;
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
const formData = new FormData();
formData.append('image', file);
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/v1/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
if (!res.ok) {
throw new Error('Không thể tải ảnh lên.');
}
const data = await res.json();
setAvatar(data.url);
notify({
title: 'Thành công',
message: 'Đã cập nhật ảnh đại diện xem trước.',
type: 'success',
});
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Tải ảnh thất bại.',
type: 'error',
});
} finally {
setIsUploading(false);
}
};
const handleSave = async () => {
if (!name.trim()) {
notify({
title: 'Lỗi',
message: 'Tên hiển thị không được để trống.',
type: 'error',
});
return;
}
setIsSaving(true);
const updateData: any = {
name,
phone,
address,
avatar,
};
if (currentPassword && newPassword) {
// Typically the backend might check the old password, let's pass it
updateData.password = newPassword;
// We pass both or verify password on backend
} else if (newPassword && !currentPassword) {
notify({
title: 'Lỗi',
message: 'Vui lòng cung cấp mật khẩu hiện tại để đổi mật khẩu.',
type: 'error',
});
setIsSaving(false);
return;
}
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/v1/users/${user.id}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(updateData),
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.message || 'Không thể lưu thay đổi.');
}
const updatedUser = await res.json();
// Update local storage user object
const storedUser = localStorage.getItem('user');
if (storedUser) {
const userObj = JSON.parse(storedUser);
const mergedUser = { ...userObj, ...updatedUser };
localStorage.setItem('user', JSON.stringify(mergedUser));
if (onSaveSuccess) {
onSaveSuccess(mergedUser);
}
}
notify({
title: 'Thành công',
message: 'Thông tin hồ sơ cá nhân đã được lưu thành công.',
type: 'success',
});
// Clear password inputs
setCurrentPassword('');
setNewPassword('');
onClose();
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Lưu thay đổi thất bại.',
type: 'error',
});
} finally {
setIsSaving(false);
}
};
// Helper to render initials fallback
const renderInitialsAvatar = (name: string) => {
const initials = name
? name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()
: 'U';
return (
<div className="w-full h-full flex items-center justify-center bg-indigo-600 text-white font-bold text-2xl">
{initials}
</div>
);
};
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-xl h-[92vh] sm:h-auto max-h-[92vh] sm:max-h-[85vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header bar */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white">Chỉnh sửa hồ nhân</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Scrollable Form Workspace Canvas */}
<div className="flex-1 overflow-y-auto p-5 space-y-5">
{/* Avatar Upload Container Component */}
<div className="flex flex-col items-center justify-center space-y-2">
<div className="relative w-20 h-20 rounded-full border-2 border-slate-700 overflow-hidden bg-slate-850 group shadow-lg">
{isUploading ? (
<div className="w-full h-full flex items-center justify-center bg-slate-800/85">
<Loader2 className="w-6 h-6 text-indigo-400 animate-spin" />
</div>
) : avatar ? (
<img src={avatar} alt="Profile Avatar" className="w-full h-full object-cover" />
) : (
renderInitialsAvatar(name || 'User')
)}
<label className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 cursor-pointer transition-opacity duration-200">
<Camera className="w-5 h-5 text-white" />
<span className="text-[8px] text-white/80 mt-1">Thay nh</span>
<input type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} disabled={isUploading} />
</label>
</div>
<span className="text-[10px] text-slate-500 font-semibold">Di chuột qua nh nhấp đ tải hình đi diện mới</span>
</div>
{/* Text Input Row Fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="font-bold text-slate-400">Tên hiển thị:</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all font-semibold"
placeholder="Nhập tên hiển thị"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-bold text-slate-400">Số điện thoại:</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all font-semibold"
placeholder="Nhập số điện thoại"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-bold text-slate-400">Đa chỉ liên hệ:</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all font-semibold"
placeholder="Nhập địa chỉ của bạn"
/>
</div>
{/* EMAIL COMPONENT BOUNDARY - CRITICAL REQUIREMENT: DISABLED CHANGING */}
<div className="flex flex-col gap-1.5 bg-slate-950/30 p-3.5 rounded-xl border border-slate-850">
<label className="font-bold text-slate-500">Đa chỉ Email đăng nhập (Không thể chỉnh sửa):</label>
<input
type="email"
disabled
className="bg-slate-950/50 border border-slate-850 text-slate-500 rounded-xl p-3 cursor-not-allowed select-none font-semibold"
value={user.email || ''}
/>
</div>
{/* Security Password Mutation Stack */}
<div className="border-t border-slate-800/60 pt-4 flex flex-col gap-3">
<span className="font-bold text-slate-300 text-sm">Thay đi mật khẩu đăng nhập</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<input
type="password"
placeholder="Mật khẩu hiện tại"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 outline-none transition-all font-semibold"
/>
<input
type="password"
placeholder="Mật khẩu mới"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 outline-none transition-all font-semibold"
/>
</div>
</div>
{/* Core Configuration Toggles (Moved from Top-Bar into profile settings context) */}
<div className="border-t border-slate-800/60 pt-4 grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="font-bold text-slate-400">Lựa chọn ngôn ngữ:</label>
<select
value={lang}
onChange={(e) => changeLanguage(e.target.value as any)}
className="bg-slate-955 border border-slate-800 rounded-xl p-3 text-white focus:outline-none cursor-pointer font-bold"
>
<option value="vi">Tiếng Việt (ICT)</option>
<option value="en">English (US)</option>
<option value="zh"> (ZH)</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-bold text-slate-400">Lựa chọn giao diện:</label>
<select
value={theme}
onChange={(e) => changeTheme(e.target.value as any)}
className="bg-slate-955 border border-slate-800 rounded-xl p-3 text-white focus:outline-none cursor-pointer font-bold"
>
<option value="dark">Chế đ tối (Dark Mode)</option>
<option value="light">Chế đ sáng (Light Mode)</option>
<option value="system">Chế đ hệ thống (System)</option>
</select>
</div>
</div>
</div>
{/* Action Bottom Save Trigger */}
<div className="p-4 bg-slate-950 border-t border-slate-800/60 flex justify-end gap-3 shrink-0">
<button
onClick={onClose}
disabled={isSaving}
className="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 disabled:opacity-50 text-slate-300 font-bold rounded-xl transition-all active:scale-95 cursor-pointer"
>
Hủy
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 font-bold text-white rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 flex items-center gap-1.5 cursor-pointer"
>
{isSaving ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Đang lưu...
</>
) : (
<>
<Check className="w-3.5 h-3.5" />
Lưu cấu hình
</>
)}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,442 @@
import React, { useEffect, useState } from 'react';
import { X, Search, Users, UserPlus, UserCheck, MessageSquare, Trash2, Check, UserX, Loader2 } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
interface FriendsManagerModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
onOpenChatWithUser?: (userId: string) => void;
}
export const FriendsManagerModal: React.FC<FriendsManagerModalProps> = ({
isOpen,
onClose,
user,
onOpenChatWithUser,
}) => {
const notify = useNotification();
const confirm = useConfirm();
const [activeTab, setActiveTab] = useState<'friends' | 'pending' | 'search'>('friends');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false);
// Connection Lists
const [connections, setConnections] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const getHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('token')}`,
});
// Fetch direct connections list
const fetchConnections = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/v1/connections', { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
setConnections(data || []);
}
} catch (e) {
console.error('[FriendsManagerModal] Error fetching connections:', e);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (isOpen && user) {
fetchConnections();
}
}, [isOpen, user]);
// Handle connection search lookup
const handleSearchUsers = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
setIsSearching(true);
try {
const res = await fetch(`/api/v1/users?q=${encodeURIComponent(searchQuery)}`, { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
// Filter out current user from search results
setSearchResults((data || []).filter((u: any) => u.id !== user?.id));
}
} catch (e) {
console.error('[FriendsManagerModal] Search error:', e);
} finally {
setIsSearching(false);
}
};
// Accept/Reject friend request
const handleUpdateStatus = async (connId: string, status: 'ACCEPTED' | 'REJECTED') => {
try {
const res = await fetch(`/api/v1/connections/${connId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ status }),
});
if (res.ok) {
notify({
title: 'Thành công',
message: status === 'ACCEPTED' ? 'Đã chấp nhận kết nối.' : 'Đã từ chối kết nối.',
type: 'success',
});
fetchConnections();
} else {
throw new Error('Cập nhật kết nối thất bại.');
}
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message,
type: 'error',
});
}
};
// Disconnect / Unfriend / Cancel request
const handleDisconnect = async (connId: string, name: string) => {
const ok = 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 ${name} không?`,
});
if (!ok) return;
try {
const res = await fetch(`/api/v1/connections/${connId}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (res.ok) {
notify({
title: 'Thành công',
message: 'Hủy kết nối thành công.',
type: 'success',
});
fetchConnections();
} else {
throw new Error('Lỗi hủy kết nối.');
}
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message,
type: 'error',
});
}
};
// Send request connection
const handleSendRequest = async (receiverId: string) => {
try {
const res = await fetch('/api/v1/connections', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ receiverId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || 'Gửi lời mời thất bại.');
notify({
title: 'Thành công',
message: 'Đã gửi lời mời kết nối thành công.',
type: 'success',
});
fetchConnections();
// Reset search lists to update button states
setSearchResults(prev => prev.map(u => u.id === receiverId ? { ...u, pendingSent: true } : u));
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message,
type: 'error',
});
}
};
if (!isOpen || !user) return null;
// Filter lists
const activeFriends = connections.filter((c: any) => c.status === 'ACCEPTED');
// Received pending requests
const pendingRequests = connections.filter((c: any) => c.status === 'PENDING' && c.targetUser?.id === user?.id);
const getStatusText = (targetUserId: string) => {
const existing = connections.find(
c => c.targetUser?.id === targetUserId || c.requester?.id === targetUserId
);
if (!existing) return null;
if (existing.status === 'ACCEPTED') return 'FRIEND';
if (existing.status === 'PENDING') {
return existing.requester?.id === user?.id ? 'SENT' : 'RECEIVED';
}
return null;
};
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white flex items-center gap-2">
<Users className="w-5 h-5 text-indigo-400" /> Danh sách bạn
</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Tab Selection Header */}
<div className="bg-slate-950/40 p-2.5 border-b border-slate-850 flex gap-2 shrink-0">
{[
{ id: 'friends', label: `Bạn bè (${activeFriends.length})` },
{ id: 'pending', label: `Lời mời (${pendingRequests.length})` },
{ id: 'search', label: 'Tìm bạn mới' }
].map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 py-2 rounded-xl text-center font-bold transition-all cursor-pointer ${
isActive
? 'bg-indigo-650 text-white shadow-md'
: 'bg-slate-900/40 hover:bg-slate-850 text-slate-400'
}`}
>
{tab.label}
</button>
);
})}
</div>
{/* Content body */}
<div className="flex-1 overflow-y-auto p-5">
{isLoading && activeTab !== 'search' ? (
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
<Loader2 className="w-8 h-8 text-indigo-400 animate-spin" />
<span className="text-slate-400 font-medium">Đang tải dữ liệu...</span>
</div>
) : activeTab === 'friends' ? (
activeFriends.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
<Users className="w-8 h-8 text-slate-500" />
</div>
<div>
<div className="font-bold text-white text-sm">Chưa bạn kết nối</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Chọn mục "Tìm bạn mới" đ tìm kiếm gửi lời mời kết bạn.</p>
</div>
</div>
) : (
<div className="space-y-2.5">
{activeFriends.map((conn) => {
const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
return (
<div
key={conn.id}
className="p-3 bg-slate-950/40 border border-slate-850 rounded-xl flex items-center justify-between gap-3"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full bg-slate-850 overflow-hidden flex items-center justify-center font-bold border border-slate-800 shrink-0">
{friend.avatar ? (
<img src={friend.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span>{friend.name.charAt(0).toUpperCase()}</span>
)}
</div>
<div className="min-w-0">
<h5 className="font-bold text-white truncate text-xs">{friend.name}</h5>
<p className="text-[10px] text-slate-400 truncate mt-0.5">{friend.email}</p>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => {
if (onOpenChatWithUser) {
onClose();
onOpenChatWithUser(friend.id);
}
}}
className="p-2 bg-indigo-650/15 hover:bg-indigo-650 text-indigo-400 hover:text-white rounded-xl transition-all cursor-pointer"
title="Nhắn tin nhanh"
>
<MessageSquare className="w-4 h-4" />
</button>
<button
onClick={() => handleDisconnect(conn.id, friend.name)}
className="p-2 bg-rose-650/15 hover:bg-rose-650 text-rose-400 hover:text-white rounded-xl transition-all cursor-pointer"
title="Hủy kết bạn"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
)
) : activeTab === 'pending' ? (
pendingRequests.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
<UserPlus className="w-8 h-8 text-slate-500" />
</div>
<div>
<div className="font-bold text-white text-sm">Không lời mời kết bạn</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn không lời mời kết bạn nào đang chờ duyệt.</p>
</div>
</div>
) : (
<div className="space-y-2.5">
{pendingRequests.map((conn) => {
const requester = conn.requester;
return (
<div
key={conn.id}
className="p-3 bg-slate-950/40 border border-slate-850 rounded-xl flex items-center justify-between gap-3"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full bg-slate-850 overflow-hidden flex items-center justify-center font-bold border border-slate-800 shrink-0">
{requester.avatar ? (
<img src={requester.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span>{requester.name.charAt(0).toUpperCase()}</span>
)}
</div>
<div className="min-w-0">
<h5 className="font-bold text-white truncate text-xs">{requester.name}</h5>
<p className="text-[10px] text-slate-400 truncate mt-0.5">{requester.email}</p>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => handleUpdateStatus(conn.id, 'ACCEPTED')}
className="px-3.5 py-1.5 bg-emerald-650 hover:bg-emerald-600 font-bold text-white rounded-xl text-[10px] transition-all flex items-center gap-1 cursor-pointer"
>
<Check className="w-3.5 h-3.5" /> Chấp nhận
</button>
<button
onClick={() => handleDisconnect(conn.id, requester.name)}
className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-xl transition-all cursor-pointer"
title="Từ chối lời mời"
>
<UserX className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)
) : (
// Search Tab panel
<div className="space-y-4">
<form onSubmit={handleSearchUsers} className="flex gap-2">
<div className="flex-1 relative">
<input
type="text"
placeholder="Tìm theo Tên hoặc Số điện thoại..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-2.5 pl-9 pr-4 text-white focus:outline-none focus:border-indigo-650"
/>
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3.5" />
</div>
<button
type="submit"
disabled={isSearching || !searchQuery.trim()}
className="px-4 py-2.5 bg-indigo-650 hover:bg-indigo-600 disabled:opacity-50 text-white font-bold rounded-xl transition-all flex items-center gap-1 cursor-pointer shrink-0"
>
{isSearching ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Tìm kiếm'}
</button>
</form>
<div className="space-y-2.5">
{isSearching ? (
<div className="flex justify-center items-center py-10">
<Loader2 className="w-6 h-6 text-slate-500 animate-spin" />
</div>
) : searchResults.length === 0 ? (
searchQuery.trim() && (
<div className="text-center text-slate-500 py-10 italic">
Không tìm thấy kết quả phù hợp.
</div>
)
) : (
searchResults.map((u) => {
const status = getStatusText(u.id);
return (
<div
key={u.id}
className="p-3 bg-slate-950/40 border border-slate-850 rounded-xl flex items-center justify-between gap-3"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full bg-slate-850 overflow-hidden flex items-center justify-center font-bold border border-slate-800 shrink-0">
{u.avatar ? (
<img src={u.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span>{u.name.charAt(0).toUpperCase()}</span>
)}
</div>
<div className="min-w-0">
<h5 className="font-bold text-white truncate text-xs">{u.name}</h5>
<p className="text-[10px] text-slate-400 truncate mt-0.5">{u.email}</p>
</div>
</div>
<div className="shrink-0">
{status === 'FRIEND' ? (
<span className="text-[10px] text-indigo-400 font-bold flex items-center gap-1">
<UserCheck className="w-3.5 h-3.5" /> Đã kết nối
</span>
) : status === 'SENT' || u.pendingSent ? (
<span className="text-[10px] text-slate-500 italic">
Đã gửi lời mời
</span>
) : status === 'RECEIVED' ? (
<span className="text-[10px] text-amber-400 font-semibold">
Chờ bạn duyệt
</span>
) : (
<button
onClick={() => handleSendRequest(u.id)}
className="px-3.5 py-1.5 bg-slate-800 hover:bg-slate-700 font-bold text-slate-200 rounded-xl text-[10px] transition-all flex items-center gap-1 cursor-pointer"
>
<UserPlus className="w-3.5 h-3.5" /> Kết nối
</button>
)}
</div>
</div>
);
})
)}
</div>
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,514 @@
import React, { useEffect, useState, useRef } from 'react';
import { X, Image as ImageIcon, Send, MapPin, Loader2, Search, Smile, Users } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
interface LiveChatModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
defaultChatUserId?: string | null;
}
export const LiveChatModal: React.FC<LiveChatModalProps> = ({
isOpen,
onClose,
user,
defaultChatUserId,
}) => {
const notify = useNotification();
const [connections, setConnections] = useState<any[]>([]);
const [activeChatUser, setActiveChatUser] = useState<any | null>(null);
const [chatMessages, setChatMessages] = useState<any[]>([]);
const [newMessage, setNewMessage] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isLoadingContacts, setIsLoadingContacts] = useState(false);
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
const [isSending, setIsSending] = useState(false);
// Attachments
const [attachedImage, setAttachedImage] = useState<File | null>(null);
const [attachedImageUrl, setAttachedImageUrl] = useState<string | null>(null);
const [attachedLocation, setAttachedLocation] = useState<{ latitude: number; longitude: number } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const getHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('token')}`,
});
// Fetch connections
const fetchConnections = async () => {
setIsLoadingContacts(true);
try {
const res = await fetch('/api/v1/connections', { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
// Accepted connections only
const activeConns = data.filter((c: any) => c.status === 'ACCEPTED');
setConnections(activeConns);
}
} catch (e) {
console.error('[LiveChatModal] Error fetching connections:', e);
} finally {
setIsLoadingContacts(false);
}
};
// Fetch conversation messages
const fetchMessages = async (targetUserId: string) => {
setIsLoadingMessages(true);
try {
const res = await fetch(`/api/v1/messages/${targetUserId}`, { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
setChatMessages(data || []);
}
} catch (e) {
console.error('[LiveChatModal] Error fetching messages:', e);
} finally {
setIsLoadingMessages(false);
}
};
// Load contacts when modal opens
useEffect(() => {
if (isOpen && user) {
fetchConnections();
}
}, [isOpen, user]);
// Set active chat user dynamically if default is provided
useEffect(() => {
if (isOpen && defaultChatUserId && connections.length > 0) {
const conn = connections.find(
(c: any) => c.targetUser?.id === defaultChatUserId || c.requester?.id === defaultChatUserId
);
if (conn) {
const target = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
setActiveChatUser(target);
}
}
}, [isOpen, defaultChatUserId, connections]);
// Load chat messages when activeChatUser shifts
useEffect(() => {
if (activeChatUser) {
fetchMessages(activeChatUser.id);
(window as any).activeChatUserId = activeChatUser.id;
}
return () => {
(window as any).activeChatUserId = undefined;
};
}, [activeChatUser]);
// Handle incoming live messages from custom window event dispatched by App.tsx socket
useEffect(() => {
const handleMessageReceived = (e: Event) => {
const msg = (e as CustomEvent).detail;
if (activeChatUser && (msg.senderId === activeChatUser.id || msg.receiverId === activeChatUser.id)) {
setChatMessages((prev) => [...prev, msg]);
} else {
// Reload contacts to update latest message snippets/badge alerts
fetchConnections();
}
};
window.addEventListener('app:messageReceived', handleMessageReceived);
return () => {
window.removeEventListener('app:messageReceived', handleMessageReceived);
};
}, [activeChatUser]);
// Scroll to chat baseline
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatMessages]);
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setAttachedImage(file);
setAttachedImageUrl(URL.createObjectURL(file));
notify({
title: 'Đã đính kèm ảnh',
message: `${file.name} đã được chọn để gửi.`,
type: 'info',
});
}
};
const handleAttachGps = () => {
if (!navigator.geolocation) {
notify({
title: 'Không được hỗ trợ',
message: 'Trình duyệt của bạn không hỗ trợ định vị.',
type: 'error',
});
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
setAttachedLocation({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
});
notify({
title: 'Đã đính kèm định vị',
message: `Vị trí (${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}) đã được ghi nhận.`,
type: 'success',
});
},
() => {
notify({
title: 'Lỗi định vị',
message: 'Không thể lấy vị trí hiện tại. Hãy kiểm tra quyền GPS.',
type: 'error',
});
}
);
};
const handleSendMessage = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!newMessage.trim() && !attachedImage && !attachedLocation) return;
if (!activeChatUser) return;
setIsSending(true);
try {
let uploadedUrl: string | null = null;
if (attachedImage) {
const formData = new FormData();
formData.append('file', attachedImage);
const uploadRes = await fetch('/api/v1/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
body: formData,
});
if (uploadRes.ok) {
const uploadData = await uploadRes.json();
uploadedUrl = uploadData.url;
} else {
throw new Error('Upload ảnh thất bại.');
}
}
const bodyData = {
receiverId: activeChatUser.id,
content: newMessage,
mediaUrl: uploadedUrl,
latitude: attachedLocation?.latitude || null,
longitude: attachedLocation?.longitude || null,
};
const sendRes = await fetch('/api/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify(bodyData),
});
if (sendRes.ok) {
const sentMsg = await sendRes.json();
setChatMessages((prev) => [...prev, sentMsg]);
setNewMessage('');
setAttachedImage(null);
setAttachedImageUrl(null);
setAttachedLocation(null);
} else {
throw new Error('Gửi tin nhắn thất bại.');
}
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Không thể gửi tin nhắn.',
type: 'error',
});
} finally {
setIsSending(false);
}
};
if (!isOpen || !user) return null;
// Filter connections by search query
const filteredConnections = connections.filter((conn: any) => {
const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
return friend?.name?.toLowerCase().includes(searchQuery.toLowerCase());
});
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-4xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white flex items-center gap-2">
<Users className="w-5 h-5 text-indigo-400" /> Trò chuyện trực tiếp
</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Workspace panel */}
<div className="flex-1 flex flex-col md:flex-row min-h-0">
{/* Left Column - Contacts Sidebar */}
<div className="w-full md:w-80 border-r border-slate-800/80 flex flex-col min-h-0 bg-slate-950/20 shrink-0">
<div className="p-3 border-b border-slate-800/50">
<div className="relative">
<input
type="text"
placeholder="Tìm kiếm bạn bè..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-950/80 border border-slate-850 rounded-xl py-2 pl-9 pr-4 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-650"
/>
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-2.5" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
{isLoadingContacts ? (
<div className="flex justify-center items-center py-10">
<Loader2 className="w-6 h-6 text-slate-500 animate-spin" />
</div>
) : filteredConnections.length === 0 ? (
<div className="text-center text-slate-500 py-10 italic">
Không tìm thấy bạn nào.
</div>
) : (
filteredConnections.map((conn: any) => {
const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
const isActive = activeChatUser?.id === friend.id;
return (
<button
key={conn.id}
onClick={() => setActiveChatUser(friend)}
className={`w-full flex items-center gap-3 p-3 rounded-xl transition-all text-left cursor-pointer ${
isActive
? 'bg-indigo-650 text-white font-bold'
: 'bg-slate-950/20 hover:bg-slate-850 text-slate-300'
}`}
>
<div className="relative shrink-0">
<div className="w-10 h-10 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center overflow-hidden font-bold">
{friend.avatar ? (
<img src={friend.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span>{friend.name.charAt(0).toUpperCase()}</span>
)}
</div>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-green-500 rounded-full border-2 border-slate-900" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs truncate font-bold">{friend.name}</div>
<div className={`text-[10px] truncate mt-0.5 ${isActive ? 'text-slate-200' : 'text-slate-500'}`}>
{friend.email}
</div>
</div>
</button>
);
})
)}
</div>
</div>
{/* Right Column - Active Chat View */}
<div className="flex-1 flex flex-col min-w-0 bg-slate-950/40">
{activeChatUser ? (
<>
{/* Active contact bar */}
<div className="px-4 py-3 bg-slate-900/60 border-b border-slate-800/60 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-800 overflow-hidden flex items-center justify-center font-bold">
{activeChatUser.avatar ? (
<img src={activeChatUser.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span>{activeChatUser.name.charAt(0).toUpperCase()}</span>
)}
</div>
<div className="min-w-0">
<div className="font-bold text-white text-xs">{activeChatUser.name}</div>
<div className="text-[10px] text-green-400 flex items-center gap-1 font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block animate-pulse" /> Đang hoạt đng
</div>
</div>
</div>
{/* Messages stream */}
<div className="flex-1 overflow-y-auto p-4 space-y-3.5">
{isLoadingMessages ? (
<div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 text-indigo-400 animate-spin" />
</div>
) : chatMessages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center text-slate-500 gap-1.5">
<Smile className="w-8 h-8 text-slate-600" />
<span>Hãy gửi tin nhắn đ bắt đu cuộc trò chuyện.</span>
</div>
) : (
chatMessages.map((msg: any) => {
const isMe = msg.senderId === user.id;
const dateStr = msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' }) : '';
return (
<div
key={msg.id}
className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[75%] flex flex-col gap-1`}>
<div
className={`p-3 rounded-2xl break-words text-xs shadow-md ${
isMe
? 'bg-indigo-650 text-white rounded-tr-none'
: 'bg-slate-800/90 text-slate-100 rounded-tl-none'
}`}
>
{/* Attached Image */}
{msg.mediaUrl && (
<div className="mb-2 rounded-xl overflow-hidden max-w-xs border border-black/10">
<img
src={msg.mediaUrl}
alt="Chat attachment"
className="max-h-40 w-full object-cover cursor-pointer"
onClick={() => window.open(msg.mediaUrl, '_blank')}
/>
</div>
)}
{/* Attached Location */}
{msg.latitude && msg.longitude && (
<a
href={`https://maps.google.com/?q=${msg.latitude},${msg.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="mb-2 flex items-center gap-2 p-2 bg-black/20 hover:bg-black/30 rounded-xl text-[10px] text-blue-300 font-bold border border-blue-500/20"
>
<MapPin className="w-4 h-4 text-rose-500 shrink-0" />
<span>Vị trí: {msg.latitude.toFixed(5)}, {msg.longitude.toFixed(5)}</span>
</a>
)}
{msg.content && <p className="leading-relaxed whitespace-pre-wrap">{msg.content}</p>}
</div>
<span className={`text-[9px] text-slate-500 ${isMe ? 'text-right' : 'text-left'}`}>
{dateStr}
</span>
</div>
</div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Bottom Input Area */}
<form onSubmit={handleSendMessage} className="p-3 bg-slate-900 border-t border-slate-800/80 flex flex-col gap-2 shrink-0">
{/* Attachment Previews */}
{(attachedImageUrl || attachedLocation) && (
<div className="flex flex-wrap gap-2 p-2 bg-slate-950/60 rounded-xl border border-slate-850">
{attachedImageUrl && (
<div className="relative w-14 h-14 rounded-lg overflow-hidden border border-slate-750">
<img src={attachedImageUrl} alt="Preview" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => { setAttachedImage(null); setAttachedImageUrl(null); }}
className="absolute top-0.5 right-0.5 p-0.5 bg-black/70 hover:bg-black text-white rounded-full"
>
<X className="w-3 h-3" />
</button>
</div>
)}
{attachedLocation && (
<div className="flex items-center gap-1.5 px-3 py-1 bg-rose-950/30 text-rose-300 border border-rose-800/30 rounded-xl text-[10px] font-bold">
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0" />
<span>Vị trí GPS</span>
<button
type="button"
onClick={() => setAttachedLocation(null)}
className="p-0.5 bg-rose-900/50 hover:bg-rose-900 text-white rounded-full ml-1"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="p-2.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-xl transition-all cursor-pointer shrink-0"
title="Đính kèm ảnh"
>
<ImageIcon className="w-4 h-4" />
</button>
<input
type="file"
ref={fileInputRef}
accept="image/*"
onChange={handleImageSelect}
className="hidden"
/>
<button
type="button"
onClick={handleAttachGps}
className="p-2.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-xl transition-all cursor-pointer shrink-0"
title="Gửi vị trí định vị"
>
<MapPin className="w-4 h-4 text-rose-500" />
</button>
<input
type="text"
placeholder="Nhập tin nhắn..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
className="flex-1 bg-slate-950 border border-slate-800 rounded-xl py-2.5 px-4 text-white focus:outline-none focus:border-indigo-650"
/>
<button
type="submit"
disabled={isSending || (!newMessage.trim() && !attachedImage && !attachedLocation)}
className="p-2.5 bg-indigo-650 hover:bg-indigo-600 disabled:opacity-50 text-white rounded-xl transition-all cursor-pointer shrink-0"
>
{isSending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</button>
</div>
</form>
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center p-8 gap-3">
<div className="w-16 h-16 bg-slate-900 border border-slate-800 rounded-full flex items-center justify-center">
<Smile className="w-8 h-8 text-slate-600 animate-bounce" />
</div>
<div>
<div className="font-bold text-white text-sm">Chưa chọn bạn hội thoại</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Chọn một người bạn danh sách bên trái đ bắt đu cuộc trò chuyện riêng của bạn.</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,308 @@
import React, { useEffect, useState } from 'react';
import { X, Calendar, Users, Navigation, Trash2, Loader2, Compass, Play } from 'lucide-react';
import { useTourStore } from '../../store/useTourStore';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
interface MyToursModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
onViewTour: (tourId: string) => void;
onOpenNavigation?: (payload: any) => void;
}
export const MyToursModal: React.FC<MyToursModalProps> = ({
isOpen,
onClose,
user,
onViewTour,
onOpenNavigation,
}) => {
const notify = useNotification();
const confirm = useConfirm();
const publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const deleteTour = useTourStore(state => state.deleteTour);
const [isLoading, setIsLoading] = useState(false);
const [isDeletingId, setIsDeletingId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'ongoing' | 'upcoming' | 'past'>('ongoing');
useEffect(() => {
if (isOpen && user) {
const loadTours = async () => {
setIsLoading(true);
try {
await fetchPublicTours();
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
loadTours();
}
}, [isOpen, fetchPublicTours, user]);
if (!isOpen || !user) return null;
// Filter tours where user is participant
const myTours = publicTours.filter(tour =>
tour.participants?.some((p: any) => p.userId === user?.id)
);
const now = new Date();
// Classify tours
const ongoingTours = myTours.filter(tour => {
if (!tour.startDate || !tour.endDate) return false;
const start = new Date(tour.startDate);
const end = new Date(tour.endDate);
return start <= now && end >= now;
});
const upcomingTours = myTours.filter(tour => {
if (!tour.startDate) return true;
const start = new Date(tour.startDate);
return start > now;
});
const pastTours = myTours.filter(tour => {
if (!tour.endDate) return false;
const end = new Date(tour.endDate);
return end < now;
});
const getActiveList = () => {
switch (activeTab) {
case 'ongoing': return ongoingTours;
case 'upcoming': return upcomingTours;
case 'past': return pastTours;
}
};
const handleDelete = async (tourId: string, tourTitle: string) => {
const ok = await confirm({
title: 'Xóa chuyến đi?',
message: `Bạn có chắc chắn muốn xóa chuyến đi "${tourTitle}" không? Hành động này không thể hoàn tác.`,
});
if (!ok) return;
setIsDeletingId(tourId);
try {
await deleteTour(tourId);
notify({
title: 'Thành công',
message: 'Đã xóa chuyến đi thành công.',
type: 'success',
});
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Xóa chuyến đi thất bại.',
type: 'error',
});
} finally {
setIsDeletingId(null);
}
};
const handleStartNavigation = (tour: any) => {
if (!onOpenNavigation) {
notify({
title: 'Lưu ý',
message: 'Tính năng dẫn đường chỉ khả dụng trong chế độ bản đồ.',
type: 'info',
});
return;
}
const firstLeg = tour.legs?.[0];
const origin = firstLeg?.locations?.[0] || { lat: 10.7769, lng: 106.7009 };
const destination = firstLeg?.locations?.[firstLeg?.locations?.length - 1] || { lat: 10.8231, lng: 106.6297, name: 'Điểm kết thúc' };
onOpenNavigation({
tourId: tour.id,
origin: { lat: Number(origin.latitude || origin.lat), lng: Number(origin.longitude || origin.lng) },
destination: {
lat: Number(destination.latitude || destination.lat),
lng: Number(destination.longitude || destination.lng),
name: destination.name || 'Điểm kết thúc'
},
tourTitle: tour.title,
});
onClose();
};
// Calculate ongoing timeline progress percentage
const getProgressPercent = (tour: any) => {
if (!tour.startDate || !tour.endDate) return 0;
const start = new Date(tour.startDate).getTime();
const end = new Date(tour.endDate).getTime();
const current = now.getTime();
if (current >= end) return 100;
if (current <= start) return 0;
return Math.round(((current - start) / (end - start)) * 100);
};
const currentList = getActiveList();
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-2xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white flex items-center gap-2">
<Compass className="w-5 h-5 text-indigo-400" /> Hành trình của tôi
</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Tab Selection Header */}
<div className="bg-slate-950/40 p-2.5 border-b border-slate-850 flex gap-2 shrink-0">
{[
{ id: 'ongoing', label: 'Đang thực hiện' },
{ id: 'upcoming', label: 'Sắp khởi hành' },
{ id: 'past', label: 'Đã hoàn thành' }
].map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 py-2 rounded-xl text-center font-bold transition-all cursor-pointer ${isActive
? 'bg-indigo-650 text-white shadow-md'
: 'bg-slate-900/40 hover:bg-slate-850 text-slate-400'
}`}
>
{tab.label}
</button>
);
})}
</div>
{/* Content body */}
<div className="flex-1 overflow-y-auto p-5">
{isLoading ? (
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
<Loader2 className="w-8 h-8 text-indigo-400 animate-spin" />
<span className="text-slate-400 font-medium">Đang tải danh sách chuyến đi...</span>
</div>
) : currentList.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
<Compass className="w-8 h-8 text-slate-500" />
</div>
<div>
<div className="font-bold text-white text-sm">Chưa hành trình nào</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn không chuyến đi nào trong mục này.</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{currentList.map((tour) => {
const isOwner = tour.participants?.some(
(p: any) => p.userId === user?.id && p.role === 'OWNER'
);
const startDateStr = tour.startDate ? new Date(tour.startDate).toLocaleDateString('vi-VN') : '';
const endDateStr = tour.endDate ? new Date(tour.endDate).toLocaleDateString('vi-VN') : '';
const progress = getProgressPercent(tour);
// Fallback tour banner photo
const tourBanner = tour.coverImage || 'https://images.unsplash.com/photo-1488646953014-85cb44e25828?w=500';
return (
<div
key={tour.id}
className="bg-slate-950/40 border border-slate-850 hover:border-slate-750 rounded-2xl overflow-hidden flex flex-col justify-between transition-all"
>
{/* Banner cover */}
<div className="relative h-28 w-full overflow-hidden">
<img src={tourBanner} alt="Cover Banner" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 to-transparent opacity-90" />
<div className="absolute bottom-3 left-3 right-3 min-w-0">
<h4 className="font-bold text-white text-xs truncate drop-shadow-md">{tour.title}</h4>
<p className="text-[10px] text-slate-300 font-medium mt-0.5">{startDateStr} - {endDateStr}</p>
</div>
</div>
{/* Progress details */}
<div className="p-3.5 space-y-3 flex-1 flex flex-col justify-between">
{activeTab === 'ongoing' && (
<div className="space-y-1">
<div className="flex justify-between items-center text-[10px] text-slate-400 font-bold">
<span>Tiến đ hành trình</span>
<span className="text-indigo-400">{progress}%</span>
</div>
<div className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden">
<div className="bg-indigo-500 h-full rounded-full transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}
<div className="flex items-center gap-3 text-[10px] text-slate-400 font-semibold">
<span className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 text-slate-500" />
{tour.participants?.length || 0} thành viên
</span>
<span className="flex items-center gap-1.5">
<Navigation className="w-3.5 h-3.5 text-slate-500" />
{tour.legs?.length || 0} chặng đi
</span>
</div>
<div className="flex justify-end items-center gap-2 border-t border-slate-850/60 pt-3 mt-1">
{isOwner && (
<button
onClick={() => handleDelete(tour.id, tour.title)}
disabled={isDeletingId === tour.id}
className="p-2 text-rose-400 hover:bg-rose-500/10 rounded-xl transition-all cursor-pointer disabled:opacity-50"
title="Xóa chuyến đi"
>
{isDeletingId === tour.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
</button>
)}
<button
onClick={() => handleStartNavigation(tour)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 font-bold text-indigo-300 rounded-xl transition-all active:scale-95 cursor-pointer"
title="Bắt đầu dẫn đường"
>
<Play className="w-3 h-3 text-indigo-400 fill-indigo-400" /> Dẫn đưng
</button>
<button
onClick={() => {
onClose();
onViewTour(tour.id);
}}
className="flex items-center gap-1.5 px-3.5 py-1.5 bg-indigo-650 hover:bg-indigo-600 font-bold text-white rounded-xl transition-all active:scale-95 cursor-pointer shadow-md shadow-indigo-900/10"
>
Hành trình Tour
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,496 @@
import React, { useEffect, useState } from 'react';
import { X, Image as ImageIcon, Loader2, Download, Eye, MapPin, Tag, Trash2, Edit2, Check } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
interface PhotoGalleryModalProps {
isOpen: boolean;
onClose: () => void;
user: any;
}
const PHOTO_TAGS = [
{ value: 'phong-canh', label: '🏞️ Phong cảnh' },
{ value: 'con-nguoi', label: '👥 Con người' },
{ value: 'doi-thuong', label: '🎒 Đời thường' },
{ value: 'bien', label: '🌊 Biển' },
{ value: 'nui', label: '⛰️ Núi' },
{ value: 'do-thi', label: '🏙️ Đô thị' },
{ value: 'thuc-an', label: '🍜 Thức ăn' },
{ value: 'cho', label: '🛍️ Chợ' },
{ value: 'hien-dai', label: '🏗️ Hiện đại' },
{ value: 'dong-vat', label: '🦁 Động vật' },
{ value: 'thu-cung', label: '🐕 Thú cưng' }
];
export const PhotoGalleryModal: React.FC<PhotoGalleryModalProps> = ({
isOpen,
onClose,
user,
}) => {
const notify = useNotification();
const confirm = useConfirm();
const [photos, setPhotos] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Filtering states
const [filterTourId, setFilterTourId] = useState<string>('all');
const [filterTag, setFilterTag] = useState<string>('all');
// Preview / Editor States
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
const [editingPhotoId, setEditingPhotoId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editTags, setEditTags] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isDeletingId, setIsDeletingId] = useState<string | null>(null);
const getHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('token')}`,
});
const loadPhotos = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/v1/users/me/photos', { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
setPhotos(data || []);
} else {
throw new Error('Không thể tải thư viện ảnh.');
}
} catch (e: any) {
console.error(e);
notify({
title: 'Lỗi',
message: e.message || 'Không thể tải ảnh.',
type: 'error',
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (isOpen && user) {
loadPhotos();
}
}, [isOpen, user]);
if (!isOpen || !user) return null;
// Extract unique tours from photos list for dropdown filtering
const uniqueToursMap = new Map();
photos.forEach(p => {
if (p.tour?.id && p.tour?.title) {
uniqueToursMap.set(p.tour.id, p.tour.title);
}
});
const uniqueTours = Array.from(uniqueToursMap.entries()).map(([id, title]) => ({ id, title }));
// Handle Photo Deletion
const handleDelete = async (photoId: string) => {
const ok = await confirm({
title: 'Xóa ảnh?',
message: 'Bạn có chắc chắn muốn xóa bức ảnh này không? Ảnh sẽ được chuyển vào thùng rác.',
});
if (!ok) return;
setIsDeletingId(photoId);
try {
const res = await fetch(`/api/v1/photos/${photoId}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (res.ok) {
notify({
title: 'Thành công',
message: 'Đã xóa ảnh thành công.',
type: 'success',
});
setPhotos(prev => prev.filter(p => p.id !== photoId));
if (selectedPhoto?.id === photoId) setSelectedPhoto(null);
} else {
throw new Error('Xóa ảnh thất bại.');
}
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Không thể xóa ảnh.',
type: 'error',
});
} finally {
setIsDeletingId(null);
}
};
// Start Editing Tag details
const startEdit = (photo: any) => {
setEditingPhotoId(photo.id);
const meta = photo.metadata || {};
setEditTitle(meta.title || '');
setEditDescription(meta.description || '');
setEditTags(meta.tags || []);
};
const handleTagToggle = (tagValue: string) => {
setEditTags(prev =>
prev.includes(tagValue)
? prev.filter(t => t !== tagValue)
: [...prev, tagValue]
);
};
// Save Tagging Details
const saveEdit = async (photoId: string) => {
setIsSaving(true);
try {
const res = await fetch(`/api/v1/photos/${photoId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({
title: editTitle,
description: editDescription,
tags: editTags,
}),
});
if (res.ok) {
notify({
title: 'Thành công',
message: 'Cập nhật thông tin ảnh thành công.',
type: 'success',
});
// Reload photos list to synchronize
await loadPhotos();
setEditingPhotoId(null);
} else {
throw new Error('Lỗi cập nhật ảnh.');
}
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Cập nhật thất bại.',
type: 'error',
});
} finally {
setIsSaving(false);
}
};
const handleDownload = async (url: string, filename: string) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (e) {
console.error(e);
notify({
title: 'Lỗi tải về',
message: 'Không thể tải trực tiếp ảnh xuống thiết bị.',
type: 'error',
});
}
};
// Filter photos
const filteredPhotos = photos.filter((photo) => {
const matchTour = filterTourId === 'all' || photo.tour?.id === filterTourId;
const matchTag = filterTag === 'all' || photo.metadata?.tags?.includes(filterTag);
return matchTour && matchTag;
});
return (
<div className="fixed inset-0 z-[1000000] bg-black/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-4 pointer-events-auto">
<div className="bg-slate-900 w-full sm:max-w-4xl h-[92vh] sm:h-[80vh] rounded-t-3xl sm:rounded-2xl flex flex-col overflow-hidden text-slate-200 text-xs border border-slate-800/80 shadow-2xl animate-in slide-in-from-bottom sm:zoom-in-95 duration-300">
{/* Header */}
<div className="px-5 py-4 border-b border-slate-800/60 flex justify-between items-center shrink-0">
<span className="font-bold text-sm text-white flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-emerald-400" /> Thư viện nh
</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Dual Filter Header Select Pinned Matrix */}
<div className="bg-slate-950/40 p-3.5 border-b border-slate-850 flex flex-col sm:flex-row gap-3.5 shrink-0">
<div className="flex-1 flex flex-col gap-1.5">
<label className="font-bold text-slate-400 text-[10px] uppercase tracking-wider">Theo hành trình:</label>
<select
value={filterTourId}
onChange={(e) => setFilterTourId(e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white font-bold cursor-pointer focus:outline-none focus:border-indigo-650"
>
<option value="all">Tất cả hành trình</option>
{uniqueTours.map((t) => (
<option key={t.id} value={t.id}>{t.title}</option>
))}
</select>
</div>
<div className="flex-1 flex flex-col gap-1.5">
<label className="font-bold text-slate-400 text-[10px] uppercase tracking-wider">Theo thẻ phân loại:</label>
<select
value={filterTag}
onChange={(e) => setFilterTag(e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white font-bold cursor-pointer focus:outline-none focus:border-indigo-650"
>
<option value="all">Tất cả thẻ tags</option>
{PHOTO_TAGS.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
{/* Content body grid workspace */}
<div className="flex-1 overflow-y-auto p-5">
{isLoading ? (
<div className="h-full flex items-center justify-center flex-col gap-2 py-20">
<Loader2 className="w-8 h-8 text-emerald-400 animate-spin" />
<span className="text-slate-400 font-medium">Đang tải thư viện nh...</span>
</div>
) : filteredPhotos.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center py-20 px-6 gap-3">
<div className="w-16 h-16 bg-slate-850 rounded-full flex items-center justify-center border border-slate-800">
<ImageIcon className="w-8 h-8 text-slate-500" />
</div>
<div>
<div className="font-bold text-white text-sm">Không tìm thấy bức nh nào</div>
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Không nh nào khớp với bộ lọc hiện tại.</p>
</div>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{filteredPhotos.map((photo) => {
const photoTagsList = photo.metadata?.tags || [];
return (
<div
key={photo.id}
className="group relative aspect-square rounded-2xl overflow-hidden bg-slate-950 border border-slate-850 hover:border-slate-700 shadow-lg transition-all flex flex-col"
>
<img
src={photo.imageUrl}
alt="Gallery Item"
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
{/* Standard tags badge dot count */}
{photoTagsList.length > 0 && (
<div className="absolute top-2.5 left-2.5 bg-black/60 backdrop-blur-md text-white font-bold text-[9px] px-2 py-0.5 rounded-full flex items-center gap-1 border border-white/10 z-10">
<Tag className="w-2.5 h-2.5 text-emerald-400 shrink-0" />
<span>{photoTagsList.length} tags</span>
</div>
)}
{/* Hover controls overlay */}
<div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 flex flex-col justify-between p-3.5 transition-opacity duration-250 z-10">
<div className="flex justify-end gap-1.5">
<button
onClick={() => startEdit(photo)}
className="p-2 bg-slate-900/85 hover:bg-amber-650 text-white rounded-xl transition-colors cursor-pointer"
title="Sửa thông tin"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(photo.id)}
disabled={isDeletingId === photo.id}
className="p-2 bg-slate-900/85 hover:bg-rose-650 text-white rounded-xl transition-colors cursor-pointer disabled:opacity-50"
title="Xóa ảnh"
>
{isDeletingId === photo.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={() => setSelectedPhoto(photo)}
className="p-2 bg-slate-900/85 hover:bg-indigo-650 text-white rounded-xl transition-colors cursor-pointer"
title="Xem chi tiết"
>
<Eye className="w-3.5 h-3.5" />
</button>
</div>
<div className="min-w-0">
<div className="text-[10px] font-bold text-white truncate">
{photo.metadata?.title || 'Chưa đặt tiêu đề'}
</div>
{photo.tour?.title && (
<div className="text-[9px] font-bold text-indigo-300 truncate flex items-center gap-1 mt-0.5">
<MapPin className="w-2.5 h-2.5 shrink-0" />
{photo.tour.title}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Editor & Tag modification Dialog */}
{editingPhotoId && (
<div className="fixed inset-0 z-[1000001] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 pointer-events-auto">
<div className="bg-slate-900 w-full max-w-md rounded-2xl overflow-hidden border border-slate-800 p-5 space-y-4 shadow-2xl">
<div className="flex justify-between items-center pb-2 border-b border-slate-800">
<span className="font-bold text-sm text-white">Chỉnh sửa thông tin nh</span>
<button
onClick={() => setEditingPhotoId(null)}
className="p-1 hover:bg-slate-800 text-slate-400 hover:text-white rounded-lg transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3 text-xs">
<div className="flex flex-col gap-1">
<label className="font-bold text-slate-400">Tiêu đ nh:</label>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Ví dụ: Hoàng hôn biển Ba Động..."
className="bg-slate-950 border border-slate-805 rounded-xl p-2.5 text-white focus:outline-none focus:border-indigo-650"
/>
</div>
<div className="flex flex-col gap-1">
<label className="font-bold text-slate-400"> tả nh:</label>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Ghi lại kỷ niệm..."
rows={2}
className="bg-slate-950 border border-slate-805 rounded-xl p-2.5 text-white resize-none focus:outline-none focus:border-indigo-650"
/>
</div>
<div className="space-y-1.5">
<label className="font-bold text-slate-400 block mb-1">Gắn thẻ phân loại (Hashtags):</label>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto p-1.5 bg-slate-950/60 border border-slate-850 rounded-xl">
{PHOTO_TAGS.map((tag) => {
const isSelected = editTags.includes(tag.value);
return (
<button
key={tag.value}
type="button"
onClick={() => handleTagToggle(tag.value)}
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all cursor-pointer flex items-center gap-1 ${
isSelected
? 'bg-emerald-950/40 text-emerald-400 border-emerald-500/50'
: 'bg-slate-900 border-slate-800 text-slate-400 hover:border-slate-700'
}`}
>
{isSelected && <Check className="w-3 h-3 text-emerald-400 shrink-0" />}
{tag.label}
</button>
);
})}
</div>
</div>
</div>
<div className="pt-3 border-t border-slate-850 flex justify-end gap-2">
<button
onClick={() => setEditingPhotoId(null)}
className="px-4 py-2 bg-slate-850 hover:bg-slate-800 font-bold text-slate-300 rounded-xl"
>
Hủy
</button>
<button
onClick={() => saveEdit(editingPhotoId)}
disabled={isSaving}
className="px-4 py-2 bg-indigo-650 hover:bg-indigo-600 font-bold text-white rounded-xl flex items-center gap-1.5 disabled:opacity-50"
>
{isSaving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
Lưu lại
</button>
</div>
</div>
</div>
)}
{/* Fullscreen Preview overlay */}
{selectedPhoto && (
<div className="fixed inset-0 z-[2000000] bg-black/95 flex flex-col justify-between p-4 pointer-events-auto">
{/* Close trigger top bar */}
<div className="flex justify-between items-center w-full pb-3 border-b border-slate-900">
<div className="text-white font-bold text-xs truncate">
{selectedPhoto.metadata?.title || 'Xem ảnh'}
</div>
<button
onClick={() => setSelectedPhoto(null)}
className="p-2 bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white rounded-xl cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Fullscreen Photo view */}
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-2xl w-full flex flex-col gap-3">
<img
src={selectedPhoto.originalUrl || selectedPhoto.imageUrl}
alt="Fullscreen Preview"
className="max-w-full max-h-[60vh] object-contain rounded-xl shadow-2xl mx-auto"
/>
<div className="bg-slate-900/60 border border-slate-800 p-4 rounded-2xl space-y-1.5">
{selectedPhoto.metadata?.title && (
<h4 className="font-bold text-white text-xs">{selectedPhoto.metadata.title}</h4>
)}
{selectedPhoto.metadata?.description && (
<p className="text-slate-400 text-[10px]">{selectedPhoto.metadata.description}</p>
)}
<div className="flex flex-wrap gap-1.5 mt-2">
{selectedPhoto.metadata?.tags?.map((t: string) => (
<span
key={t}
className="px-2 py-0.5 bg-slate-950/80 border border-slate-800 text-slate-400 text-[9px] font-bold rounded-full"
>
#{t}
</span>
))}
</div>
</div>
</div>
</div>
{/* Action bottom bar */}
<div className="flex justify-center items-center py-4 border-t border-slate-900 gap-3">
<button
onClick={() => handleDownload(selectedPhoto.originalUrl || selectedPhoto.imageUrl, `yotrip-photo-${selectedPhoto.id}.jpg`)}
className="flex items-center gap-2 px-5 py-3 bg-emerald-650 hover:bg-emerald-600 font-bold text-white rounded-xl shadow-lg transition-all active:scale-95 cursor-pointer"
>
<Download className="w-4 h-4" /> Tải về tệp gốc (.jpg)
</button>
</div>
</div>
)}
</div>
);
};
+115 -149
View File
@@ -1,19 +1,25 @@
import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, useMap, useMapEvents, Tooltip } from 'react-leaflet';
import { MapContainer, TileLayer, Marker, useMapEvents, Tooltip } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTourStore } from '@/store/useTourStore';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft, Lock, Globe, Sun, Moon, Laptop, Users, ShieldAlert, Star } from 'lucide-react';
import { X, Navigation, Image as ImageIcon, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft, Users, ShieldAlert, Star } from 'lucide-react';
import { UserManagementModal } from '@/components/UserManagementModal';
import { ReportBusinessModal } from '../components/ReportBusinessModal';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
import { CreateTourModal } from '../components/CreateTourModal';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { PublicPhotoModal } from '../components/PublicPhotoModal';
import { MapProfileDropdown } from '@/components/MapProfileDropdown';
import { ProfileSettingsModal } from '@/components/ProfileSettingsModal';
import { LoginModal } from '@/components/LoginModal';
import { MyToursModal } from '../components/modals/MyToursModal';
import { PhotoGalleryModal as MyPhotosModal } from '../components/modals/PhotoGalleryModal';
import { LiveChatModal } from '../components/modals/LiveChatModal';
import { FriendsManagerModal } from '../components/modals/FriendsManagerModal';
// Fix lỗi icon mặc định của Leaflet
const DefaultIcon = L.icon({
@@ -67,11 +73,9 @@ function MapTracker() {
return null;
}
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
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onLoginSuccess, onGoToDashboard, onOpenNavigation }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onLoginSuccess?: (user: any) => void, onGoToDashboard?: (tab?: 'tours' | 'connections' | 'photos' | 'chats') => void, onOpenNavigation?: (payload: any) => void }) => {
const guestToken = localStorage.getItem('guest_token');
const isLoggedInOrGuest = user || guestToken;
const isAuthenticated = !!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);
@@ -80,36 +84,8 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
const notify = useNotification();
const confirm = useConfirm();
const { t, lang, changeLanguage } = useTranslation();
const { theme, changeTheme } = useTheme();
const { t } = useTranslation();
const handlePromoteAdmin = async (secretKey: string) => {
try {
const response = await fetch('/api/v1/auth/promote-admin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ secretKey })
});
const data = await response.json();
if (response.ok && data.success) {
const updatedUser = { ...user, isAdmin: true };
localStorage.setItem('user', JSON.stringify(updatedUser));
if (onLoginSuccess) {
onLoginSuccess(updatedUser);
}
notify({ title: t('success'), message: 'Đã kích hoạt quyền quản trị thành công!', type: 'success' });
setIsAdminModalOpen(true);
} else {
notify({ title: t('error'), message: data.message || t('invalidSecretKey'), type: 'error' });
}
} catch (err) {
console.error(err);
notify({ title: t('error'), message: 'Lỗi mạng khi kích hoạt Admin.', type: 'error' });
}
};
// Refs for mobile long-press detection
const touchTimerRef = React.useRef<any>(null);
@@ -252,12 +228,18 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
};
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isProfileSettingsOpen, setIsProfileSettingsOpen] = useState(false);
const [isMyToursOpen, setIsMyToursOpen] = useState(false);
const [isMyPhotosOpen, setIsMyPhotosOpen] = useState(false);
const [isChatOpen, setIsChatOpen] = useState(false);
const [isFriendsOpen, setIsFriendsOpen] = useState(false);
const [chatTargetUserId, setChatTargetUserId] = useState<string | null>(null);
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false);
const [blacklist, setBlacklist] = useState<any[]>([]);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false);
const storeMapCenter = useTourStore(state => state.mapCenter);
// Recommendations and GPS States
const [recommendedLocations, setRecommendedLocations] = useState<any[]>([]);
@@ -438,6 +420,12 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
if (user || localStorage.getItem('token')) {
fetchPublicTours();
}
const targetTourId = localStorage.getItem('viewTourOnLand');
if (targetTourId) {
localStorage.removeItem('viewTourOnLand');
onViewTour(targetTourId);
}
}, []);
// Chỉ lấy vị trí GPS ban đầu để hiển thị marker, KHÔNG tự động nhảy bản đồ đến vị trí đó
@@ -799,124 +787,35 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</div>
</div>
{/* 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 vị người dùng */}
<button
onClick={requestGpsPosition}
className="w-11 h-11 bg-[var(--surface)] rounded-full shadow-xl hover:bg-blue-100 text-blue-600 transition-all flex items-center justify-center border border-[var(--border)] shrink-0"
title="Vị trí của tôi"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
</button>
{/* Nút Ảnh của tôi */}
{isLoggedInOrGuest && (
{/* Nhóm bên phải: Menu cá nhân hợp nhất */}
<div className="flex items-center gap-3 pointer-events-auto">
{isAuthenticated && (
<button
onClick={() => {
console.log("Đang mở Ảnh của tôi...");
onOpenMyPhotos();
setChatTargetUserId(null);
setIsChatOpen(true);
}}
className="w-11 h-11 md:w-auto bg-[var(--surface)] p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-100 text-blue-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold border border-blue-100 shrink-0"
title="Ảnh của tôi"
className="w-11 h-11 bg-slate-900 border border-slate-800 rounded-full flex items-center justify-center shadow-xl hover:bg-slate-805 text-slate-300 hover:text-white transition-all active:scale-95 cursor-pointer shrink-0 relative group"
title="Trò chuyện trực tiếp"
>
<ImageIcon className="w-5 h-5" />
<span className="hidden md:inline text-sm">nh của tôi</span>
</button>
)}
{/* Nút tạo Tour mới */}
{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"
title="Tạo Tour mới"
>
<Navigation className="w-5 h-5" />
<span className="hidden md:inline text-sm">Tạo Tour</span>
</button>
)}
{/* Nút Báo cáo sai phạm */}
<button
onClick={() => setIsReportModalOpen(true)}
className="w-11 h-11 md:w-auto bg-red-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 cursor-pointer"
title={t('reportBusinessBtn') || 'Báo cáo sai phạm'}
>
<ShieldAlert className="w-5 h-5" />
<span className="hidden md:inline text-sm">{t('reportBusinessBtn') || 'Báo cáo'}</span>
</button>
{/* Lựa chọn Ngôn ngữ */}
<div className="relative group shrink-0">
<button className="w-11 h-11 bg-[var(--surface)] dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-[var(--background)] dark:hover:bg-slate-700 text-[var(--text-secondary)] dark:text-slate-200 transition-all flex items-center justify-center border border-[var(--border)] dark:border-slate-700">
<Globe className="w-5 h-5" />
</button>
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-[var(--surface)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
<button onClick={() => changeLanguage('vi')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'vi' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>Tiếng Việt</button>
<button onClick={() => changeLanguage('en')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'en' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>English</button>
<button onClick={() => changeLanguage('zh')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'zh' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}></button>
</div>
</div>
{/* Lựa chọn Giao diện */}
<div className="relative group shrink-0">
<button className="w-11 h-11 bg-[var(--surface)] dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-[var(--background)] dark:hover:bg-slate-700 text-[var(--text-secondary)] dark:text-slate-200 transition-all flex items-center justify-center border border-[var(--border)] dark:border-slate-700">
{theme === 'light' && <Sun className="w-5 h-5 text-amber-500" />}
{theme === 'dark' && <Moon className="w-5 h-5 text-indigo-400" />}
{theme === 'system' && <Laptop className="w-5 h-5 animate-pulse" />}
</button>
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-[var(--surface)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
<button onClick={() => changeTheme('light')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'light' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
<Sun className="w-3.5 h-3.5 text-amber-500" /> {t('themeLight')}
</button>
<button onClick={() => changeTheme('dark')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'dark' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
<Moon className="w-3.5 h-3.5 text-indigo-400" /> {t('themeDark')}
</button>
<button onClick={() => changeTheme('system')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'system' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
<Laptop className="w-3.5 h-3.5" /> {t('themeSystem')}
</button>
</div>
</div>
{/* Nút quản lý người dùng cho Admin */}
{user?.isAdmin && (
<button
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')}
>
<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
onClick={onLogout}
className="w-11 h-11 md:w-auto bg-[var(--surface)] p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold text-[var(--text-secondary)] border border-[var(--border)] shrink-0"
title="Đăng xuất"
>
<LogOut className="w-5 h-5" />
<span className="hidden md:inline text-sm">Rời đi</span>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 group-hover:scale-105 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-blue-500 rounded-full" />
</button>
)}
<MapProfileDropdown
user={user}
onLogout={onLogout}
onOpenSettings={() => setIsProfileSettingsOpen(true)}
onOpenCreateTour={() => setIsCreateModalOpen(true)}
onOpenReport={() => setIsReportModalOpen(true)}
onOpenLogin={() => setIsLoginModalOpen(true)}
onOpenMyPhotos={() => setIsMyPhotosOpen(true)}
onOpenMyTours={() => setIsMyToursOpen(true)}
onOpenFriends={() => setIsFriendsOpen(true)}
onOpenAdmin={() => setIsAdminModalOpen(true)}
/>
</div>
</div>
@@ -1679,6 +1578,73 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
initialLatitude={mapCenter ? mapCenter[0] : undefined}
initialLongitude={mapCenter ? mapCenter[1] : undefined}
/>
{/* Profile Settings Modal */}
<ProfileSettingsModal
isOpen={isProfileSettingsOpen}
onClose={() => setIsProfileSettingsOpen(false)}
user={user}
onSaveSuccess={onLoginSuccess}
/>
{/* Login Modal for Guest Auth promotion */}
<LoginModal
isOpen={isLoginModalOpen}
onClose={() => setIsLoginModalOpen(false)}
onLoginSuccess={(loggedInUser) => {
setIsLoginModalOpen(false);
if (onLoginSuccess) {
onLoginSuccess(loggedInUser);
}
}}
/>
{/* My Tours Modal */}
<MyToursModal
isOpen={isMyToursOpen}
onClose={() => setIsMyToursOpen(false)}
user={user}
onViewTour={onViewTour}
onOpenNavigation={onOpenNavigation}
/>
{/* My Photos Modal */}
<MyPhotosModal
isOpen={isMyPhotosOpen}
onClose={() => setIsMyPhotosOpen(false)}
user={user}
/>
{/* Live Chat Modal */}
<LiveChatModal
isOpen={isChatOpen}
onClose={() => setIsChatOpen(false)}
user={user}
defaultChatUserId={chatTargetUserId}
/>
{/* Friends Manager Modal */}
<FriendsManagerModal
isOpen={isFriendsOpen}
onClose={() => setIsFriendsOpen(false)}
user={user}
onOpenChatWithUser={(userId) => {
setChatTargetUserId(userId);
setIsChatOpen(true);
}}
/>
{/* Floating GPS positioning button */}
<button
onClick={requestGpsPosition}
className="absolute bottom-6 right-6 z-[1000] w-12 h-12 bg-[var(--surface)] hover:bg-[var(--background)] rounded-full shadow-2xl border border-[var(--border)] flex items-center justify-center text-blue-600 active:scale-95 transition-all pointer-events-auto cursor-pointer"
title="Vị trí của tôi"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
</button>
</div>
);
};
+124 -69
View File
@@ -1,12 +1,17 @@
import React, { useState, useRef, useEffect } from 'react';
import { LogIn, Compass, Map as MapIcon, Camera, ShieldAlert, Image as ImageIcon, X } from 'lucide-react';
import { Compass, Map as MapIcon, Camera, ShieldAlert, Image as ImageIcon, X } from 'lucide-react';
import { LoginModal } from '../components/LoginModal';
import { ReportBusinessModal } from '../components/ReportBusinessModal';
import { TagSelectModal } from '../components/TagSelectModal';
import { MapProfileDropdown } from '../components/MapProfileDropdown';
import { ProfileSettingsModal } from '../components/ProfileSettingsModal';
import { MyToursModal } from '../components/modals/MyToursModal';
import { PhotoGalleryModal as MyPhotosModal } from '../components/modals/PhotoGalleryModal';
import { LiveChatModal } from '../components/modals/LiveChatModal';
import { FriendsManagerModal } from '../components/modals/FriendsManagerModal';
import { useNotification } from '@/hooks/useNotification';
import { processImageModeration } from '../hooks/useImageModeration';
import { useTranslation } from '../hooks/useTranslation';
import { useTheme } from '../hooks/useTheme';
import { compressImage } from '../utils/image';
interface LandingPageProps {
@@ -15,10 +20,27 @@ interface LandingPageProps {
onGoToMap?: () => void;
onLoginSuccess?: (user: any) => void;
isInitialSetup?: boolean;
user?: any;
onLogout?: () => void;
onGoToDashboard?: (tab?: 'tours' | 'connections' | 'photos' | 'chats') => void;
onOpenNavigation?: (payload: any) => void;
}
export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMap, onLoginSuccess }) => {
export const LandingPage: React.FC<LandingPageProps> = ({
onGoToSignup,
onGoToMap,
onLoginSuccess,
user,
onLogout,
onOpenNavigation,
}) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const [isProfileSettingsOpen, setIsProfileSettingsOpen] = useState(false);
const [isMyToursOpen, setIsMyToursOpen] = useState(false);
const [isMyPhotosOpen, setIsMyPhotosOpen] = useState(false);
const [isChatOpen, setIsChatOpen] = useState(false);
const [isFriendsOpen, setIsFriendsOpen] = useState(false);
const [chatTargetUserId, setChatTargetUserId] = useState<string | null>(null);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isPhotoSourceModalOpen, setIsPhotoSourceModalOpen] = useState(false);
@@ -29,8 +51,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
const [pendingPhotoLocation, setPendingPhotoLocation] = useState<GeolocationPosition | null>(null);
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
const notify = useNotification();
const { t, lang, changeLanguage } = useTranslation();
const { theme, changeTheme } = useTheme();
const { t } = useTranslation();
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
@@ -222,23 +243,29 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
notify({ title: 'Đang tải lên...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
try {
// 1. Xóa token cũ nếu có đ đảm bảo guest không bị nhầm lẫn
localStorage.removeItem('token');
localStorage.removeItem('user');
// 1. Kiểm tra xem người dùng đã đăng nhập chưa
const token = localStorage.getItem('token');
const guestToken = localStorage.getItem('guest_token');
const isRealUser = token && !guestToken;
// 2. Tạo tài khoản khách và lấy token
let guestToken = localStorage.getItem('guest_token');
let uploadToken = token;
// 2. Nếu là khách, tạo tài khoản khách và lấy token
if (!isRealUser) {
let currentGuestToken = guestToken;
let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null');
if (!guestToken) {
if (!currentGuestToken) {
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo phiên khách.');
const guestData = await guestRes.json();
guestToken = guestData.access_token;
currentGuestToken = guestData.access_token;
guestUser = guestData.user;
localStorage.setItem('guest_token', guestToken!);
localStorage.setItem('guest_token', currentGuestToken!);
localStorage.setItem('guest_user', JSON.stringify(guestUser));
}
uploadToken = currentGuestToken;
}
// 3. Tải ảnh lên
const formData = new FormData();
@@ -254,28 +281,26 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST',
headers: { 'Authorization': `Bearer ${guestToken!}` },
headers: { 'Authorization': `Bearer ${uploadToken!}` },
body: formData,
});
if (uploadRes.status === 401) {
if (uploadRes.status === 401 && !isRealUser) {
console.warn('Guest token invalid or expired. Creating a new guest user and retrying...');
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
localStorage.removeItem('token');
localStorage.removeItem('user');
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo lại phiên khách.');
const guestData = await guestRes.json();
guestToken = guestData.access_token;
guestUser = guestData.user;
localStorage.setItem('guest_token', guestToken!);
const newGuestToken = guestData.access_token;
const guestUser = guestData.user;
localStorage.setItem('guest_token', newGuestToken!);
localStorage.setItem('guest_user', JSON.stringify(guestUser));
uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST',
headers: { 'Authorization': `Bearer ${guestToken!}` },
headers: { 'Authorization': `Bearer ${newGuestToken!}` },
body: formData,
});
}
@@ -330,8 +355,7 @@ return (
draggable="false"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`h-full image-pan-element ${
!isLoggedIn ? 'select-none pointer-events-none' : ''
className={`h-full image-pan-element ${!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
alt="Travel Background 1"
/>
@@ -345,8 +369,7 @@ return (
draggable="false"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`h-full image-pan-element ${
!isLoggedIn ? 'select-none pointer-events-none' : ''
className={`h-full image-pan-element ${!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
alt="Travel Background 2"
/>
@@ -407,8 +430,7 @@ return (
<button
key={idx}
onClick={() => setCurrentBgIndex(idx)}
className={`w-2 h-2 rounded-full transition-all ${
currentBgIndex === idx
className={`w-2 h-2 rounded-full transition-all ${currentBgIndex === idx
? 'bg-emerald-500 w-6'
: 'bg-white/40 hover:bg-white/60'
}`}
@@ -424,44 +446,33 @@ return (
<span className="text-lg sm:text-xl font-black tracking-tighter uppercase hidden sm:block">YoTrip</span>
</div>
<div className="flex items-center gap-1.5 sm:gap-3">
{/* Language Selector */}
<select
value={lang}
onChange={(e) => changeLanguage(e.target.value as any)}
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-2 py-1 sm:px-3 sm:py-1.5 text-[11px] sm:text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer max-w-[80px] sm:max-w-none"
>
<option value="vi" className="text-black">Tiếng Việt</option>
<option value="en" className="text-black">English</option>
<option value="zh" className="text-black"></option>
</select>
{/* Theme Selector */}
<select
value={theme}
onChange={(e) => changeTheme(e.target.value as any)}
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-2 py-1 sm:px-3 sm:py-1.5 text-[11px] sm:text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer max-w-[80px] sm:max-w-none"
>
<option value="light" className="text-black">{t('themeLight') || 'Sáng'}</option>
<option value="dark" className="text-black">{t('themeDark') || 'Tối'}</option>
<option value="system" className="text-black">{t('themeSystem') || 'Hệ thống'}</option>
</select>
<div className="flex items-center gap-3 pointer-events-auto">
{user && !localStorage.getItem('guest_token') && (
<button
onClick={() => setIsReportModalOpen(true)}
className="flex items-center justify-center gap-1.5 bg-red-600/80 backdrop-blur-md text-white font-bold py-1.5 px-2.5 sm:py-2 sm:px-3.5 rounded-full border border-red-500/30 hover:bg-red-500 transition-all shadow-md active:scale-95 text-xs sm:text-sm cursor-pointer"
onClick={() => {
setChatTargetUserId(null);
setIsChatOpen(true);
}}
className="w-11 h-11 bg-slate-900 border border-slate-800 rounded-full flex items-center justify-center shadow-xl hover:bg-slate-805 text-slate-300 hover:text-white transition-all active:scale-95 cursor-pointer shrink-0 relative group"
title="Trò chuyện trực tiếp"
>
<ShieldAlert className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('reportBusinessBtn')}</span>
</button>
<button
onClick={() => setIsLoginModalOpen(true)}
className="flex items-center justify-center gap-1 sm:gap-2 bg-white/15 backdrop-blur-md text-white font-bold py-1.5 px-2.5 sm:py-2 sm:px-4 rounded-full border border-white/25 hover:bg-white/25 transition-all shadow-md active:scale-95 text-xs sm:text-sm cursor-pointer"
>
<LogIn className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span>{t('login')}</span>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 group-hover:scale-105 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-blue-500 rounded-full" />
</button>
)}
<MapProfileDropdown
user={user}
onLogout={onLogout}
onOpenSettings={() => setIsProfileSettingsOpen(true)}
onOpenCreateTour={() => onGoToMap?.()}
onOpenReport={() => setIsReportModalOpen(true)}
onOpenLogin={() => setIsLoginModalOpen(true)}
onOpenMyPhotos={() => setIsMyPhotosOpen(true)}
onOpenMyTours={() => setIsMyToursOpen(true)}
onOpenFriends={() => setIsFriendsOpen(true)}
/>
</div>
</div>
@@ -502,7 +513,7 @@ return (
</div>
{/* Floating Blacklist Panel (Right Side on Desktop) */}
<div className="absolute right-6 top-24 bottom-36 z-20 hidden md:flex flex-col w-72 bg-slate-900/60 backdrop-blur-md border border-white/10 rounded-[32px] p-5 text-white overflow-hidden shadow-2xl animate-in slide-in-from-right duration-500 animate-out duration-300">
<div className="absolute right-6 top-24 bottom-36 z-10 hidden md:flex flex-col w-72 bg-slate-900/60 backdrop-blur-md border border-white/10 rounded-[32px] p-5 text-white overflow-hidden shadow-2xl animate-in slide-in-from-right duration-500 animate-out duration-300">
<h3 className="text-base font-black flex items-center gap-2 mb-3 border-b border-white/10 pb-2 text-red-400">
<ShieldAlert className="w-5 h-5 text-red-500" /> {t('blacklistTitle')}
</h3>
@@ -593,8 +604,7 @@ return (
<button
key={photo.id}
onClick={() => setCurrentBgIndex(index)}
className={`relative w-14 h-14 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 shadow-inner bg-slate-800 ${
currentBgIndex === index ? 'scale-110 shadow-lg' : 'hover:scale-105'
className={`relative w-14 h-14 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 shadow-inner bg-slate-800 ${currentBgIndex === index ? 'scale-110 shadow-lg' : 'hover:scale-105'
}`}
>
<img
@@ -603,14 +613,12 @@ return (
draggable="false"
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
className={`w-full h-full object-cover ${
!isLoggedIn ? 'select-none pointer-events-none' : ''
className={`w-full h-full object-cover ${!isLoggedIn ? 'select-none pointer-events-none' : ''
}`}
/>
{/* Border Overlay absolute to prevent border clipping or corner overlap */}
{/* Inset by 1.5px so it does not get clipped by parent overflow-hidden border */}
<div className={`absolute inset-[1.5px] rounded-[10px] border-2 pointer-events-none transition-colors ${
currentBgIndex === index ? 'border-emerald-500' : 'border-white/20'
<div className={`absolute inset-[1.5px] rounded-[10px] border-2 pointer-events-none transition-colors ${currentBgIndex === index ? 'border-emerald-500' : 'border-white/20'
}`} />
</button>
))}
@@ -732,6 +740,53 @@ return (
</div>
</div>
)}
{/* Profile Settings Modal */}
<ProfileSettingsModal
isOpen={isProfileSettingsOpen}
onClose={() => setIsProfileSettingsOpen(false)}
user={user}
onSaveSuccess={onLoginSuccess}
/>
{/* My Tours Modal */}
<MyToursModal
isOpen={isMyToursOpen}
onClose={() => setIsMyToursOpen(false)}
user={user}
onViewTour={(tourId) => {
if (onGoToMap) {
localStorage.setItem('viewTourOnLand', tourId);
onGoToMap();
}
}}
onOpenNavigation={onOpenNavigation}
/>
{/* My Photos Modal */}
<MyPhotosModal
isOpen={isMyPhotosOpen}
onClose={() => setIsMyPhotosOpen(false)}
user={user}
/>
{/* Live Chat Modal */}
<LiveChatModal
isOpen={isChatOpen}
onClose={() => setIsChatOpen(false)}
user={user}
defaultChatUserId={chatTargetUserId}
/>
{/* Friends Manager Modal */}
<FriendsManagerModal
isOpen={isFriendsOpen}
onClose={() => setIsFriendsOpen(false)}
user={user}
onOpenChatWithUser={(userId) => {
setChatTargetUserId(userId);
setIsChatOpen(true);
}}
/>
</div>
);
};
+35 -17
View File
@@ -1,6 +1,4 @@
import React, { useEffect, useState, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import { Capacitor } from '@capacitor/core';
import {
Compass,
Users,
@@ -39,13 +37,15 @@ interface MemberDashboardProps {
onExploreTours: () => void;
onViewTour: (tourId: string, fromPage?: 'explore' | 'dashboard') => void;
onOpenMyPhotos: () => void;
initialTab?: 'tours' | 'connections' | 'photos' | 'chats';
}
export const MemberDashboard: React.FC<MemberDashboardProps> = ({
user,
onLogout,
onExploreTours,
onViewTour
onViewTour,
initialTab,
}) => {
// GUARD: Prevent guest users from accessing dashboard
const guestToken = localStorage.getItem('guest_token');
@@ -70,7 +70,13 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
const publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const [activeTab, setActiveTab] = useState<'tours' | 'connections' | 'photos' | 'chats'>('tours');
const [activeTab, setActiveTab] = useState<'tours' | 'connections' | 'photos' | 'chats'>(initialTab || 'tours');
useEffect(() => {
if (initialTab) {
setActiveTab(initialTab);
}
}, [initialTab]);
const [connectionSubTab, setConnectionSubTab] = useState<'list' | 'search' | 'pending'>('list');
// Connection states
@@ -528,18 +534,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
useEffect(() => {
if (!user?.id) return;
// Connect to WebSocket using same origin/proxy
const socket = Capacitor.isNativePlatform()
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
: io();
socketRef.current = socket;
socket.on('connect', () => {
console.log('[WS] MemberDashboard connected:', socket.id);
socket.emit('joinUser', user.id);
});
socket.on('messageReceived', (message: any) => {
const handleMessageReceived = (e: Event) => {
const message = (e as CustomEvent).detail;
// If we are actively chatting with the sender of this message
if (activeChatUser && (message.senderId === activeChatUser.id || message.receiverId === activeChatUser.id)) {
setChatMessages(prev => [...prev, message]);
@@ -940,7 +936,7 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
</div>
{/* Explore Map Quick Button */}
<div className="p-4 border-b border-slate-900">
<div className="p-4 border-b border-slate-900 flex flex-col gap-2">
<button
onClick={onExploreTours}
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"
@@ -948,6 +944,17 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
<Compass className="w-5 h-5 animate-spin-slow" />
{t('exploreTourMap')}
</button>
<a
href={`${import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn'}/downloads/yotrip-latest.apk`}
download="yotrip.apk"
className="w-full py-3 px-4 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-2xl font-bold flex items-center justify-center gap-2 shadow-md active:scale-95 transition-all text-xs text-center"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Tải ng dụng Android (APK)</span>
</a>
</div>
{/* Menu Items List */}
@@ -1197,6 +1204,17 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
<Compass className="w-4 h-4 animate-spin-slow" />
Khám phá Bản đ Tour
</button>
<a
href={`${import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn'}/downloads/yotrip-latest.apk`}
download="yotrip.apk"
className="w-full py-2.5 px-4 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-xl font-bold flex items-center justify-center gap-2 shadow-md active:scale-95 transition-all duration-150 text-xs"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Tải ng dụng Android (APK)</span>
</a>
</div>
{/* Vertical Tabs */}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+27
View File
@@ -1120,6 +1120,7 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1137,6 +1138,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1154,6 +1156,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1171,6 +1174,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1188,6 +1192,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1205,6 +1210,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1222,6 +1228,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1239,6 +1246,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1256,6 +1264,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1273,6 +1282,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1290,6 +1300,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1307,6 +1318,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1324,6 +1336,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1341,6 +1354,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1358,6 +1372,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1375,6 +1390,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1392,6 +1408,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1409,6 +1426,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1426,6 +1444,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1443,6 +1462,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1460,6 +1480,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1477,6 +1498,7 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1494,6 +1516,7 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1511,6 +1534,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1528,6 +1552,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1545,6 +1570,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -7035,6 +7061,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
+1
View File
@@ -9,6 +9,7 @@
],
"scripts": {
"build": "npm run build --workspace=backend && npm run build --workspace=frontend",
"build:android": "cd frontend/android && ./gradlew assembleRelease && cd ../.. && sh scripts/deploy-apk.sh",
"start:backend": "npm run start:dev --workspace=backend",
"start:frontend": "npm run start:dev --workspace=frontend",
"start:dev": "concurrently -n \"BACKEND,FRONTEND\" -c \"magenta,cyan\" \"npm run start:dev --workspace=backend\" \"npm run dev --workspace=frontend\"",
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Define relative path coordinates (adjusted for monorepo structure)
ANDROID_OUTPUT_PATH="./frontend/android/app/build/outputs/apk/release/app-release.apk"
BACKEND_TARGET_DIR="./backend/public/downloads"
TARGET_FILE_NAME="yotrip-latest.apk"
echo "🚀 Starting automated post-build Android deployment pipeline..."
# 1. Verify compiler target exists
if [ -f "$ANDROID_OUTPUT_PATH" ]; then
# 2. Ensure target storage folder structure is active
mkdir -p "$BACKEND_TARGET_DIR"
# 3. Copy and force overwrite the old production bundle with the updated version
cp -f "$ANDROID_OUTPUT_PATH" "$BACKEND_TARGET_DIR/$TARGET_FILE_NAME"
echo "✅ Success! New build copied safely to $BACKEND_TARGET_DIR/$TARGET_FILE_NAME"
echo "🔗 Direct Download Link Active: /downloads/$TARGET_FILE_NAME"
else
echo "❌ Critical Error: Android build output artifact not found at $ANDROID_OUTPUT_PATH"
exit 1
fi
+117
View File
@@ -0,0 +1,117 @@
# To AI Agent: Implement Server-Hosted Android APK Download Button and Automated Build Deployment Pipeline
## 1. Context & Feature Objective
We are adding a native Android app distribution workflow directly from our self-hosted server backend. Instead of relying purely on app stores, users visiting the web version from an Android device must be able to download the official compiled `.apk` file directly.
**Objective:** 1. **Backend Asset Exposure:** Configure a secure, static file directory on the Node.js/Express server to host the production `.apk` binary.
2. **Build Pipeline Link Automation:** Create a post-build deployment shell script. Every time a new production Android APK is generated (`release`), the script must automatically rename and copy it to the backend's public distribution folder under a persistent file pointer name (`yotrip-latest.apk`).
3. **Frontend Action Button:** Add an interactive "Tải ứng dụng Android" action row with a download icon inside both the Member and Guest profile menu sheets.
---
## 2. Technical Architecture & Implementation Steps
[Android Build Output] ➔ [deploy-apk.sh Script] ➔ [Backend public/downloads/yotrip-latest.apk]
[Frontend UI Button] ➔ ➔ ➔ [Triggers HTTP GET Request] ➔ ➔ ➔ ➔ ➔ ┛
### Step 1: Configure Backend Static Asset Folder
Locate the core server setup file (e.g., `server.ts`, `app.ts`, or `index.js`). Ensure a dedicated folder path named `public/downloads` is created and mapped to express static file serving handlers:
```typescript
import express from 'express';
import path from 'path';
const app = express();
// Ensure the directory exists: public/downloads/
const downloadsDir = path.join(__dirname, '../public/downloads');
/* ✅ BACKEND STATIC MIDDLEWARE REGISTRATION
This exposes the file at: [https://yourdomain.com/downloads/yotrip-latest.apk](https://yourdomain.com/downloads/yotrip-latest.apk)
*/
app.use('/downloads', express.static(downloadsDir, {
setHeaders: (res) => {
// Force browser engines to download the file directly instead of trying to parse it
res.set('Content-Type', 'application/vnd.android.package-archive');
res.set('Content-Disposition', 'attachment; filename="yotrip-latest.apk"');
}
}));
### Step 2: Automate APK Release Mapping Link (Post-Build Script)
Create an automation script file named scripts/deploy-apk.sh in the root environment. This script runs instantly after your Android compiler output is generated (e.g., via Gradle ./gradlew assembleRelease or Capacitor/Cordova build actions):
#!/bin/bash
# Define relative path coordinates
ANDROID_OUTPUT_PATH="./android/app/build/outputs/apk/release/app-release.apk"
BACKEND_TARGET_DIR="./backend/public/downloads"
TARGET_FILE_NAME="yotrip-latest.apk"
echo "🚀 Starting automated post-build Android deployment pipeline..."
# 1. Verify compiler target exists
if [ -f "$ANDROID_OUTPUT_PATH" ]; then
# 2. Ensure target storage folder structure is active
mkdir -p "$BACKEND_TARGET_DIR"
# 3. Copy and force overwrite the old production bundle with the updated version
cp -f "$ANDROID_OUTPUT_PATH" "$BACKEND_TARGET_DIR/$TARGET_FILE_NAME"
echo "✅ Success! New build copied safely to $BACKEND_TARGET_DIR/$TARGET_FILE_NAME"
echo "🔗 Direct Download Link Active: /downloads/$TARGET_FILE_NAME"
else
echo "❌ Critical Error: Android build output artifact not found at $ANDROID_OUTPUT_PATH"
exit 1
fi
Add "build:android": "cd android && ./gradlew assembleRelease && cd .. && sh scripts/deploy-apk.sh" inside package.json scripts matrix for unified execution hooks.
### Step 3: Add Download Trigger into Frontend UI (MapProfileDropdown.tsx)
Locate the unified dropdown menu component created in the previous layout consolidation phase. Inject the direct-download operational action rows:
// Define the static destination asset link helper
const APK_DOWNLOAD_URL = `${process.env.REACT_APP_API_BASE_URL || ''}/downloads/yotrip-latest.apk`;
/* --- INSIDE AUTHENTICATED MEMBER STACK SECTION --- */
<div className="flex flex-col space-y-1">
{/* Existing Dashboard, Profile, Create Tour rows... */}
{/* NEW: ANDROID APK DIRECT DOWNLOAD BUTTON */}
<a
href={APK_DOWNLOAD_URL}
download="yotrip.apk"
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-lg text-left text-xs text-slate-200 transition-colors"
>
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" className="w-4 h-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Tải ứng dụng Android (APK)</span>
</a>
</div>
/* --- INSIDE ANONYMOUS GUEST STACK SECTION --- */
<div className="flex flex-col space-y-1">
{/* Existing Guest language/theme configurations... */}
<div className="h-[1px] bg-slate-800 my-1 mx-2" />
{/* NEW: GUEST STATE ANDROID APK DOWNLOAD BUTTON */}
<a
href={APK_DOWNLOAD_URL}
download="yotrip.apk"
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-lg text-left text-xs text-slate-200 transition-colors"
>
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" className="w-4 h-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span>Cài đặt bản Android (.APK)</span>
</a>
{/* Existing Register/Login Button row below... */}
</div>
## 3. Verification & Acceptance Criteria for AI Agent
[ ] Deployment Script Validation: Run the build sequence script. Confirm that backend/public/downloads/yotrip-latest.apk updates its file modification timestamp matching the compiler execution timing logs.
[ ] Direct Download Header Safety: Trigger a request to GET /downloads/yotrip-latest.apk. The network tab response must show content-type: application/vnd.android.package-archive to guarantee mobile devices instantly trigger package installation workflows.
[ ] UI Integrity Test: Open the drop menu layout panel on a mobile simulator frame. Confirm that clicking the text icon row acts as a standard link target that downloads the binary file smoothly without breaking route navigation states.