Compare commits
4 Commits
5f49070a98
...
813b41933b
| Author | SHA1 | Date | |
|---|---|---|---|
| 813b41933b | |||
| 59153cffc2 | |||
| 4ee46371fa | |||
| 400a3d098d |
@@ -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.
|
||||||
@@ -154,6 +154,17 @@ async function bootstrap() {
|
|||||||
app.useStaticAssets(UPLOAD_ROOT, {
|
app.useStaticAssets(UPLOAD_ROOT, {
|
||||||
prefix: '/uploads/',
|
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);
|
const prisma = app.get(prisma_service_1.PrismaService);
|
||||||
startAutoCleanup(prisma);
|
startAutoCleanup(prisma);
|
||||||
await app.listen(3001);
|
await app.listen(3001);
|
||||||
|
|||||||
@@ -114,6 +114,21 @@ async function bootstrap() {
|
|||||||
prefix: '/uploads/',
|
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);
|
const prisma = app.get(PrismaService);
|
||||||
startAutoCleanup(prisma);
|
startAutoCleanup(prisma);
|
||||||
|
|
||||||
@@ -2169,12 +2184,7 @@ class PhotoController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uploaderId = req.user.id;
|
const uploaderId = req.user.id;
|
||||||
const isAnonymous = req.user.isAnonymous;
|
// 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 đồ
|
||||||
|
|
||||||
// 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.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
@@ -2309,7 +2319,7 @@ class PhotoController {
|
|||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
async updatePhoto(
|
async updatePhoto(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@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
|
@Req() req: any
|
||||||
) {
|
) {
|
||||||
const photo = await this.prisma.photo.findUnique({
|
const photo = await this.prisma.photo.findUnique({
|
||||||
@@ -2332,6 +2342,7 @@ class PhotoController {
|
|||||||
lng: body.longitude !== undefined ? body.longitude : currentMetadata.lng,
|
lng: body.longitude !== undefined ? body.longitude : currentMetadata.lng,
|
||||||
title: body.title !== undefined ? body.title : currentMetadata.title,
|
title: body.title !== undefined ? body.title : currentMetadata.title,
|
||||||
description: body.description !== undefined ? body.description : currentMetadata.description,
|
description: body.description !== undefined ? body.description : currentMetadata.description,
|
||||||
|
tags: body.tags !== undefined ? body.tags : currentMetadata.tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.prisma.photo.update({
|
return this.prisma.photo.update({
|
||||||
@@ -2463,7 +2474,7 @@ class UserController {
|
|||||||
return this.prisma.user.update({
|
return this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
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 }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 868 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 994 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 844 KiB |
|
Before Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 662 KiB |
|
Before Width: | Height: | Size: 522 KiB |
|
Before Width: | Height: | Size: 644 KiB |
@@ -15,7 +15,7 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
|||||||
FROM base AS build
|
FROM base AS build
|
||||||
ARG VITE_GOOGLE_CLIENT_ID
|
ARG VITE_GOOGLE_CLIENT_ID
|
||||||
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
||||||
RUN npm install
|
RUN npm install --legacy-peer-deps
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||||
<title>Travel Planner</title>
|
<title>Travel Planner</title>
|
||||||
|
|
||||||
<!-- Open Graph Meta Tags for Social Media Sharing -->
|
<!-- Open Graph Meta Tags for Social Media Sharing -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://yotrip.labz.io.vn" />
|
<meta property="og:url" content="https://yotrip.labz.io.vn" />
|
||||||
<meta property="og:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
<meta property="og:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||||
<meta property="og: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 property="og: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 property="og:image" content="https://yotrip.labz.io.vn/background.avif" />
|
<meta property="og:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||||
<meta property="og:image:width" content="1200" />
|
<meta property="og:image:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
|
|
||||||
<!-- Twitter Card Meta Tags -->
|
<!-- Twitter Card Meta Tags -->
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
<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: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" />
|
<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/rolldown-runtime-Cyuzqnbw.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-others-VIU5qAPG.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-editor-DOXkNaRS.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-maps-DY-S_hoR.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="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-maps-CcXJxNtP.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/vendor-react-CuSj0z1j.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -12,6 +12,15 @@ server {
|
|||||||
add_header Cache-Control "public, max-age=31536000";
|
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
|
# JavaScript and CSS files - immutable caching
|
||||||
location ~* \.(?:js|css)$ {
|
location ~* \.(?:js|css)$ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import SignupPage from './pages/SignupPage';
|
|||||||
import { MyPhotosPage } from './pages/MyPhotosPage';
|
import { MyPhotosPage } from './pages/MyPhotosPage';
|
||||||
import { MyNotePage } from './pages/MyNotePage';
|
import { MyNotePage } from './pages/MyNotePage';
|
||||||
import { JoinTourPage } from './pages/JoinTourPage';
|
import { JoinTourPage } from './pages/JoinTourPage';
|
||||||
import { MemberDashboard } from './pages/MemberDashboard';
|
|
||||||
import { AdminDashboard } from './pages/AdminDashboard';
|
import { AdminDashboard } from './pages/AdminDashboard';
|
||||||
import { ShareJourneyPage } from './pages/ShareJourneyPage';
|
import { ShareJourneyPage } from './pages/ShareJourneyPage';
|
||||||
import { TourNavigationPage } from './pages/TourNavigationPage';
|
import { TourNavigationPage } from './pages/TourNavigationPage';
|
||||||
import { ConfirmProvider } from './hooks/useConfirm';
|
import { ConfirmProvider } from './hooks/useConfirm';
|
||||||
import { NotificationProvider, useNotification } from './hooks/useNotification';
|
import { NotificationProvider, useNotification } from './hooks/useNotification';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
interface GlobalNotificationListenerProps {
|
interface GlobalNotificationListenerProps {
|
||||||
user: any;
|
user: any;
|
||||||
@@ -33,7 +33,9 @@ const GlobalNotificationListener: React.FC<GlobalNotificationListenerProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.id) return;
|
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', () => {
|
socketInstance.on('connect', () => {
|
||||||
console.log('[WS] Global notification socket connected:', socketInstance.id);
|
console.log('[WS] Global notification socket connected:', socketInstance.id);
|
||||||
@@ -136,7 +138,7 @@ function App() {
|
|||||||
);
|
);
|
||||||
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||||
const [isPublicTourView, setIsPublicTourView] = 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);
|
const [navigationPayload, setNavigationPayload] = useState<{ tourId: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -158,7 +160,6 @@ function App() {
|
|||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const guestToken = localStorage.getItem('guest_token');
|
const guestToken = localStorage.getItem('guest_token');
|
||||||
const storedUser = localStorage.getItem('user');
|
const storedUser = localStorage.getItem('user');
|
||||||
const storedGuestUser = localStorage.getItem('guest_user');
|
|
||||||
|
|
||||||
let loggedInUser = null;
|
let loggedInUser = null;
|
||||||
if (token && storedUser) {
|
if (token && storedUser) {
|
||||||
@@ -188,7 +189,7 @@ function App() {
|
|||||||
if (loggedInUser.isAdmin) {
|
if (loggedInUser.isAdmin) {
|
||||||
setCurrentPage('admin');
|
setCurrentPage('admin');
|
||||||
} else {
|
} else {
|
||||||
setCurrentPage('dashboard');
|
setCurrentPage('landing');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setCurrentPage('landing');
|
setCurrentPage('landing');
|
||||||
@@ -207,14 +208,7 @@ function App() {
|
|||||||
// Nếu là admin, chuyển đến admin dashboard
|
// Nếu là admin, chuyển đến admin dashboard
|
||||||
setCurrentPage('admin');
|
setCurrentPage('admin');
|
||||||
} else {
|
} else {
|
||||||
// Only set to dashboard if this is a real user (has token), not a guest
|
// Keep on current page after successful login, modal will close
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const guestToken = localStorage.getItem('guest_token');
|
|
||||||
if (token && !guestToken) {
|
|
||||||
setCurrentPage('dashboard');
|
|
||||||
} else {
|
|
||||||
setCurrentPage('landing');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -225,10 +219,11 @@ function App() {
|
|||||||
setCurrentPage('landing');
|
setCurrentPage('landing');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewTour = (tourId: string, fromPage?: 'explore' | 'dashboard') => {
|
|
||||||
|
const handleViewTour = (tourId: string, fromPage?: 'explore') => {
|
||||||
setCurrentTourId(tourId);
|
setCurrentTourId(tourId);
|
||||||
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
|
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');
|
setCurrentPage('tourDetail');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -293,30 +288,11 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBackFromExplore = () => {
|
const handleBackFromExplore = () => {
|
||||||
// Only allow real users (with token, not guest_token)
|
setCurrentPage('landing');
|
||||||
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 = () => {
|
const handleGoToDashboard = (_tab?: 'tours' | 'connections' | 'photos' | 'chats') => {
|
||||||
// Only allow real users (with token, not guest_token)
|
// Deprecated dashboard redirect: keeping empty function to satisfy interface prop requirements
|
||||||
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 handleGoToHome = () => {
|
const handleGoToHome = () => {
|
||||||
@@ -344,32 +320,7 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<AdminDashboard
|
<AdminDashboard
|
||||||
user={user}
|
user={user}
|
||||||
onNavigate={setCurrentPage}
|
onNavigate={(page) => setCurrentPage(page as any)}
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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')}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -381,7 +332,10 @@ function App() {
|
|||||||
onBack={handleBackFromTourDetail}
|
onBack={handleBackFromTourDetail}
|
||||||
isPublicView={isPublicTourView}
|
isPublicView={isPublicTourView}
|
||||||
onOpenNotes={() => setCurrentPage('notes')}
|
onOpenNotes={() => setCurrentPage('notes')}
|
||||||
onOpenNavigationPage={handleOpenNavigationPage}
|
onOpenNavigationPage={(routeData) => handleOpenNavigationPage({
|
||||||
|
tourId: currentTourId!,
|
||||||
|
...routeData
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -410,9 +364,9 @@ function App() {
|
|||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
user={user}
|
user={user}
|
||||||
onViewTour={handleViewTour}
|
onViewTour={handleViewTour}
|
||||||
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
|
|
||||||
onLoginSuccess={handleLoginSuccess}
|
onLoginSuccess={handleLoginSuccess}
|
||||||
onGoToDashboard={handleGoToDashboard}
|
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>
|
</NotificationProvider>
|
||||||
</ConfirmProvider>
|
</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 bè
|
||||||
|
</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 ký / Đăng nhập
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 có 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ồ sơ cá 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 và 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 bè
|
||||||
|
</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 có bạn bè 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 và 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 có 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 có 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 bè 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 tư 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 có hành trình nào</div>
|
||||||
|
<p className="text-slate-500 mt-1 max-w-xs mx-auto">Bạn không có 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 có ả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">Mô 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,19 +1,25 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
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';
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
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 { UserManagementModal } from '@/components/UserManagementModal';
|
||||||
import { ReportBusinessModal } from '../components/ReportBusinessModal';
|
import { ReportBusinessModal } from '../components/ReportBusinessModal';
|
||||||
import { useNotification } from '@/hooks/useNotification';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { useConfirm } from '@/hooks/useConfirm';
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
import { CreateTourModal } from '../components/CreateTourModal';
|
import { CreateTourModal } from '../components/CreateTourModal';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
|
||||||
import { PublicPhotoModal } from '../components/PublicPhotoModal';
|
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
|
// Fix lỗi icon mặc định của Leaflet
|
||||||
const DefaultIcon = L.icon({
|
const DefaultIcon = L.icon({
|
||||||
@@ -67,11 +73,9 @@ function MapTracker() {
|
|||||||
return null;
|
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 }) => {
|
||||||
// Check if user is logged in (real user) or is a guest
|
|
||||||
const guestToken = localStorage.getItem('guest_token');
|
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
|
// 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 publicTours = useTourStore(state => state.publicTours);
|
||||||
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||||
@@ -80,36 +84,8 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
|
|
||||||
const notify = useNotification();
|
const notify = useNotification();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const { t, lang, changeLanguage } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme, changeTheme } = useTheme();
|
|
||||||
|
|
||||||
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
|
// Refs for mobile long-press detection
|
||||||
const touchTimerRef = React.useRef<any>(null);
|
const touchTimerRef = React.useRef<any>(null);
|
||||||
@@ -252,12 +228,18 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
};
|
};
|
||||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = 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 [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||||||
const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false);
|
const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false);
|
||||||
const [blacklist, setBlacklist] = useState<any[]>([]);
|
const [blacklist, setBlacklist] = useState<any[]>([]);
|
||||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||||
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false);
|
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false);
|
||||||
const storeMapCenter = useTourStore(state => state.mapCenter);
|
|
||||||
|
|
||||||
// Recommendations and GPS States
|
// Recommendations and GPS States
|
||||||
const [recommendedLocations, setRecommendedLocations] = useState<any[]>([]);
|
const [recommendedLocations, setRecommendedLocations] = useState<any[]>([]);
|
||||||
@@ -438,6 +420,12 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
if (user || localStorage.getItem('token')) {
|
if (user || localStorage.getItem('token')) {
|
||||||
fetchPublicTours();
|
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í đó
|
// 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nhóm bên phải: Các thao tác người dùng */}
|
{/* Nhóm bên phải: Menu cá nhân hợp nhất */}
|
||||||
<div className="flex items-center gap-2 pointer-events-auto">
|
<div className="flex items-center gap-3 pointer-events-auto">
|
||||||
{/* Nút định vị người dùng */}
|
{isAuthenticated && (
|
||||||
<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 && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log("Đang mở Ảnh của tôi...");
|
setChatTargetUserId(null);
|
||||||
onOpenMyPhotos();
|
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"
|
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="Ảnh của tôi"
|
title="Trò chuyện trực tiếp"
|
||||||
>
|
>
|
||||||
<ImageIcon className="w-5 h-5" />
|
<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}>
|
||||||
<span className="hidden md:inline text-sm">Ảnh của tôi</span>
|
<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" />
|
||||||
</button>
|
</svg>
|
||||||
)}
|
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-blue-500 rounded-full" />
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1679,6 +1578,73 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
|||||||
initialLatitude={mapCenter ? mapCenter[0] : undefined}
|
initialLatitude={mapCenter ? mapCenter[0] : undefined}
|
||||||
initialLongitude={mapCenter ? mapCenter[1] : 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
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 { LoginModal } from '../components/LoginModal';
|
||||||
import { ReportBusinessModal } from '../components/ReportBusinessModal';
|
import { ReportBusinessModal } from '../components/ReportBusinessModal';
|
||||||
import { TagSelectModal } from '../components/TagSelectModal';
|
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 { useNotification } from '@/hooks/useNotification';
|
||||||
import { processImageModeration } from '../hooks/useImageModeration';
|
import { processImageModeration } from '../hooks/useImageModeration';
|
||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
import { useTheme } from '../hooks/useTheme';
|
|
||||||
import { compressImage } from '../utils/image';
|
import { compressImage } from '../utils/image';
|
||||||
|
|
||||||
interface LandingPageProps {
|
interface LandingPageProps {
|
||||||
@@ -15,10 +20,27 @@ interface LandingPageProps {
|
|||||||
onGoToMap?: () => void;
|
onGoToMap?: () => void;
|
||||||
onLoginSuccess?: (user: any) => void;
|
onLoginSuccess?: (user: any) => void;
|
||||||
isInitialSetup?: boolean;
|
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 [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 [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
|
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
|
||||||
const [isPhotoSourceModalOpen, setIsPhotoSourceModalOpen] = 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 [pendingPhotoLocation, setPendingPhotoLocation] = useState<GeolocationPosition | null>(null);
|
||||||
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
|
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
|
||||||
const notify = useNotification();
|
const notify = useNotification();
|
||||||
const { t, lang, changeLanguage } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme, changeTheme } = useTheme();
|
|
||||||
|
|
||||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||||
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||||||
@@ -112,7 +133,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
const photoUrl = mainPhoto.imageUrl || mainPhoto.originalUrl || '/background.avif';
|
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 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.`;
|
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
|
// Update og:image
|
||||||
let ogImage = document.querySelector('meta[property="og:image"]');
|
let ogImage = document.querySelector('meta[property="og:image"]');
|
||||||
if (!ogImage) {
|
if (!ogImage) {
|
||||||
@@ -121,7 +142,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
document.head.appendChild(ogImage);
|
document.head.appendChild(ogImage);
|
||||||
}
|
}
|
||||||
ogImage.setAttribute('content', `https://yotrip.labz.io.vn${photoUrl}`);
|
ogImage.setAttribute('content', `https://yotrip.labz.io.vn${photoUrl}`);
|
||||||
|
|
||||||
// Update og:title
|
// Update og:title
|
||||||
let ogTitle = document.querySelector('meta[property="og:title"]');
|
let ogTitle = document.querySelector('meta[property="og:title"]');
|
||||||
if (!ogTitle) {
|
if (!ogTitle) {
|
||||||
@@ -130,7 +151,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
document.head.appendChild(ogTitle);
|
document.head.appendChild(ogTitle);
|
||||||
}
|
}
|
||||||
ogTitle.setAttribute('content', photoTitle);
|
ogTitle.setAttribute('content', photoTitle);
|
||||||
|
|
||||||
// Update og:description
|
// Update og:description
|
||||||
let ogDescription = document.querySelector('meta[property="og:description"]');
|
let ogDescription = document.querySelector('meta[property="og:description"]');
|
||||||
if (!ogDescription) {
|
if (!ogDescription) {
|
||||||
@@ -139,7 +160,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
document.head.appendChild(ogDescription);
|
document.head.appendChild(ogDescription);
|
||||||
}
|
}
|
||||||
ogDescription.setAttribute('content', photoDescription);
|
ogDescription.setAttribute('content', photoDescription);
|
||||||
|
|
||||||
// Update twitter:image
|
// Update twitter:image
|
||||||
let twitterImage = document.querySelector('meta[name="twitter:image"]');
|
let twitterImage = document.querySelector('meta[name="twitter:image"]');
|
||||||
if (!twitterImage) {
|
if (!twitterImage) {
|
||||||
@@ -168,9 +189,9 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Nén ảnh trước
|
// Nén ảnh trước
|
||||||
const compressedFile = await compressImage(file);
|
const compressedFile = await compressImage(file);
|
||||||
@@ -201,11 +222,11 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
// Lưu file và location vào state pending, hiển thị modal tags
|
// Lưu file và location vào state pending, hiển thị modal tags
|
||||||
setPendingPhotoFile(processedFile);
|
setPendingPhotoFile(processedFile);
|
||||||
setPendingPhotoLocation(location);
|
setPendingPhotoLocation(location);
|
||||||
|
|
||||||
// Tạo preview URL cho ảnh
|
// Tạo preview URL cho ảnh
|
||||||
const previewUrl = URL.createObjectURL(processedFile);
|
const previewUrl = URL.createObjectURL(processedFile);
|
||||||
setPhotoPreviewUrl(previewUrl);
|
setPhotoPreviewUrl(previewUrl);
|
||||||
|
|
||||||
setIsTagsModalOpen(true);
|
setIsTagsModalOpen(true);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notify({ title: 'Lỗi', message: error.message, type: 'error' });
|
notify({ title: 'Lỗi', message: error.message, type: 'error' });
|
||||||
@@ -222,24 +243,30 @@ 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' });
|
notify({ title: 'Đang tải lên...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Xóa token cũ nếu có để đảm bảo guest không bị nhầm lẫn
|
// 1. Kiểm tra xem người dùng đã đăng nhập chưa
|
||||||
localStorage.removeItem('token');
|
const token = localStorage.getItem('token');
|
||||||
localStorage.removeItem('user');
|
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;
|
||||||
let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null');
|
|
||||||
|
// 2. Nếu là khách, tạo tài khoản khách và lấy token
|
||||||
if (!guestToken) {
|
if (!isRealUser) {
|
||||||
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
let currentGuestToken = guestToken;
|
||||||
if (!guestRes.ok) throw new Error('Không thể tạo phiên khách.');
|
let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null');
|
||||||
const guestData = await guestRes.json();
|
|
||||||
guestToken = guestData.access_token;
|
if (!currentGuestToken) {
|
||||||
guestUser = guestData.user;
|
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
||||||
localStorage.setItem('guest_token', guestToken!);
|
if (!guestRes.ok) throw new Error('Không thể tạo phiên khách.');
|
||||||
localStorage.setItem('guest_user', JSON.stringify(guestUser));
|
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
|
// 3. Tải ảnh lên
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('images', pendingPhotoFile);
|
formData.append('images', pendingPhotoFile);
|
||||||
@@ -251,31 +278,29 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
if (selectedTags.length > 0) {
|
if (selectedTags.length > 0) {
|
||||||
formData.append('tags', JSON.stringify(selectedTags));
|
formData.append('tags', JSON.stringify(selectedTags));
|
||||||
}
|
}
|
||||||
|
|
||||||
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
|
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': `Bearer ${guestToken!}` },
|
headers: { 'Authorization': `Bearer ${uploadToken!}` },
|
||||||
body: formData,
|
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...');
|
console.warn('Guest token invalid or expired. Creating a new guest user and retrying...');
|
||||||
localStorage.removeItem('guest_token');
|
localStorage.removeItem('guest_token');
|
||||||
localStorage.removeItem('guest_user');
|
localStorage.removeItem('guest_user');
|
||||||
localStorage.removeItem('token');
|
|
||||||
localStorage.removeItem('user');
|
|
||||||
|
|
||||||
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
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.');
|
if (!guestRes.ok) throw new Error('Không thể tạo lại phiên khách.');
|
||||||
const guestData = await guestRes.json();
|
const guestData = await guestRes.json();
|
||||||
guestToken = guestData.access_token;
|
const newGuestToken = guestData.access_token;
|
||||||
guestUser = guestData.user;
|
const guestUser = guestData.user;
|
||||||
localStorage.setItem('guest_token', guestToken!);
|
localStorage.setItem('guest_token', newGuestToken!);
|
||||||
localStorage.setItem('guest_user', JSON.stringify(guestUser));
|
localStorage.setItem('guest_user', JSON.stringify(guestUser));
|
||||||
|
|
||||||
uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
|
uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': `Bearer ${guestToken!}` },
|
headers: { 'Authorization': `Bearer ${newGuestToken!}` },
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -284,7 +309,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
const errorData = await uploadRes.json();
|
const errorData = await uploadRes.json();
|
||||||
throw new Error(errorData.message || 'Tải ảnh thất bại.');
|
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' });
|
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
|
// 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<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-dvh w-full overflow-hidden font-sans bg-[var(--background)] relative">
|
<div className="h-dvh w-full overflow-hidden font-sans bg-[var(--background)] relative">
|
||||||
{/* Background Image with Horizontal Panning */}
|
{/* Background Image with Horizontal Panning */}
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
{/* Active image for panning */}
|
{/* Active image for panning */}
|
||||||
<div className={`absolute inset-0 image-pan-container ${activeSlot === 1 && fade1 ? 'block' : 'hidden'}`}>
|
<div className={`absolute inset-0 image-pan-container ${activeSlot === 1 && fade1 ? 'block' : 'hidden'}`}>
|
||||||
{bg1 && (
|
{bg1 && (
|
||||||
<img
|
<img
|
||||||
key={bg1}
|
key={bg1}
|
||||||
src={bg1}
|
src={bg1}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
className={`h-full image-pan-element ${
|
className={`h-full image-pan-element ${!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
}`}
|
||||||
}`}
|
alt="Travel Background 1"
|
||||||
alt="Travel Background 1"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`absolute inset-0 image-pan-container ${activeSlot === 2 && fade2 ? 'block' : 'hidden'}`}>
|
<div className={`absolute inset-0 image-pan-container ${activeSlot === 2 && fade2 ? 'block' : 'hidden'}`}>
|
||||||
{bg2 && (
|
{bg2 && (
|
||||||
<img
|
<img
|
||||||
key={bg2}
|
key={bg2}
|
||||||
src={bg2}
|
src={bg2}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
className={`h-full image-pan-element ${
|
className={`h-full image-pan-element ${!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
}`}
|
||||||
}`}
|
alt="Travel Background 2"
|
||||||
alt="Travel Background 2"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -407,11 +430,10 @@ return (
|
|||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
onClick={() => setCurrentBgIndex(idx)}
|
onClick={() => setCurrentBgIndex(idx)}
|
||||||
className={`w-2 h-2 rounded-full transition-all ${
|
className={`w-2 h-2 rounded-full transition-all ${currentBgIndex === idx
|
||||||
currentBgIndex === idx
|
? 'bg-emerald-500 w-6'
|
||||||
? 'bg-emerald-500 w-6'
|
|
||||||
: 'bg-white/40 hover:bg-white/60'
|
: 'bg-white/40 hover:bg-white/60'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -423,45 +445,34 @@ return (
|
|||||||
<Compass className="w-7 h-7 sm:w-8 sm:h-8" />
|
<Compass className="w-7 h-7 sm:w-8 sm:h-8" />
|
||||||
<span className="text-lg sm:text-xl font-black tracking-tighter uppercase hidden sm:block">YoTrip</span>
|
<span className="text-lg sm:text-xl font-black tracking-tighter uppercase hidden sm:block">YoTrip</span>
|
||||||
</div>
|
</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 */}
|
<div className="flex items-center gap-3 pointer-events-auto">
|
||||||
<select
|
{user && !localStorage.getItem('guest_token') && (
|
||||||
value={theme}
|
<button
|
||||||
onChange={(e) => changeTheme(e.target.value as any)}
|
onClick={() => {
|
||||||
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"
|
setChatTargetUserId(null);
|
||||||
>
|
setIsChatOpen(true);
|
||||||
<option value="light" className="text-black">{t('themeLight') || 'Sáng'}</option>
|
}}
|
||||||
<option value="dark" className="text-black">{t('themeDark') || 'Tối'}</option>
|
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"
|
||||||
<option value="system" className="text-black">{t('themeSystem') || 'Hệ thống'}</option>
|
title="Trò chuyện trực tiếp"
|
||||||
</select>
|
>
|
||||||
|
<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}>
|
||||||
<button
|
<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" />
|
||||||
onClick={() => setIsReportModalOpen(true)}
|
</svg>
|
||||||
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"
|
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-blue-500 rounded-full" />
|
||||||
>
|
</button>
|
||||||
<ShieldAlert className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
)}
|
||||||
<span className="hidden sm:inline">{t('reportBusinessBtn')}</span>
|
<MapProfileDropdown
|
||||||
</button>
|
user={user}
|
||||||
|
onLogout={onLogout}
|
||||||
<button
|
onOpenSettings={() => setIsProfileSettingsOpen(true)}
|
||||||
onClick={() => setIsLoginModalOpen(true)}
|
onOpenCreateTour={() => onGoToMap?.()}
|
||||||
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"
|
onOpenReport={() => setIsReportModalOpen(true)}
|
||||||
>
|
onOpenLogin={() => setIsLoginModalOpen(true)}
|
||||||
<LogIn className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
onOpenMyPhotos={() => setIsMyPhotosOpen(true)}
|
||||||
<span>{t('login')}</span>
|
onOpenMyTours={() => setIsMyToursOpen(true)}
|
||||||
</button>
|
onOpenFriends={() => setIsFriendsOpen(true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -502,7 +513,7 @@ return (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Blacklist Panel (Right Side on Desktop) */}
|
{/* 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">
|
<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')}
|
<ShieldAlert className="w-5 h-5 text-red-500" /> {t('blacklistTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -552,32 +563,32 @@ return (
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input chọn file ẩn để chụp/chọn ảnh */}
|
{/* Input chọn file ẩn để chụp/chọn ảnh */}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
accept="image/*"
|
|
||||||
capture="environment"
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Camera input - capture="environment" for rear camera */}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={cameraInputRef}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
accept="image/*"
|
|
||||||
capture="environment"
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gallery input - no capture attribute for file picker */}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={galleryInputRef}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="hidden"
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Camera input - capture="environment" for rear camera */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={cameraInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gallery input - no capture attribute for file picker */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={galleryInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Unified Bottom Container: Gallery thumbnails on top, action buttons below */}
|
{/* Unified Bottom Container: Gallery thumbnails on top, action buttons below */}
|
||||||
@@ -593,25 +604,22 @@ return (
|
|||||||
<button
|
<button
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
onClick={() => setCurrentBgIndex(index)}
|
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 ${
|
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'
|
||||||
currentBgIndex === index ? 'scale-110 shadow-lg' : 'hover:scale-105'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={photo.imageUrl}
|
src={photo.imageUrl}
|
||||||
alt="Community thumbnail"
|
alt="Community thumbnail"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
||||||
className={`w-full h-full object-cover ${
|
className={`w-full h-full object-cover ${!isLoggedIn ? 'select-none pointer-events-none' : ''
|
||||||
!isLoggedIn ? 'select-none pointer-events-none' : ''
|
}`}
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
{/* Border Overlay absolute to prevent border clipping or corner overlap */}
|
{/* 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 */}
|
{/* 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 ${
|
<div className={`absolute inset-[1.5px] rounded-[10px] border-2 pointer-events-none transition-colors ${currentBgIndex === index ? 'border-emerald-500' : 'border-white/20'
|
||||||
currentBgIndex === index ? 'border-emerald-500' : 'border-white/20'
|
}`} />
|
||||||
}`} />
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -620,7 +628,7 @@ return (
|
|||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div className="w-full flex gap-3 items-center justify-center">
|
<div className="w-full flex gap-3 items-center justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={onGoToMap}
|
onClick={onGoToMap}
|
||||||
className="flex-1 flex items-center justify-center gap-2 bg-emerald-600/90 hover:bg-emerald-500 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-emerald-500/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap"
|
className="flex-1 flex items-center justify-center gap-2 bg-emerald-600/90 hover:bg-emerald-500 text-white font-bold py-3.5 rounded-2xl transition-all shadow-2xl border border-emerald-500/30 active:scale-95 text-xs sm:text-sm whitespace-nowrap"
|
||||||
>
|
>
|
||||||
@@ -628,7 +636,7 @@ return (
|
|||||||
<span>{t('shortExplore') || 'Khám phá'}</span>
|
<span>{t('shortExplore') || 'Khám phá'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsPhotoSourceModalOpen(true)}
|
onClick={() => 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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -636,7 +644,7 @@ return (
|
|||||||
<span>{t('shortCamera') || 'Chụp ảnh'}</span>
|
<span>{t('shortCamera') || 'Chụp ảnh'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -649,9 +657,9 @@ return (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Modal Component */}
|
{/* Login Modal Component */}
|
||||||
<LoginModal
|
<LoginModal
|
||||||
isOpen={isLoginModalOpen}
|
isOpen={isLoginModalOpen}
|
||||||
onClose={() => setIsLoginModalOpen(false)}
|
onClose={() => setIsLoginModalOpen(false)}
|
||||||
onSwitchToSignup={onGoToSignup}
|
onSwitchToSignup={onGoToSignup}
|
||||||
onLoginSuccess={onLoginSuccess}
|
onLoginSuccess={onLoginSuccess}
|
||||||
/>
|
/>
|
||||||
@@ -732,6 +740,53 @@ return (
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import { io, Socket } from 'socket.io-client';
|
|
||||||
import { Capacitor } from '@capacitor/core';
|
|
||||||
import {
|
import {
|
||||||
Compass,
|
Compass,
|
||||||
Users,
|
Users,
|
||||||
@@ -39,13 +37,15 @@ interface MemberDashboardProps {
|
|||||||
onExploreTours: () => void;
|
onExploreTours: () => void;
|
||||||
onViewTour: (tourId: string, fromPage?: 'explore' | 'dashboard') => void;
|
onViewTour: (tourId: string, fromPage?: 'explore' | 'dashboard') => void;
|
||||||
onOpenMyPhotos: () => void;
|
onOpenMyPhotos: () => void;
|
||||||
|
initialTab?: 'tours' | 'connections' | 'photos' | 'chats';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||||
user,
|
user,
|
||||||
onLogout,
|
onLogout,
|
||||||
onExploreTours,
|
onExploreTours,
|
||||||
onViewTour
|
onViewTour,
|
||||||
|
initialTab,
|
||||||
}) => {
|
}) => {
|
||||||
// GUARD: Prevent guest users from accessing dashboard
|
// GUARD: Prevent guest users from accessing dashboard
|
||||||
const guestToken = localStorage.getItem('guest_token');
|
const guestToken = localStorage.getItem('guest_token');
|
||||||
@@ -70,7 +70,13 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
const publicTours = useTourStore(state => state.publicTours);
|
const publicTours = useTourStore(state => state.publicTours);
|
||||||
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
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');
|
const [connectionSubTab, setConnectionSubTab] = useState<'list' | 'search' | 'pending'>('list');
|
||||||
|
|
||||||
// Connection states
|
// Connection states
|
||||||
@@ -528,18 +534,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
|
|
||||||
// Connect to WebSocket using same origin/proxy
|
const handleMessageReceived = (e: Event) => {
|
||||||
const socket = Capacitor.isNativePlatform()
|
const message = (e as CustomEvent).detail;
|
||||||
? 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) => {
|
|
||||||
// If we are actively chatting with the sender of this message
|
// If we are actively chatting with the sender of this message
|
||||||
if (activeChatUser && (message.senderId === activeChatUser.id || message.receiverId === activeChatUser.id)) {
|
if (activeChatUser && (message.senderId === activeChatUser.id || message.receiverId === activeChatUser.id)) {
|
||||||
setChatMessages(prev => [...prev, message]);
|
setChatMessages(prev => [...prev, message]);
|
||||||
@@ -888,8 +884,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={`absolute -bottom-1 -right-1 p-1.5 rounded-full border shadow-md transition-all active:scale-95 z-20 ${muteNotifications
|
className={`absolute -bottom-1 -right-1 p-1.5 rounded-full border shadow-md transition-all active:scale-95 z-20 ${muteNotifications
|
||||||
? 'bg-slate-900 border-slate-800 text-slate-400 hover:text-slate-200'
|
? 'bg-slate-900 border-slate-800 text-slate-400 hover:text-slate-200'
|
||||||
: 'bg-indigo-650 border-indigo-500 text-white hover:bg-indigo-600'
|
: 'bg-indigo-650 border-indigo-500 text-white hover:bg-indigo-600'
|
||||||
}`}
|
}`}
|
||||||
title={muteNotifications ? "Bật thông báo đẩy" : "Tắt thông báo đẩy"}
|
title={muteNotifications ? "Bật thông báo đẩy" : "Tắt thông báo đẩy"}
|
||||||
>
|
>
|
||||||
@@ -940,7 +936,7 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Explore Map Quick Button */}
|
{/* 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
|
<button
|
||||||
onClick={onExploreTours}
|
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"
|
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" />
|
<Compass className="w-5 h-5 animate-spin-slow" />
|
||||||
{t('exploreTourMap')}
|
{t('exploreTourMap')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Menu Items List */}
|
{/* Menu Items List */}
|
||||||
@@ -955,8 +962,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSelectTab('tours')}
|
onClick={() => handleSelectTab('tours')}
|
||||||
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${activeTab === 'tours'
|
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${activeTab === 'tours'
|
||||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||||
: 'bg-slate-850/80 border-slate-700/80'
|
: 'bg-slate-850/80 border-slate-700/80'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -982,8 +989,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSelectTab('photos')}
|
onClick={() => handleSelectTab('photos')}
|
||||||
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${activeTab === 'photos'
|
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${activeTab === 'photos'
|
||||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||||
: 'bg-slate-850/80 border-slate-700/80'
|
: 'bg-slate-850/80 border-slate-700/80'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1007,8 +1014,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSelectTab('connections')}
|
onClick={() => handleSelectTab('connections')}
|
||||||
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${activeTab === 'connections'
|
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${activeTab === 'connections'
|
||||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||||
: 'bg-slate-900/60 border-slate-800/60'
|
: 'bg-slate-900/60 border-slate-800/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1034,8 +1041,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSelectTab('chats')}
|
onClick={() => handleSelectTab('chats')}
|
||||||
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${activeTab === 'chats'
|
className={`w-full p-4 hover:bg-slate-900 text-white rounded-2xl flex items-center justify-between font-bold active:scale-98 transition-all border ${activeTab === 'chats'
|
||||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||||
: 'bg-slate-900/60 border-slate-800/60'
|
: 'bg-slate-900/60 border-slate-800/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1083,8 +1090,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<div className="hidden md:block absolute bottom-0 left-0 w-[500px] h-[500px] bg-purple-500/5 rounded-full blur-[120px] pointer-events-none z-0"></div>
|
<div className="hidden md:block absolute bottom-0 left-0 w-[500px] h-[500px] bg-purple-500/5 rounded-full blur-[120px] pointer-events-none z-0"></div>
|
||||||
|
|
||||||
<div className={`w-full relative z-10 flex overflow-hidden transition-all duration-350 ${isMobile
|
<div className={`w-full relative z-10 flex overflow-hidden transition-all duration-350 ${isMobile
|
||||||
? 'flex-col h-full bg-slate-950'
|
? 'flex-col h-full bg-slate-950'
|
||||||
: 'max-w-7xl h-[90vh] bg-slate-900/30 border border-slate-800/80 shadow-2xl rounded-3xl backdrop-blur-md'
|
: 'max-w-7xl h-[90vh] bg-slate-900/30 border border-slate-800/80 shadow-2xl rounded-3xl backdrop-blur-md'
|
||||||
}`}>
|
}`}>
|
||||||
|
|
||||||
{/* Mobile Detail Header: Only on Mobile detail mode */}
|
{/* Mobile Detail Header: Only on Mobile detail mode */}
|
||||||
@@ -1139,8 +1146,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={`absolute -bottom-1 -right-1 p-1.5 rounded-full border shadow-md transition-all active:scale-90 z-20 ${muteNotifications
|
className={`absolute -bottom-1 -right-1 p-1.5 rounded-full border shadow-md transition-all active:scale-90 z-20 ${muteNotifications
|
||||||
? 'bg-slate-900 border-slate-800 text-slate-400 hover:text-slate-200'
|
? 'bg-slate-900 border-slate-800 text-slate-400 hover:text-slate-200'
|
||||||
: 'bg-indigo-650 border-indigo-500 text-white hover:bg-indigo-600'
|
: 'bg-indigo-650 border-indigo-500 text-white hover:bg-indigo-600'
|
||||||
}`}
|
}`}
|
||||||
title={muteNotifications ? "Bật thông báo đẩy" : "Tắt thông báo đẩy"}
|
title={muteNotifications ? "Bật thông báo đẩy" : "Tắt thông báo đẩy"}
|
||||||
>
|
>
|
||||||
@@ -1197,6 +1204,17 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<Compass className="w-4 h-4 animate-spin-slow" />
|
<Compass className="w-4 h-4 animate-spin-slow" />
|
||||||
Khám phá Bản đồ Tour
|
Khám phá Bản đồ Tour
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Vertical Tabs */}
|
{/* Vertical Tabs */}
|
||||||
@@ -1204,8 +1222,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSelectTab('tours')}
|
onClick={() => handleSelectTab('tours')}
|
||||||
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${activeTab === 'tours'
|
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${activeTab === 'tours'
|
||||||
? 'bg-slate-800/80 text-white border-l-4 border-indigo-500 shadow-md'
|
? 'bg-slate-800/80 text-white border-l-4 border-indigo-500 shadow-md'
|
||||||
: 'text-slate-400 hover:bg-slate-800/30 hover:text-slate-200'
|
: 'text-slate-400 hover:bg-slate-800/30 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1223,8 +1241,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSelectTab('photos')}
|
onClick={() => handleSelectTab('photos')}
|
||||||
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${activeTab === 'photos'
|
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${activeTab === 'photos'
|
||||||
? 'bg-slate-800/80 text-white border-l-4 border-indigo-500 shadow-md'
|
? 'bg-slate-800/80 text-white border-l-4 border-indigo-500 shadow-md'
|
||||||
: 'text-slate-400 hover:bg-slate-800/30 hover:text-slate-200'
|
: 'text-slate-400 hover:bg-slate-800/30 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1242,8 +1260,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSelectTab('chats')}
|
onClick={() => handleSelectTab('chats')}
|
||||||
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${activeTab === 'chats'
|
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${activeTab === 'chats'
|
||||||
? 'bg-slate-800/80 text-white border-l-4 border-indigo-500 shadow-md'
|
? 'bg-slate-800/80 text-white border-l-4 border-indigo-500 shadow-md'
|
||||||
: 'text-slate-400 hover:bg-slate-800/30 hover:text-slate-200'
|
: 'text-slate-400 hover:bg-slate-800/30 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1260,8 +1278,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSelectTab('connections')}
|
onClick={() => handleSelectTab('connections')}
|
||||||
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${activeTab === 'connections'
|
className={`w-full py-3 px-4 rounded-xl flex items-center justify-between text-sm font-bold transition-all duration-150 ${activeTab === 'connections'
|
||||||
? 'bg-slate-800/80 text-white border-l-4 border-indigo-500 shadow-md'
|
? 'bg-slate-800/80 text-white border-l-4 border-indigo-500 shadow-md'
|
||||||
: 'text-slate-400 hover:bg-slate-800/30 hover:text-slate-200'
|
: 'text-slate-400 hover:bg-slate-800/30 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1307,8 +1325,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
|
|
||||||
{/* Tab content renders here */}
|
{/* Tab content renders here */}
|
||||||
<div className={`flex-1 relative z-10 flex flex-col ${activeTab === 'chats' || activeTab === 'photos'
|
<div className={`flex-1 relative z-10 flex flex-col ${activeTab === 'chats' || activeTab === 'photos'
|
||||||
? 'overflow-hidden p-0'
|
? 'overflow-hidden p-0'
|
||||||
: 'p-4 md:p-8 overflow-y-auto'
|
: 'p-4 md:p-8 overflow-y-auto'
|
||||||
}`}>
|
}`}>
|
||||||
|
|
||||||
{/* TAB 1: MY TOURS */}
|
{/* TAB 1: MY TOURS */}
|
||||||
@@ -1502,8 +1520,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setConnectionSubTab('list')}
|
onClick={() => setConnectionSubTab('list')}
|
||||||
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all ${connectionSubTab === 'list'
|
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all ${connectionSubTab === 'list'
|
||||||
? 'bg-indigo-600 text-white shadow-md'
|
? 'bg-indigo-600 text-white shadow-md'
|
||||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Danh sách kết nối
|
Danh sách kết nối
|
||||||
@@ -1511,8 +1529,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setConnectionSubTab('search')}
|
onClick={() => setConnectionSubTab('search')}
|
||||||
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${connectionSubTab === 'search'
|
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${connectionSubTab === 'search'
|
||||||
? 'bg-indigo-600 text-white shadow-md'
|
? 'bg-indigo-600 text-white shadow-md'
|
||||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Search className="w-3.5 h-3.5" /> Tìm thành viên mới
|
<Search className="w-3.5 h-3.5" /> Tìm thành viên mới
|
||||||
@@ -1520,8 +1538,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setConnectionSubTab('pending')}
|
onClick={() => setConnectionSubTab('pending')}
|
||||||
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all relative ${connectionSubTab === 'pending'
|
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all relative ${connectionSubTab === 'pending'
|
||||||
? 'bg-indigo-600 text-white shadow-md'
|
? 'bg-indigo-600 text-white shadow-md'
|
||||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Yêu cầu chờ duyệt
|
Yêu cầu chờ duyệt
|
||||||
@@ -1572,8 +1590,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<p className="text-[10px] text-slate-400 truncate">{connUser.email}</p>
|
<p className="text-[10px] text-slate-400 truncate">{connUser.email}</p>
|
||||||
<div className="mt-1.5 flex items-center gap-1.5">
|
<div className="mt-1.5 flex items-center gap-1.5">
|
||||||
<span className={`px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-wider border ${conn.type === 'FAMILY'
|
<span className={`px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-wider border ${conn.type === 'FAMILY'
|
||||||
? 'bg-rose-950/40 text-rose-300 border-rose-900/50'
|
? 'bg-rose-950/40 text-rose-300 border-rose-900/50'
|
||||||
: 'bg-indigo-950/40 text-indigo-300 border-indigo-900/50'
|
: 'bg-indigo-950/40 text-indigo-300 border-indigo-900/50'
|
||||||
}`}>
|
}`}>
|
||||||
{conn.type === 'FAMILY' ? t('familyGroup') : t('friends')}
|
{conn.type === 'FAMILY' ? t('familyGroup') : t('friends')}
|
||||||
</span>
|
</span>
|
||||||
@@ -1679,8 +1697,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
{statusText ? (
|
{statusText ? (
|
||||||
<span className={`px-3 py-1 rounded-lg text-xs font-bold border ${statusText === t('friends') || statusText === t('familyGroup')
|
<span className={`px-3 py-1 rounded-lg text-xs font-bold border ${statusText === t('friends') || statusText === t('familyGroup')
|
||||||
? 'bg-emerald-950/30 border-emerald-900/50 text-emerald-300'
|
? 'bg-emerald-950/30 border-emerald-900/50 text-emerald-300'
|
||||||
: 'bg-slate-800/80 border-slate-700 text-slate-400'
|
: 'bg-slate-800/80 border-slate-700 text-slate-400'
|
||||||
}`}>
|
}`}>
|
||||||
{statusText}
|
{statusText}
|
||||||
</span>
|
</span>
|
||||||
@@ -1842,8 +1860,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
key={conn.id}
|
key={conn.id}
|
||||||
onClick={() => handleSelectChatUser(connUser)}
|
onClick={() => handleSelectChatUser(connUser)}
|
||||||
className={`w-full p-3 rounded-xl flex items-center gap-3 transition-all text-left ${isActive
|
className={`w-full p-3 rounded-xl flex items-center gap-3 transition-all text-left ${isActive
|
||||||
? 'bg-indigo-650 text-white shadow-md'
|
? 'bg-indigo-650 text-white shadow-md'
|
||||||
: 'text-slate-350 hover:bg-slate-850/40 hover:text-slate-100'
|
: 'text-slate-350 hover:bg-slate-850/40 hover:text-slate-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{connUser.avatar ? (
|
{connUser.avatar ? (
|
||||||
@@ -1929,8 +1947,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
className={`flex flex-col max-w-[70%] ${isMe ? 'self-end items-end' : 'self-start items-start'}`}
|
className={`flex flex-col max-w-[70%] ${isMe ? 'self-end items-end' : 'self-start items-start'}`}
|
||||||
>
|
>
|
||||||
<div className={`p-3.5 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${isMe
|
<div className={`p-3.5 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${isMe
|
||||||
? 'bg-indigo-650 text-white rounded-br-none shadow-md shadow-indigo-950/20'
|
? 'bg-indigo-650 text-white rounded-br-none shadow-md shadow-indigo-950/20'
|
||||||
: 'bg-slate-800 text-slate-200 rounded-bl-none border border-slate-700/60'
|
: 'bg-slate-800 text-slate-200 rounded-bl-none border border-slate-700/60'
|
||||||
}`}>
|
}`}>
|
||||||
{msg.attachmentUrl && (
|
{msg.attachmentUrl && (
|
||||||
<div className="relative rounded-lg overflow-hidden border border-black/10 max-w-xs group/img">
|
<div className="relative rounded-lg overflow-hidden border border-black/10 max-w-xs group/img">
|
||||||
@@ -1956,8 +1974,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${isMe
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${isMe
|
||||||
? 'bg-indigo-750 border-indigo-750/30 text-indigo-100 hover:bg-indigo-800'
|
? 'bg-indigo-750 border-indigo-750/30 text-indigo-100 hover:bg-indigo-800'
|
||||||
: 'bg-slate-900/60 border-slate-800/80 text-slate-200 hover:bg-slate-900'
|
: 'bg-slate-900/60 border-slate-800/80 text-slate-200 hover:bg-slate-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
|
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -1120,6 +1120,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1137,6 +1138,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1154,6 +1156,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1171,6 +1174,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1188,6 +1192,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1205,6 +1210,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1222,6 +1228,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1239,6 +1246,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1256,6 +1264,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1273,6 +1282,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1290,6 +1300,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1307,6 +1318,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1324,6 +1336,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1341,6 +1354,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1358,6 +1372,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1375,6 +1390,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1392,6 +1408,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1409,6 +1426,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1426,6 +1444,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1443,6 +1462,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1460,6 +1480,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1477,6 +1498,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1494,6 +1516,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1511,6 +1534,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1528,6 +1552,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1545,6 +1570,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7035,6 +7061,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build --workspace=backend && npm run build --workspace=frontend",
|
"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:backend": "npm run start:dev --workspace=backend",
|
||||||
"start:frontend": "npm run start:dev --workspace=frontend",
|
"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\"",
|
"start:dev": "concurrently -n \"BACKEND,FRONTEND\" -c \"magenta,cyan\" \"npm run start:dev --workspace=backend\" \"npm run dev --workspace=frontend\"",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||