feat: cho phép người dùng và người quản lí xóa bình luận

This commit is contained in:
2026-06-16 12:21:44 +07:00
parent fc96ee9eb8
commit 047170d4be
4 changed files with 120 additions and 29 deletions
+56 -4
View File
@@ -1,12 +1,15 @@
import React, { useState, useEffect } from 'react';
import { X, Send, MessageSquare, User, Loader2 } from 'lucide-react';
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
import { io } from 'socket.io-client';
import { useTourStore } from '@/store/useTourStore';
import { ConfirmModal } from './ConfirmModal';
interface Comment {
id: string;
userName: string;
content: string;
createdAt: string;
userId: string;
}
interface CommentModalProps {
@@ -15,13 +18,23 @@ interface CommentModalProps {
locationId: string;
locationName: string;
onCommentAdded?: () => void; // Callback to update comment count on parent
onCommentDeleted?: () => void;
isPublicView?: boolean; // New prop to indicate public view
}
export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, locationId, locationName, onCommentAdded, isPublicView = false }) => {
export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, locationId, locationName, onCommentAdded, onCommentDeleted, isPublicView = false }) => {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [confirmState, setConfirmState] = useState<{ open: boolean; commentId: string }>({ open: false, commentId: '' });
const userRole = useTourStore(state => state.userRole);
const currentUserId = React.useMemo(() => {
try {
const user = JSON.parse(localStorage.getItem('user') || '{}');
return user.id;
} catch { return null; }
}, []);
const fetchComments = async () => {
setIsLoading(true);
@@ -39,7 +52,8 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
id: c.id,
userName: c.user?.name || 'Ẩn danh',
content: c.content,
createdAt: c.createdAt
createdAt: c.createdAt,
userId: c.userId
})));
}
} catch (error) {
@@ -97,6 +111,23 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
}
};
const handleDelete = async (commentId: string) => {
try {
const res = await fetch(`/api/v1/locations/comments/${commentId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (res.ok) {
setComments(prev => prev.filter(c => c.id !== commentId));
onCommentDeleted?.();
}
} catch (error) {
console.error('Lỗi khi xóa bình luận:', error);
}
};
if (!isOpen) return null;
return (
@@ -131,7 +162,17 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
</div>
<div className="flex-1">
<div className="bg-white p-3 rounded-2xl rounded-tl-none border border-gray-100 shadow-sm">
<p className="text-xs font-black text-gray-900 mb-1">{c.userName}</p>
<div className="flex justify-between items-start mb-1">
<p className="text-xs font-black text-gray-900">{c.userName}</p>
{(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && (
<button
onClick={() => setConfirmState({ open: true, commentId: c.id })}
className="text-gray-400 hover:text-red-500 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<p className="text-sm text-gray-600 leading-relaxed">{c.content}</p>
</div>
<p className="text-[10px] text-gray-400 mt-1 ml-1 font-medium">
@@ -160,6 +201,17 @@ export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, loc
</div>
</div>
</div>
<ConfirmModal
isOpen={confirmState.open}
title="Xóa bình luận"
message="Bạn có chắc chắn muốn xóa bình luận này không? Hành động này sẽ không thể hoàn tác."
onConfirm={() => {
handleDelete(confirmState.commentId);
setConfirmState({ open: false, commentId: '' });
}}
onCancel={() => setConfirmState({ open: false, commentId: '' })}
/>
</div>
);
};
+36 -25
View File
@@ -1,42 +1,53 @@
import React, { useState } from 'react';
import { X } from 'lucide-react';
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
interface ConfirmModalProps {
isOpen: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
message?: string;
onConfirm: () => void;
onCancel: () => void;
}
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
isOpen,
title = 'Xác nhận',
message,
confirmText = 'Xác nhận',
cancelText = 'Hủy',
onConfirm,
onCancel,
}) => {
export const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, title, message, onConfirm, onCancel }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[2200] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={onCancel} />
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
<h3 className="text-base font-bold text-gray-900">{title}</h3>
<p className="mt-2 text-sm text-gray-600">{message}</p>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onCancel} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
{cancelText}
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4">
{/* Backdrop */}
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onCancel} />
{/* Modal Content */}
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-4">
<div className="w-12 h-12 rounded-2xl bg-red-50 flex items-center justify-center text-red-500 shadow-inner">
<AlertTriangle className="w-6 h-6" />
</div>
<button onClick={onCancel} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
<button onClick={onConfirm} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
{confirmText}
</div>
<h3 className="text-xl font-black text-gray-900 mb-2">{title || 'Xác nhận'}</h3>
<p className="text-sm text-gray-500 mb-8 leading-relaxed">
{message || 'Bạn có chắc chắn muốn thực hiện hành động này không?'}
</p>
<div className="grid grid-cols-2 gap-3">
<button
onClick={onCancel}
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
>
Hủy bỏ
</button>
<button
onClick={onConfirm}
className="py-4 bg-red-600 hover:bg-red-700 text-white font-bold rounded-2xl shadow-lg shadow-red-100 transition-all active:scale-95"
>
Xác nhận
</button>
</div>
</div>
</div>
);
};
};
@@ -80,6 +80,19 @@ export const ItineraryTimeline = ({
useTourStore.setState({ legs: updatedLegs });
};
const handleCommentDecrement = (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: Math.max(0, (loc._count?.comments || 1) - 1) } }
: loc
)
}));
useTourStore.setState({ legs: updatedLegs });
};
// State cho Modal sửa chặng
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingLegData, setEditingLegData] = useState({
@@ -575,6 +588,7 @@ export const ItineraryTimeline = ({
locationName={commentLocationName}
isPublicView={isPublicView}
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
/>
</div>
);
+14
View File
@@ -223,6 +223,19 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
useTourStore.setState({ legs: updatedLegs });
};
const handleCommentDecrement = (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: Math.max(0, (loc._count?.comments || 1) - 1) } }
: loc
)
}));
useTourStore.setState({ legs: updatedLegs });
};
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
const setMapCenter = useTourStore(state => state.setMapCenter);
const updateTourStartPoint = useTourStore(state => state.updateTourStartPoint);
@@ -1199,6 +1212,7 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
locationName={commentLocationName}
isPublicView={isPublicView} // Pass isPublicView
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
/>
</div>
);