1655 lines
80 KiB
TypeScript
1655 lines
80 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { MapContainer, TileLayer, Marker, useMap, useMapEvents, Tooltip } from 'react-leaflet';
|
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
|
import L from 'leaflet';
|
|
import 'leaflet/dist/leaflet.css';
|
|
import { useTourStore } from '@/store/useTourStore';
|
|
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft, Lock, Globe, Sun, Moon, Laptop, Users, ShieldAlert, Star } from 'lucide-react';
|
|
import { UserManagementModal } from '@/components/UserManagementModal';
|
|
import { ReportBusinessModal } from '../components/ReportBusinessModal';
|
|
import { useNotification } from '@/hooks/useNotification';
|
|
import { useConfirm } from '@/hooks/useConfirm';
|
|
import { CreateTourModal } from '../components/CreateTourModal';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useTheme } from '@/hooks/useTheme';
|
|
import { PublicPhotoModal } from '../components/PublicPhotoModal';
|
|
|
|
// Fix lỗi icon mặc định của Leaflet
|
|
const DefaultIcon = L.icon({
|
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
|
iconSize: [25, 41],
|
|
iconAnchor: [12, 41],
|
|
});
|
|
L.Marker.prototype.options.icon = DefaultIcon;
|
|
|
|
// Tag ID to Label Mapping for Public Photos
|
|
const PHOTO_TAG_LABELS: { [key: string]: string } = {
|
|
'phong-canh': '🏞️ Phong cảnh',
|
|
'con-nguoi': '👥 Con người',
|
|
'doi-thuong': '🎒 Đời thường',
|
|
'bien': '🌊 Biển',
|
|
'nui': '⛰️ Núi',
|
|
'do-thi': '🏙️ Đô thị',
|
|
'thuc-an': '🍜 Thức ăn',
|
|
'cho': '🛍️ Chợ',
|
|
'hien-dai': '🏗️ Hiện đại',
|
|
'dong-vat': '🦁 Động vật',
|
|
'thu-cung': '🐕 Thú cưng'
|
|
};
|
|
|
|
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
|
|
function RecenterMap({ position }: { position: [number, number] }) {
|
|
const map = useMap();
|
|
useEffect(() => {
|
|
map.setView(position, map.getZoom());
|
|
}, [position, map]);
|
|
return null;
|
|
}
|
|
|
|
// Component Helper để đóng menu khi tương tác với bản đồ
|
|
function MapEvents({ onMapAction }: { onMapAction: () => void }) {
|
|
useMapEvents({
|
|
click: () => onMapAction(),
|
|
movestart: onMapAction,
|
|
dragstart: onMapAction,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
// Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ
|
|
function MapTracker() {
|
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
|
useMapEvents({
|
|
moveend: (e) => {
|
|
const map = e.target;
|
|
const center = map.getCenter();
|
|
const zoom = map.getZoom();
|
|
const coords: [number, number] = [center.lat, center.lng];
|
|
|
|
setMapCenter(coords);
|
|
// Lưu vị trí và mức zoom vào localStorage để sử dụng cho lần sau
|
|
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
|
|
},
|
|
});
|
|
return null;
|
|
}
|
|
|
|
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos, onLoginSuccess, onGoToDashboard }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => void, onGoToDashboard?: () => void }) => {
|
|
// Check if user is logged in (real user) or is a guest
|
|
const guestToken = localStorage.getItem('guest_token');
|
|
const isLoggedInOrGuest = user || guestToken;
|
|
|
|
// Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa
|
|
const publicTours = useTourStore(state => state.publicTours);
|
|
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
|
const fetchTour = useTourStore(state => state.fetchTour);
|
|
const setMapCenter = useTourStore(state => state.setMapCenter);
|
|
|
|
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);
|
|
const touchMovedRef = React.useRef<boolean>(false);
|
|
|
|
// Phân loại trạng thái Tour dựa vào thời gian
|
|
const getTourStatus = (tour: any) => {
|
|
const now = new Date();
|
|
const startDate = tour.startDate ? new Date(tour.startDate) : null;
|
|
const endDate = tour.endDate ? new Date(tour.endDate) : null;
|
|
if (endDate && endDate < now) {
|
|
return { color: 'white', borderClass: 'border-white', label: 'Hành trình đã kết thúc' };
|
|
}
|
|
if (startDate && endDate && startDate <= now && endDate >= now) {
|
|
return { color: 'red', borderClass: 'border-rose-500', label: 'Hành trình đang diễn ra' };
|
|
}
|
|
return { color: 'green', borderClass: 'border-emerald-500', label: 'Hành trình mới tạo' };
|
|
};
|
|
|
|
const triggerJoinConfirmation = async (tour: any) => {
|
|
const currentUserId = user?.id;
|
|
const myParticipant = tour.participants?.find((p: any) => p.userId === currentUserId);
|
|
if (myParticipant) {
|
|
notify({
|
|
title: 'Thông báo',
|
|
message: 'Bạn đã là thành viên của hành trình này.',
|
|
type: 'info'
|
|
});
|
|
return;
|
|
}
|
|
const hasPendingRequest = tour.joinRequests && tour.joinRequests.length > 0;
|
|
if (hasPendingRequest) {
|
|
notify({
|
|
title: 'Thông báo',
|
|
message: 'Bạn đã gửi yêu cầu tham gia hành trình này và đang chờ duyệt.',
|
|
type: 'info'
|
|
});
|
|
return;
|
|
}
|
|
|
|
const isConfirmed = await confirm({
|
|
title: 'Yêu cầu tham gia Tour',
|
|
message: `Bạn có chắc chắn muốn gửi yêu cầu tham gia vào tour "${tour.title}" không?`
|
|
});
|
|
|
|
if (isConfirmed) {
|
|
try {
|
|
const res = await fetch(`/api/v1/tours/${tour.id}/join-requests`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(data.message || 'Gửi yêu cầu tham gia thất bại.');
|
|
}
|
|
notify({
|
|
title: 'Thành công',
|
|
message: 'Đã gửi yêu cầu tham gia tour. Vui lòng chờ chủ tour duyệt.',
|
|
type: 'success'
|
|
});
|
|
fetchPublicTours();
|
|
} catch (err: any) {
|
|
notify({
|
|
title: 'Lỗi',
|
|
message: err.message || 'Không thể gửi yêu cầu tham gia.',
|
|
type: 'error'
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
|
|
const [initialViewState] = useState(() => {
|
|
const saved = localStorage.getItem('map_view_state');
|
|
if (saved) {
|
|
try { return JSON.parse(saved); } catch (e) { return null; }
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
|
|
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
|
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
|
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
|
|
const [selectedPhotoGroup, setSelectedPhotoGroup] = useState<any[]>([]);
|
|
const [selectedPhotoFilterTags, setSelectedPhotoFilterTags] = useState<string[]>([]);
|
|
|
|
const groupedPhotos = React.useMemo(() => {
|
|
// Filter photos by selected tags if any are selected
|
|
let filteredPhotos = publicPhotos;
|
|
if (selectedPhotoFilterTags.length > 0) {
|
|
filteredPhotos = publicPhotos.filter((photo) => {
|
|
const photoTags = photo.metadata?.tags as string[] | undefined;
|
|
if (!Array.isArray(photoTags)) return false;
|
|
// Check if photo has at least one of the selected tags
|
|
return selectedPhotoFilterTags.some(tag => photoTags.includes(tag));
|
|
});
|
|
}
|
|
|
|
const groups: { [key: string]: any[] } = {};
|
|
filteredPhotos.forEach((photo) => {
|
|
const lat = photo.metadata?.lat;
|
|
const lng = photo.metadata?.lng;
|
|
if (typeof lat === 'number' && typeof lng === 'number') {
|
|
const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
|
|
if (!groups[key]) {
|
|
groups[key] = [];
|
|
}
|
|
groups[key].push(photo);
|
|
}
|
|
});
|
|
// Sort each group by likes descending
|
|
Object.values(groups).forEach((group) => {
|
|
group.sort((a, b) => {
|
|
const likesA = a.metadata?.likedUserIds?.length || 0;
|
|
const likesB = b.metadata?.likedUserIds?.length || 0;
|
|
return likesB - likesA;
|
|
});
|
|
});
|
|
return Object.values(groups);
|
|
}, [publicPhotos, selectedPhotoFilterTags]);
|
|
|
|
const fetchPublicPhotos = async () => {
|
|
try {
|
|
const response = await fetch('/api/v1/public-photos');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setPublicPhotos(data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching public photos:', error);
|
|
}
|
|
};
|
|
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
|
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 {
|
|
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);
|
|
}
|
|
};
|
|
|
|
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
|
|
const [suggestions, setSuggestions] = useState<{ type: 'tour' | 'location', id: string, name: string, lat?: number, lon?: number }[]>([]);
|
|
const [isSearchingSuggestions, setIsSearchingSuggestions] = useState(false);
|
|
|
|
// Logic xử lý gợi ý tự động khi người dùng gõ
|
|
useEffect(() => {
|
|
const timer = setTimeout(async () => {
|
|
if (searchQuery.trim().length < 2) {
|
|
setSuggestions([]);
|
|
return;
|
|
}
|
|
|
|
setIsSearchingSuggestions(true);
|
|
try {
|
|
// 1. Lọc các Tour hiện có khớp với từ khóa
|
|
const tourMatches = publicTours
|
|
.filter(t => t.title.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
.map(t => ({ type: 'tour' as const, id: t.id, name: t.title }));
|
|
|
|
// 2. Tìm kiếm địa điểm thực tế trên bản đồ qua OpenStreetMap
|
|
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=5&addressdetails=1&accept-language=vi`);
|
|
const data = await res.json();
|
|
|
|
const locationMatches = data.map((item: any) => ({
|
|
type: 'location' as const,
|
|
id: item.place_id,
|
|
name: item.display_name,
|
|
lat: parseFloat(item.lat),
|
|
lon: parseFloat(item.lon)
|
|
}));
|
|
|
|
// Hợp nhất kết quả: Tour ưu tiên lên đầu
|
|
setSuggestions([...tourMatches, ...locationMatches]);
|
|
} catch (err) {
|
|
console.error("Lỗi tìm kiếm gợi ý:", err);
|
|
} finally {
|
|
setIsSearchingSuggestions(false);
|
|
}
|
|
}, 500); // Debounce 500ms để tránh gọi API quá nhiều
|
|
return () => clearTimeout(timer);
|
|
}, [searchQuery, publicTours]);
|
|
|
|
const [selectedFilterTag, setSelectedFilterTag] = useState<string | null>(null);
|
|
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
|
|
|
|
// Tổng hợp nhãn từ danh sách Tour đang có để hiển thị bộ lọc đầy đủ (bao gồm cả nhãn tùy chỉnh)
|
|
const allFilterTags = React.useMemo(() => {
|
|
const tagsSet = new Set(availableTags);
|
|
publicTours.forEach(tour => {
|
|
tour.tags?.forEach((tag: string) => tagsSet.add(tag));
|
|
});
|
|
return Array.from(tagsSet);
|
|
}, [publicTours]);
|
|
|
|
// Tổng hợp nhãn từ danh sách ảnh công khai để lọc ảnh
|
|
const availablePhotoTags = React.useMemo(() => {
|
|
const tagsSet = new Set<string>();
|
|
publicPhotos.forEach(photo => {
|
|
const tags = photo.metadata?.tags as string[] | undefined;
|
|
if (Array.isArray(tags)) {
|
|
tags.forEach(tag => tagsSet.add(tag));
|
|
}
|
|
});
|
|
return Array.from(tagsSet).sort();
|
|
}, [publicPhotos]);
|
|
|
|
// State cho menu chuột phải chia sẻ
|
|
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean, isParticipant: boolean, hasPendingRequest: boolean } | null>(null);
|
|
|
|
const handleShare = (id: string, title: string) => {
|
|
const shareUrl = `${window.location.origin}?viewTour=${id}`;
|
|
if (navigator.share) {
|
|
navigator.share({
|
|
title: title,
|
|
text: `Khám phá hành trình du lịch: ${title}`,
|
|
url: shareUrl,
|
|
}).catch(() => {});
|
|
} else if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
|
notify({
|
|
title: 'Thành công',
|
|
message: 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!',
|
|
type: 'success'
|
|
});
|
|
});
|
|
} else {
|
|
// Giải pháp dự phòng cho môi trường không có HTTPS
|
|
const textArea = document.createElement("textarea");
|
|
textArea.value = shareUrl;
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
notify({
|
|
title: 'Thành công',
|
|
message: 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!',
|
|
type: 'success'
|
|
});
|
|
} catch (err) {}
|
|
document.body.removeChild(textArea);
|
|
}
|
|
setShareMenu(null);
|
|
};
|
|
|
|
const handleRequestJoin = async (tourId: string) => {
|
|
setShareMenu(null);
|
|
try {
|
|
const res = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(data.message || 'Gửi yêu cầu tham gia thất bại.');
|
|
}
|
|
notify({
|
|
title: 'Thành công',
|
|
message: 'Đã gửi yêu cầu tham gia tour. Vui lòng chờ chủ tour duyệt.',
|
|
type: 'success'
|
|
});
|
|
fetchPublicTours();
|
|
} catch (err: any) {
|
|
notify({
|
|
title: 'Lỗi',
|
|
message: err.message || 'Không thể gửi yêu cầu tham gia.',
|
|
type: 'error'
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSelectSuggestion = (s: any) => {
|
|
if (s.type === 'tour') {
|
|
onViewTour(s.id);
|
|
} else if (s.lat && s.lon) {
|
|
const pos: [number, number] = [s.lat, s.lon];
|
|
setUserPos(pos);
|
|
setMapCenter(pos);
|
|
notify({
|
|
title: 'Tìm thấy địa điểm',
|
|
message: `Đã di chuyển bản đồ tới: ${s.name.split(',')[0]}`,
|
|
type: 'success'
|
|
});
|
|
}
|
|
setSuggestions([]);
|
|
setSearchQuery('');
|
|
};
|
|
|
|
const filteredTours = React.useMemo(() => {
|
|
if (!selectedFilterTag) return publicTours;
|
|
return publicTours.filter(tour => tour.tags?.includes(selectedFilterTag));
|
|
}, [publicTours, selectedFilterTag]);
|
|
|
|
useEffect(() => {
|
|
// Luôn tải danh sách ảnh công khai để hiển thị trên bản đồ cho tất cả mọi người
|
|
fetchPublicPhotos();
|
|
|
|
// Chỉ tải danh sách tour khi người dùng đã đăng nhập và có token
|
|
if (user || localStorage.getItem('token')) {
|
|
fetchPublicTours();
|
|
}
|
|
|
|
// Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị
|
|
if (!initialViewState) {
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
|
|
setUserPos(posArray);
|
|
setMapCenter(posArray);
|
|
},
|
|
() => console.log("Không thể lấy vị trí người dùng")
|
|
);
|
|
} else {
|
|
setMapCenter(initialViewState.center);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const photoId = params.get('photoId');
|
|
if (photoId && publicPhotos.length > 0) {
|
|
const foundPhoto = publicPhotos.find((p) => p.id === photoId);
|
|
if (foundPhoto) {
|
|
const lat = foundPhoto.metadata?.lat;
|
|
const lng = foundPhoto.metadata?.lng;
|
|
if (typeof lat === 'number' && typeof lng === 'number') {
|
|
const group = publicPhotos.filter((p) => {
|
|
const pLat = p.metadata?.lat;
|
|
const pLng = p.metadata?.lng;
|
|
return typeof pLat === 'number' && typeof pLng === 'number' &&
|
|
Math.abs(pLat - lat) < 0.00001 &&
|
|
Math.abs(pLng - lng) < 0.00001;
|
|
});
|
|
group.sort((a, b) => {
|
|
const likesA = a.metadata?.likedUserIds?.length || 0;
|
|
const likesB = b.metadata?.likedUserIds?.length || 0;
|
|
return likesB - likesA;
|
|
});
|
|
setSelectedPhoto(foundPhoto);
|
|
setSelectedPhotoGroup(group);
|
|
} else {
|
|
setSelectedPhoto(foundPhoto);
|
|
setSelectedPhotoGroup([foundPhoto]);
|
|
}
|
|
}
|
|
}
|
|
}, [publicPhotos]);
|
|
|
|
return (
|
|
<div className="h-dvh w-full relative overflow-hidden">
|
|
{/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */}
|
|
<div className="absolute top-[calc(1rem+env(safe-area-inset-top,0px))] left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none">
|
|
{/* Nhóm bên trái: Quay lại và Thông tin vị trí */}
|
|
<div className="flex items-center gap-3 pointer-events-auto">
|
|
<button
|
|
onClick={onBack}
|
|
className="w-11 h-11 flex items-center justify-center bg-[var(--surface)] rounded-full shadow-xl hover:bg-[var(--background)] transition-all border border-[var(--border)] shrink-0"
|
|
title="Quay lại"
|
|
>
|
|
<ChevronLeft className="w-6 h-6 text-[var(--text-primary)]" />
|
|
</button>
|
|
|
|
{/* Nút lọc Tag và Dropdown */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setIsFilterDropdownOpen(prev => !prev)}
|
|
className="w-11 h-11 bg-[var(--surface)] rounded-full shadow-xl hover:bg-[var(--background)] transition-all border border-[var(--border)] flex items-center justify-center shrink-0"
|
|
title="Lọc theo loại"
|
|
>
|
|
<Filter className="w-6 h-6 text-[var(--text-primary)]" />
|
|
</button>
|
|
|
|
{/* Filter Dropdown Content */}
|
|
{isFilterDropdownOpen && (
|
|
<div className="absolute top-full left-0 mt-3 bg-[var(--surface)]/90 backdrop-blur-md p-3 rounded-2xl shadow-xl border border-[var(--surface)]/20 flex flex-col gap-3 max-w-[220px] z-[1003] animate-in slide-in-from-left-2 duration-200 max-h-[400px] overflow-y-auto">
|
|
{/* Tour Filter Section */}
|
|
<div>
|
|
<div className="flex items-center gap-2 px-2 py-1 border-b border-[var(--border)] mb-1.5">
|
|
<Filter className="w-3.5 h-3.5 text-blue-600" />
|
|
<span className="text-[10px] font-black uppercase text-[var(--text-muted)] tracking-wider">🧳 Chuyến đi</span>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<button
|
|
onClick={() => { setSelectedFilterTag(null); setIsFilterDropdownOpen(false); }}
|
|
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-[var(--background)] text-[var(--text-muted)] hover:bg-[var(--border)]'}`}
|
|
>
|
|
Tất cả
|
|
</button>
|
|
{allFilterTags.map(tag => (
|
|
<button
|
|
key={tag}
|
|
onClick={() => { setSelectedFilterTag(tag === selectedFilterTag ? null : tag); setIsFilterDropdownOpen(false); }}
|
|
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-[var(--background)] text-[var(--text-muted)] hover:bg-[var(--border)]'}`}
|
|
>
|
|
{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Photo Filter Section */}
|
|
{availablePhotoTags.length > 0 && (
|
|
<div className="border-t border-[var(--border)] pt-3">
|
|
<div className="flex items-center gap-2 px-2 py-1 border-b border-[var(--border)] mb-1.5">
|
|
<ImageIcon className="w-3.5 h-3.5 text-emerald-600" />
|
|
<span className="text-[10px] font-black uppercase text-[var(--text-muted)] tracking-wider">📸 Ảnh công khai</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<button
|
|
onClick={() => { setSelectedPhotoFilterTags([]); setIsFilterDropdownOpen(false); }}
|
|
className={`px-2 py-1 rounded-lg text-[9px] font-bold transition-all ${selectedPhotoFilterTags.length === 0 ? 'bg-emerald-600 text-white' : 'bg-[var(--background)] text-[var(--text-muted)] hover:bg-[var(--border)]'}`}
|
|
>
|
|
Tất cả
|
|
</button>
|
|
{availablePhotoTags.map(tagId => {
|
|
const tagLabel = PHOTO_TAG_LABELS[tagId] || tagId;
|
|
return (
|
|
<button
|
|
key={tagId}
|
|
onClick={() => {
|
|
setSelectedPhotoFilterTags(prev =>
|
|
prev.includes(tagId)
|
|
? prev.filter(t => t !== tagId)
|
|
: [...prev, tagId]
|
|
);
|
|
}}
|
|
className={`px-2 py-1 rounded-lg text-[9px] font-bold transition-all ${selectedPhotoFilterTags.includes(tagId) ? 'bg-emerald-600 text-white' : 'bg-[var(--background)] text-[var(--text-muted)] hover:bg-[var(--border)]'}`}
|
|
title={tagLabel}
|
|
>
|
|
{tagLabel.length > 13 ? tagLabel.substring(0, 13) + '...' : tagLabel}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search Box - Thay thế div "Khám phá khu vực" */}
|
|
<div className="relative flex items-center bg-[var(--surface)]/90 backdrop-blur-md rounded-2xl shadow-xl border border-[var(--surface)]/20 flex-1 max-w-xs sm:max-w-md pointer-events-auto hidden sm:flex px-4 py-3">
|
|
<Navigation className="w-4 h-4 text-blue-600 mr-2 flex-shrink-0" />
|
|
<input
|
|
type="text"
|
|
placeholder="Tìm kiếm địa điểm, tour..."
|
|
className="flex-1 bg-transparent outline-none text-[var(--text-primary)] text-sm font-medium"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
{isSearchingSuggestions && <Loader2 className="w-4 h-4 animate-spin text-blue-500 mr-2" />}
|
|
{searchQuery && (
|
|
<button onClick={() => { setSearchQuery(''); setSuggestions([]); }} className="p-1 text-[var(--text-muted)] hover:text-[var(--text-secondary)] rounded-full">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Dropdown danh sách gợi ý */}
|
|
{suggestions.length > 0 && (
|
|
<div className="absolute top-full left-0 right-0 mt-3 bg-[var(--surface)]/95 backdrop-blur-md rounded-2xl shadow-2xl border border-[var(--surface)]/20 overflow-hidden z-[1003] animate-in slide-in-from-top-2 duration-200">
|
|
{suggestions.map((s, idx) => (
|
|
<button
|
|
key={`${s.type}-${s.id}-${idx}`}
|
|
onClick={() => handleSelectSuggestion(s)}
|
|
className="w-full text-left px-4 py-3 hover:bg-[var(--background)] flex items-center gap-3 transition-colors border-b border-[var(--border)] last:border-0"
|
|
>
|
|
<div className={`p-2 rounded-xl flex-shrink-0 ${s.type === 'tour' ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'}`}>
|
|
{s.type === 'tour' ? <ImageIcon className="w-4 h-4" /> : <MapPin className="w-4 h-4" />}
|
|
</div>
|
|
<div className="flex flex-col min-w-0">
|
|
<span className="text-sm font-bold text-[var(--text-primary)] truncate">{s.name}</span>
|
|
<span className="text-[10px] font-black uppercase text-[var(--text-muted)] tracking-wider">
|
|
{s.type === 'tour' ? 'Chuyến đi của bạn' : 'Địa điểm trên bản đồ'}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Nhóm bên phải: Các thao tác người dùng */}
|
|
<div className="flex items-center gap-2 pointer-events-auto">
|
|
{/* Nút Ảnh của tôi */}
|
|
{isLoggedInOrGuest && (
|
|
<button
|
|
onClick={() => {
|
|
console.log("Đang mở Ảnh của tôi...");
|
|
onOpenMyPhotos();
|
|
}}
|
|
className="w-11 h-11 md:w-auto bg-[var(--surface)] p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-100 text-blue-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold border border-blue-100 shrink-0"
|
|
title="Ảnh của tôi"
|
|
>
|
|
<ImageIcon className="w-5 h-5" />
|
|
<span className="hidden md:inline text-sm">Ảnh của tôi</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Nút tạo Tour mới */}
|
|
{user && !localStorage.getItem('guest_token') && (
|
|
<button
|
|
onClick={() => setIsCreateModalOpen(true)}
|
|
className="w-11 h-11 md:w-auto bg-green-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0"
|
|
title="Tạo Tour mới"
|
|
>
|
|
<Navigation className="w-5 h-5" />
|
|
<span className="hidden md:inline text-sm">Tạo Tour</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Nút Báo cáo sai phạm */}
|
|
<button
|
|
onClick={() => setIsReportModalOpen(true)}
|
|
className="w-11 h-11 md:w-auto bg-red-600 p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-700 text-white transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 cursor-pointer"
|
|
title={t('reportBusinessBtn') || 'Báo cáo sai phạm'}
|
|
>
|
|
<ShieldAlert className="w-5 h-5" />
|
|
<span className="hidden md:inline text-sm">{t('reportBusinessBtn') || 'Báo cáo'}</span>
|
|
</button>
|
|
|
|
{/* Lựa chọn Ngôn ngữ */}
|
|
<div className="relative group shrink-0">
|
|
<button className="w-11 h-11 bg-[var(--surface)] dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-[var(--background)] dark:hover:bg-slate-700 text-[var(--text-secondary)] dark:text-slate-200 transition-all flex items-center justify-center border border-[var(--border)] dark:border-slate-700">
|
|
<Globe className="w-5 h-5" />
|
|
</button>
|
|
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-[var(--surface)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
|
|
<button onClick={() => changeLanguage('vi')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'vi' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>Tiếng Việt</button>
|
|
<button onClick={() => changeLanguage('en')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'en' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>English</button>
|
|
<button onClick={() => changeLanguage('zh')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold ${lang === 'zh' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>中文</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Lựa chọn Giao diện */}
|
|
<div className="relative group shrink-0">
|
|
<button className="w-11 h-11 bg-[var(--surface)] dark:bg-slate-800 p-0 rounded-2xl shadow-xl hover:bg-[var(--background)] dark:hover:bg-slate-700 text-[var(--text-secondary)] dark:text-slate-200 transition-all flex items-center justify-center border border-[var(--border)] dark:border-slate-700">
|
|
{theme === 'light' && <Sun className="w-5 h-5 text-amber-500" />}
|
|
{theme === 'dark' && <Moon className="w-5 h-5 text-indigo-400" />}
|
|
{theme === 'system' && <Laptop className="w-5 h-5 animate-pulse" />}
|
|
</button>
|
|
<div className="absolute right-0 top-12 mt-1 hidden group-hover:block bg-[var(--surface)] dark:bg-slate-800 border border-[var(--border)] dark:border-slate-700 rounded-2xl shadow-2xl p-2 z-[9999] w-32 animate-in fade-in duration-200">
|
|
<button onClick={() => changeTheme('light')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'light' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
|
|
<Sun className="w-3.5 h-3.5 text-amber-500" /> {t('themeLight')}
|
|
</button>
|
|
<button onClick={() => changeTheme('dark')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'dark' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
|
|
<Moon className="w-3.5 h-3.5 text-indigo-400" /> {t('themeDark')}
|
|
</button>
|
|
<button onClick={() => changeTheme('system')} className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-2 ${theme === 'system' ? 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400' : 'text-[var(--text-secondary)] dark:text-slate-200 hover:bg-[var(--background)] dark:hover:bg-slate-700'}`}>
|
|
<Laptop className="w-3.5 h-3.5" /> {t('themeSystem')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Nút quản lý người dùng cho Admin */}
|
|
{user?.isAdmin && (
|
|
<button
|
|
onClick={() => setIsAdminModalOpen(true)}
|
|
className="w-11 h-11 md:w-auto p-0 md:px-4 md:py-3 rounded-2xl shadow-xl transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 bg-blue-600 hover:bg-blue-700 text-white"
|
|
title={t('systemBtn')}
|
|
>
|
|
<Settings className="w-5 h-5" />
|
|
<span className="hidden md:inline text-sm">{t('systemBtn')}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Nút Bảng điều khiển của tôi - chỉ hiển thị cho người dùng đã đăng nhập (không phải khách) */}
|
|
{user && !localStorage.getItem('guest_token') && onGoToDashboard && (
|
|
<button
|
|
onClick={onGoToDashboard}
|
|
className="w-11 h-11 md:w-auto p-0 md:px-4 md:py-3 rounded-2xl shadow-xl transition-all flex items-center justify-center md:justify-start gap-2 font-bold shrink-0 bg-emerald-600 hover:bg-emerald-700 text-white"
|
|
title="Bảng điều khiển của tôi"
|
|
>
|
|
<Settings className="w-5 h-5" />
|
|
<span className="hidden md:inline text-sm">Bảng điều khiển của tôi</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Nút đăng xuất */}
|
|
{onLogout && (
|
|
<button
|
|
onClick={onLogout}
|
|
className="w-11 h-11 md:w-auto bg-[var(--surface)] p-0 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center justify-center md:justify-start gap-2 font-bold text-[var(--text-secondary)] border border-[var(--border)] shrink-0"
|
|
title="Đăng xuất"
|
|
>
|
|
<LogOut className="w-5 h-5" />
|
|
<span className="hidden md:inline text-sm">Rời đi</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<MapContainer
|
|
center={userPos}
|
|
zoom={mapZoom}
|
|
className="h-full w-full"
|
|
preferCanvas={true}
|
|
attributionControl={false}
|
|
>
|
|
<TileLayer
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
|
|
{/* Theo dõi di chuyển bản đồ */}
|
|
<MapTracker />
|
|
{/* Đóng menu và dropdown khi tương tác bản đồ */}
|
|
<MapEvents onMapAction={() => { setShareMenu(null); setSuggestions([]); setIsFilterDropdownOpen(false); }} />
|
|
|
|
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
|
<RecenterMap position={userPos} />
|
|
|
|
<MarkerClusterGroup key={`cluster-${filteredTours.length}`} chunkedLoading>
|
|
{filteredTours.map((tour) => {
|
|
let startLoc = null;
|
|
if (tour.legs && tour.legs.length > 0) {
|
|
for (const leg of tour.legs) {
|
|
if (leg.locations && leg.locations.length > 0) {
|
|
startLoc = leg.locations[0];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
|
const markerPos = startLoc
|
|
? [startLoc.latitude, startLoc.longitude] as [number, number]
|
|
: userPos;
|
|
const status = getTourStatus(tour);
|
|
|
|
return (
|
|
<Marker
|
|
key={tour.id}
|
|
position={markerPos}
|
|
eventHandlers={{
|
|
click: () => {
|
|
if (status.color === 'green') {
|
|
notify({
|
|
title: 'Thông báo',
|
|
message: 'Đây là hành trình mới tạo. Vui lòng nhấn chuột phải (hoặc nhấn giữ trên màn hình điện thoại) để gửi yêu cầu tham gia.',
|
|
type: 'info'
|
|
});
|
|
} else {
|
|
onViewTour(tour.id);
|
|
}
|
|
},
|
|
contextmenu: (e: any) => {
|
|
if (status.color === 'green') {
|
|
triggerJoinConfirmation(tour);
|
|
} else {
|
|
const currentUserId = user?.id;
|
|
const myParticipant = tour.participants?.find((p: any) => p.userId === currentUserId);
|
|
const isParticipant = !!myParticipant;
|
|
const myRole = myParticipant?.role;
|
|
const canShare = isParticipant && ['OWNER', 'MANAGER', 'MEMBER'].includes(myRole);
|
|
const hasPendingRequest = tour.joinRequests && tour.joinRequests.length > 0;
|
|
|
|
// Hiển thị menu tại vị trí chuột
|
|
setShareMenu({
|
|
x: e.containerPoint.x,
|
|
y: e.containerPoint.y,
|
|
id: tour.id,
|
|
title: tour.title,
|
|
canShare,
|
|
isParticipant,
|
|
hasPendingRequest
|
|
});
|
|
}
|
|
},
|
|
touchstart: () => {
|
|
if (status.color === 'green') {
|
|
if (touchTimerRef.current) {
|
|
clearTimeout(touchTimerRef.current);
|
|
}
|
|
touchMovedRef.current = false;
|
|
touchTimerRef.current = setTimeout(() => {
|
|
if (!touchMovedRef.current) {
|
|
triggerJoinConfirmation(tour);
|
|
}
|
|
}, 700);
|
|
}
|
|
},
|
|
touchend: () => {
|
|
if (touchTimerRef.current) {
|
|
clearTimeout(touchTimerRef.current);
|
|
touchTimerRef.current = null;
|
|
}
|
|
},
|
|
touchmove: () => {
|
|
touchMovedRef.current = true;
|
|
if (touchTimerRef.current) {
|
|
clearTimeout(touchTimerRef.current);
|
|
touchTimerRef.current = null;
|
|
}
|
|
}
|
|
} as any}
|
|
icon={L.divIcon({
|
|
className: 'custom-bubble',
|
|
html: `
|
|
<div class="relative group w-14 h-14">
|
|
<div class="w-14 h-14 rounded-full border-4 ${status.borderClass} shadow-lg overflow-hidden transition-transform group-hover:scale-110 flex items-center justify-center bg-[var(--background)]">
|
|
<img src="${tourImage}" class="w-full h-full object-cover" onerror="this.src='https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=100'"/>
|
|
</div>
|
|
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
|
S
|
|
</div>
|
|
</div>
|
|
`,
|
|
iconSize: [56, 56],
|
|
iconAnchor: [28, 28]
|
|
})}
|
|
>
|
|
<Tooltip direction="top" offset={[0, -28]} opacity={1}>
|
|
<div className="p-1.5 max-w-[180px]">
|
|
<div className="font-black text-blue-600 text-[11px] mb-1 uppercase tracking-tight truncate">{tour.title}</div>
|
|
{tour.tags && tour.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mb-1">
|
|
{tour.tags.map((tag: string) => (
|
|
<span key={tag} className="px-1.5 py-0.5 bg-blue-50 text-blue-500 rounded text-[8px] font-bold border border-blue-100">{tag}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{tour.description && (
|
|
<div className="text-[10px] text-[var(--text-muted)] line-clamp-2 leading-tight italic mb-1.5">
|
|
{tour.description}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-1.5 pt-1 border-t border-[var(--border)]">
|
|
<span className={`w-2 h-2 rounded-full ${
|
|
status.color === 'green' ? 'bg-emerald-500' :
|
|
status.color === 'red' ? 'bg-rose-500' : 'bg-[var(--text-muted)]'
|
|
}`} />
|
|
<span className="text-[9px] font-bold text-[var(--text-secondary)]">{status.label}</span>
|
|
</div>
|
|
</div>
|
|
</Tooltip>
|
|
</Marker>
|
|
);
|
|
})}
|
|
</MarkerClusterGroup>
|
|
|
|
{groupedPhotos.map((photoGroup) => {
|
|
const latestPhoto = photoGroup[0];
|
|
const lat = latestPhoto.metadata?.lat;
|
|
const lng = latestPhoto.metadata?.lng;
|
|
if (typeof lat !== 'number' || typeof lng !== 'number') return null;
|
|
|
|
return (
|
|
<Marker
|
|
key={latestPhoto.id}
|
|
position={[lat, lng]}
|
|
eventHandlers={{
|
|
click: () => {
|
|
setSelectedPhoto(latestPhoto);
|
|
setSelectedPhotoGroup(photoGroup);
|
|
const params = new URLSearchParams(window.location.search);
|
|
params.set('photoId', latestPhoto.id);
|
|
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
|
|
}
|
|
}}
|
|
icon={L.divIcon({
|
|
className: 'custom-photo-bubble',
|
|
html: `
|
|
<a href="${window.location.origin}/api/v1/public-photos/${latestPhoto.id}/share" onclick="event.preventDefault();" class="relative group block">
|
|
<div class="w-12 h-12 rounded-full border-4 border-emerald-500 shadow-lg overflow-hidden transition-transform group-hover:scale-110 relative">
|
|
<img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover select-none" draggable="false" />
|
|
<div class="absolute inset-0 bg-transparent select-none z-10"></div>
|
|
</div>
|
|
<div class="absolute -bottom-1 -right-1 bg-emerald-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
|
📸
|
|
</div>
|
|
${photoGroup.length > 1 ? `
|
|
<div class="absolute -top-1 -left-1 bg-rose-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[9px] font-black text-white shadow-md animate-bounce">
|
|
${photoGroup.length}
|
|
</div>
|
|
` : ''}
|
|
</a>
|
|
`,
|
|
iconSize: [48, 48],
|
|
iconAnchor: [24, 24]
|
|
})}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* 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' ? t('businessRestaurant') : item.type === 'HOTEL' ? t('businessHotel') : t('businessHomestay')}
|
|
</div>
|
|
{item.address && (
|
|
<div className="text-[10px] text-[var(--text-muted)] mb-1 font-semibold">{item.address}</div>
|
|
)}
|
|
{item.phone && (
|
|
<div className="text-[9px] text-[var(--text-muted)]">SĐT: {item.phone}</div>
|
|
)}
|
|
{item.email && (
|
|
<div className="text-[9px] text-[var(--text-muted)]">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ẻ */}
|
|
{shareMenu && (
|
|
<div
|
|
className="absolute z-[2000] bg-[var(--surface)] rounded-2xl shadow-2xl border border-[var(--border)] py-2 w-48 animate-in zoom-in-95 duration-200"
|
|
style={{ top: shareMenu.y, left: shareMenu.x }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{shareMenu.isParticipant ? (
|
|
shareMenu.canShare ? (
|
|
<button
|
|
onClick={() => handleShare(shareMenu.id, shareMenu.title)}
|
|
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-blue-700 flex items-center gap-2 transition-colors"
|
|
>
|
|
<Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết
|
|
</button>
|
|
) : (
|
|
<div className="px-4 py-2 text-xs text-[var(--text-muted)] italic font-bold">Bạn đã gia nhập tour này</div>
|
|
)
|
|
) : shareMenu.hasPendingRequest ? (
|
|
<button
|
|
disabled
|
|
className="w-full text-left px-4 py-2 text-sm font-bold text-[var(--text-muted)] flex items-center gap-2 cursor-not-allowed bg-[var(--background)]/50"
|
|
>
|
|
<Clock className="w-4 h-4 text-[var(--text-muted)]" /> Đang chờ duyệt...
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => handleRequestJoin(shareMenu.id)}
|
|
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-blue-700 flex items-center gap-2 transition-colors"
|
|
>
|
|
<UserPlus className="w-4 h-4 text-blue-600" /> Yêu cầu tham gia Tour
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Admin Modal */}
|
|
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
|
|
|
{/* Create Tour Modal */}
|
|
<CreateTourModal
|
|
isOpen={isCreateModalOpen}
|
|
onClose={() => setIsCreateModalOpen(false)}
|
|
onSuccess={(tour) => {
|
|
// Tự động tạo ghi chú mới cho hành trình vừa tạo
|
|
const savedNotes = localStorage.getItem('my_journey_notes');
|
|
let notes = [];
|
|
try {
|
|
notes = savedNotes ? JSON.parse(savedNotes) : [];
|
|
} catch (e) { notes = []; }
|
|
|
|
const newTourNote = {
|
|
id: Date.now().toString(),
|
|
tourId: tour.id,
|
|
title: `Ghi chú của hành trình: ${tour.title}`,
|
|
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${tour.title}</strong> của bạn tại đây...</p>`,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
localStorage.setItem('my_journey_notes', JSON.stringify([newTourNote, ...notes]));
|
|
|
|
fetchTour(tour.id);
|
|
onViewTour(tour.id);
|
|
}}
|
|
/>
|
|
|
|
{selectedPhoto && (
|
|
<PublicPhotoModal
|
|
isOpen={!!selectedPhoto}
|
|
onClose={() => {
|
|
setSelectedPhoto(null);
|
|
setSelectedPhotoGroup([]);
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.has('photoId')) {
|
|
params.delete('photoId');
|
|
const newSearch = params.toString();
|
|
const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ''}`;
|
|
window.history.replaceState({}, '', newUrl);
|
|
}
|
|
}}
|
|
photo={selectedPhoto}
|
|
photoGroup={selectedPhotoGroup}
|
|
onSelectPhoto={(photo) => {
|
|
setSelectedPhoto(photo);
|
|
const params = new URLSearchParams(window.location.search);
|
|
params.set('photoId', photo.id);
|
|
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
|
|
}}
|
|
onLoginSuccess={onLoginSuccess}
|
|
onUpdatePhoto={(updatedPhoto) => {
|
|
setPublicPhotos((prev) =>
|
|
prev.map((p) => (p.id === updatedPhoto.id ? updatedPhoto : p))
|
|
);
|
|
setSelectedPhoto(updatedPhoto);
|
|
setSelectedPhotoGroup((prev) =>
|
|
prev.map((p) => (p.id === updatedPhoto.id ? updatedPhoto : p))
|
|
);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 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-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 text-[var(--text-primary)] dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-[var(--background)] 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-[var(--surface)]/95 dark:bg-slate-900/95 backdrop-blur-md border border-[var(--border)] 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-[var(--text-muted)] 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-[var(--background)]/50 dark:bg-slate-850/50 rounded-xl border border-[var(--border)]/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-[var(--text-muted)] flex items-center justify-center bg-[var(--background)] dark:bg-slate-800 rounded-lg">
|
|
{idx + 1}
|
|
</div>
|
|
<span className="text-xs font-bold text-[var(--text-primary)] 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-[var(--text-muted)] font-medium">({u.ratingCount})</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</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-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 text-[var(--text-primary)] dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-[var(--background)] 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-[var(--surface)] dark:bg-slate-900 border border-[var(--border)] dark:border-slate-800 text-[var(--text-primary)] dark:text-slate-100 shadow-2xl rounded-2xl py-2.5 px-4 text-xs font-bold flex items-center gap-2 hover:bg-[var(--background)] 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-[var(--surface)]/95 dark:bg-slate-900/95 backdrop-blur-md border border-[var(--border)] 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-[var(--border)] 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-[var(--text-muted)] 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' ? t('businessRestaurant') : item.type === 'HOTEL' ? t('businessHotel') : t('businessHomestay')}
|
|
</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-[var(--text-secondary)] dark:text-slate-400 font-medium italic mt-1.5 pt-1.5 border-t border-[var(--border)] dark:border-slate-800 whitespace-pre-line">
|
|
{item.description}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Blacklist Panel */}
|
|
{isBlacklistOpen && (
|
|
<div className="bg-[var(--surface)]/95 dark:bg-slate-900/95 backdrop-blur-md border border-[var(--border)] 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-[var(--border)] 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-[var(--text-muted)] 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-[var(--border)] 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">{t('businessRestaurant')}</option>
|
|
<option value="HOTEL">{t('businessHotel')}</option>
|
|
<option value="HOMESTAY">{t('businessHomestay')}</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>
|
|
);
|
|
}; |