diff --git a/CHANGE_BUTTON.md b/CHANGE_BUTTON.md
deleted file mode 100644
index 4c73d4f..0000000
--- a/CHANGE_BUTTON.md
+++ /dev/null
@@ -1,226 +0,0 @@
-# To AI Agent: Consolidate Map Top-Bar into Unified Profile/Guest Dropdown Menu and Create Profile Modals
-
-## 1. Context & Architectural Refactor Goal
-We are optimizing the overcrowded top-bar control deck on the main Map Explorer page (`image_55397c.png`). Currently, a long row of utility buttons compromises mobile readability and viewport real estate.
-
-**Objective:** Consolidate all secondary control buttons into a single **Avatar/Guest Profile Button**.
-- When clicked, it will open an adaptive dropdown action menu (or a smooth mobile bottom-sheet) customized dynamically based on the session authentication state (**Authenticated Member** vs. **Anonymous Guest**).
-- Migrate the global "Language" and "Theme/Interface" buttons from the old top-bar directly into this new profile configuration lifecycle.
-
----
-
-## 2. Authentication State Menus Specification
-
-### 2.1. State A: Authenticated Member Dropdown (User is logged in)
-Display the user's fetched avatar image (`user.avatarUrl`). Clicking it reveals these options:
-1. **Bảng điều khiển (Dashboard):** [Icon: LayoutDashboard] Redirects to user command center. *Note: Global language/theme quick-toggles are moved inside here/settings.*
-2. **Cài đặt cá nhân (Settings):** [Icon: Settings] Opens the comprehensive **`ProfileSettingsModal.tsx`**.
-3. **Tạo tour (Create Tour):** [Icon: Compass / MapPin] Launches the step-by-step route wizard.
-4. **Báo cáo vi phạm (Report):** [Icon: ShieldAlert] Opens an infraction query form.
---- (Visual Divider Line) ---
-5. **Đăng xuất (Logout):** [Icon: LogOut] Standard session termination trigger.
-
-### 2.2. State B: Anonymous Guest Dropdown (User is not logged in)
-Display a default vector Guest Icon. Clicking it reveals these options:
-1. **Cài đặt (Settings):** [Icon: Sliders] Opens a lightweight configuration block allowing the Guest to dynamically choose **Ngôn ngữ** (Language) and **Giao diện** (Light/Dark Theme) stored in local storage cache.
---- (Visual Divider Line) ---
-2. **Đăng ký / Đăng nhập (Auth):** [Icon: LogIn] Spawns the central authentication entry shield modal.
-
----
-
-## 3. Component Blueprints & Implementation Steps
-
-### Step 1: Create the Unified Top-Bar Controller (`MapProfileDropdown.tsx`)
-Deploy this highly accessible responsive menu container. On screen dimensions mimicking mobile/Android viewports, it is highly recommended to render this as a slick **Bottom-Sheet Overlay** for better thumb-touch accuracy.
-
-```typescript
-import React, { useState } from 'react';
-import { useAuth } from '../context/AuthContext'; // Leverage existing state system
-import { LayoutDashboard, Settings, Compass, ShieldAlert, LogOut, LogIn, Sliders } from 'lucide-react';
-
-export const MapProfileDropdown: React.FC<{ onOpenSettings: () => void }> = ({ onOpenSettings }) => {
- const { isAuthenticated, user, logout, loginRedirect } = useAuth();
- const [isOpen, setIsOpen] = useState(false);
-
- return (
-
- {/* TRIGGER BUTTON */}
-
setIsOpen(!isOpen)}
- className="w-11 h-11 rounded-full border-2 border-white bg-slate-800 flex items-center justify-center overflow-hidden shadow-lg active:scale-95 transition-transform"
- >
- {isAuthenticated && user?.avatarUrl ? (
-
- ) : (
- /* Default Guest Avatar Icon placeholder */
- G
- )}
-
-
- {/* DROPDOWN MENU / MOBILE BOTTOM SHEET LAYER */}
- {isOpen && (
- <>
- {/* Backdrop screen closer click-catcher */}
-
setIsOpen(false)} />
-
-
- {isAuthenticated ? (
- /* --- LOGGED IN MENU OPTION ROW STACK --- */
-
-
- Bảng điều khiển
-
-
{ setIsOpen(false); onOpenSettings(); }} className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-lg text-left">
- Cài đặt cá nhân
-
-
- Tạo tour
-
-
- Báo cáo vi phạm
-
-
-
- Đăng xuất
-
-
- ) : (
- /* --- GUEST ANONYMOUS MENU OPTION ROW STACK --- */
-
-
Tùy chỉnh nhanh
- {/* Guest Quick Config Modules */}
-
-
- Ngôn ngữ:
-
- Tiếng Việt
- English
-
-
-
- Giao diện:
-
- Tối
- Sáng
-
-
-
-
-
- Đăng ký / Đăng nhập
-
-
- )}
-
- >
- )}
-
- );
-};
-
-### Step 2: Implement the Main Profile Modification Panel (ProfileSettingsModal.tsx)
-Create this secure responsive form layer. It must conform to absolute constraint logic (such as locking email changes) and include select toggles for app themes.
-
-import React, { useState } from 'react';
-import { X, Camera } from 'lucide-react';
-
-export const ProfileSettingsModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
- if (!isOpen) return null;
-
- return (
-
-
-
- {/* Header bar */}
-
- Chỉnh sửa hồ sơ cá nhân
-
-
-
- {/* Scrollable Form Workspace Canvas */}
-
-
- {/* Avatar Upload Container Component */}
-
-
-
-
-
-
-
-
-
Chạm ảnh để tải lên hình đại diện mới
-
-
- {/* Text Input Row Fields */}
-
-
-
- Địa chỉ liên hệ:
-
-
-
- {/* EMAIL COMPONENT BOUNDARY - CRITICAL REQUIREMENT: DISABLED CHANGING */}
-
- Địa chỉ Email đăng nhập (Không thể chỉnh sửa):
-
-
-
- {/* Security Password Mutation Stack */}
-
-
- {/* Core Configuration Toggles (Moved from Top-Bar into profile settings context) */}
-
-
- Lựa chọn ngôn ngữ:
-
- Tiếng Việt (ICT)
- English (US)
-
-
-
- Lựa chọn giao diện:
-
- Chế độ tối (Dark Mode)
- Chế độ sáng (Light Mode)
-
-
-
-
-
-
- {/* Action Bottom Save Trigger */}
-
- Hủy
- Lưu cấu hình
-
-
-
-
- );
-};
-
-## 4. Quality Verification & Acceptance Checklist for AI Agent
-
- [ ] Top-Bar Streamlining: Verify that old utility layout buttons boxed inside the top-bar (image_55397c.png) are safely stripped out, leaving only the Back button, Title, and the new single Dropdown icon asset.
-
- [ ] Cross-Platform Android Touch Targets: On mobile browser layouts, clicking the avatar must expand a screen-bottom popup block container (fixed bottom-0 left-0 right-0) ensuring an native-app tactile interaction.
-
- [ ] Email Immutability Guard: Inspect the editing form inside ProfileSettingsModal.tsx. Confirm the input node for the user's profile email address possess the disabled state attribute flag so typing alterations are completely locked out.
-
- [ ] System State Isolation: Confirm changes inside language and theme inputs successfully bubble triggers upwards to refresh local storage parameters instantly without dropping routing view connections.
\ No newline at end of file
diff --git a/MEMBER_BUTTON.md b/MEMBER_BUTTON.md
new file mode 100644
index 0000000..c353753
--- /dev/null
+++ b/MEMBER_BUTTON.md
@@ -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 `
` 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 (
+
+
+ {/* 1. Nút "Tạo tour" */}
+
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">
+
+ Tạo tour
+
+
+ {/* 2. Nút "Hành trình của tôi" */}
+
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">
+
+ Hành trình của tôi
+
+
+ {/* 3. Nút "Thư viện ảnh" */}
+
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">
+
+ Thư viện ảnh
+
+
+ {/* 4. Nút "Cài đặt" */}
+
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">
+
+ Cài đặt
+
+
+ {/* 5. Nút "Báo cáo vi phạm" */}
+
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">
+
+ Báo cáo vi phạm
+
+
+
+
+ {/* 6. Nút "Danh sách bạn bè" */}
+
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">
+
+ Danh sách bạn bè
+
+
+ {/* 7. Nút "Đăng xuất" */}
+
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">
+
+ Đăng xuất
+
+
+
+ );
+};
+
+## 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.
diff --git a/backend/src/main.ts b/backend/src/main.ts
index 7067ca1..8342255 100644
--- a/backend/src/main.ts
+++ b/backend/src/main.ts
@@ -2184,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);
@@ -2324,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({
@@ -2347,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({
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5b34fb2..ae3890a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -6,7 +6,6 @@ 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';
@@ -139,7 +138,7 @@ function App() {
);
const [currentTourId, setCurrentTourId] = useState
(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(() => {
@@ -161,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) {
@@ -191,7 +189,7 @@ function App() {
if (loggedInUser.isAdmin) {
setCurrentPage('admin');
} else {
- setCurrentPage('dashboard');
+ setCurrentPage('landing');
}
} else {
setCurrentPage('landing');
@@ -210,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
}
};
@@ -228,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');
};
@@ -296,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');
- }
+ 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 = () => {
@@ -347,32 +320,7 @@ function App() {
return (
- );
- }
-
- 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 (
- setCurrentPage('signup')}
- />
- );
- }
-
- return (
- setCurrentPage('explore')}
- onViewTour={handleViewTour}
- onOpenMyPhotos={() => setCurrentPage('myPhotos')}
+ onNavigate={(page) => setCurrentPage(page as any)}
/>
);
}
@@ -384,7 +332,10 @@ function App() {
onBack={handleBackFromTourDetail}
isPublicView={isPublicTourView}
onOpenNotes={() => setCurrentPage('notes')}
- onOpenNavigationPage={handleOpenNavigationPage}
+ onOpenNavigationPage={(routeData) => handleOpenNavigationPage({
+ tourId: currentTourId!,
+ ...routeData
+ })}
/>
);
}
@@ -413,9 +364,9 @@ function App() {
onLogout={handleLogout}
user={user}
onViewTour={handleViewTour}
- onOpenMyPhotos={() => setCurrentPage('myPhotos')}
onLoginSuccess={handleLoginSuccess}
onGoToDashboard={handleGoToDashboard}
+ onOpenNavigation={handleOpenNavigationPage}
/>
);
}
@@ -457,7 +408,18 @@ function App() {
);
}
- return setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
+ return (
+ setCurrentPage('explore')}
+ onGoToSignup={() => setCurrentPage('signup')}
+ onGoToMap={() => setCurrentPage('explore')}
+ onLoginSuccess={handleLoginSuccess}
+ user={user}
+ onLogout={handleLogout}
+ onGoToDashboard={handleGoToDashboard}
+ onOpenNavigation={handleOpenNavigationPage}
+ />
+ );
})()}
diff --git a/frontend/src/components/MapProfileDropdown.tsx b/frontend/src/components/MapProfileDropdown.tsx
index 4edd553..5360c6d 100644
--- a/frontend/src/components/MapProfileDropdown.tsx
+++ b/frontend/src/components/MapProfileDropdown.tsx
@@ -1,29 +1,31 @@
import React, { useState } from 'react';
-import { LayoutDashboard, Settings, Compass, ShieldAlert, LogOut, LogIn, Globe, Sun, Moon, Image as ImageIcon } from 'lucide-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;
- onGoToDashboard?: () => void;
onOpenSettings: () => void;
onOpenCreateTour: () => void;
onOpenReport: () => void;
onOpenLogin: () => void;
- onOpenMyPhotos?: () => void;
+ onOpenMyPhotos: () => void;
+ onOpenMyTours: () => void;
+ onOpenFriends: () => void;
onOpenAdmin?: () => void;
}
export const MapProfileDropdown: React.FC = ({
user,
onLogout,
- onGoToDashboard,
onOpenSettings,
onOpenCreateTour,
onOpenReport,
onOpenLogin,
onOpenMyPhotos,
+ onOpenMyTours,
+ onOpenFriends,
onOpenAdmin,
}) => {
const [isOpen, setIsOpen] = useState(false);
@@ -44,6 +46,11 @@ export const MapProfileDropdown: React.FC = ({
);
};
+ const handleItemClick = (callback: () => void) => {
+ setIsOpen(false);
+ callback();
+ };
+
return (
{/* TRIGGER BUTTON */}
@@ -69,17 +76,18 @@ export const MapProfileDropdown: React.FC
= ({
{isOpen && (
<>
setIsOpen(false)}
/>
{isAuthenticated ? (
+ {/* User info header */}
{user?.avatar ? (
@@ -94,57 +102,73 @@ export const MapProfileDropdown: React.FC = ({
+ {/* Extra System Admin option */}
{user?.isAdmin && (
{ setIsOpen(false); onOpenAdmin?.(); }}
- className="flex items-center gap-3 w-full px-4 py-3 bg-blue-950/20 hover:bg-slate-805 rounded-xl text-left transition-colors font-bold text-blue-400 cursor-pointer"
+ 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"
>
Quản trị hệ thống
)}
+ {/* 1. Tạo tour */}
{ setIsOpen(false); onGoToDashboard?.(); }}
- className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
+ 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"
>
- Bảng điều khiển
+ Tạo tour
+ {/* 2. Hành trình của tôi */}
{ setIsOpen(false); onOpenSettings(); }}
- className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
+ 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"
>
- Cài đặt cá nhân
-
-
-
{ setIsOpen(false); onOpenCreateTour(); }}
- className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
- >
- Tạo Tour mới
+ Hành trình của tôi
+ {/* 3. Thư viện ảnh */}
{ setIsOpen(false); onOpenMyPhotos?.(); }}
- className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
+ 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"
>
- Ảnh của tôi
+ Thư viện ảnh
+ {/* 4. Cài đặt */}
{ setIsOpen(false); onOpenReport(); }}
- className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
+ 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"
>
- Báo cáo sai phạm
+ Cài đặt
-
-
+ {/* 5. Báo cáo vi phạm */}
{ setIsOpen(false); onLogout?.(); }}
- className="flex items-center gap-3 w-full px-4 py-3 text-rose-400 hover:bg-slate-800 rounded-xl text-left font-black transition-colors cursor-pointer"
+ 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"
>
- Đăng xuất
+ Báo cáo vi phạm
+
+
+ {/* STRICT VISUAL DIVIDER LINE */}
+
+
+ {/* 6. Danh sách bạn bè */}
+
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"
+ >
+ Danh sách bạn bè
+
+
+ {/* 7. Đăng xuất */}
+
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"
+ >
+ Đăng xuất
) : (
@@ -188,17 +212,10 @@ export const MapProfileDropdown: React.FC
= ({
- { setIsOpen(false); onOpenMyPhotos?.(); }}
- className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
- >
- Ảnh của tôi
-
-
{ setIsOpen(false); onOpenLogin(); }}
+ 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"
>
Đăng ký / Đăng nhập
diff --git a/frontend/src/components/MyPhotosModal.tsx b/frontend/src/components/MyPhotosModal.tsx
new file mode 100644
index 0000000..9249ccb
--- /dev/null
+++ b/frontend/src/components/MyPhotosModal.tsx
@@ -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 = ({
+ isOpen,
+ onClose,
+ user,
+}) => {
+ const notify = useNotification();
+
+ const [photos, setPhotos] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [selectedPhoto, setSelectedPhoto] = useState(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 (
+
+
+
+ {/* Header */}
+
+
+ Thư viện ảnh
+
+
+
+
+
+
+ {/* Content body */}
+
+ {isLoading ? (
+
+
+ Đang tải thư viện ảnh của bạn...
+
+ ) : photos.length === 0 ? (
+
+
+
+
+
+
Thư viện ảnh trống
+
Bạn chưa đăng tải bức ảnh nào trong các chuyến đi của mình.
+
+
+ ) : (
+
+ {photos.map((photo) => {
+ const dateStr = photo.capturedAt ? new Date(photo.capturedAt).toLocaleDateString('vi-VN') : '';
+ return (
+
+
+
+ {/* Hover controls overlay */}
+
+
+ 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"
+ >
+
+
+ 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"
+ >
+
+
+
+
+
+ {photo.tour?.title && (
+
+
+ {photo.tour.title}
+
+ )}
+
+
+ {dateStr}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* Fullscreen Preview overlay */}
+ {selectedPhoto && (
+
+ {/* Close trigger top bar */}
+
+
+ {selectedPhoto.tour?.title || 'Xem ảnh'}
+
+
setSelectedPhoto(null)}
+ className="p-2 bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white rounded-xl cursor-pointer"
+ >
+
+
+
+
+ {/* Fullscreen Photo view */}
+
+
+
+
+ {/* Action bottom bar */}
+
+ 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"
+ >
+ Tải về tệp gốc (.jpg)
+
+
+
+ )}
+
+ );
+};
diff --git a/frontend/src/components/MyToursModal.tsx b/frontend/src/components/MyToursModal.tsx
new file mode 100644
index 0000000..a45506d
--- /dev/null
+++ b/frontend/src/components/MyToursModal.tsx
@@ -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 = ({
+ 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(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 (
+
+
+
+ {/* Header */}
+
+
+ Hành trình của tôi
+
+
+
+
+
+
+ {/* Content body */}
+
+ {isLoading ? (
+
+
+ Đang tải danh sách chuyến đi...
+
+ ) : myTours.length === 0 ? (
+
+
+
+
+
+
Chưa có hành trình nào
+
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!
+
+
+ ) : (
+
+ {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 (
+
+
+
+
{tour.title}
+
+
+
+ {startDateStr} - {endDateStr}
+
+
+
+ {tour.participants?.length || 0} thành viên
+
+
+
+
+
+
+ {isOwner && (
+ 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 ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {
+ 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"
+ >
+ Xem hành trình
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/modals/FriendsManagerModal.tsx b/frontend/src/components/modals/FriendsManagerModal.tsx
new file mode 100644
index 0000000..c00c095
--- /dev/null
+++ b/frontend/src/components/modals/FriendsManagerModal.tsx
@@ -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 = ({
+ 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([]);
+ const [isSearching, setIsSearching] = useState(false);
+
+ // Connection Lists
+ const [connections, setConnections] = useState([]);
+ 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 (
+
+
+
+ {/* Header */}
+
+
+ Danh sách bạn bè
+
+
+
+
+
+
+ {/* Tab Selection Header */}
+
+ {[
+ { 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 (
+ 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}
+
+ );
+ })}
+
+
+ {/* Content body */}
+
+ {isLoading && activeTab !== 'search' ? (
+
+
+ Đang tải dữ liệu...
+
+ ) : activeTab === 'friends' ? (
+ activeFriends.length === 0 ? (
+
+
+
+
+
+
Chưa có bạn bè kết nối
+
Chọn mục "Tìm bạn mới" để tìm kiếm và gửi lời mời kết bạn.
+
+
+ ) : (
+
+ {activeFriends.map((conn) => {
+ const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
+ return (
+
+
+
+ {friend.avatar ? (
+
+ ) : (
+
{friend.name.charAt(0).toUpperCase()}
+ )}
+
+
+
{friend.name}
+
{friend.email}
+
+
+
+
+ {
+ 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"
+ >
+
+
+ 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"
+ >
+
+
+
+
+ );
+ })}
+
+ )
+ ) : activeTab === 'pending' ? (
+ pendingRequests.length === 0 ? (
+
+
+
+
+
+
Không có lời mời kết bạn
+
Bạn không có lời mời kết bạn nào đang chờ duyệt.
+
+
+ ) : (
+
+ {pendingRequests.map((conn) => {
+ const requester = conn.requester;
+ return (
+
+
+
+ {requester.avatar ? (
+
+ ) : (
+
{requester.name.charAt(0).toUpperCase()}
+ )}
+
+
+
{requester.name}
+
{requester.email}
+
+
+
+
+ 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"
+ >
+ Chấp nhận
+
+ 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"
+ >
+
+
+
+
+ );
+ })}
+
+ )
+ ) : (
+ // Search Tab panel
+
+
+
+
+ {isSearching ? (
+
+
+
+ ) : searchResults.length === 0 ? (
+ searchQuery.trim() && (
+
+ Không tìm thấy kết quả phù hợp.
+
+ )
+ ) : (
+ searchResults.map((u) => {
+ const status = getStatusText(u.id);
+ return (
+
+
+
+ {u.avatar ? (
+
+ ) : (
+
{u.name.charAt(0).toUpperCase()}
+ )}
+
+
+
+
+
+ {status === 'FRIEND' ? (
+
+ Đã kết nối
+
+ ) : status === 'SENT' || u.pendingSent ? (
+
+ Đã gửi lời mời
+
+ ) : status === 'RECEIVED' ? (
+
+ Chờ bạn duyệt
+
+ ) : (
+ 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"
+ >
+ Kết nối
+
+ )}
+
+
+ );
+ })
+ )}
+
+
+ )}
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/modals/LiveChatModal.tsx b/frontend/src/components/modals/LiveChatModal.tsx
new file mode 100644
index 0000000..3e4047f
--- /dev/null
+++ b/frontend/src/components/modals/LiveChatModal.tsx
@@ -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 = ({
+ isOpen,
+ onClose,
+ user,
+ defaultChatUserId,
+}) => {
+ const notify = useNotification();
+
+ const [connections, setConnections] = useState([]);
+ const [activeChatUser, setActiveChatUser] = useState(null);
+ const [chatMessages, setChatMessages] = useState([]);
+ 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(null);
+ const [attachedImageUrl, setAttachedImageUrl] = useState(null);
+ const [attachedLocation, setAttachedLocation] = useState<{ latitude: number; longitude: number } | null>(null);
+
+ const fileInputRef = useRef(null);
+ const messagesEndRef = useRef(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) => {
+ 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 (
+
+
+
+ {/* Header */}
+
+
+ Trò chuyện trực tiếp
+
+
+
+
+
+
+ {/* Workspace panel */}
+
+
+ {/* Left Column - Contacts Sidebar */}
+
+
+
+ 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"
+ />
+
+
+
+
+
+ {isLoadingContacts ? (
+
+
+
+ ) : filteredConnections.length === 0 ? (
+
+ Không tìm thấy bạn bè nào.
+
+ ) : (
+ filteredConnections.map((conn: any) => {
+ const friend = conn.targetUser?.id === user?.id ? conn.requester : conn.targetUser;
+ const isActive = activeChatUser?.id === friend.id;
+
+ return (
+
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'
+ }`}
+ >
+
+
+ {friend.avatar ? (
+
+ ) : (
+
{friend.name.charAt(0).toUpperCase()}
+ )}
+
+
+
+
+
+
{friend.name}
+
+ {friend.email}
+
+
+
+ );
+ })
+ )}
+
+
+
+ {/* Right Column - Active Chat View */}
+
+ {activeChatUser ? (
+ <>
+ {/* Active contact bar */}
+
+
+ {activeChatUser.avatar ? (
+
+ ) : (
+
{activeChatUser.name.charAt(0).toUpperCase()}
+ )}
+
+
+
{activeChatUser.name}
+
+ Đang hoạt động
+
+
+
+
+ {/* Messages stream */}
+
+ {isLoadingMessages ? (
+
+
+
+ ) : chatMessages.length === 0 ? (
+
+
+ Hãy gửi tin nhắn để bắt đầu cuộc trò chuyện.
+
+ ) : (
+ 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 (
+
+ );
+ })
+ )}
+
+
+
+ {/* Bottom Input Area */}
+
+ >
+ ) : (
+
+
+
+
+
+
Chưa chọn bạn hội thoại
+
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 tư của bạn.
+
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/modals/MyToursModal.tsx b/frontend/src/components/modals/MyToursModal.tsx
new file mode 100644
index 0000000..a58c624
--- /dev/null
+++ b/frontend/src/components/modals/MyToursModal.tsx
@@ -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 = ({
+ 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(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 (
+
+
+
+ {/* Header */}
+
+
+ Hành trình của tôi
+
+
+
+
+
+
+ {/* Tab Selection Header */}
+
+ {[
+ { 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 (
+ 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}
+
+ );
+ })}
+
+
+ {/* Content body */}
+
+ {isLoading ? (
+
+
+ Đang tải danh sách chuyến đi...
+
+ ) : currentList.length === 0 ? (
+
+
+
+
+
+
Chưa có hành trình nào
+
Bạn không có chuyến đi nào trong mục này.
+
+
+ ) : (
+
+ {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 (
+
+ {/* Banner cover */}
+
+
+
+
+
+
{tour.title}
+
{startDateStr} - {endDateStr}
+
+
+
+ {/* Progress details */}
+
+ {activeTab === 'ongoing' && (
+
+
+ Tiến độ hành trình
+ {progress}%
+
+
+
+ )}
+
+
+
+
+ {tour.participants?.length || 0} thành viên
+
+
+
+ {tour.legs?.length || 0} chặng đi
+
+
+
+
+ {isOwner && (
+
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 ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
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"
+ >
+ Dẫn đường
+
+
+
{
+ 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
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/modals/PhotoGalleryModal.tsx b/frontend/src/components/modals/PhotoGalleryModal.tsx
new file mode 100644
index 0000000..b4f87ca
--- /dev/null
+++ b/frontend/src/components/modals/PhotoGalleryModal.tsx
@@ -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 = ({
+ isOpen,
+ onClose,
+ user,
+}) => {
+ const notify = useNotification();
+ const confirm = useConfirm();
+
+ const [photos, setPhotos] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // Filtering states
+ const [filterTourId, setFilterTourId] = useState('all');
+ const [filterTag, setFilterTag] = useState('all');
+
+ // Preview / Editor States
+ const [selectedPhoto, setSelectedPhoto] = useState(null);
+ const [editingPhotoId, setEditingPhotoId] = useState(null);
+ const [editTitle, setEditTitle] = useState('');
+ const [editDescription, setEditDescription] = useState('');
+ const [editTags, setEditTags] = useState([]);
+ const [isSaving, setIsSaving] = useState(false);
+ const [isDeletingId, setIsDeletingId] = useState(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 (
+
+
+
+ {/* Header */}
+
+
+ Thư viện ảnh
+
+
+
+
+
+
+ {/* Dual Filter Header Select Pinned Matrix */}
+
+
+ Theo hành trình:
+ 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"
+ >
+ Tất cả hành trình
+ {uniqueTours.map((t) => (
+ {t.title}
+ ))}
+
+
+
+
+ Theo thẻ phân loại:
+ 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"
+ >
+ Tất cả thẻ tags
+ {PHOTO_TAGS.map((t) => (
+ {t.label}
+ ))}
+
+
+
+
+ {/* Content body grid workspace */}
+
+ {isLoading ? (
+
+
+ Đang tải thư viện ảnh...
+
+ ) : filteredPhotos.length === 0 ? (
+
+
+
+
+
+
Không tìm thấy bức ảnh nào
+
Không có ảnh nào khớp với bộ lọc hiện tại.
+
+
+ ) : (
+
+ {filteredPhotos.map((photo) => {
+ const photoTagsList = photo.metadata?.tags || [];
+
+ return (
+
+
+
+ {/* Standard tags badge dot count */}
+ {photoTagsList.length > 0 && (
+
+
+ {photoTagsList.length} tags
+
+ )}
+
+ {/* Hover controls overlay */}
+
+
+ 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"
+ >
+
+
+ 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 ? (
+
+ ) : (
+
+ )}
+
+ 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"
+ >
+
+
+
+
+
+
+ {photo.metadata?.title || 'Chưa đặt tiêu đề'}
+
+ {photo.tour?.title && (
+
+
+ {photo.tour.title}
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* Editor & Tag modification Dialog */}
+ {editingPhotoId && (
+
+
+
+ Chỉnh sửa thông tin ảnh
+ setEditingPhotoId(null)}
+ className="p-1 hover:bg-slate-800 text-slate-400 hover:text-white rounded-lg transition-colors"
+ >
+
+
+
+
+
+
+ Tiêu đề ảnh:
+ 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"
+ />
+
+
+
+ Mô tả ảnh:
+
+
+
+
Gắn thẻ phân loại (Hashtags):
+
+ {PHOTO_TAGS.map((tag) => {
+ const isSelected = editTags.includes(tag.value);
+ return (
+ 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 && }
+ {tag.label}
+
+ );
+ })}
+
+
+
+
+
+ setEditingPhotoId(null)}
+ className="px-4 py-2 bg-slate-850 hover:bg-slate-800 font-bold text-slate-300 rounded-xl"
+ >
+ Hủy
+
+ 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 && }
+ Lưu lại
+
+
+
+
+ )}
+
+ {/* Fullscreen Preview overlay */}
+ {selectedPhoto && (
+
+ {/* Close trigger top bar */}
+
+
+ {selectedPhoto.metadata?.title || 'Xem ảnh'}
+
+
setSelectedPhoto(null)}
+ className="p-2 bg-slate-900 hover:bg-slate-800 text-slate-400 hover:text-white rounded-xl cursor-pointer"
+ >
+
+
+
+
+ {/* Fullscreen Photo view */}
+
+
+
+
+ {selectedPhoto.metadata?.title && (
+
{selectedPhoto.metadata.title}
+ )}
+ {selectedPhoto.metadata?.description && (
+
{selectedPhoto.metadata.description}
+ )}
+
+ {selectedPhoto.metadata?.tags?.map((t: string) => (
+
+ #{t}
+
+ ))}
+
+
+
+
+
+ {/* Action bottom bar */}
+
+ 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"
+ >
+ Tải về tệp gốc (.jpg)
+
+
+
+ )}
+
+ );
+};
diff --git a/frontend/src/pages/ExploreMap.tsx b/frontend/src/pages/ExploreMap.tsx
index fefc3c7..d4de887 100644
--- a/frontend/src/pages/ExploreMap.tsx
+++ b/frontend/src/pages/ExploreMap.tsx
@@ -16,6 +16,10 @@ 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({
@@ -69,7 +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 }) => {
+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 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);
@@ -223,6 +229,11 @@ 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(null);
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const [trustedUsers, setTrustedUsers] = useState([]);
const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false);
@@ -409,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í đó
@@ -771,16 +788,32 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
{/* Nhóm bên phải: Menu cá nhân hợp nhất */}
-
+
+ {isAuthenticated && (
+
{
+ 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"
+ >
+
+
+
+
+
+ )}
setIsProfileSettingsOpen(true)}
onOpenCreateTour={() => setIsCreateModalOpen(true)}
onOpenReport={() => setIsReportModalOpen(true)}
onOpenLogin={() => setIsLoginModalOpen(true)}
- onOpenMyPhotos={onOpenMyPhotos}
+ onOpenMyPhotos={() => setIsMyPhotosOpen(true)}
+ onOpenMyTours={() => setIsMyToursOpen(true)}
+ onOpenFriends={() => setIsFriendsOpen(true)}
onOpenAdmin={() => setIsAdminModalOpen(true)}
/>
@@ -1566,6 +1599,41 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
}}
/>
+ {/* My Tours Modal */}
+
setIsMyToursOpen(false)}
+ user={user}
+ onViewTour={onViewTour}
+ onOpenNavigation={onOpenNavigation}
+ />
+
+ {/* My Photos Modal */}
+ setIsMyPhotosOpen(false)}
+ user={user}
+ />
+
+ {/* Live Chat Modal */}
+ setIsChatOpen(false)}
+ user={user}
+ defaultChatUserId={chatTargetUserId}
+ />
+
+ {/* Friends Manager Modal */}
+ setIsFriendsOpen(false)}
+ user={user}
+ onOpenChatWithUser={(userId) => {
+ setChatTargetUserId(userId);
+ setIsChatOpen(true);
+ }}
+ />
+
{/* Floating GPS positioning button */}
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 = ({ onGoToSignup, onGoToMap, onLoginSuccess }) => {
+export const LandingPage: React.FC = ({
+ 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(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 = ({ onGoToSignup, onGoToMa
const [pendingPhotoLocation, setPendingPhotoLocation] = useState(null);
const [photoPreviewUrl, setPhotoPreviewUrl] = useState('');
const notify = useNotification();
- const { t, lang, changeLanguage } = useTranslation();
- const { theme, changeTheme } = useTheme();
+ const { t } = useTranslation();
const [publicPhotos, setPublicPhotos] = useState([]);
const [trustedUsers, setTrustedUsers] = useState([]);
@@ -112,7 +133,7 @@ export const LandingPage: React.FC = ({ onGoToSignup, onGoToMa
const photoUrl = mainPhoto.imageUrl || mainPhoto.originalUrl || '/background.avif';
const photoTitle = mainPhoto.metadata?.title || 'Travel Planner - Khám phá chuyến đi tuyệt vời';
const photoDescription = mainPhoto.metadata?.description || `Được chia sẻ bởi ${mainPhoto.uploader?.name || 'một thành viên'}. Khám phá những hành trình tuyệt vời trên Travel Planner.`;
-
+
// Update og:image
let ogImage = document.querySelector('meta[property="og:image"]');
if (!ogImage) {
@@ -121,7 +142,7 @@ export const LandingPage: React.FC = ({ onGoToSignup, onGoToMa
document.head.appendChild(ogImage);
}
ogImage.setAttribute('content', `https://yotrip.labz.io.vn${photoUrl}`);
-
+
// Update og:title
let ogTitle = document.querySelector('meta[property="og:title"]');
if (!ogTitle) {
@@ -130,7 +151,7 @@ export const LandingPage: React.FC = ({ onGoToSignup, onGoToMa
document.head.appendChild(ogTitle);
}
ogTitle.setAttribute('content', photoTitle);
-
+
// Update og:description
let ogDescription = document.querySelector('meta[property="og:description"]');
if (!ogDescription) {
@@ -139,7 +160,7 @@ export const LandingPage: React.FC = ({ onGoToSignup, onGoToMa
document.head.appendChild(ogDescription);
}
ogDescription.setAttribute('content', photoDescription);
-
+
// Update twitter:image
let twitterImage = document.querySelector('meta[name="twitter:image"]');
if (!twitterImage) {
@@ -168,9 +189,9 @@ export const LandingPage: React.FC = ({ onGoToSignup, onGoToMa
const handleFileChange = async (event: React.ChangeEvent) => {
const file = event.target.files?.[0];
if (!file) return;
-
+
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
-
+
try {
// Nén ảnh trước
const compressedFile = await compressImage(file);
@@ -201,11 +222,11 @@ export const LandingPage: React.FC = ({ onGoToSignup, onGoToMa
// Lưu file và location vào state pending, hiển thị modal tags
setPendingPhotoFile(processedFile);
setPendingPhotoLocation(location);
-
+
// Tạo preview URL cho ảnh
const previewUrl = URL.createObjectURL(processedFile);
setPhotoPreviewUrl(previewUrl);
-
+
setIsTagsModalOpen(true);
} catch (error: any) {
notify({ title: 'Lỗi', message: error.message, type: 'error' });
@@ -222,24 +243,30 @@ export const LandingPage: React.FC = ({ 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');
-
- // 2. Tạo tài khoản khách và lấy token
- let guestToken = localStorage.getItem('guest_token');
- let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null');
-
- if (!guestToken) {
- 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;
- guestUser = guestData.user;
- localStorage.setItem('guest_token', guestToken!);
- localStorage.setItem('guest_user', JSON.stringify(guestUser));
+ // 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;
+
+ 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 (!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();
+ currentGuestToken = guestData.access_token;
+ guestUser = guestData.user;
+ localStorage.setItem('guest_token', currentGuestToken!);
+ localStorage.setItem('guest_user', JSON.stringify(guestUser));
+ }
+ uploadToken = currentGuestToken;
}
-
+
// 3. Tải ảnh lên
const formData = new FormData();
formData.append('images', pendingPhotoFile);
@@ -251,31 +278,29 @@ export const LandingPage: React.FC = ({ onGoToSignup, onGoToMa
if (selectedTags.length > 0) {
formData.append('tags', JSON.stringify(selectedTags));
}
-
+
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,
});
}
@@ -284,7 +309,7 @@ export const LandingPage: React.FC = ({ onGoToSignup, onGoToMa
const errorData = await uploadRes.json();
throw new Error(errorData.message || 'Tải ảnh thất bại.');
}
-
+
notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' });
// Xóa pendingInviteToken và parameter token khỏi URL để tránh tự động chuyển sang JoinTourPage
@@ -317,38 +342,36 @@ export const LandingPage: React.FC = ({ onGoToSignup, onGoToMa
};
-return (
+ return (
{/* Background Image with Horizontal Panning */}
{/* Active image for panning */}
{bg1 && (
-
{ if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
- className={`h-full image-pan-element ${
- !isLoggedIn ? 'select-none pointer-events-none' : ''
- }`}
- alt="Travel Background 1"
+ className={`h-full image-pan-element ${!isLoggedIn ? 'select-none pointer-events-none' : ''
+ }`}
+ alt="Travel Background 1"
/>
)}
{bg2 && (
-
{ if (!isLoggedIn) e.preventDefault(); }}
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
- className={`h-full image-pan-element ${
- !isLoggedIn ? 'select-none pointer-events-none' : ''
- }`}
- alt="Travel Background 2"
+ className={`h-full image-pan-element ${!isLoggedIn ? 'select-none pointer-events-none' : ''
+ }`}
+ alt="Travel Background 2"
/>
)}
@@ -407,11 +430,10 @@ return (
setCurrentBgIndex(idx)}
- className={`w-2 h-2 rounded-full transition-all ${
- currentBgIndex === idx
- ? 'bg-emerald-500 w-6'
+ className={`w-2 h-2 rounded-full transition-all ${currentBgIndex === idx
+ ? 'bg-emerald-500 w-6'
: 'bg-white/40 hover:bg-white/60'
- }`}
+ }`}
/>
))}
@@ -423,58 +445,34 @@ return (
YoTrip
-
-
- {/* Language Selector */}
-
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"
- >
- Tiếng Việt
- English
- 中文
-
- {/* Theme Selector */}
-
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"
- >
- {t('themeLight') || 'Sáng'}
- {t('themeDark') || 'Tối'}
- {t('themeSystem') || 'Hệ thống'}
-
-
- {/* Android APK Download Button */}
-
-
-
-
- Tải bản Android (.APK)
-
-
-
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"
- >
-
- {t('reportBusinessBtn')}
-
-
-
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"
- >
-
- {t('login')}
-
+
+ {user && !localStorage.getItem('guest_token') && (
+
{
+ 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"
+ >
+
+
+
+
+
+ )}
+
setIsProfileSettingsOpen(true)}
+ onOpenCreateTour={() => onGoToMap?.()}
+ onOpenReport={() => setIsReportModalOpen(true)}
+ onOpenLogin={() => setIsLoginModalOpen(true)}
+ onOpenMyPhotos={() => setIsMyPhotosOpen(true)}
+ onOpenMyTours={() => setIsMyToursOpen(true)}
+ onOpenFriends={() => setIsFriendsOpen(true)}
+ />
@@ -515,7 +513,7 @@ return (
{/* Floating Blacklist Panel (Right Side on Desktop) */}
-
+
@@ -633,7 +628,7 @@ return (
{/* Buttons */}
-
@@ -641,7 +636,7 @@ return (
{t('shortExplore') || 'Khám phá'}
- setIsPhotoSourceModalOpen(true)}
className="flex-1 flex items-center justify-center gap-2 bg-white/20 backdrop-blur-lg hover:bg-white/30 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-white/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap"
>
@@ -649,7 +644,7 @@ return (
{t('shortCamera') || 'Chụp ảnh'}
-
+