feat: tính năng chia sẻ khẩn cấp

This commit is contained in:
2026-06-21 12:52:47 +07:00
parent 8d79cd76f6
commit 403c169ddd
21 changed files with 2939 additions and 132 deletions
+140 -7
View File
@@ -5,11 +5,13 @@ const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerCluste
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 } from 'lucide-react';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft, Lock, Globe, Sun, Moon, Laptop, Users } from 'lucide-react';
import { UserManagementModal } from '@/components/UserManagementModal';
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';
// Fix lỗi icon mặc định của Leaflet
@@ -67,6 +69,36 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
const notify = useNotification();
const confirm = useConfirm();
const { t, lang, changeLanguage } = 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
const touchTimerRef = React.useRef<any>(null);
@@ -193,6 +225,24 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
};
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false);
const fetchTrustedUsers = async () => {
try {
const res = await fetch('/api/v1/users/trusted');
if (res.ok) {
const data = await res.json();
setTrustedUsers(data);
}
} catch (e) {
console.error('Lỗi khi tải thành viên uy tín:', e);
}
};
useEffect(() => {
fetchTrustedUsers();
}, []);
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
const [searchQuery, setSearchQuery] = useState(''); // State cho giá trị tìm kiếm
const [suggestions, setSuggestions] = useState<{ type: 'tour' | 'location', id: string, name: string, lat?: number, lon?: number }[]>([]);
@@ -515,15 +565,58 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</button>
)}
{/* Lựa chọn Ngôn ngữ */}
<div className="relative group shrink-0">
<button className="w-11 h-11 bg-white dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 transition-all flex items-center justify-center border border-gray-100 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-white dark:bg-slate-800 border border-gray-100 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-slate-700 dark:text-slate-200 hover:bg-slate-50 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-slate-700 dark:text-slate-200 hover:bg-slate-50 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-slate-700 dark:text-slate-200 hover:bg-slate-50 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-white dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-slate-50 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 transition-all flex items-center justify-center border border-gray-100 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-white dark:bg-slate-800 border border-gray-100 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-slate-700 dark:text-slate-200 hover:bg-slate-50 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-slate-700 dark:text-slate-200 hover:bg-slate-50 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-slate-700 dark:text-slate-200 hover:bg-slate-50 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 && (
{user && (
<button
onClick={() => setIsAdminModalOpen(true)}
className="w-11 h-11 md:w-auto bg-blue-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
title="Quản lý hệ thống"
onClick={() => {
if (user.isAdmin) {
setIsAdminModalOpen(true);
} else {
const key = window.prompt(t('enterSecretKey'));
if (key) {
handlePromoteAdmin(key);
}
}
}}
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 ${
user.isAdmin ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-slate-700 hover:bg-slate-800 text-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-750 dark:border-slate-700 border border-slate-600'
}`}
title={t('systemBtn')}
>
<Settings className="w-5 h-5" />
<span className="hidden md:inline text-sm">Hệ thống</span>
{user.isAdmin ? <Settings className="w-5 h-5" /> : <Lock className="w-5 h-5" />}
<span className="hidden md:inline text-sm">{t('systemBtn')}</span>
</button>
)}
@@ -832,6 +925,46 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
}}
/>
)}
{/* Floating Trusted Leaderboard Panel (Bottom Left) */}
<div className="absolute bottom-6 left-6 z-[1002] pointer-events-auto flex flex-col items-start gap-2">
<button
onClick={() => setIsLeaderboardOpen(prev => !prev)}
className="bg-white dark:bg-slate-900 border border-gray-150 dark:border-slate-800 text-slate-800 dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-slate-850 transition-all cursor-pointer active:scale-95"
>
<Users className="w-4 h-4 text-amber-500" />
<span>{t('trustedMembers')} ({trustedUsers.length})</span>
</button>
{isLeaderboardOpen && (
<div className="bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-gray-100 dark:border-slate-800 rounded-3xl shadow-2xl p-4 w-72 max-h-[350px] overflow-y-auto no-scrollbar animate-in slide-in-from-bottom-2 duration-300">
<h4 className="text-xs font-black uppercase text-amber-600 dark:text-amber-500 tracking-widest mb-3 flex items-center gap-2">
🏆 {t('trustedMembers')}
</h4>
{trustedUsers.length === 0 ? (
<p className="text-xs text-gray-400 italic">Chưa thành viên nào đưc đánh giá.</p>
) : (
<div className="space-y-2.5">
{trustedUsers.map((u, idx) => (
<div key={u.id} className="flex items-center justify-between gap-3 p-2 bg-gray-50/50 dark:bg-slate-850/50 rounded-xl border border-gray-100/50 dark:border-slate-800/50">
<div className="flex items-center gap-2 min-w-0">
<div className="w-5 h-5 font-bold text-[10px] text-gray-500 flex items-center justify-center bg-gray-100 dark:bg-slate-800 rounded-lg">
{idx + 1}
</div>
<span className="text-xs font-bold text-gray-800 dark:text-slate-200 truncate">{u.name}</span>
</div>
<div className="flex items-center gap-1 text-[11px] font-bold text-amber-500 shrink-0">
<span></span>
<span>{u.averageScore}</span>
<span className="text-[9px] text-gray-400 font-medium">({u.ratingCount})</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
};