feat: tíng năng trò chuyện giữa các thành viên và tin nhắn trực tiếp cho bạn bè
This commit is contained in:
@@ -5,9 +5,10 @@ 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 } from 'lucide-react';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft } from 'lucide-react';
|
||||
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { CreateTourModal } from '../components/CreateTourModal';
|
||||
import { PublicPhotoModal } from '../components/PublicPhotoModal';
|
||||
|
||||
@@ -65,6 +66,80 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||
|
||||
const notify = useNotification();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// 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(() => {
|
||||
@@ -328,7 +403,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
className="w-11 h-11 flex items-center justify-center bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 shrink-0"
|
||||
title="Quay lại"
|
||||
>
|
||||
<X className="w-6 h-6 text-gray-800" />
|
||||
<ChevronLeft className="w-6 h-6 text-gray-800" />
|
||||
</button>
|
||||
|
||||
{/* Nút lọc Tag và Dropdown */}
|
||||
@@ -487,43 +562,92 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
|
||||
<MarkerClusterGroup key={`cluster-${filteredTours.length}`} chunkedLoading>
|
||||
{filteredTours.map((tour) => {
|
||||
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||
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: () => onViewTour(tour.id),
|
||||
contextmenu: (e) => {
|
||||
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;
|
||||
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
|
||||
});
|
||||
// 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">
|
||||
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
||||
<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-gray-100">
|
||||
<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">
|
||||
@@ -531,13 +655,13 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [48, 48],
|
||||
iconAnchor: [24, 24]
|
||||
iconSize: [56, 56],
|
||||
iconAnchor: [28, 28]
|
||||
})}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -20]} opacity={1}>
|
||||
<div className="p-1 max-w-[180px]">
|
||||
<div className="font-black text-blue-600 text-[11px] mb-0.5 uppercase tracking-tight truncate">{tour.title}</div>
|
||||
<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) => (
|
||||
@@ -546,10 +670,17 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
</div>
|
||||
)}
|
||||
{tour.description && (
|
||||
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic">
|
||||
<div className="text-[10px] text-gray-500 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-gray-100">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
status.color === 'green' ? 'bg-emerald-500' :
|
||||
status.color === 'red' ? 'bg-rose-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
<span className="text-[9px] font-bold text-gray-600">{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
|
||||
Reference in New Issue
Block a user