221 lines
8.9 KiB
TypeScript
221 lines
8.9 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
|
|
import { io } from 'socket.io-client';
|
|
import { Capacitor } from '@capacitor/core';
|
|
import { useTourStore } from '@/store/useTourStore';
|
|
import { ConfirmModal } from './ConfirmModal';
|
|
|
|
interface Comment {
|
|
id: string;
|
|
userName: string;
|
|
content: string;
|
|
createdAt: string;
|
|
userId?: string;
|
|
}
|
|
|
|
interface CommentModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
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, onCommentDeleted, isPublicView = false }) => {
|
|
const [comments, setComments] = useState<Comment[]>([]);
|
|
const [newComment, setNewComment] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [confirmState, setConfirmState] = useState({ 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);
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const headers: Record<string, string> = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
|
|
headers
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setComments(data.map((c: any) => ({
|
|
id: c.id,
|
|
userName: c.user?.name || 'Ẩn danh',
|
|
content: c.content,
|
|
createdAt: c.createdAt,
|
|
userId: c.userId
|
|
})));
|
|
}
|
|
} catch (error) {
|
|
console.error('Lỗi khi tải bình luận:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || !locationId) return;
|
|
|
|
fetchComments();
|
|
|
|
// Lắng nghe bình luận mới qua Proxy (không cần hardcode URL)
|
|
const socket = Capacitor.isNativePlatform()
|
|
? io(import.meta.env.VITE_BACKEND_URL || 'https://yotrip.labz.io.vn')
|
|
: io();
|
|
socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể
|
|
|
|
socket.on('commentAdded', (newCommentData: any) => {
|
|
if (newCommentData.locationId === locationId) {
|
|
setComments(prev => {
|
|
// Tránh trùng lặp nếu chính mình gửi
|
|
if (prev.find(c => c.id === newCommentData.id)) return prev;
|
|
return [...prev, {
|
|
id: newCommentData.id,
|
|
userName: newCommentData.user?.name || 'Ẩn danh',
|
|
content: newCommentData.content,
|
|
createdAt: newCommentData.createdAt,
|
|
userId: newCommentData.userId || newCommentData.user?.id
|
|
}];
|
|
});
|
|
}
|
|
});
|
|
|
|
return () => { socket.disconnect(); };
|
|
}, [isOpen, locationId]);
|
|
|
|
const handleSend = async () => {
|
|
if (!newComment.trim()) return;
|
|
try {
|
|
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify({ content: newComment })
|
|
});
|
|
if (res.ok) {
|
|
setNewComment('');
|
|
fetchComments();
|
|
onCommentAdded?.();
|
|
}
|
|
} catch (error) {
|
|
console.error('Lỗi khi gửi bình luận:', error);
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<div className="fixed inset-0 z-[5000] flex items-end sm:items-center justify-center p-0 sm:p-4">
|
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onClose} />
|
|
<div className="relative w-full h-full sm:max-w-md bg-[var(--surface)] rounded-t-2xl sm:rounded-[32px] shadow-2xl overflow-hidden flex flex-col sm:max-h-[85vh] animate-in zoom-in-95 duration-200">
|
|
{/* Header */}
|
|
<div className="p-6 border-b border-[var(--border)] flex justify-between items-center bg-[var(--surface)] sticky top-0 z-10 shrink-0">
|
|
<div>
|
|
<h3 className="text-xl font-black text-[var(--text-primary)] flex items-center gap-2">
|
|
<MessageSquare className="w-5 h-5 text-blue-600" />
|
|
Bình luận
|
|
</h3>
|
|
<p className="text-xs text-[var(--text-muted)] font-bold uppercase tracking-widest mt-1 truncate max-w-[250px]">{locationName}</p>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors">
|
|
<X className="w-5 h-5 text-[var(--text-muted)]" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Comment List */}
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-[var(--background)]/50">
|
|
{isLoading ? (
|
|
<div className="flex justify-center py-10"><Loader2 className="w-6 h-6 animate-spin text-blue-600" /></div>
|
|
) : comments.length === 0 ? (
|
|
<div className="text-center py-10 text-[var(--text-muted)] italic text-sm">Chưa có bình luận nào.</div>
|
|
) : (
|
|
comments.map((c) => (
|
|
<div key={c.id} className="flex gap-3 animate-in slide-in-from-left-2 duration-300">
|
|
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0 border border-blue-200">
|
|
<User className="w-4 h-4 text-blue-600" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="bg-[var(--surface)] p-3 rounded-2xl rounded-tl-none border border-[var(--border)] shadow-sm">
|
|
<div className="flex justify-between items-start mb-1">
|
|
<p className="text-xs font-black text-[var(--text-primary)]">{c.userName}</p>
|
|
{(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && (
|
|
<button
|
|
onClick={() => setConfirmState({ open: true, commentId: c.id })}
|
|
className="text-[var(--text-muted)] hover:text-red-500 transition-colors"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{c.content}</p>
|
|
</div>
|
|
<p className="text-[10px] text-[var(--text-muted)] mt-1 ml-1 font-medium">
|
|
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Input Area */}
|
|
<div className="p-4 bg-[var(--surface)] border-t border-[var(--border)] shrink-0">
|
|
<div className="relative flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={newComment}
|
|
onChange={(e) => setNewComment(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
|
placeholder={isPublicView ? 'Đăng nhập để bình luận...' : 'Viết bình luận...'}
|
|
className="flex-1 bg-[var(--background)] border border-[var(--border)] rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-[var(--surface)] transition-all"
|
|
/>
|
|
<button onClick={handleSend} disabled={!newComment.trim() || isPublicView} className="p-3 bg-blue-600 text-white rounded-2xl hover:bg-blue-700 disabled:opacity-50 disabled:bg-gray-300 transition-all active:scale-95 shadow-lg shadow-blue-100">
|
|
<Send className="w-4 h-4" />
|
|
</button>
|
|
</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>
|
|
);
|
|
}; |