Thêm tính năng bình luận ở mỗi điểm của chặng
This commit is contained in:
@@ -67,7 +67,10 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPublicTours();
|
||||
// Chỉ fetch dữ liệu 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) {
|
||||
@@ -162,29 +165,28 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
: userPos;
|
||||
|
||||
return (
|
||||
<React.Fragment key={tour.id}>
|
||||
<Marker
|
||||
position={markerPos}
|
||||
eventHandlers={{
|
||||
click: () => onViewTour(tour.id)
|
||||
}}
|
||||
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">
|
||||
<img src="${tourImage}" class="w-full h-full object-cover" />
|
||||
<Marker
|
||||
key={tour.id}
|
||||
position={markerPos}
|
||||
eventHandlers={{
|
||||
click: () => onViewTour(tour.id)
|
||||
}}
|
||||
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">
|
||||
<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>
|
||||
<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: [48, 48],
|
||||
iconAnchor: [24, 24]
|
||||
})}
|
||||
/>
|
||||
</React.Fragment>
|
||||
`,
|
||||
iconSize: [48, 48],
|
||||
iconAnchor: [24, 24]
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { ItineraryTimeline } from '../components/ItineraryTimeline';
|
||||
import { ExpenseManager } from '../components/ExpenseManager';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
@@ -6,6 +7,7 @@ import { AddLocationModal } from '@/components/AddLocationModal';
|
||||
import { AddMemberModal } from '../components/AddMemberModal';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
|
||||
import { CommentModal } from '@/components/CommentModal';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||
@@ -25,7 +27,8 @@ import {
|
||||
Flag,
|
||||
Clock,
|
||||
Check,
|
||||
X
|
||||
X,
|
||||
MessageSquare
|
||||
} from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
|
||||
@@ -184,10 +187,29 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
const [adultCountInput, setAdultCountInput] = useState(currentTour?.adultCount ?? 0);
|
||||
const [childCountInput, setChildCountInput] = useState(currentTour?.childCount ?? 0);
|
||||
const [childDiscountInput, setChildDiscountInput] = useState(currentTour?.childDiscount ?? 0);
|
||||
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
||||
const [commentLocationId, setCommentLocationId] = useState('');
|
||||
const [commentLocationName, setCommentLocationName] = useState('');
|
||||
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
|
||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
||||
|
||||
const fetchTour = useTourStore(state => state.fetchTour);
|
||||
|
||||
// Hàm tối ưu để cập nhật số lượng bình luận mà không cần fetch lại toàn bộ Tour
|
||||
const handleCommentIncrement = (locationId: string) => {
|
||||
const currentLegs = useTourStore.getState().legs;
|
||||
const updatedLegs = currentLegs.map(leg => ({
|
||||
...leg,
|
||||
locations: leg.locations.map(loc =>
|
||||
loc.id === locationId
|
||||
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
|
||||
: loc
|
||||
)
|
||||
}));
|
||||
// Cập nhật trực tiếp vào Store
|
||||
useTourStore.setState({ legs: updatedLegs });
|
||||
};
|
||||
|
||||
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||
const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint);
|
||||
@@ -241,6 +263,24 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Thiết lập kết nối WebSocket Real-time
|
||||
useEffect(() => {
|
||||
if (!currentTour) return;
|
||||
|
||||
const socket = io(); // Kết nối qua Proxy của Vite (cùng origin)
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.emit('joinTour', currentTour.id);
|
||||
});
|
||||
|
||||
socket.on('commentAdded', (data: any) => {
|
||||
// Cập nhật UI ngay lập tức khi bất kỳ ai bình luận
|
||||
handleCommentIncrement(data.locationId);
|
||||
});
|
||||
|
||||
return () => { socket.disconnect(); };
|
||||
}, [currentTour?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Lấy tour đầu tiên từ danh sách khám phá để hiển thị demo
|
||||
if (publicTours.length > 0 && !currentTour) {
|
||||
@@ -708,8 +748,21 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
return (
|
||||
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}>
|
||||
<Popup>
|
||||
<div className="font-bold">{loc.name}</div>
|
||||
<div className="text-xs text-gray-500">{loc.type}</div>
|
||||
<div className="p-1">
|
||||
<div className="font-bold text-gray-900">{loc.name}</div>
|
||||
<div className="text-[10px] text-gray-500 mb-2 uppercase tracking-tight">{loc.type}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCommentLocationId(loc.id);
|
||||
setCommentLocationName(loc.name);
|
||||
setIsCommentModalOpen(true);
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg text-[10px] font-black transition-all border border-blue-100"
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
BÌNH LUẬN {loc._count?.comments > 0 && `(${loc._count.comments})`}
|
||||
</button>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
@@ -1030,6 +1083,13 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
type={notificationModal.modalState?.type}
|
||||
onConfirm={() => notificationModal.closeModal()}
|
||||
/>
|
||||
<CommentModal
|
||||
isOpen={isCommentModalOpen}
|
||||
onClose={() => setIsCommentModalOpen(false)}
|
||||
locationId={commentLocationId}
|
||||
locationName={commentLocationName}
|
||||
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user