feat: tính năng chia sẻ khẩn cấp
This commit is contained in:
@@ -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 có 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>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,9 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-react';
|
||||
import { LoginModal } from '../components/LoginModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { processImageModeration } from '../hooks/useImageModeration';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
interface LandingPageProps {
|
||||
onContinue?: () => void;
|
||||
@@ -41,8 +44,11 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const notify = useNotification();
|
||||
const { t, lang, changeLanguage } = useTranslation();
|
||||
const { theme, changeTheme } = useTheme();
|
||||
|
||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||||
const [currentBgIndex, setCurrentBgIndex] = useState(0);
|
||||
const [bg1, setBg1] = useState('/background.avif');
|
||||
const [bg2, setBg2] = useState('');
|
||||
@@ -87,8 +93,21 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
||||
}
|
||||
};
|
||||
|
||||
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(() => {
|
||||
fetchPublicPhotos();
|
||||
fetchTrustedUsers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -106,6 +125,14 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
||||
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
||||
|
||||
try {
|
||||
// 0. Kiểm duyệt ảnh
|
||||
const moderationResult = await processImageModeration(file);
|
||||
if (moderationResult.blocked) {
|
||||
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
|
||||
return;
|
||||
}
|
||||
const processedFile = moderationResult.file;
|
||||
|
||||
// Lấy tọa độ hiện tại của người dùng với cơ chế chống treo (Promise.race)
|
||||
const location = await Promise.race([
|
||||
new Promise<GeolocationPosition | null>((resolve) => {
|
||||
@@ -138,7 +165,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
||||
|
||||
// 2. Tải ảnh lên
|
||||
const formData = new FormData();
|
||||
formData.append('images', file);
|
||||
formData.append('images', processedFile);
|
||||
if (location) {
|
||||
formData.append('latitude', location.coords.latitude.toString());
|
||||
formData.append('longitude', location.coords.longitude.toString());
|
||||
@@ -247,20 +274,99 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
||||
`}</style>
|
||||
|
||||
{/* Top Bar - Thanh điều hướng trên cùng */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 p-4 pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center">
|
||||
<div className="absolute top-0 left-0 right-0 z-20 p-4 pt-[calc(1rem+env(safe-area-inset-top,0px))] flex justify-between items-center bg-gradient-to-b from-slate-950/40 to-transparent">
|
||||
<div className="flex items-center gap-2 text-white drop-shadow-lg">
|
||||
<Compass className="w-8 h-8" />
|
||||
<span className="text-xl font-black tracking-tighter uppercase hidden sm:block">Travel Planner</span>
|
||||
<span className="text-xl font-black tracking-tighter uppercase hidden sm:block">YoTrip</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Language Selector */}
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => changeLanguage(e.target.value as any)}
|
||||
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-3 py-1.5 text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer"
|
||||
>
|
||||
<option value="vi" className="text-black">Tiếng Việt</option>
|
||||
<option value="en" className="text-black">English</option>
|
||||
<option value="zh" className="text-black">中文</option>
|
||||
</select>
|
||||
|
||||
{/* Theme Selector */}
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => changeTheme(e.target.value as any)}
|
||||
className="bg-black/40 hover:bg-black/60 border border-white/20 text-white rounded-full px-3 py-1.5 text-xs font-bold focus:outline-none backdrop-blur-md cursor-pointer"
|
||||
>
|
||||
<option value="light" className="text-black">{t('themeLight') || 'Sáng'}</option>
|
||||
<option value="dark" className="text-black">{t('themeDark') || 'Tối'}</option>
|
||||
<option value="system" className="text-black">{t('themeSystem') || 'Hệ thống'}</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => setIsLoginModalOpen(true)}
|
||||
className="flex items-center justify-center gap-2 bg-white/15 backdrop-blur-md text-white font-bold py-2 px-4 rounded-full border border-white/25 hover:bg-white/25 transition-all shadow-md active:scale-95"
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
<span className="text-sm">{t('login')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsLoginModalOpen(true)}
|
||||
className="flex items-center justify-center gap-2 bg-white/10 backdrop-blur-md text-white font-bold py-2 px-4 rounded-full border border-white/20 hover:bg-white/20 transition-all"
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
<span className="text-sm">Đăng nhập</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Floating Trusted Leaderboard Panel (Left Side on Desktop) */}
|
||||
<div className="absolute left-6 top-24 bottom-36 z-20 hidden md:flex flex-col w-72 bg-slate-900/60 backdrop-blur-md border border-white/10 rounded-[32px] p-5 text-white overflow-hidden shadow-2xl animate-in slide-in-from-left duration-500">
|
||||
<h3 className="text-base font-black flex items-center gap-2 mb-3 border-b border-white/10 pb-2">
|
||||
🏆 {t('trustedMembers')}
|
||||
</h3>
|
||||
<div className="flex-1 overflow-y-auto space-y-3 pr-1 no-scrollbar">
|
||||
{trustedUsers.length === 0 ? (
|
||||
<div className="text-xs text-white/50 italic py-4">Chưa có đánh giá nào.</div>
|
||||
) : (
|
||||
trustedUsers.map((u, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 bg-white/5 hover:bg-white/10 p-2.5 rounded-2xl border border-white/5 transition-all">
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-800 border border-white/10 flex items-center justify-center font-bold overflow-hidden">
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span>{u.name.charAt(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="absolute -top-1 -left-1 bg-amber-500 text-[10px] text-white font-black px-1.5 py-0.5 rounded-full border border-slate-950">
|
||||
{idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-bold truncate">{u.name}</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] text-amber-400 font-bold">★ {u.averageScore}</span>
|
||||
<span className="text-[9px] text-white/40 font-semibold">({u.ratingCount})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Only: Trusted Members Top Bar */}
|
||||
{trustedUsers.length > 0 && (
|
||||
<div className="absolute top-24 left-4 right-4 z-20 md:hidden flex flex-col gap-1 bg-slate-950/45 backdrop-blur-sm p-2 rounded-2xl border border-white/5">
|
||||
<span className="text-[9px] font-black uppercase tracking-wider text-white/70 px-1">
|
||||
🏆 {t('trustedMembers')}
|
||||
</span>
|
||||
<div className="flex gap-2 overflow-x-auto no-scrollbar py-0.5">
|
||||
{trustedUsers.slice(0, 5).map((u, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5 bg-slate-950/60 border border-white/10 px-2.5 py-1 rounded-full shrink-0">
|
||||
<span className="text-[9px] font-bold text-amber-400">#{idx + 1}</span>
|
||||
<span className="text-[10px] font-bold text-white truncate max-w-[70px]">{u.name}</span>
|
||||
<span className="text-[9px] text-amber-400 font-bold">★ {u.averageScore}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body: Animation người lữ hành */}
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10" >
|
||||
{/* Kích hoạt click vào file input để chọn hoặc chụp ảnh */}
|
||||
@@ -279,20 +385,24 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
||||
|
||||
{/* Community Gallery Previews */}
|
||||
{publicPhotos.length > 0 && (
|
||||
<div className="absolute bottom-[calc(7rem+env(safe-area-inset-bottom,0px))] left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
|
||||
<div className="absolute bottom-[calc(7.5rem+env(safe-area-inset-bottom,0px))] left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-white/70 drop-shadow-md">
|
||||
Khoảnh khắc từ cộng đồng ({publicPhotos.length})
|
||||
{t('momentsTitle')} ({publicPhotos.length})
|
||||
</span>
|
||||
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-2 px-4 justify-center">
|
||||
{publicPhotos.slice(0, 8).map((photo, index) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
onClick={() => setCurrentBgIndex(index)}
|
||||
className={`relative w-14 h-14 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
|
||||
currentBgIndex === index ? 'border-emerald-500 scale-110 shadow-lg' : 'border-white/20 hover:border-white/50'
|
||||
className={`relative w-14 h-14 rounded-xl overflow-hidden transition-all active:scale-95 shrink-0 shadow-inner bg-slate-800 ${
|
||||
currentBgIndex === index ? 'scale-110 shadow-lg' : 'hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<img src={photo.imageUrl} alt="Community thumbnail" className="w-full h-full object-cover" />
|
||||
{/* Border Overlay absolute to prevent border clipping or corner overlap */}
|
||||
<div className={`absolute inset-0 rounded-xl border-2 pointer-events-none transition-colors ${
|
||||
currentBgIndex === index ? 'border-emerald-500' : 'border-white/20'
|
||||
}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -310,13 +420,21 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
|
||||
)}
|
||||
|
||||
{/* Bottom Bar - Nút hành động chính */}
|
||||
<div className="absolute bottom-[calc(2rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4">
|
||||
<div className="absolute bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))] left-1/2 -translate-x-1/2 z-20 w-full px-4 flex flex-col sm:flex-row gap-3 items-center justify-center max-w-lg">
|
||||
<button
|
||||
onClick={onGoToMap}
|
||||
className="w-full max-w-md mx-auto flex items-center justify-center gap-3 bg-white/20 backdrop-blur-lg text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-2xl border border-white/30 hover:bg-white/30 active:scale-95"
|
||||
className="w-full flex items-center justify-center gap-3 bg-emerald-600/90 text-white font-bold py-3.5 px-6 rounded-2xl transition-all shadow-2xl border border-emerald-500/30 hover:bg-emerald-500 hover:scale-[1.02] active:scale-95 text-sm"
|
||||
>
|
||||
<MapIcon className="w-5 h-5" />
|
||||
Khám phá các hành trình du lịch
|
||||
{t('exploreToursBtn')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full flex items-center justify-center gap-3 bg-white/20 backdrop-blur-lg text-white font-bold py-3.5 px-6 rounded-2xl transition-all shadow-2xl border border-white/30 hover:bg-white/30 hover:scale-[1.02] active:scale-95 text-sm"
|
||||
>
|
||||
<Camera className="w-5 h-5" />
|
||||
{t('quickCamera')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import {
|
||||
Phone,
|
||||
MapPin,
|
||||
Calendar,
|
||||
ShieldAlert,
|
||||
ChevronLeft,
|
||||
LocateFixed,
|
||||
Loader2,
|
||||
PhoneCall,
|
||||
Compass,
|
||||
Map as MapIcon,
|
||||
List
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
interface ParticipantInfo {
|
||||
id: string;
|
||||
role: string;
|
||||
displayName: string | null;
|
||||
user: UserInfo | null;
|
||||
}
|
||||
|
||||
interface LocationInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
plannedStart: string | null;
|
||||
plannedEnd: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface LegInfo {
|
||||
id: string;
|
||||
sequence: number;
|
||||
note: string | null;
|
||||
description: string | null;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
locations: LocationInfo[];
|
||||
}
|
||||
|
||||
interface TourInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
creator: UserInfo;
|
||||
participants: ParticipantInfo[];
|
||||
legs: LegInfo[];
|
||||
}
|
||||
|
||||
interface ShareJourneyPageProps {
|
||||
token: string;
|
||||
onGoToHome: () => void;
|
||||
}
|
||||
|
||||
// Component to dynamically recenter map to a location coordinate
|
||||
const MapRecenter = ({ position }: { position: [number, number] | null }) => {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (position) {
|
||||
map.setView(position, 15, { animate: true });
|
||||
}
|
||||
}, [position, map]);
|
||||
return null;
|
||||
};
|
||||
|
||||
// Component to adjust bounds to fit all stop locations
|
||||
const MapBoundsFit = ({ locations, trigger }: { locations: LocationInfo[]; trigger: number }) => {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (locations.length > 0) {
|
||||
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
|
||||
if (locations.length === 1) {
|
||||
map.setView([locations[0].latitude, locations[0].longitude], 14, { animate: true });
|
||||
} else {
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||
}
|
||||
}
|
||||
}, [locations, trigger, map]);
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ShareJourneyPage: React.FC<ShareJourneyPageProps> = ({ token, onGoToHome }) => {
|
||||
const { t, lang, changeLanguage } = useTranslation();
|
||||
const { theme, changeTheme } = useTheme();
|
||||
|
||||
const [tour, setTour] = useState<TourInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [focusedLocation, setFocusedLocation] = useState<[number, number] | null>(null);
|
||||
const [fitBoundsTrigger, setFitBoundsTrigger] = useState(0);
|
||||
const [activeTab, setActiveTab] = useState<'map' | 'itinerary'>('map');
|
||||
|
||||
// Viewer's own GPS tracking
|
||||
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
|
||||
const [isLocating, setIsLocating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSharedTour = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/v1/tours/share/${token}`);
|
||||
if (!res.ok) {
|
||||
throw new Error('Not found or disabled');
|
||||
}
|
||||
const data = await res.json();
|
||||
setTour(data.tour);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError('expired_or_not_found');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (token) {
|
||||
fetchSharedTour();
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const allLocations = useMemo(() => {
|
||||
if (!tour) return [];
|
||||
const locs: LocationInfo[] = [];
|
||||
tour.legs.forEach(leg => {
|
||||
leg.locations.forEach(loc => {
|
||||
locs.push(loc);
|
||||
});
|
||||
});
|
||||
return locs;
|
||||
}, [tour]);
|
||||
|
||||
const ownerContact = tour?.creator;
|
||||
const managerContacts = useMemo(() => {
|
||||
if (!tour) return [];
|
||||
return tour.participants
|
||||
.filter(p => p.role === 'MANAGER' && p.user)
|
||||
.map(p => ({
|
||||
name: p.displayName || p.user?.name || '',
|
||||
phone: p.user?.phone || ''
|
||||
}))
|
||||
.filter(c => c.phone);
|
||||
}, [tour]);
|
||||
|
||||
const handleLocateMe = () => {
|
||||
if (!navigator.geolocation) {
|
||||
alert('Trình duyệt của bạn không hỗ trợ định vị.');
|
||||
return;
|
||||
}
|
||||
setIsLocating(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const coords: [number, number] = [position.coords.latitude, position.coords.longitude];
|
||||
setUserLocation(coords);
|
||||
setFocusedLocation(coords);
|
||||
setIsLocating(false);
|
||||
},
|
||||
(err) => {
|
||||
console.error(err);
|
||||
setIsLocating(false);
|
||||
alert('Không thể lấy vị trí hiện tại. Vui lòng bật định vị GPS.');
|
||||
},
|
||||
{ enableHighAccuracy: true }
|
||||
);
|
||||
};
|
||||
|
||||
// Custom Map Markers
|
||||
const mapIcons = useMemo(() => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
return {
|
||||
start: L.divIcon({
|
||||
className: '!bg-transparent !border-none',
|
||||
html: `<div class="w-8 h-8 bg-rose-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-xs font-black text-white animate-in zoom-in duration-300">S</div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
}),
|
||||
end: L.divIcon({
|
||||
className: '!bg-transparent !border-none',
|
||||
html: `<div class="w-8 h-8 bg-emerald-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center text-xs font-black text-white animate-in zoom-in duration-300">E</div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
}),
|
||||
visit: L.divIcon({
|
||||
className: '!bg-transparent !border-none',
|
||||
html: `<div class="w-6 h-6 bg-violet-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center"><div class="w-1.5 h-1.5 bg-white rounded-full opacity-60"></div></div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
}),
|
||||
user: L.divIcon({
|
||||
className: '!bg-transparent !border-none',
|
||||
html: `
|
||||
<div class="relative flex items-center justify-center">
|
||||
<div class="absolute w-8 h-8 bg-blue-400 rounded-full opacity-35 animate-ping"></div>
|
||||
<div class="w-5 h-5 bg-blue-600 rounded-full border-2 border-white shadow-xl flex items-center justify-center z-10"></div>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
})
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 transition-colors">
|
||||
<Loader2 className="w-12 h-12 text-rose-500 animate-spin mb-4" />
|
||||
<p className="font-bold text-sm">{t('loading')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !tour) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 p-4 transition-colors">
|
||||
<div className="bg-white dark:bg-slate-900 border border-gray-100 dark:border-slate-800 shadow-2xl rounded-[32px] p-8 max-w-md w-full text-center flex flex-col items-center gap-5">
|
||||
<div className="w-16 h-16 bg-rose-50 dark:bg-rose-950/20 border border-rose-100 dark:border-rose-900/30 rounded-full flex items-center justify-center text-rose-500">
|
||||
<ShieldAlert className="w-8 h-8" />
|
||||
</div>
|
||||
<h2 className="text-xl font-black tracking-tight">{t('error')}</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||
{t('linkExpired')}
|
||||
</p>
|
||||
<button
|
||||
onClick={onGoToHome}
|
||||
className="w-full bg-slate-900 dark:bg-slate-100 hover:bg-slate-800 dark:hover:bg-white text-white dark:text-slate-900 font-bold py-3 px-6 rounded-2xl transition-all active:scale-95 text-sm"
|
||||
>
|
||||
{t('appName')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100 transition-colors overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="h-16 shrink-0 bg-white/70 dark:bg-slate-900/70 backdrop-blur-md border-b border-gray-100 dark:border-slate-800 px-4 md:px-6 flex items-center justify-between z-20">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onGoToHome}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-xl transition-all active:scale-95 text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 bg-rose-500 rounded-full animate-pulse"></span>
|
||||
<h1 className="text-sm md:text-base font-black uppercase tracking-tight">{t('emergencyJourney')}</h1>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 dark:text-slate-400 truncate max-w-[200px] sm:max-w-xs">{tour.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
{/* Language Selector */}
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => changeLanguage(e.target.value as any)}
|
||||
className="bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 text-slate-800 dark:text-slate-100 rounded-xl px-2 py-1.5 text-xs font-bold focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="vi">VI</option>
|
||||
<option value="en">EN</option>
|
||||
<option value="zh">ZH</option>
|
||||
</select>
|
||||
|
||||
{/* Theme Selector */}
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => changeTheme(e.target.value as any)}
|
||||
className="bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 text-slate-800 dark:text-slate-100 rounded-xl px-2 py-1.5 text-xs font-bold focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="light">{t('themeLight')}</option>
|
||||
<option value="dark">{t('themeDark')}</option>
|
||||
<option value="system">{t('themeSystem')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Tab Switcher */}
|
||||
<div className="lg:hidden h-12 shrink-0 bg-white dark:bg-slate-900 border-b border-gray-100 dark:border-slate-800 flex items-center z-10 p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('map')}
|
||||
className={`flex-1 h-full flex items-center justify-center gap-2 text-xs font-bold rounded-lg transition-all ${activeTab === 'map' ? 'bg-rose-500 text-white shadow-md' : 'text-slate-500 dark:text-slate-400'}`}
|
||||
>
|
||||
<MapIcon className="w-4 h-4" />
|
||||
{t('viewMap')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('itinerary')}
|
||||
className={`flex-1 h-full flex items-center justify-center gap-2 text-xs font-bold rounded-lg transition-all ${activeTab === 'itinerary' ? 'bg-rose-500 text-white shadow-md' : 'text-slate-500 dark:text-slate-400'}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
{t('viewTimeline')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Layout Area */}
|
||||
<div className="flex-1 flex flex-col lg:grid lg:grid-cols-12 overflow-hidden">
|
||||
{/* Left Side Pane (Itinerary and Contacts) */}
|
||||
<div className={`flex-col lg:col-span-4 bg-white dark:bg-slate-900 border-r border-gray-100 dark:border-slate-800 overflow-y-auto no-scrollbar ${activeTab === 'itinerary' ? 'flex h-full' : 'hidden lg:flex'}`}>
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Tour Meta Info */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg md:text-xl font-black text-slate-900 dark:text-white leading-tight">{tour.title}</h2>
|
||||
{tour.startDate && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<Calendar className="w-4 h-4 text-rose-500" />
|
||||
<span>
|
||||
{new Date(tour.startDate).toLocaleDateString()}
|
||||
{tour.endDate && ` - ${new Date(tour.endDate).toLocaleDateString()}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{tour.description && (
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed bg-slate-50 dark:bg-slate-950 p-3 rounded-2xl border border-gray-100 dark:border-slate-800/40">
|
||||
{tour.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emergency Contacts */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-black text-rose-600 dark:text-rose-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Phone className="w-4 h-4" />
|
||||
{t('emergencyContacts')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Creator/Owner contact */}
|
||||
{ownerContact && (
|
||||
<div className="bg-rose-50/50 dark:bg-rose-950/10 border border-rose-100/50 dark:border-rose-950/20 p-4 rounded-2xl flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-rose-600 dark:text-rose-400 font-bold uppercase tracking-wider">{t('tourOwner')}</div>
|
||||
<div className="text-sm font-black text-slate-900 dark:text-white truncate mt-0.5">{ownerContact.name || 'Anonymous'}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{ownerContact.phone || t('noPhone')}</div>
|
||||
</div>
|
||||
{ownerContact.phone && (
|
||||
<a
|
||||
href={`tel:${ownerContact.phone}`}
|
||||
className="w-10 h-10 bg-rose-500 hover:bg-rose-600 text-white rounded-xl shadow-lg shadow-rose-500/20 flex items-center justify-center shrink-0 active:scale-95 transition-all"
|
||||
>
|
||||
<PhoneCall className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manager contacts */}
|
||||
{managerContacts.map((mgr, idx) => (
|
||||
<div key={idx} className="bg-slate-50 dark:bg-slate-950 border border-gray-100 dark:border-slate-800 p-4 rounded-2xl flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 font-bold uppercase tracking-wider">{t('tourManager')}</div>
|
||||
<div className="text-sm font-black text-slate-900 dark:text-white truncate mt-0.5">{mgr.name}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{mgr.phone}</div>
|
||||
</div>
|
||||
<a
|
||||
href={`tel:${mgr.phone}`}
|
||||
className="w-10 h-10 bg-slate-900 dark:bg-slate-100 hover:bg-slate-800 dark:hover:bg-white text-white dark:text-slate-900 rounded-xl shadow-md flex items-center justify-center shrink-0 active:scale-95 transition-all"
|
||||
>
|
||||
<PhoneCall className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!ownerContact?.phone && managerContacts.length === 0 && (
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 italic">{t('noPhone')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stops Hierarchy List */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">
|
||||
{t('viewTimeline')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4 relative before:absolute before:left-3 before:top-2 before:bottom-2 before:w-[2px] before:bg-gray-100 dark:before:bg-slate-800">
|
||||
{allLocations.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 italic pl-6">{t('noStops')}</p>
|
||||
) : (
|
||||
allLocations.map((loc, idx) => {
|
||||
const isStart = idx === 0;
|
||||
const isEnd = idx === allLocations.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={loc.id}
|
||||
onClick={() => {
|
||||
setFocusedLocation([loc.latitude, loc.longitude]);
|
||||
// Auto switch to map tab on mobile when clicking a location
|
||||
setActiveTab('map');
|
||||
}}
|
||||
className="group flex items-start gap-4 cursor-pointer pl-1 transition-all"
|
||||
>
|
||||
{/* Dot indicator */}
|
||||
<div className="relative mt-1 shrink-0 z-10">
|
||||
{isStart ? (
|
||||
<div className="w-5 h-5 bg-rose-600 rounded-full border-4 border-white dark:border-slate-950 shadow-md flex items-center justify-center text-[8px] font-black text-white">S</div>
|
||||
) : isEnd ? (
|
||||
<div className="w-5 h-5 bg-emerald-600 rounded-full border-4 border-white dark:border-slate-950 shadow-md flex items-center justify-center text-[8px] font-black text-white">E</div>
|
||||
) : (
|
||||
<div className="w-4 h-4 bg-violet-600 rounded-full border-4 border-white dark:border-slate-950 shadow-sm ml-0.5"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex-1 bg-slate-50/50 hover:bg-slate-50 dark:bg-slate-950/40 dark:hover:bg-slate-950/80 p-3 rounded-2xl border border-gray-100/50 dark:border-slate-800/40 transition-all select-none">
|
||||
<h4 className="text-xs font-black text-slate-800 dark:text-slate-200 group-hover:text-rose-500 dark:group-hover:text-rose-400 transition-colors">
|
||||
{loc.name}
|
||||
</h4>
|
||||
{loc.address && (
|
||||
<p className="text-[10px] text-slate-500 dark:text-slate-400 truncate mt-0.5">{loc.address}</p>
|
||||
)}
|
||||
{loc.plannedStart && (
|
||||
<p className="text-[9px] text-slate-400 dark:text-slate-500 mt-1">
|
||||
{new Date(loc.plannedStart).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{loc.plannedEnd && ` - ${new Date(loc.plannedEnd).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side Map Pane */}
|
||||
<div className={`relative flex-1 lg:col-span-8 h-full bg-slate-100 dark:bg-slate-950 ${activeTab === 'map' ? 'flex' : 'hidden lg:flex'}`}>
|
||||
{allLocations.length > 0 ? (
|
||||
<MapContainer
|
||||
center={[allLocations[0].latitude, allLocations[0].longitude]}
|
||||
zoom={13}
|
||||
preferCanvas={true}
|
||||
className="h-full w-full z-0"
|
||||
attributionControl={false}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution=""
|
||||
/>
|
||||
|
||||
{/* Fit map bounds helper */}
|
||||
<MapBoundsFit locations={allLocations} trigger={fitBoundsTrigger} />
|
||||
|
||||
{/* Recenter helper */}
|
||||
<MapRecenter position={focusedLocation} />
|
||||
|
||||
{/* Polyline Route */}
|
||||
{allLocations.length > 1 && (
|
||||
<Polyline
|
||||
positions={allLocations.map(l => [l.latitude, l.longitude])}
|
||||
color="#ec4899"
|
||||
weight={4}
|
||||
dashArray="6, 12"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Location Stop Markers */}
|
||||
{allLocations.map((loc, idx) => {
|
||||
const isStart = idx === 0;
|
||||
const isEnd = idx === allLocations.length - 1;
|
||||
const markerIcon = isStart ? mapIcons.start : isEnd ? mapIcons.end : mapIcons.visit;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={loc.id}
|
||||
position={[loc.latitude, loc.longitude]}
|
||||
icon={markerIcon}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-1">
|
||||
<div className="font-black text-xs text-slate-900">{loc.name}</div>
|
||||
{loc.address && <div className="text-[10px] text-gray-500 mt-0.5">{loc.address}</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Viewer's GPS tracking marker */}
|
||||
{userLocation && (
|
||||
<Marker position={userLocation} icon={mapIcons.user}>
|
||||
<Popup>
|
||||
<div className="font-bold text-xs">{t('myCoords') || 'Vị trí của tôi'}</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
</MapContainer>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-400">
|
||||
<MapPin className="w-12 h-12 stroke-1 mb-2 animate-bounce" />
|
||||
<p className="text-xs">{t('noStops')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Actions on Map */}
|
||||
<div className="absolute bottom-6 right-6 z-[1000] flex flex-col gap-3">
|
||||
{/* Locate Me button */}
|
||||
<button
|
||||
onClick={handleLocateMe}
|
||||
className="w-12 h-12 bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-800 rounded-2xl shadow-xl flex items-center justify-center text-slate-700 dark:text-slate-200 hover:bg-gray-50 dark:hover:bg-slate-800 transition-all active:scale-95"
|
||||
title="Định vị của tôi"
|
||||
>
|
||||
{isLocating ? (
|
||||
<Loader2 className="w-5 h-5 text-rose-500 animate-spin" />
|
||||
) : (
|
||||
<LocateFixed className={`w-5 h-5 ${userLocation ? 'text-rose-500' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Fit bounds button */}
|
||||
{allLocations.length > 0 && (
|
||||
<button
|
||||
onClick={() => setFitBoundsTrigger(prev => prev + 1)}
|
||||
className="w-12 h-12 bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-800 rounded-2xl shadow-xl flex items-center justify-center text-slate-700 dark:text-slate-200 hover:bg-gray-50 dark:hover:bg-slate-800 transition-all active:scale-95"
|
||||
title="Bao phủ toàn bộ"
|
||||
>
|
||||
<Compass className="w-5 h-5 text-violet-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { io } from 'socket.io-client';
|
||||
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
||||
import { ExpenseManager } from '../components/ExpenseManager';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import { AddLocationModal } from '@/components/AddLocationModal';
|
||||
import { AddMemberModal } from '../components/AddMemberModal';
|
||||
import { MembersTab } from '../components/MembersTab';
|
||||
@@ -253,7 +254,7 @@ const MapHoverTip = ({ canEdit }: { canEdit: boolean }) => {
|
||||
);
|
||||
};
|
||||
// Menu ngữ cảnh cho bản đồ
|
||||
const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.LatLng) => void }) => {
|
||||
const MapContextMenu = ({ onAction, onOpen }: { onAction: (action: string, latlng: L.LatLng) => void; onOpen?: () => void }) => {
|
||||
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -271,6 +272,7 @@ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.Lat
|
||||
|
||||
console.log(`[MAP] Context menu triggered at: ${e.latlng.lat}, ${e.latlng.lng}`);
|
||||
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
|
||||
if (onOpen) onOpen();
|
||||
},
|
||||
|
||||
moveend: (e) => {
|
||||
@@ -352,6 +354,9 @@ export const TourDetailPage = ({
|
||||
onOpenNotes?: () => void
|
||||
}) => {
|
||||
// Tách biệt các state và actions để tối ưu performance - Chuyển lên đầu để tránh lỗi initialization
|
||||
const { t } = useTranslation();
|
||||
const notify = useNotification();
|
||||
const confirm = useConfirm();
|
||||
const currentTour = useTourStore(state => state.currentTour);
|
||||
const legs = useTourStore(state => state.legs);
|
||||
const publicTours = useTourStore(state => state.publicTours);
|
||||
@@ -378,6 +383,187 @@ export const TourDetailPage = ({
|
||||
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||
const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false);
|
||||
|
||||
const [isRatingModalOpen, setIsRatingModalOpen] = useState(false);
|
||||
const [ratingTargetUser, setRatingTargetUser] = useState<any>(null);
|
||||
const [ratingScores, setRatingScores] = useState({
|
||||
honesty: 5,
|
||||
transparency: 5,
|
||||
enthusiasm: 5,
|
||||
cheerfulness: 5,
|
||||
seriousness: 5,
|
||||
planning: 5,
|
||||
survival: 5
|
||||
});
|
||||
const [ratingComment, setRatingComment] = useState('');
|
||||
const [isSubmittingRating, setIsSubmittingRating] = useState(false);
|
||||
|
||||
const handleSubmitRating = async () => {
|
||||
if (!ratingTargetUser) return;
|
||||
setIsSubmittingRating(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`/api/v1/tours/${tourId}/ratings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
targetUserId: ratingTargetUser.userId || ratingTargetUser.user?.id,
|
||||
...ratingScores,
|
||||
comment: ratingComment
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
notify({ title: 'Thành công', message: 'Cảm ơn bạn đã gửi đánh giá!', type: 'success' });
|
||||
setIsRatingModalOpen(false);
|
||||
setRatingComment('');
|
||||
setRatingScores({
|
||||
honesty: 5,
|
||||
transparency: 5,
|
||||
enthusiasm: 5,
|
||||
cheerfulness: 5,
|
||||
seriousness: 5,
|
||||
planning: 5,
|
||||
survival: 5
|
||||
});
|
||||
} else {
|
||||
const err = await res.json();
|
||||
notify({ title: 'Lỗi', message: err.message || 'Lỗi khi gửi đánh giá.', type: 'error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notify({ title: 'Lỗi', message: 'Lỗi mạng khi gửi đánh giá.', type: 'error' });
|
||||
} finally {
|
||||
setIsSubmittingRating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [shareStatus, setShareStatus] = useState<{ isEnabled: boolean; token: string } | null>(null);
|
||||
|
||||
const fetchShareStatus = async () => {
|
||||
if (isPublicView) return;
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`/api/v1/tours/${tourId}/share`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setShareStatus(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching share status:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleShare = async (isEnabled: boolean) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`/api/v1/tours/${tourId}/share`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ isEnabled })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setShareStatus(data);
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: isEnabled ? 'Đã bật chia sẻ hành trình cứu hộ.' : 'Đã tắt chia sẻ.',
|
||||
type: 'success'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPDF = async () => {
|
||||
if (!(window as any).html2pdf) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js';
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load html2pdf'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `
|
||||
.pdf-exclude { display: none !important; }
|
||||
.pdf-container { padding: 40px !important; color: #000 !important; background: #fff !important; }
|
||||
.pdf-title { font-size: 24px !important; font-weight: bold !important; margin-bottom: 20px !important; text-align: center !important; }
|
||||
.pdf-timeline { margin-top: 20px; }
|
||||
.pdf-location-card { border: 1px solid #e5e7eb; padding: 15px; border-radius: 12px; margin-bottom: 15px; background: #fafafa; }
|
||||
.pdf-leg-header { font-size: 16px; font-weight: bold; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #3b82f6; padding-bottom: 5px; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.className = 'pdf-container font-sans text-black bg-white';
|
||||
|
||||
const titleEl = document.createElement('h1');
|
||||
titleEl.className = 'pdf-title';
|
||||
titleEl.innerText = `Hành Trình: ${currentTour?.title || 'Tour Itinerary'}`;
|
||||
element.appendChild(titleEl);
|
||||
|
||||
const subEl = document.createElement('div');
|
||||
subEl.style.textAlign = 'center';
|
||||
subEl.style.marginBottom = '30px';
|
||||
subEl.style.fontSize = '12px';
|
||||
subEl.style.color = '#555';
|
||||
subEl.innerText = `Thời gian: ${currentTour?.startDate ? new Date(currentTour.startDate).toLocaleDateString('vi-VN') : ''} - ${currentTour?.endDate ? new Date(currentTour.endDate).toLocaleDateString('vi-VN') : ''}`;
|
||||
element.appendChild(subEl);
|
||||
|
||||
const printDom = document.getElementById('itinerary-timeline-print-zone');
|
||||
if (printDom) {
|
||||
const clone = printDom.cloneNode(true) as HTMLElement;
|
||||
|
||||
// Clean clone layout by removing action buttons and interactive inputs, expenses
|
||||
clone.querySelectorAll('button, input, textarea, .pdf-exclude, .expense-badge, .paid-by-badge, .comment-section, .location-actions, .mt-2.text-indigo-650, .flex.gap-2.mt-2').forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
// Clear styles or apply simple standard styles so PDF generation is clean
|
||||
clone.style.background = 'white';
|
||||
clone.style.color = 'black';
|
||||
|
||||
element.appendChild(clone);
|
||||
} else {
|
||||
notify({ title: 'Lỗi', message: 'Không tìm thấy vùng hiển thị lịch trình để xuất PDF.', type: 'error' });
|
||||
document.head.removeChild(style);
|
||||
return;
|
||||
}
|
||||
|
||||
const opt = {
|
||||
margin: 10,
|
||||
filename: `Lich_trinh_${currentTour?.title || 'tour'}.pdf`,
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||
};
|
||||
|
||||
try {
|
||||
notify({ title: 'Đang tạo PDF...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
||||
await (window as any).html2pdf().from(element).set(opt).save();
|
||||
notify({ title: 'Thành công', message: 'Lịch trình đã được xuất ra tập tin PDF thành công.', type: 'success' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
notify({ title: 'Lỗi', message: 'Không thể xuất PDF lịch trình.', type: 'error' });
|
||||
} finally {
|
||||
document.head.removeChild(style);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchShareStatus();
|
||||
}, [tourId]);
|
||||
|
||||
// State cho vị trí và hướng của người dùng
|
||||
|
||||
|
||||
@@ -834,8 +1020,6 @@ export const TourDetailPage = ({
|
||||
const rejectJoinRequest = useTourStore(state => state.rejectJoinRequest);
|
||||
const deleteTour = useTourStore(state => state.deleteTour);
|
||||
|
||||
const confirm = useConfirm();
|
||||
const notify = useNotification();
|
||||
|
||||
// Khôi phục vị trí và mức zoom từ localStorage
|
||||
const [initialViewState] = useState(() => {
|
||||
@@ -1071,8 +1255,8 @@ export const TourDetailPage = ({
|
||||
|
||||
const handleNavigateToLocation = (location: any) => {
|
||||
setMapCenter([location.latitude, location.longitude]);
|
||||
setViewMode('map');
|
||||
setLocateTrigger(prev => prev + 1);
|
||||
setIsMapFullscreen(true);
|
||||
|
||||
notify({
|
||||
title: 'Bắt đầu chỉ đường',
|
||||
@@ -1784,21 +1968,30 @@ export const TourDetailPage = ({
|
||||
{activeTab === 'plan' && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-gray-100 p-1 rounded-2xl flex gap-1">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex-1" />
|
||||
<div className="bg-gray-100 dark:bg-slate-800 p-1 rounded-2xl flex gap-1">
|
||||
<button
|
||||
onClick={() => setViewMode('timeline')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" /> Danh sách
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('map')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white dark:bg-slate-900 shadow-sm text-blue-600 dark:text-blue-400' : 'text-gray-500'}`}
|
||||
>
|
||||
<MapIconLucide className="w-3.5 h-3.5" /> Bản đồ
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex justify-end">
|
||||
<button
|
||||
onClick={handleExportPDF}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs font-bold shadow-md transition-all active:scale-95"
|
||||
>
|
||||
📥 {t('exportPDF') || 'Xuất PDF'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'timeline' ? (
|
||||
@@ -2538,6 +2731,54 @@ export const TourDetailPage = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emergency Sharing Settings */}
|
||||
<div className="p-6 bg-white dark:bg-slate-900 rounded-3xl border border-dashed border-rose-200 dark:border-rose-950/40 shadow-sm animate-in zoom-in-95">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🚨</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{t('emergencyShare')}</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">{t('emergencyShareTooltip')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{shareStatus && (
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shareStatus.isEnabled}
|
||||
onChange={(e) => handleToggleShare(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-rose-500"></div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shareStatus?.isEnabled && (
|
||||
<div className="mt-4 bg-rose-50 dark:bg-rose-950/20 p-4 rounded-2xl border border-rose-100 dark:border-rose-950/30 flex flex-col gap-2">
|
||||
<div className="text-xs font-bold text-rose-700 dark:text-rose-400 uppercase tracking-widest">Đường dẫn chia sẻ khẩn cấp:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={`${window.location.origin}/journey/${shareStatus.token}`}
|
||||
className="flex-1 bg-white dark:bg-slate-800 border dark:border-slate-700 rounded-xl px-3 py-2 text-xs text-slate-800 dark:text-slate-100 select-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/journey/${shareStatus.token}`);
|
||||
notify({ title: 'Thành công', message: 'Đã sao chép liên kết!', type: 'success' });
|
||||
}}
|
||||
className="bg-rose-600 hover:bg-rose-700 text-white font-bold px-3 py-2 rounded-xl text-xs transition-all active:scale-95 shrink-0"
|
||||
>
|
||||
{t('copyShareLink')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="p-6 bg-white rounded-3xl border border-gray-100 shadow-sm animate-in zoom-in-95">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@@ -2930,6 +3171,20 @@ export const TourDetailPage = ({
|
||||
)}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button onClick={() => setIsMemberDetailOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100">Đóng</button>
|
||||
|
||||
{!isPublicView && currentUser && selectedMember && (selectedMember.userId || selectedMember.user?.id) && currentUser.id !== (selectedMember.userId || selectedMember.user?.id) && (selectedMember.role === 'OWNER' || selectedMember.role === 'MANAGER') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsMemberDetailOpen(false);
|
||||
setRatingTargetUser(selectedMember);
|
||||
setIsRatingModalOpen(true);
|
||||
}}
|
||||
className="px-3 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-xl text-sm font-bold flex items-center gap-1 shadow-md transition-all active:scale-95"
|
||||
>
|
||||
⭐ {t('rateOrganizer') || 'Đánh giá'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canEdit && selectedMember.role !== 'OWNER' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -2961,6 +3216,105 @@ export const TourDetailPage = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organizer Rating Modal */}
|
||||
{isRatingModalOpen && ratingTargetUser && (
|
||||
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in" onClick={() => setIsRatingModalOpen(false)} />
|
||||
<div className="relative w-full max-w-lg bg-white dark:bg-slate-900 rounded-[32px] shadow-2xl p-8 max-h-[90vh] overflow-y-auto flex flex-col animate-in zoom-in-95 duration-250 text-slate-800 dark:text-slate-100 border dark:border-slate-800">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-black text-slate-950 dark:text-white flex items-center gap-2">
|
||||
⭐ {t('rateTitle') || 'Đánh giá Người tạo Tour'}
|
||||
</h3>
|
||||
<button onClick={() => setIsRatingModalOpen(false)} className="p-2 hover:bg-gray-150 dark:hover:bg-slate-850 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 bg-slate-50 dark:bg-slate-850 p-4 rounded-2xl border border-slate-100 dark:border-slate-800">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold overflow-hidden">
|
||||
{ratingTargetUser.avatar ? (
|
||||
<img src={ratingTargetUser.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span>{ratingTargetUser.user?.name?.charAt(0) || ratingTargetUser.displayName?.charAt(0) || '?'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-black dark:text-white">{ratingTargetUser.user?.name || ratingTargetUser.displayName}</div>
|
||||
<div className="text-xs text-gray-450 uppercase tracking-widest font-bold">{ratingTargetUser.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 flex-1">
|
||||
{[
|
||||
{ key: 'honesty', label: t('honesty') || 'Trung thực' },
|
||||
{ key: 'transparency', label: t('transparency') || 'Minh bạch' },
|
||||
{ key: 'enthusiasm', label: t('enthusiasm') || 'Nhiệt tình' },
|
||||
{ key: 'cheerfulness', label: t('cheerfulness') || 'Vui vẻ' },
|
||||
{ key: 'seriousness', label: t('seriousness') || 'Nghiêm túc' },
|
||||
{ key: 'planning', label: t('planning') || 'Có kế hoạch' },
|
||||
{ key: 'survival', label: t('survival') || 'Kỹ năng sinh tồn' }
|
||||
].map((c) => (
|
||||
<div key={c.key} className="flex items-center justify-between border-b border-slate-100 dark:border-slate-850 pb-2">
|
||||
<span className="text-xs font-bold text-slate-700 dark:text-slate-350">{c.label}</span>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const currentVal = (ratingScores as any)[c.key];
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setRatingScores(prev => ({ ...prev, [c.key]: star }))}
|
||||
className={`w-6 h-6 text-xl transition-all active:scale-95 ${
|
||||
star <= currentVal ? 'text-amber-450' : 'text-gray-300 dark:text-gray-700 hover:text-amber-300'
|
||||
}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Nhận xét khác</label>
|
||||
<textarea
|
||||
value={ratingComment}
|
||||
onChange={(e) => setRatingComment(e.target.value)}
|
||||
placeholder={t('rateCommentPlaceholder') || 'Nhập ý kiến đánh giá khác...'}
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-slate-850 border border-gray-200 dark:border-slate-800 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-xs resize-none text-slate-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRatingModalOpen(false)}
|
||||
className="py-3.5 bg-gray-100 dark:bg-slate-800 hover:bg-gray-250 dark:hover:bg-slate-700 text-gray-600 dark:text-slate-300 font-bold rounded-2xl transition-all active:scale-95 text-xs"
|
||||
>
|
||||
{t('cancel') || 'Hủy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmitRating}
|
||||
disabled={isSubmittingRating}
|
||||
className="py-3.5 bg-amber-500 hover:bg-amber-600 disabled:opacity-50 text-white font-bold rounded-2xl shadow-lg transition-all active:scale-95 text-xs flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{isSubmittingRating ? (
|
||||
<Loader2 className="w-4.5 h-4.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<span>{t('save') || 'Gửi đánh giá'}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CommentModal
|
||||
isOpen={isCommentModalOpen}
|
||||
onClose={() => setIsCommentModalOpen(false)}
|
||||
|
||||
Reference in New Issue
Block a user