fix: gộp các nút của thành viên và guest vào một
@@ -0,0 +1,226 @@
|
||||
# To AI Agent: Consolidate Map Top-Bar into Unified Profile/Guest Dropdown Menu and Create Profile Modals
|
||||
|
||||
## 1. Context & Architectural Refactor Goal
|
||||
We are optimizing the overcrowded top-bar control deck on the main Map Explorer page (`image_55397c.png`). Currently, a long row of utility buttons compromises mobile readability and viewport real estate.
|
||||
|
||||
**Objective:** Consolidate all secondary control buttons into a single **Avatar/Guest Profile Button**.
|
||||
- When clicked, it will open an adaptive dropdown action menu (or a smooth mobile bottom-sheet) customized dynamically based on the session authentication state (**Authenticated Member** vs. **Anonymous Guest**).
|
||||
- Migrate the global "Language" and "Theme/Interface" buttons from the old top-bar directly into this new profile configuration lifecycle.
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication State Menus Specification
|
||||
|
||||
### 2.1. State A: Authenticated Member Dropdown (User is logged in)
|
||||
Display the user's fetched avatar image (`user.avatarUrl`). Clicking it reveals these options:
|
||||
1. **Bảng điều khiển (Dashboard):** [Icon: LayoutDashboard] Redirects to user command center. *Note: Global language/theme quick-toggles are moved inside here/settings.*
|
||||
2. **Cài đặt cá nhân (Settings):** [Icon: Settings] Opens the comprehensive **`ProfileSettingsModal.tsx`**.
|
||||
3. **Tạo tour (Create Tour):** [Icon: Compass / MapPin] Launches the step-by-step route wizard.
|
||||
4. **Báo cáo vi phạm (Report):** [Icon: ShieldAlert] Opens an infraction query form.
|
||||
--- (Visual Divider Line) ---
|
||||
5. **Đăng xuất (Logout):** [Icon: LogOut] Standard session termination trigger.
|
||||
|
||||
### 2.2. State B: Anonymous Guest Dropdown (User is not logged in)
|
||||
Display a default vector Guest Icon. Clicking it reveals these options:
|
||||
1. **Cài đặt (Settings):** [Icon: Sliders] Opens a lightweight configuration block allowing the Guest to dynamically choose **Ngôn ngữ** (Language) and **Giao diện** (Light/Dark Theme) stored in local storage cache.
|
||||
--- (Visual Divider Line) ---
|
||||
2. **Đăng ký / Đăng nhập (Auth):** [Icon: LogIn] Spawns the central authentication entry shield modal.
|
||||
|
||||
---
|
||||
|
||||
## 3. Component Blueprints & Implementation Steps
|
||||
|
||||
### Step 1: Create the Unified Top-Bar Controller (`MapProfileDropdown.tsx`)
|
||||
Deploy this highly accessible responsive menu container. On screen dimensions mimicking mobile/Android viewports, it is highly recommended to render this as a slick **Bottom-Sheet Overlay** for better thumb-touch accuracy.
|
||||
|
||||
```typescript
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext'; // Leverage existing state system
|
||||
import { LayoutDashboard, Settings, Compass, ShieldAlert, LogOut, LogIn, Sliders } from 'lucide-react';
|
||||
|
||||
export const MapProfileDropdown: React.FC<{ onOpenSettings: () => void }> = ({ onOpenSettings }) => {
|
||||
const { isAuthenticated, user, logout, loginRedirect } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block text-left select-none">
|
||||
{/* TRIGGER BUTTON */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-11 h-11 rounded-full border-2 border-white bg-slate-800 flex items-center justify-center overflow-hidden shadow-lg active:scale-95 transition-transform"
|
||||
>
|
||||
{isAuthenticated && user?.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
/* Default Guest Avatar Icon placeholder */
|
||||
<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 && (
|
||||
<>
|
||||
{/* Backdrop screen closer click-catcher */}
|
||||
<div className="fixed inset-0 z-40 bg-black/20 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-50 w-full sm:w-64 bg-slate-900 border-t sm:border border-slate-800 rounded-t-2xl sm:rounded-xl p-2 shadow-2xl animate-slide-up sm:animate-fade-in text-xs text-slate-200">
|
||||
{isAuthenticated ? (
|
||||
/* --- LOGGED IN MENU OPTION ROW STACK --- */
|
||||
<div className="flex flex-col space-y-1">
|
||||
<button className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-lg text-left">
|
||||
<LayoutDashboard className="w-4 h-4 text-blue-400" /> Bảng điều khiển
|
||||
</button>
|
||||
<button onClick={() => { setIsOpen(false); onOpenSettings(); }} className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-lg text-left">
|
||||
<Settings className="w-4 h-4 text-emerald-400" /> Cài đặt cá nhân
|
||||
</button>
|
||||
<button className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-lg text-left">
|
||||
<Compass className="w-4 h-4 text-amber-400" /> Tạo tour
|
||||
</button>
|
||||
<button className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-lg text-left">
|
||||
<ShieldAlert className="w-4 h-4 text-rose-400" /> Báo cáo vi phạm
|
||||
</button>
|
||||
<div className="h-[1px] bg-slate-800 my-1 mx-2" />
|
||||
<button onClick={logout} className="flex items-center gap-3 w-full px-4 py-3 text-rose-400 hover:bg-slate-800 rounded-lg text-left font-bold">
|
||||
<LogOut className="w-4 h-4" /> Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* --- GUEST ANONYMOUS MENU OPTION ROW STACK --- */
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="px-4 py-2 text-[11px] font-bold uppercase tracking-wider text-slate-500">Tùy chỉnh nhanh</div>
|
||||
{/* Guest Quick Config Modules */}
|
||||
<div className="px-4 py-2 flex flex-col gap-2 bg-slate-950/40 rounded-lg m-1">
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span>Ngôn ngữ:</span>
|
||||
<select className="bg-slate-800 border border-slate-700 rounded px-1.5 py-0.5 text-white text-[11px]">
|
||||
<option value="vi">Tiếng Việt</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span>Giao diện:</span>
|
||||
<select className="bg-slate-800 border border-slate-700 rounded px-1.5 py-0.5 text-white text-[11px]">
|
||||
<option value="dark">Tối</option>
|
||||
<option value="light">Sáng</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[1px] bg-slate-800 my-1 mx-2" />
|
||||
<button onClick={loginRedirect} className="flex items-center gap-3 w-full px-4 py-3 text-blue-400 hover:bg-slate-800 rounded-lg text-left font-bold">
|
||||
<LogIn className="w-4 h-4" /> Đăng ký / Đăng nhập
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
### Step 2: Implement the Main Profile Modification Panel (ProfileSettingsModal.tsx)
|
||||
Create this secure responsive form layer. It must conform to absolute constraint logic (such as locking email changes) and include select toggles for app themes.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { X, Camera } from 'lucide-react';
|
||||
|
||||
export const ProfileSettingsModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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-2xl sm:rounded-xl flex flex-col overflow-hidden text-slate-200 text-xs">
|
||||
|
||||
{/* Header bar */}
|
||||
<div className="px-4 py-3.5 border-b border-slate-800 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 bg-slate-800 hover:bg-slate-700 rounded-lg"><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-800 group">
|
||||
<img src="/placeholder-avatar.png" alt="Profile View" className="w-full h-full object-cover" />
|
||||
<label className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 cursor-pointer transition-opacity">
|
||||
<Camera className="w-5 h-5 text-white" />
|
||||
<input type="file" accept="image/*" className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500">Chạm ảnh để tải lên 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" className="bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-white focus:border-blue-500 outline-none" defaultValue="Loc Pham" />
|
||||
</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" className="bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-white focus:border-blue-500 outline-none" />
|
||||
</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" className="bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-white focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
|
||||
{/* EMAIL COMPONENT BOUNDARY - CRITICAL REQUIREMENT: DISABLED CHANGING */}
|
||||
<div className="flex flex-col gap-1.5 bg-slate-950/30 p-3 rounded-lg border border-slate-800/60">
|
||||
<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-800 text-slate-500 rounded-lg p-2.5 cursor-not-allowed select-none" defaultValue="yeunhiepanh.photo@gmail.com" />
|
||||
</div>
|
||||
|
||||
{/* Security Password Mutation Stack */}
|
||||
<div className="border-t border-slate-800 pt-4 flex flex-col gap-3">
|
||||
<span className="font-bold text-slate-300">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" className="bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-white" />
|
||||
<input type="password" placeholder="Mật khẩu mới" className="bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Configuration Toggles (Moved from Top-Bar into profile settings context) */}
|
||||
<div className="border-t border-slate-800 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 className="bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-white">
|
||||
<option value="vi">Tiếng Việt (ICT)</option>
|
||||
<option value="en">English (US)</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 className="bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-white">
|
||||
<option value="dark">Chế độ tối (Dark Mode)</option>
|
||||
<option value="light">Chế độ sáng (Light Mode)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Action Bottom Save Trigger */}
|
||||
<div className="p-4 bg-slate-950 border-t border-slate-800 flex justify-end gap-3 shrink-0">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-slate-300">Hủy</button>
|
||||
<button className="px-5 py-2 bg-blue-600 hover:bg-blue-500 font-bold text-white rounded-lg">Lưu cấu hình</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
## 4. Quality Verification & Acceptance Checklist for AI Agent
|
||||
|
||||
[ ] Top-Bar Streamlining: Verify that old utility layout buttons boxed inside the top-bar (image_55397c.png) are safely stripped out, leaving only the Back button, Title, and the new single Dropdown icon asset.
|
||||
|
||||
[ ] Cross-Platform Android Touch Targets: On mobile browser layouts, clicking the avatar must expand a screen-bottom popup block container (fixed bottom-0 left-0 right-0) ensuring an native-app tactile interaction.
|
||||
|
||||
[ ] Email Immutability Guard: Inspect the editing form inside ProfileSettingsModal.tsx. Confirm the input node for the user's profile email address possess the disabled state attribute flag so typing alterations are completely locked out.
|
||||
|
||||
[ ] System State Isolation: Confirm changes inside language and theme inputs successfully bubble triggers upwards to refresh local storage parameters instantly without dropping routing view connections.
|
||||
@@ -154,6 +154,17 @@ async function bootstrap() {
|
||||
app.useStaticAssets(UPLOAD_ROOT, {
|
||||
prefix: '/uploads/',
|
||||
});
|
||||
const downloadsDir = path.join(process.cwd(), 'public/downloads');
|
||||
if (!fs.existsSync(downloadsDir)) {
|
||||
fs.mkdirSync(downloadsDir, { recursive: true });
|
||||
}
|
||||
app.useStaticAssets(downloadsDir, {
|
||||
prefix: '/downloads/',
|
||||
setHeaders: (res) => {
|
||||
res.set('Content-Type', 'application/vnd.android.package-archive');
|
||||
res.set('Content-Disposition', 'attachment; filename="yotrip-latest.apk"');
|
||||
}
|
||||
});
|
||||
const prisma = app.get(prisma_service_1.PrismaService);
|
||||
startAutoCleanup(prisma);
|
||||
await app.listen(3001);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Dummy APK content
|
||||
@@ -114,6 +114,21 @@ async function bootstrap() {
|
||||
prefix: '/uploads/',
|
||||
});
|
||||
|
||||
// Tự động tạo thư mục downloads nếu chưa tồn tại
|
||||
const downloadsDir = path.join(process.cwd(), 'public/downloads');
|
||||
if (!fs.existsSync(downloadsDir)) {
|
||||
fs.mkdirSync(downloadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Khai báo thư mục lưu trữ APK tải về
|
||||
app.useStaticAssets(downloadsDir, {
|
||||
prefix: '/downloads/',
|
||||
setHeaders: (res) => {
|
||||
res.set('Content-Type', 'application/vnd.android.package-archive');
|
||||
res.set('Content-Disposition', 'attachment; filename="yotrip-latest.apk"');
|
||||
}
|
||||
});
|
||||
|
||||
const prisma = app.get(PrismaService);
|
||||
startAutoCleanup(prisma);
|
||||
|
||||
@@ -2463,7 +2478,7 @@ class UserController {
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true }
|
||||
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, avatar: true, phone: true, address: true }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
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
|
||||
ARG VITE_GOOGLE_CLIENT_ID
|
||||
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
||||
RUN npm install
|
||||
RUN npm install --legacy-peer-deps
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<title>Travel Planner</title>
|
||||
|
||||
<!-- Open Graph Meta Tags for Social Media Sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<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: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:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<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:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
||||
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<script type="module" crossorigin src="/assets/index-BximWk33.js"></script>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<title>Travel Planner</title>
|
||||
|
||||
<!-- Open Graph Meta Tags for Social Media Sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<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: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:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<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:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
||||
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||
<script type="module" crossorigin src="/assets/index-ClmdPYvF.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-Cyuzqnbw.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-others-VIU5qAPG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-DOXkNaRS.js">
|
||||
@@ -30,9 +30,9 @@
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-pdf-IjT8x9OX.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/vendor-maps-CcXJxNtP.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/vendor-react-CuSj0z1j.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-21UejyBs.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BJ1aFth-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,6 +12,15 @@ server {
|
||||
add_header Cache-Control "public, max-age=31536000";
|
||||
}
|
||||
|
||||
# Proxy downloaded APK files from backend
|
||||
location /downloads/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# JavaScript and CSS files - immutable caching
|
||||
location ~* \.(?:js|css)$ {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TourNavigationPage } from './pages/TourNavigationPage';
|
||||
import { ConfirmProvider } from './hooks/useConfirm';
|
||||
import { NotificationProvider, useNotification } from './hooks/useNotification';
|
||||
import { io } from 'socket.io-client';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
interface GlobalNotificationListenerProps {
|
||||
user: any;
|
||||
@@ -33,7 +34,9 @@ const GlobalNotificationListener: React.FC<GlobalNotificationListenerProps> = ({
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const socketInstance = io();
|
||||
const socketInstance = Capacitor.isNativePlatform()
|
||||
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
|
||||
: io();
|
||||
|
||||
socketInstance.on('connect', () => {
|
||||
console.log('[WS] Global notification socket connected:', socketInstance.id);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useState } from 'react';
|
||||
import { LayoutDashboard, Settings, Compass, ShieldAlert, LogOut, LogIn, Globe, Sun, Moon, Image as ImageIcon } from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
interface MapProfileDropdownProps {
|
||||
user: any;
|
||||
onLogout?: () => void;
|
||||
onGoToDashboard?: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenCreateTour: () => void;
|
||||
onOpenReport: () => void;
|
||||
onOpenLogin: () => void;
|
||||
onOpenMyPhotos?: () => void;
|
||||
onOpenAdmin?: () => void;
|
||||
}
|
||||
|
||||
export const MapProfileDropdown: React.FC<MapProfileDropdownProps> = ({
|
||||
user,
|
||||
onLogout,
|
||||
onGoToDashboard,
|
||||
onOpenSettings,
|
||||
onOpenCreateTour,
|
||||
onOpenReport,
|
||||
onOpenLogin,
|
||||
onOpenMyPhotos,
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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-40 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-50 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">
|
||||
<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>
|
||||
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
onClick={() => { setIsOpen(false); onOpenAdmin?.(); }}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 bg-blue-950/20 hover:bg-slate-805 rounded-xl text-left transition-colors font-bold text-blue-400 cursor-pointer"
|
||||
>
|
||||
<Settings className="w-4 h-4 shrink-0 text-blue-400" /> Quản trị hệ thống
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => { setIsOpen(false); onGoToDashboard?.(); }}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4 text-blue-400 shrink-0" /> Bảng điều khiển
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setIsOpen(false); onOpenSettings(); }}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
|
||||
>
|
||||
<Settings className="w-4 h-4 text-emerald-400 shrink-0" /> Cài đặt cá nhân
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setIsOpen(false); onOpenCreateTour(); }}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
|
||||
>
|
||||
<Compass className="w-4 h-4 text-amber-400 shrink-0" /> Tạo Tour mới
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setIsOpen(false); onOpenMyPhotos?.(); }}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4 text-sky-400 shrink-0" /> Ảnh của tôi
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setIsOpen(false); onOpenReport(); }}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" /> Báo cáo sai phạm
|
||||
</button>
|
||||
|
||||
<div className="h-[1px] bg-slate-855 my-1 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={() => { setIsOpen(false); onLogout?.(); }}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 text-rose-400 hover:bg-slate-800 rounded-xl text-left font-black transition-colors cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0" /> Đă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>
|
||||
|
||||
<button
|
||||
onClick={() => { setIsOpen(false); onOpenMyPhotos?.(); }}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-xl text-left transition-colors font-bold cursor-pointer"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4 text-sky-400 shrink-0" /> Ảnh của tôi
|
||||
</button>
|
||||
|
||||
<div className="h-[1px] bg-slate-850 my-1 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={() => { setIsOpen(false); 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,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>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,21 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, useMap, useMapEvents, Tooltip } from 'react-leaflet';
|
||||
import { MapContainer, TileLayer, Marker, useMapEvents, Tooltip } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft, Lock, Globe, Sun, Moon, Laptop, Users, ShieldAlert, Star } from 'lucide-react';
|
||||
import { X, Navigation, Image as ImageIcon, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft, Users, ShieldAlert, Star } from 'lucide-react';
|
||||
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||
import { ReportBusinessModal } from '../components/ReportBusinessModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { CreateTourModal } from '../components/CreateTourModal';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { PublicPhotoModal } from '../components/PublicPhotoModal';
|
||||
import { MapProfileDropdown } from '@/components/MapProfileDropdown';
|
||||
import { ProfileSettingsModal } from '@/components/ProfileSettingsModal';
|
||||
import { LoginModal } from '@/components/LoginModal';
|
||||
|
||||
// Fix lỗi icon mặc định của Leaflet
|
||||
const DefaultIcon = L.icon({
|
||||
@@ -68,10 +70,6 @@ function MapTracker() {
|
||||
}
|
||||
|
||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos, onLoginSuccess, onGoToDashboard }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => void, onGoToDashboard?: () => void }) => {
|
||||
// Check if user is logged in (real user) or is a guest
|
||||
const guestToken = localStorage.getItem('guest_token');
|
||||
const isLoggedInOrGuest = user || guestToken;
|
||||
|
||||
// Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa
|
||||
const publicTours = useTourStore(state => state.publicTours);
|
||||
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||
@@ -80,36 +78,8 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
|
||||
const notify = useNotification();
|
||||
const confirm = useConfirm();
|
||||
const { t, lang, changeLanguage } = useTranslation();
|
||||
const { theme, changeTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handlePromoteAdmin = async (secretKey: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/promote-admin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ secretKey })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok && data.success) {
|
||||
const updatedUser = { ...user, isAdmin: true };
|
||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(updatedUser);
|
||||
}
|
||||
notify({ title: t('success'), message: 'Đã kích hoạt quyền quản trị thành công!', type: 'success' });
|
||||
setIsAdminModalOpen(true);
|
||||
} else {
|
||||
notify({ title: t('error'), message: data.message || t('invalidSecretKey'), type: 'error' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
notify({ title: t('error'), message: 'Lỗi mạng khi kích hoạt Admin.', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// Refs for mobile long-press detection
|
||||
const touchTimerRef = React.useRef<any>(null);
|
||||
@@ -252,12 +222,13 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
};
|
||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isProfileSettingsOpen, setIsProfileSettingsOpen] = useState(false);
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||||
const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false);
|
||||
const [blacklist, setBlacklist] = useState<any[]>([]);
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false);
|
||||
const storeMapCenter = useTourStore(state => state.mapCenter);
|
||||
|
||||
// Recommendations and GPS States
|
||||
const [recommendedLocations, setRecommendedLocations] = useState<any[]>([]);
|
||||
@@ -799,124 +770,19 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nhóm bên phải: Các thao tác người dùng */}
|
||||
{/* Nhóm bên phải: Menu cá nhân hợp nhất */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto">
|
||||
{/* Nút định vị người dùng */}
|
||||
<button
|
||||
onClick={requestGpsPosition}
|
||||
className="w-11 h-11 bg-[var(--surface)] rounded-full shadow-xl hover:bg-blue-100 text-blue-600 transition-all flex items-center justify-center border border-[var(--border)] shrink-0"
|
||||
title="Vị trí của tôi"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Nút Ảnh của tôi */}
|
||||
{isLoggedInOrGuest && (
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log("Đang mở Ảnh của tôi...");
|
||||
onOpenMyPhotos();
|
||||
}}
|
||||
className="w-11 h-11 md:w-auto bg-[var(--surface)] p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-100 text-blue-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold border border-blue-100 shrink-0"
|
||||
title="Ảnh của tôi"
|
||||
>
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Ảnh của tôi</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút tạo Tour mới */}
|
||||
{user && !localStorage.getItem('guest_token') && (
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="w-11 h-11 md:w-auto bg-green-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
|
||||
title="Tạo Tour mới"
|
||||
>
|
||||
<Navigation className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Tạo Tour</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút Báo cáo sai phạm */}
|
||||
<button
|
||||
onClick={() => setIsReportModalOpen(true)}
|
||||
className="w-11 h-11 md:w-auto bg-red-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 cursor-pointer"
|
||||
title={t('reportBusinessBtn') || 'Báo cáo sai phạm'}
|
||||
>
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">{t('reportBusinessBtn') || 'Báo cáo'}</span>
|
||||
</button>
|
||||
|
||||
{/* Lựa chọn Ngôn ngữ */}
|
||||
<div className="relative group shrink-0">
|
||||
<button className="w-11 h-11 bg-[var(--surface)] dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-[var(--background)] dark:hover:bg-slate-700 text-[var(--text-secondary)] dark:text-slate-200 transition-all flex items-center justify-center border border-[var(--border)] dark:border-slate-700">
|
||||
<Globe className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-[var(--surface)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
|
||||
<button onClick={() => changeLanguage('vi')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'vi' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>Tiếng Việt</button>
|
||||
<button onClick={() => changeLanguage('en')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'en' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>English</button>
|
||||
<button onClick={() => changeLanguage('zh')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'zh' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>中文</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lựa chọn Giao diện */}
|
||||
<div className="relative group shrink-0">
|
||||
<button className="w-11 h-11 bg-[var(--surface)] dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-[var(--background)] dark:hover:bg-slate-700 text-[var(--text-secondary)] dark:text-slate-200 transition-all flex items-center justify-center border border-[var(--border)] dark:border-slate-700">
|
||||
{theme === 'light' && <Sun className="w-5 h-5 text-amber-500" />}
|
||||
{theme === 'dark' && <Moon className="w-5 h-5 text-indigo-400" />}
|
||||
{theme === 'system' && <Laptop className="w-5 h-5 animate-pulse" />}
|
||||
</button>
|
||||
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-[var(--surface)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
|
||||
<button onClick={() => changeTheme('light')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'light' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
|
||||
<Sun className="w-3.5 h-3.5 text-amber-500" /> {t('themeLight')}
|
||||
</button>
|
||||
<button onClick={() => changeTheme('dark')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'dark' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
|
||||
<Moon className="w-3.5 h-3.5 text-indigo-400" /> {t('themeDark')}
|
||||
</button>
|
||||
<button onClick={() => changeTheme('system')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'system' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
|
||||
<Laptop className="w-3.5 h-3.5" /> {t('themeSystem')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nút quản lý người dùng cho Admin */}
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsAdminModalOpen(true)}
|
||||
className="w-11 h-11 md:w-auto p-0 md:px-4 md:py-3 rounded-2xl shadow-xl transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
title={t('systemBtn')}
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">{t('systemBtn')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút Bảng điều khiển của tôi - chỉ hiển thị cho người dùng đã đăng nhập (không phải khách) */}
|
||||
{user && !localStorage.getItem('guest_token') && onGoToDashboard && (
|
||||
<button
|
||||
onClick={onGoToDashboard}
|
||||
className="w-11 h-11 md:w-auto p-0 md:px-4 md:py-3 rounded-2xl shadow-xl transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
title="Bảng điều khiển của tôi"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Bảng điều khiển của tôi</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút đăng xuất */}
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-11 h-11 md:w-auto bg-[var(--surface)] p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold text-[var(--text-secondary)] border border-[var(--border)] shrink-0"
|
||||
title="Đăng xuất"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Rời đi</span>
|
||||
</button>
|
||||
)}
|
||||
<MapProfileDropdown
|
||||
user={user}
|
||||
onLogout={onLogout}
|
||||
onGoToDashboard={onGoToDashboard}
|
||||
onOpenSettings={() => setIsProfileSettingsOpen(true)}
|
||||
onOpenCreateTour={() => setIsCreateModalOpen(true)}
|
||||
onOpenReport={() => setIsReportModalOpen(true)}
|
||||
onOpenLogin={() => setIsLoginModalOpen(true)}
|
||||
onOpenMyPhotos={onOpenMyPhotos}
|
||||
onOpenAdmin={() => setIsAdminModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1679,6 +1545,38 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
initialLatitude={mapCenter ? mapCenter[0] : undefined}
|
||||
initialLongitude={mapCenter ? mapCenter[1] : undefined}
|
||||
/>
|
||||
|
||||
{/* Profile Settings Modal */}
|
||||
<ProfileSettingsModal
|
||||
isOpen={isProfileSettingsOpen}
|
||||
onClose={() => setIsProfileSettingsOpen(false)}
|
||||
user={user}
|
||||
onSaveSuccess={onLoginSuccess}
|
||||
/>
|
||||
|
||||
{/* Login Modal for Guest Auth promotion */}
|
||||
<LoginModal
|
||||
isOpen={isLoginModalOpen}
|
||||
onClose={() => setIsLoginModalOpen(false)}
|
||||
onLoginSuccess={(loggedInUser) => {
|
||||
setIsLoginModalOpen(false);
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(loggedInUser);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
@@ -447,6 +447,19 @@ return (
|
||||
<option value="system" className="text-black">{t('themeSystem') || 'Hệ thống'}</option>
|
||||
</select>
|
||||
|
||||
{/* Android APK Download Button */}
|
||||
<a
|
||||
href={`${import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn'}/downloads/yotrip-latest.apk`}
|
||||
download="yotrip.apk"
|
||||
className="flex items-center justify-center gap-1.5 bg-green-600/80 backdrop-blur-md text-white font-bold py-1.5 px-2.5 sm:py-2 sm:px-4 rounded-full border border-green-500/30 hover:bg-green-500 transition-all shadow-md active:scale-95 text-xs sm:text-sm cursor-pointer shrink-0"
|
||||
title="Tải ứng dụng Android (.APK)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5 sm:w-4 sm:h-4" 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 className="hidden sm:inline">Tải bản Android (.APK)</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
onClick={() => setIsReportModalOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 bg-red-600/80 backdrop-blur-md text-white font-bold py-1.5 px-2.5 sm:py-2 sm:px-3.5 rounded-full border border-red-500/30 hover:bg-red-500 transition-all shadow-md active:scale-95 text-xs sm:text-sm cursor-pointer"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import {
|
||||
Compass,
|
||||
Users,
|
||||
@@ -528,18 +526,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
|
||||
// Connect to WebSocket using same origin/proxy
|
||||
const socket = Capacitor.isNativePlatform()
|
||||
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
|
||||
: io();
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[WS] MemberDashboard connected:', socket.id);
|
||||
socket.emit('joinUser', user.id);
|
||||
});
|
||||
|
||||
socket.on('messageReceived', (message: any) => {
|
||||
const handleMessageReceived = (e: Event) => {
|
||||
const message = (e as CustomEvent).detail;
|
||||
// If we are actively chatting with the sender of this message
|
||||
if (activeChatUser && (message.senderId === activeChatUser.id || message.receiverId === activeChatUser.id)) {
|
||||
setChatMessages(prev => [...prev, message]);
|
||||
@@ -888,8 +876,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
|
||||
? '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-slate-900 border-slate-800 text-slate-400 hover:text-slate-200'
|
||||
: '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"}
|
||||
>
|
||||
@@ -940,7 +928,7 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Explore Map Quick Button */}
|
||||
<div className="p-4 border-b border-slate-900">
|
||||
<div className="p-4 border-b border-slate-900 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={onExploreTours}
|
||||
className="w-full py-4 px-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-2xl font-bold flex items-center justify-center gap-2 shadow-lg active:scale-98 transition-all"
|
||||
@@ -948,6 +936,17 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<Compass className="w-5 h-5 animate-spin-slow" />
|
||||
{t('exploreTourMap')}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={`${import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn'}/downloads/yotrip-latest.apk`}
|
||||
download="yotrip.apk"
|
||||
className="w-full py-3 px-4 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-2xl font-bold flex items-center justify-center gap-2 shadow-md active:scale-95 transition-all text-xs text-center"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Tải ứng dụng Android (APK)</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Menu Items List */}
|
||||
@@ -955,8 +954,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<button
|
||||
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'
|
||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||
: 'bg-slate-850/80 border-slate-700/80'
|
||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||
: 'bg-slate-850/80 border-slate-700/80'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -982,8 +981,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<button
|
||||
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'
|
||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||
: 'bg-slate-850/80 border-slate-700/80'
|
||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||
: 'bg-slate-850/80 border-slate-700/80'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1007,8 +1006,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<button
|
||||
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'
|
||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||
: 'bg-slate-900/60 border-slate-800/60'
|
||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||
: 'bg-slate-900/60 border-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1034,8 +1033,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<button
|
||||
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'
|
||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||
: 'bg-slate-900/60 border-slate-800/60'
|
||||
? 'bg-indigo-900/40 border-indigo-500/40'
|
||||
: 'bg-slate-900/60 border-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1083,8 +1082,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={`w-full relative z-10 flex overflow-hidden transition-all duration-350 ${isMobile
|
||||
? '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'
|
||||
? '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'
|
||||
}`}>
|
||||
|
||||
{/* Mobile Detail Header: Only on Mobile detail mode */}
|
||||
@@ -1139,8 +1138,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
|
||||
? '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-slate-900 border-slate-800 text-slate-400 hover:text-slate-200'
|
||||
: '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"}
|
||||
>
|
||||
@@ -1197,6 +1196,17 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<Compass className="w-4 h-4 animate-spin-slow" />
|
||||
Khám phá Bản đồ Tour
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={`${import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn'}/downloads/yotrip-latest.apk`}
|
||||
download="yotrip.apk"
|
||||
className="w-full py-2.5 px-4 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-xl font-bold flex items-center justify-center gap-2 shadow-md active:scale-95 transition-all duration-150 text-xs"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Tải ứng dụng Android (APK)</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Vertical Tabs */}
|
||||
@@ -1204,8 +1214,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<button
|
||||
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'
|
||||
? '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'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1223,8 +1233,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<button
|
||||
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'
|
||||
? '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'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1242,8 +1252,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<button
|
||||
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'
|
||||
? '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'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1260,8 +1270,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<button
|
||||
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'
|
||||
? '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'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1307,8 +1317,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
|
||||
{/* Tab content renders here */}
|
||||
<div className={`flex-1 relative z-10 flex flex-col ${activeTab === 'chats' || activeTab === 'photos'
|
||||
? 'overflow-hidden p-0'
|
||||
: 'p-4 md:p-8 overflow-y-auto'
|
||||
? 'overflow-hidden p-0'
|
||||
: 'p-4 md:p-8 overflow-y-auto'
|
||||
}`}>
|
||||
|
||||
{/* TAB 1: MY TOURS */}
|
||||
@@ -1502,8 +1512,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<button
|
||||
onClick={() => setConnectionSubTab('list')}
|
||||
className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all ${connectionSubTab === 'list'
|
||||
? 'bg-indigo-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
? 'bg-indigo-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Danh sách kết nối
|
||||
@@ -1511,8 +1521,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<button
|
||||
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'
|
||||
? 'bg-indigo-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
? 'bg-indigo-600 text-white shadow-md'
|
||||
: '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
|
||||
@@ -1520,8 +1530,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<button
|
||||
onClick={() => setConnectionSubTab('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'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
? 'bg-indigo-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Yêu cầu chờ duyệt
|
||||
@@ -1572,8 +1582,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<p className="text-[10px] text-slate-400 truncate">{connUser.email}</p>
|
||||
<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'
|
||||
? 'bg-rose-950/40 text-rose-300 border-rose-900/50'
|
||||
: 'bg-indigo-950/40 text-indigo-300 border-indigo-900/50'
|
||||
? 'bg-rose-950/40 text-rose-300 border-rose-900/50'
|
||||
: 'bg-indigo-950/40 text-indigo-300 border-indigo-900/50'
|
||||
}`}>
|
||||
{conn.type === 'FAMILY' ? t('familyGroup') : t('friends')}
|
||||
</span>
|
||||
@@ -1679,8 +1689,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
<div>
|
||||
{statusText ? (
|
||||
<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-slate-800/80 border-slate-700 text-slate-400'
|
||||
? 'bg-emerald-950/30 border-emerald-900/50 text-emerald-300'
|
||||
: 'bg-slate-800/80 border-slate-700 text-slate-400'
|
||||
}`}>
|
||||
{statusText}
|
||||
</span>
|
||||
@@ -1842,8 +1852,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
key={conn.id}
|
||||
onClick={() => handleSelectChatUser(connUser)}
|
||||
className={`w-full p-3 rounded-xl flex items-center gap-3 transition-all text-left ${isActive
|
||||
? 'bg-indigo-650 text-white shadow-md'
|
||||
: 'text-slate-350 hover:bg-slate-850/40 hover:text-slate-100'
|
||||
? 'bg-indigo-650 text-white shadow-md'
|
||||
: 'text-slate-350 hover:bg-slate-850/40 hover:text-slate-100'
|
||||
}`}
|
||||
>
|
||||
{connUser.avatar ? (
|
||||
@@ -1929,8 +1939,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
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
|
||||
? '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-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'
|
||||
}`}>
|
||||
{msg.attachmentUrl && (
|
||||
<div className="relative rounded-lg overflow-hidden border border-black/10 max-w-xs group/img">
|
||||
@@ -1956,8 +1966,8 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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-slate-900/60 border-slate-800/80 text-slate-200 hover:bg-slate-900'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -112,7 +112,7 @@
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
||||
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -126,14 +126,14 @@
|
||||
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
||||
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"backend/node_modules/@prisma/fetch-engine": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
||||
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "5.22.0",
|
||||
@@ -145,7 +145,7 @@
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
||||
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "5.22.0"
|
||||
@@ -174,7 +174,7 @@
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -4055,7 +4055,7 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/quill": {
|
||||
@@ -4078,7 +4078,7 @@
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -5929,7 +5929,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dargs": {
|
||||
@@ -7035,6 +7035,7 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspace=backend && npm run build --workspace=frontend",
|
||||
"build:android": "cd frontend/android && ./gradlew assembleRelease && cd ../.. && sh scripts/deploy-apk.sh",
|
||||
"start:backend": "npm run start:dev --workspace=backend",
|
||||
"start:frontend": "npm run start:dev --workspace=frontend",
|
||||
"start:dev": "concurrently -n \"BACKEND,FRONTEND\" -c \"magenta,cyan\" \"npm run start:dev --workspace=backend\" \"npm run dev --workspace=frontend\"",
|
||||
|
||||
@@ -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.
|
||||