fix: lỗi hiển thị ở frontend
This commit is contained in:
@@ -5,8 +5,9 @@ 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, Lock, Globe, Sun, Moon, Laptop, Users } from 'lucide-react';
|
||||
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 { UserManagementModal } from '@/components/UserManagementModal';
|
||||
import { ReportBusinessModal } from '../components/ReportBusinessModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { CreateTourModal } from '../components/CreateTourModal';
|
||||
@@ -227,6 +228,54 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||||
const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false);
|
||||
const [blacklist, setBlacklist] = useState<any[]>([]);
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false);
|
||||
const mapCenter = useTourStore(state => state.mapCenter);
|
||||
|
||||
// Recommendations and GPS States
|
||||
const [recommendedLocations, setRecommendedLocations] = useState<any[]>([]);
|
||||
const [isRecommendedOpen, setIsRecommendedOpen] = useState(false);
|
||||
const [isProposeModalOpen, setIsProposeModalOpen] = useState(false);
|
||||
const [sortBlacklistByDistance, setSortBlacklistByDistance] = useState(false);
|
||||
const [sortRecsByDistance, setSortRecsByDistance] = useState(false);
|
||||
const [userGpsPos, setUserGpsPos] = useState<[number, number] | null>(null);
|
||||
|
||||
const [proposeForm, setProposeForm] = useState({
|
||||
name: '',
|
||||
type: 'RESTAURANT',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
description: '',
|
||||
stars: 5
|
||||
});
|
||||
|
||||
const fetchBlacklist = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/reports/blacklist');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBlacklist(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Lỗi khi tải blacklist:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/recommendations');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRecommendedLocations(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Lỗi khi tải danh sách đề xuất:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTrustedUsers = async () => {
|
||||
try {
|
||||
@@ -240,8 +289,121 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
}
|
||||
};
|
||||
|
||||
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
|
||||
const R = 6371; // Earth radius in km
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
};
|
||||
|
||||
const requestGpsPosition = () => {
|
||||
if (!navigator.geolocation) {
|
||||
alert("Trình duyệt không hỗ trợ định vị GPS.");
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
|
||||
setUserGpsPos(posArray);
|
||||
setUserPos(posArray);
|
||||
setMapCenter(posArray);
|
||||
},
|
||||
() => {
|
||||
alert("Không thể truy cập vị trí của bạn. Vui lòng cho phép quyền truy cập vị trí.");
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleProposeSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!proposeForm.name || !proposeForm.description) {
|
||||
alert("Vui lòng điền tên địa điểm và mô tả.");
|
||||
return;
|
||||
}
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
alert("Bạn cần đăng nhập để gửi đề xuất địa điểm.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/recommendations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...proposeForm,
|
||||
latitude: parseFloat(proposeForm.latitude),
|
||||
longitude: parseFloat(proposeForm.longitude)
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
alert("Đề xuất của bạn đã được gửi thành công và đang chờ Admin phê duyệt!");
|
||||
setIsProposeModalOpen(false);
|
||||
setProposeForm({
|
||||
name: '',
|
||||
type: 'RESTAURANT',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
latitude: userPos[0].toString(),
|
||||
longitude: userPos[1].toString(),
|
||||
description: '',
|
||||
stars: 5
|
||||
});
|
||||
fetchRecommendations();
|
||||
} else {
|
||||
const errData = await res.json();
|
||||
alert(errData.message || "Gửi đề xuất thất bại.");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Đã xảy ra lỗi kết nối.");
|
||||
}
|
||||
};
|
||||
|
||||
const processedBlacklist = React.useMemo(() => {
|
||||
if (!sortBlacklistByDistance || !userGpsPos) return blacklist;
|
||||
return [...blacklist].sort((a, b) => {
|
||||
if (typeof a.latitude !== 'number' || typeof a.longitude !== 'number') return 1;
|
||||
if (typeof b.latitude !== 'number' || typeof b.longitude !== 'number') return -1;
|
||||
const distA = calculateDistance(userGpsPos[0], userGpsPos[1], a.latitude, a.longitude);
|
||||
const distB = calculateDistance(userGpsPos[0], userGpsPos[1], b.latitude, b.longitude);
|
||||
return distA - distB;
|
||||
});
|
||||
}, [blacklist, sortBlacklistByDistance, userGpsPos]);
|
||||
|
||||
const processedRecommendations = React.useMemo(() => {
|
||||
if (!sortRecsByDistance || !userGpsPos) return recommendedLocations;
|
||||
return [...recommendedLocations].sort((a, b) => {
|
||||
if (typeof a.latitude !== 'number' || typeof a.longitude !== 'number') return 1;
|
||||
if (typeof b.latitude !== 'number' || typeof b.longitude !== 'number') return -1;
|
||||
const distA = calculateDistance(userGpsPos[0], userGpsPos[1], a.latitude, a.longitude);
|
||||
const distB = calculateDistance(userGpsPos[0], userGpsPos[1], b.latitude, b.longitude);
|
||||
return distA - distB;
|
||||
});
|
||||
}, [recommendedLocations, sortRecsByDistance, userGpsPos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isProposeModalOpen) {
|
||||
setProposeForm(prev => ({
|
||||
...prev,
|
||||
latitude: userPos[0].toString(),
|
||||
longitude: userPos[1].toString()
|
||||
}));
|
||||
}
|
||||
}, [isProposeModalOpen, userPos]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrustedUsers();
|
||||
fetchBlacklist();
|
||||
fetchRecommendations();
|
||||
}, []);
|
||||
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
|
||||
@@ -565,6 +727,16 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</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-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">
|
||||
@@ -824,6 +996,88 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Render blacklist markers */}
|
||||
{blacklist.map((item) => {
|
||||
if (typeof item.latitude !== 'number' || typeof item.longitude !== 'number') return null;
|
||||
return (
|
||||
<Marker
|
||||
key={`blacklist-${item.id}`}
|
||||
position={[item.latitude, item.longitude]}
|
||||
icon={L.divIcon({
|
||||
html: `<div class="bg-red-600 text-white p-2 rounded-full shadow-lg border-2 border-white flex items-center justify-center animate-pulse"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield-alert"><path d="M20 13c0 5-3.5 7.5-7.66 9.7a1 1 0 0 1-.68 0C7.5 20.5 4 18 4 13V6a1 1 0 0 1 .76-.97l8-2a1 1 0 0 1 .48 0l8 2A1 1 0 0 1 20 6z"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg></div>`,
|
||||
className: 'custom-blacklist-marker',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
})}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -16]} opacity={1}>
|
||||
<div className="p-2 max-w-[200px] text-left">
|
||||
<div className="font-black text-red-600 text-xs mb-1 uppercase tracking-tight flex items-center gap-1">
|
||||
⚠️ Blacklist: {item.name}
|
||||
</div>
|
||||
<div className="text-[10px] bg-red-50 text-red-700 px-1.5 py-0.5 rounded font-black uppercase tracking-wider mb-1 w-max">
|
||||
{item.type}
|
||||
</div>
|
||||
{item.address && (
|
||||
<div className="text-[10px] text-gray-500 mb-1 font-semibold">{item.address}</div>
|
||||
)}
|
||||
{item.phone && (
|
||||
<div className="text-[9px] text-gray-400">SĐT: {item.phone}</div>
|
||||
)}
|
||||
<div className="text-[10px] text-red-600 font-medium italic mt-1 pt-1 border-t border-red-100">
|
||||
Lý do: {item.reason}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Render recommended markers */}
|
||||
{recommendedLocations.map((item) => {
|
||||
if (typeof item.latitude !== 'number' || typeof item.longitude !== 'number') return null;
|
||||
return (
|
||||
<Marker
|
||||
key={`recommendation-${item.id}`}
|
||||
position={[item.latitude, item.longitude]}
|
||||
icon={L.divIcon({
|
||||
html: `<div class="bg-emerald-600 text-white p-2 rounded-full shadow-lg border-2 border-white flex items-center justify-center animate-pulse"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div>`,
|
||||
className: 'custom-recommended-marker',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
})}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -16]} opacity={1}>
|
||||
<div className="p-2.5 max-w-[220px] text-left">
|
||||
<div className="font-black text-emerald-600 text-xs mb-1 uppercase tracking-tight flex items-center gap-1">
|
||||
🌟 {item.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 text-amber-500 mb-1">
|
||||
{Array.from({ length: item.stars }).map((_, i) => (
|
||||
<span key={i}>★</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[10px] bg-emerald-50 text-emerald-700 px-1.5 py-0.5 rounded font-black uppercase tracking-wider mb-1 w-max">
|
||||
{item.type === 'RESTAURANT' ? 'Nhà hàng' : item.type === 'HOTEL' ? 'Khách sạn' : 'Homestay'}
|
||||
</div>
|
||||
{item.address && (
|
||||
<div className="text-[10px] text-gray-500 mb-1 font-semibold">{item.address}</div>
|
||||
)}
|
||||
{item.phone && (
|
||||
<div className="text-[9px] text-gray-400">SĐT: {item.phone}</div>
|
||||
)}
|
||||
{item.email && (
|
||||
<div className="text-[9px] text-gray-400">Email: {item.email}</div>
|
||||
)}
|
||||
<div className="text-[10px] text-slate-600 font-medium italic mt-1 pt-1 border-t border-emerald-100 whitespace-pre-line">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
|
||||
{/* Context Menu Chia sẻ */}
|
||||
@@ -965,6 +1219,353 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating Widgets Container (Bottom Right) */}
|
||||
<div className="absolute bottom-6 right-6 z-[1002] pointer-events-auto flex flex-col items-end gap-2">
|
||||
{/* Recommended Locations Toggle Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRecommendedOpen(prev => !prev);
|
||||
setIsBlacklistOpen(false);
|
||||
}}
|
||||
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 border-emerald-500/20"
|
||||
>
|
||||
<Star className="w-4 h-4 text-emerald-500 fill-emerald-500 animate-pulse" />
|
||||
<span>{t('recommendedTitle') || 'Đề xuất dịch vụ'} ({recommendedLocations.length})</span>
|
||||
</button>
|
||||
|
||||
{/* Blacklist Toggle Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsBlacklistOpen(prev => !prev);
|
||||
setIsRecommendedOpen(false);
|
||||
}}
|
||||
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 border-red-500/20"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4 text-red-500 animate-pulse" />
|
||||
<span>{t('blacklistTitle') || 'Danh sách đen'} ({blacklist.length})</span>
|
||||
</button>
|
||||
|
||||
{/* Recommendations Panel */}
|
||||
{isRecommendedOpen && (
|
||||
<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-emerald-600 dark:text-emerald-500 tracking-widest mb-3 flex items-center gap-2 border-b border-gray-100 dark:border-slate-800 pb-2">
|
||||
🌟 {t('recommendedTitle') || 'Đề xuất chất lượng'}
|
||||
</h4>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!sortRecsByDistance && !userGpsPos) {
|
||||
requestGpsPosition();
|
||||
}
|
||||
setSortRecsByDistance(prev => !prev);
|
||||
}}
|
||||
className={`text-[9px] px-2.5 py-1 rounded-xl font-bold transition-all cursor-pointer ${
|
||||
sortRecsByDistance
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-600 dark:bg-slate-850 dark:text-slate-300 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{sortRecsByDistance ? '✓ Đang lọc gần đây' : '🔍 Xem gần tôi'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsProposeModalOpen(true)}
|
||||
className="text-[9px] px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold transition-all shadow-sm cursor-pointer"
|
||||
>
|
||||
+ Đề xuất địa điểm
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{processedRecommendations.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic text-center py-4">Chưa có địa điểm đề xuất nào.</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{processedRecommendations.map((item) => {
|
||||
const dist = userGpsPos && typeof item.latitude === 'number' && typeof item.longitude === 'number'
|
||||
? calculateDistance(userGpsPos[0], userGpsPos[1], item.latitude, item.longitude)
|
||||
: null;
|
||||
return (
|
||||
<div key={item.id} className="flex flex-col gap-1 p-2.5 bg-gray-50/50 dark:bg-slate-850/50 rounded-2xl border border-gray-100/50 dark:border-slate-800/50 text-left">
|
||||
<div className="flex items-start justify-between gap-1.5">
|
||||
<span className="text-xs font-bold text-gray-800 dark:text-slate-200 truncate">{item.name}</span>
|
||||
<span className="text-[8px] bg-emerald-50 dark:bg-emerald-950/40 text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 rounded font-black shrink-0">
|
||||
{item.type === 'RESTAURANT' ? 'Nhà hàng' : item.type === 'HOTEL' ? 'Khách sạn' : 'Homestay'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 text-amber-500 text-[10px]">
|
||||
{Array.from({ length: item.stars || 5 }).map((_, i) => (
|
||||
<span key={i}>★</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{item.address && (
|
||||
<div className="text-[10px] text-gray-500 dark:text-gray-400 truncate">{item.address}</div>
|
||||
)}
|
||||
|
||||
{item.phone && (
|
||||
<div className="text-[9px] text-gray-400">SĐT: {item.phone}</div>
|
||||
)}
|
||||
|
||||
{dist !== null && (
|
||||
<div className="text-[10px] text-emerald-600 dark:text-emerald-400 font-bold">
|
||||
Cách đây: {dist < 1 ? `${Math.round(dist * 1000)} m` : `${dist.toFixed(1)} km`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.latitude && item.longitude && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserPos([item.latitude, item.longitude]);
|
||||
setMapCenter([item.latitude, item.longitude]);
|
||||
}}
|
||||
className="text-[9px] font-bold text-blue-600 dark:text-blue-400 text-left hover:underline flex items-center gap-1 mt-0.5 cursor-pointer bg-transparent border-none p-0"
|
||||
>
|
||||
📍 Định vị trên bản đồ
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="text-[10px] text-slate-600 dark:text-slate-400 font-medium italic mt-1.5 pt-1.5 border-t border-gray-100 dark:border-slate-800 whitespace-pre-line">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blacklist Panel */}
|
||||
{isBlacklistOpen && (
|
||||
<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-red-600 dark:text-red-500 tracking-widest mb-3 flex items-center gap-2 border-b border-gray-100 dark:border-slate-800 pb-2">
|
||||
⚠️ {t('blacklistTitle')}
|
||||
</h4>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!sortBlacklistByDistance && !userGpsPos) {
|
||||
requestGpsPosition();
|
||||
}
|
||||
setSortBlacklistByDistance(prev => !prev);
|
||||
}}
|
||||
className={`text-[9px] px-2.5 py-1 rounded-xl font-bold transition-all cursor-pointer ${
|
||||
sortBlacklistByDistance
|
||||
? 'bg-red-600 text-white shadow-sm'
|
||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-600 dark:bg-slate-850 dark:text-slate-300 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{sortBlacklistByDistance ? '✓ Đang lọc gần đây' : '🔍 Xem gần tôi'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{processedBlacklist.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 italic text-center py-4">{t('emptyBlacklist')}</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{processedBlacklist.map((item) => {
|
||||
const dist = userGpsPos && typeof item.latitude === 'number' && typeof item.longitude === 'number'
|
||||
? calculateDistance(userGpsPos[0], userGpsPos[1], item.latitude, item.longitude)
|
||||
: null;
|
||||
return (
|
||||
<div key={item.id} className="flex flex-col gap-1 p-2.5 bg-gray-50/50 dark:bg-slate-850/50 rounded-2xl border border-gray-100/50 dark:border-slate-800/50 text-left">
|
||||
<div className="flex items-start justify-between gap-1.5">
|
||||
<span className="text-xs font-bold text-gray-800 dark:text-slate-200 truncate">{item.name}</span>
|
||||
<span className="text-[8px] bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 px-1 py-0.5 rounded font-black shrink-0">
|
||||
{item.type}
|
||||
</span>
|
||||
</div>
|
||||
{item.address && (
|
||||
<div className="text-[10px] text-gray-500 dark:text-gray-400 truncate">{item.address}</div>
|
||||
)}
|
||||
|
||||
{item.phone && (
|
||||
<div className="text-[9px] text-gray-400">SĐT: {item.phone}</div>
|
||||
)}
|
||||
|
||||
{dist !== null && (
|
||||
<div className="text-[10px] text-red-600 dark:text-red-400 font-bold">
|
||||
Cách đây: {dist < 1 ? `${Math.round(dist * 1000)} m` : `${dist.toFixed(1)} km`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.latitude && item.longitude && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserPos([item.latitude, item.longitude]);
|
||||
setMapCenter([item.latitude, item.longitude]);
|
||||
}}
|
||||
className="text-[9px] font-bold text-blue-600 dark:text-blue-400 text-left hover:underline flex items-center gap-1 mt-0.5 cursor-pointer bg-transparent border-none p-0"
|
||||
>
|
||||
📍 Định vị trên bản đồ
|
||||
</button>
|
||||
)}
|
||||
<div className="text-[10px] text-red-600 dark:text-red-400 font-medium italic mt-1.5 pt-1.5 border-t border-gray-100 dark:border-slate-850">
|
||||
Lý do: {item.reason}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Propose Location Modal */}
|
||||
{isProposeModalOpen && (
|
||||
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-950/60 backdrop-blur-sm" onClick={() => setIsProposeModalOpen(false)} />
|
||||
<div className="relative w-full max-w-md bg-white dark:bg-slate-900 border border-gray-150 dark:border-slate-800 rounded-3xl shadow-2xl overflow-hidden flex flex-col p-6 animate-in zoom-in-95 duration-200">
|
||||
<h3 className="text-base font-black uppercase text-emerald-600 dark:text-emerald-500 tracking-wider mb-4 flex items-center gap-1.5">
|
||||
🌟 Đề xuất địa điểm chất lượng
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleProposeSubmit} className="space-y-3.5 text-left text-gray-850 dark:text-slate-100 text-xs">
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-black text-gray-450 mb-1">Tên địa điểm *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ví dụ: Khách sạn Mường Thanh"
|
||||
value={proposeForm.name}
|
||||
onChange={(e) => setProposeForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-black text-gray-455 mb-1">Loại hình *</label>
|
||||
<select
|
||||
value={proposeForm.type}
|
||||
onChange={(e) => setProposeForm(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
|
||||
>
|
||||
<option value="RESTAURANT">Nhà hàng</option>
|
||||
<option value="HOTEL">Khách sạn</option>
|
||||
<option value="HOMESTAY">Homestay</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-black text-gray-455 mb-1">Đánh giá sao *</label>
|
||||
<select
|
||||
value={proposeForm.stars}
|
||||
onChange={(e) => setProposeForm(prev => ({ ...prev, stars: parseInt(e.target.value) || 5 }))}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
|
||||
>
|
||||
<option value="5">★★★★★ (5 sao)</option>
|
||||
<option value="4">★★★★☆ (4 sao)</option>
|
||||
<option value="3">★★★☆☆ (3 sao)</option>
|
||||
<option value="2">★★☆☆☆ (2 sao)</option>
|
||||
<option value="1">★☆☆☆☆ (1 sao)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-black text-gray-455 mb-1">Số điện thoại</label>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="SĐT liên hệ"
|
||||
value={proposeForm.phone}
|
||||
onChange={(e) => setProposeForm(prev => ({ ...prev, phone: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-black text-gray-455 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email liên hệ"
|
||||
value={proposeForm.email}
|
||||
onChange={(e) => setProposeForm(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-black text-gray-455 mb-1">Địa chỉ</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Địa chỉ cụ thể"
|
||||
value={proposeForm.address}
|
||||
onChange={(e) => setProposeForm(prev => ({ ...prev, address: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 bg-gray-50 dark:bg-slate-950/40 p-2.5 rounded-xl border border-gray-150 dark:border-slate-850">
|
||||
<div>
|
||||
<label className="block text-[9px] uppercase font-black text-gray-450 mb-0.5">Vĩ độ (Lat)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={proposeForm.latitude}
|
||||
onChange={(e) => setProposeForm(prev => ({ ...prev, latitude: e.target.value }))}
|
||||
className="w-full px-2 py-1 border border-gray-200 dark:border-slate-800 rounded-lg focus:outline-none text-[11px] bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] uppercase font-black text-gray-450 mb-0.5">Kinh độ (Lng)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={proposeForm.longitude}
|
||||
onChange={(e) => setProposeForm(prev => ({ ...prev, longitude: e.target.value }))}
|
||||
className="w-full px-2 py-1 border border-gray-200 dark:border-slate-800 rounded-lg focus:outline-none text-[11px] bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-black text-gray-455 mb-1">Mô tả / Review lý do đề xuất *</label>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
placeholder="Ví dụ: Thức ăn tươi ngon, không gian ấm cúng, phục vụ chu đáo..."
|
||||
value={proposeForm.description}
|
||||
onChange={(e) => setProposeForm(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-slate-800 rounded-xl focus:outline-none focus:ring-1 focus:ring-emerald-500 bg-gray-50/20 dark:bg-slate-950/40 text-gray-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsProposeModalOpen(false)}
|
||||
className="flex-1 py-2.5 bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-750 text-gray-600 dark:text-slate-200 rounded-xl font-bold transition-all text-xs cursor-pointer"
|
||||
>
|
||||
Hủy bỏ
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold transition-all text-xs shadow-md active:scale-95 cursor-pointer"
|
||||
>
|
||||
Gửi đề xuất
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Report Business Modal */}
|
||||
<ReportBusinessModal
|
||||
isOpen={isReportModalOpen}
|
||||
onClose={() => {
|
||||
setIsReportModalOpen(false);
|
||||
fetchBlacklist();
|
||||
}}
|
||||
initialLatitude={mapCenter ? mapCenter[0] : undefined}
|
||||
initialLongitude={mapCenter ? mapCenter[1] : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user