fix: gộp các nút của thành viên và guest vào một

This commit is contained in:
2026-06-27 15:37:00 +07:00
parent 5f49070a98
commit 400a3d098d
33 changed files with 1142 additions and 250 deletions
+226
View File
@@ -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.
+11
View File
@@ -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);
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
Dummy APK content
+16 -1
View File
@@ -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);
@@ -2463,7 +2478,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 }
}); });
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 KiB

+1 -1
View File
@@ -15,7 +15,7 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
FROM base AS build 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
+2 -2
View File
@@ -21,7 +21,7 @@
<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-ClmdPYvF.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">
@@ -30,7 +30,7 @@
<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-BJ1aFth-.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+9
View File
@@ -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;
+4 -1
View File
@@ -13,6 +13,7 @@ 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 +34,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);
@@ -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 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 / Đă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ồ nhân</span>
<button
onClick={onClose}
className="p-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Scrollable Form Workspace Canvas */}
<div className="flex-1 overflow-y-auto p-5 space-y-5">
{/* Avatar Upload Container Component */}
<div className="flex flex-col items-center justify-center space-y-2">
<div className="relative w-20 h-20 rounded-full border-2 border-slate-700 overflow-hidden bg-slate-850 group shadow-lg">
{isUploading ? (
<div className="w-full h-full flex items-center justify-center bg-slate-800/85">
<Loader2 className="w-6 h-6 text-indigo-400 animate-spin" />
</div>
) : avatar ? (
<img src={avatar} alt="Profile Avatar" className="w-full h-full object-cover" />
) : (
renderInitialsAvatar(name || 'User')
)}
<label className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 cursor-pointer transition-opacity duration-200">
<Camera className="w-5 h-5 text-white" />
<span className="text-[8px] text-white/80 mt-1">Thay nh</span>
<input type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} disabled={isUploading} />
</label>
</div>
<span className="text-[10px] text-slate-500 font-semibold">Di chuột qua nh nhấp đ tải hình đi diện mới</span>
</div>
{/* Text Input Row Fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="font-bold text-slate-400">Tên hiển thị:</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all font-semibold"
placeholder="Nhập tên hiển thị"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-bold text-slate-400">Số điện thoại:</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all font-semibold"
placeholder="Nhập số điện thoại"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-bold text-slate-400">Đa chỉ liên hệ:</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all font-semibold"
placeholder="Nhập địa chỉ của bạn"
/>
</div>
{/* EMAIL COMPONENT BOUNDARY - CRITICAL REQUIREMENT: DISABLED CHANGING */}
<div className="flex flex-col gap-1.5 bg-slate-950/30 p-3.5 rounded-xl border border-slate-850">
<label className="font-bold text-slate-500">Đa chỉ Email đăng nhập (Không thể chỉnh sửa):</label>
<input
type="email"
disabled
className="bg-slate-950/50 border border-slate-850 text-slate-500 rounded-xl p-3 cursor-not-allowed select-none font-semibold"
value={user.email || ''}
/>
</div>
{/* Security Password Mutation Stack */}
<div className="border-t border-slate-800/60 pt-4 flex flex-col gap-3">
<span className="font-bold text-slate-300 text-sm">Thay đi mật khẩu đăng nhập</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<input
type="password"
placeholder="Mật khẩu hiện tại"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 outline-none transition-all font-semibold"
/>
<input
type="password"
placeholder="Mật khẩu mới"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="bg-slate-950 border border-slate-800 rounded-xl p-3 text-white focus:border-blue-500 outline-none transition-all font-semibold"
/>
</div>
</div>
{/* Core Configuration Toggles (Moved from Top-Bar into profile settings context) */}
<div className="border-t border-slate-800/60 pt-4 grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="font-bold text-slate-400">Lựa chọn ngôn ngữ:</label>
<select
value={lang}
onChange={(e) => changeLanguage(e.target.value as any)}
className="bg-slate-955 border border-slate-800 rounded-xl p-3 text-white focus:outline-none cursor-pointer font-bold"
>
<option value="vi">Tiếng Việt (ICT)</option>
<option value="en">English (US)</option>
<option value="zh"> (ZH)</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-bold text-slate-400">Lựa chọn giao diện:</label>
<select
value={theme}
onChange={(e) => changeTheme(e.target.value as any)}
className="bg-slate-955 border border-slate-800 rounded-xl p-3 text-white focus:outline-none cursor-pointer font-bold"
>
<option value="dark">Chế đ tối (Dark Mode)</option>
<option value="light">Chế đ sáng (Light Mode)</option>
<option value="system">Chế đ hệ thống (System)</option>
</select>
</div>
</div>
</div>
{/* Action Bottom Save Trigger */}
<div className="p-4 bg-slate-950 border-t border-slate-800/60 flex justify-end gap-3 shrink-0">
<button
onClick={onClose}
disabled={isSaving}
className="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 disabled:opacity-50 text-slate-300 font-bold rounded-xl transition-all active:scale-95 cursor-pointer"
>
Hủy
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 font-bold text-white rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 flex items-center gap-1.5 cursor-pointer"
>
{isSaving ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Đang lưu...
</>
) : (
<>
<Check className="w-3.5 h-3.5" />
Lưu cấu hình
</>
)}
</button>
</div>
</div>
</div>
);
};
+52 -154
View File
@@ -1,19 +1,21 @@
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';
// 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({
@@ -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 }) => { 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 // 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 +78,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 +222,13 @@ 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 [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[]>([]);
@@ -799,124 +770,19 @@ 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-2 pointer-events-auto">
{/* Nút định vị người dùng */} <MapProfileDropdown
<button user={user}
onClick={requestGpsPosition} onLogout={onLogout}
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" onGoToDashboard={onGoToDashboard}
title="Vị trí của tôi" onOpenSettings={() => setIsProfileSettingsOpen(true)}
> onOpenCreateTour={() => setIsCreateModalOpen(true)}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> onOpenReport={() => setIsReportModalOpen(true)}
<circle cx="12" cy="12" r="3" /> onOpenLogin={() => setIsLoginModalOpen(true)}
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" /> onOpenMyPhotos={onOpenMyPhotos}
</svg> onOpenAdmin={() => setIsAdminModalOpen(true)}
</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>
)}
</div> </div>
</div> </div>
@@ -1679,6 +1545,38 @@ 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);
}
}}
/>
{/* 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>
); );
}; };
+13
View File
@@ -447,6 +447,19 @@ return (
<option value="system" className="text-black">{t('themeSystem') || 'Hệ thống'}</option> <option value="system" className="text-black">{t('themeSystem') || 'Hệ thống'}</option>
</select> </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 <button
onClick={() => setIsReportModalOpen(true)} 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" 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"
+25 -15
View File
@@ -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,
@@ -528,18 +526,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]);
@@ -940,7 +928,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 +936,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 */}
@@ -1197,6 +1196,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 */}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+9 -8
View File
@@ -112,7 +112,7 @@
"version": "5.22.0", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"devOptional": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -126,14 +126,14 @@
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"devOptional": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"backend/node_modules/@prisma/fetch-engine": { "backend/node_modules/@prisma/fetch-engine": {
"version": "5.22.0", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "5.22.0", "@prisma/debug": "5.22.0",
@@ -145,7 +145,7 @@
"version": "5.22.0", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "5.22.0" "@prisma/debug": "5.22.0"
@@ -174,7 +174,7 @@
"version": "5.22.0", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"devOptional": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -4055,7 +4055,7 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/quill": { "node_modules/@types/quill": {
@@ -4078,7 +4078,7 @@
"version": "18.3.31", "version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
@@ -5929,7 +5929,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dargs": { "node_modules/dargs": {
@@ -7035,6 +7035,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,
+1
View File
@@ -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\"",
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Define relative path coordinates (adjusted for monorepo structure)
ANDROID_OUTPUT_PATH="./frontend/android/app/build/outputs/apk/release/app-release.apk"
BACKEND_TARGET_DIR="./backend/public/downloads"
TARGET_FILE_NAME="yotrip-latest.apk"
echo "🚀 Starting automated post-build Android deployment pipeline..."
# 1. Verify compiler target exists
if [ -f "$ANDROID_OUTPUT_PATH" ]; then
# 2. Ensure target storage folder structure is active
mkdir -p "$BACKEND_TARGET_DIR"
# 3. Copy and force overwrite the old production bundle with the updated version
cp -f "$ANDROID_OUTPUT_PATH" "$BACKEND_TARGET_DIR/$TARGET_FILE_NAME"
echo "✅ Success! New build copied safely to $BACKEND_TARGET_DIR/$TARGET_FILE_NAME"
echo "🔗 Direct Download Link Active: /downloads/$TARGET_FILE_NAME"
else
echo "❌ Critical Error: Android build output artifact not found at $ANDROID_OUTPUT_PATH"
exit 1
fi
+117
View File
@@ -0,0 +1,117 @@
# To AI Agent: Implement Server-Hosted Android APK Download Button and Automated Build Deployment Pipeline
## 1. Context & Feature Objective
We are adding a native Android app distribution workflow directly from our self-hosted server backend. Instead of relying purely on app stores, users visiting the web version from an Android device must be able to download the official compiled `.apk` file directly.
**Objective:** 1. **Backend Asset Exposure:** Configure a secure, static file directory on the Node.js/Express server to host the production `.apk` binary.
2. **Build Pipeline Link Automation:** Create a post-build deployment shell script. Every time a new production Android APK is generated (`release`), the script must automatically rename and copy it to the backend's public distribution folder under a persistent file pointer name (`yotrip-latest.apk`).
3. **Frontend Action Button:** Add an interactive "Tải ứng dụng Android" action row with a download icon inside both the Member and Guest profile menu sheets.
---
## 2. Technical Architecture & Implementation Steps
[Android Build Output] ➔ [deploy-apk.sh Script] ➔ [Backend public/downloads/yotrip-latest.apk]
[Frontend UI Button] ➔ ➔ ➔ [Triggers HTTP GET Request] ➔ ➔ ➔ ➔ ➔ ┛
### Step 1: Configure Backend Static Asset Folder
Locate the core server setup file (e.g., `server.ts`, `app.ts`, or `index.js`). Ensure a dedicated folder path named `public/downloads` is created and mapped to express static file serving handlers:
```typescript
import express from 'express';
import path from 'path';
const app = express();
// Ensure the directory exists: public/downloads/
const downloadsDir = path.join(__dirname, '../public/downloads');
/* ✅ BACKEND STATIC MIDDLEWARE REGISTRATION
This exposes the file at: [https://yourdomain.com/downloads/yotrip-latest.apk](https://yourdomain.com/downloads/yotrip-latest.apk)
*/
app.use('/downloads', express.static(downloadsDir, {
setHeaders: (res) => {
// Force browser engines to download the file directly instead of trying to parse it
res.set('Content-Type', 'application/vnd.android.package-archive');
res.set('Content-Disposition', 'attachment; filename="yotrip-latest.apk"');
}
}));
### Step 2: Automate APK Release Mapping Link (Post-Build Script)
Create an automation script file named scripts/deploy-apk.sh in the root environment. This script runs instantly after your Android compiler output is generated (e.g., via Gradle ./gradlew assembleRelease or Capacitor/Cordova build actions):
#!/bin/bash
# Define relative path coordinates
ANDROID_OUTPUT_PATH="./android/app/build/outputs/apk/release/app-release.apk"
BACKEND_TARGET_DIR="./backend/public/downloads"
TARGET_FILE_NAME="yotrip-latest.apk"
echo "🚀 Starting automated post-build Android deployment pipeline..."
# 1. Verify compiler target exists
if [ -f "$ANDROID_OUTPUT_PATH" ]; then
# 2. Ensure target storage folder structure is active
mkdir -p "$BACKEND_TARGET_DIR"
# 3. Copy and force overwrite the old production bundle with the updated version
cp -f "$ANDROID_OUTPUT_PATH" "$BACKEND_TARGET_DIR/$TARGET_FILE_NAME"
echo "✅ Success! New build copied safely to $BACKEND_TARGET_DIR/$TARGET_FILE_NAME"
echo "🔗 Direct Download Link Active: /downloads/$TARGET_FILE_NAME"
else
echo "❌ Critical Error: Android build output artifact not found at $ANDROID_OUTPUT_PATH"
exit 1
fi
Add "build:android": "cd android && ./gradlew assembleRelease && cd .. && sh scripts/deploy-apk.sh" inside package.json scripts matrix for unified execution hooks.
### Step 3: Add Download Trigger into Frontend UI (MapProfileDropdown.tsx)
Locate the unified dropdown menu component created in the previous layout consolidation phase. Inject the direct-download operational action rows:
// Define the static destination asset link helper
const APK_DOWNLOAD_URL = `${process.env.REACT_APP_API_BASE_URL || ''}/downloads/yotrip-latest.apk`;
/* --- INSIDE AUTHENTICATED MEMBER STACK SECTION --- */
<div className="flex flex-col space-y-1">
{/* Existing Dashboard, Profile, Create Tour rows... */}
{/* NEW: ANDROID APK DIRECT DOWNLOAD BUTTON */}
<a
href={APK_DOWNLOAD_URL}
download="yotrip.apk"
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-lg text-left text-xs text-slate-200 transition-colors"
>
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" className="w-4 h-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Tải ứng dụng Android (APK)</span>
</a>
</div>
/* --- INSIDE ANONYMOUS GUEST STACK SECTION --- */
<div className="flex flex-col space-y-1">
{/* Existing Guest language/theme configurations... */}
<div className="h-[1px] bg-slate-800 my-1 mx-2" />
{/* NEW: GUEST STATE ANDROID APK DOWNLOAD BUTTON */}
<a
href={APK_DOWNLOAD_URL}
download="yotrip.apk"
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-slate-800 rounded-lg text-left text-xs text-slate-200 transition-colors"
>
<svg xmlns="[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)" className="w-4 h-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span>Cài đặt bản Android (.APK)</span>
</a>
{/* Existing Register/Login Button row below... */}
</div>
## 3. Verification & Acceptance Criteria for AI Agent
[ ] Deployment Script Validation: Run the build sequence script. Confirm that backend/public/downloads/yotrip-latest.apk updates its file modification timestamp matching the compiler execution timing logs.
[ ] Direct Download Header Safety: Trigger a request to GET /downloads/yotrip-latest.apk. The network tab response must show content-type: application/vnd.android.package-archive to guarantee mobile devices instantly trigger package installation workflows.
[ ] UI Integrity Test: Open the drop menu layout panel on a mobile simulator frame. Confirm that clicking the text icon row acts as a standard link target that downloads the binary file smoothly without breaking route navigation states.