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:
2026-06-21 09:46:37 +07:00
parent 392a4d4766
commit 8d79cd76f6
15 changed files with 3932 additions and 234 deletions
+161 -30
View File
@@ -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>