diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d97dabb..23d3847 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { ExploreMap } from './pages/ExploreMap'; import { TourDetailPage } from './pages/TourDetailPage'; import { SignupPage } from './pages/SignupPage'; import { useTourStore } from './store/useTourStore'; +import { ConfirmProvider } from './hooks/useConfirm'; function App() { const params = new URLSearchParams(window.location.search); @@ -81,25 +82,31 @@ function App() { setCurrentPage('landing'); // Quay về trang Landing sau khi đăng ký thành công }; - if (currentPage === 'tourDetail') { - return ( - - ); - } + return ( + + {(() => { + if (currentPage === 'tourDetail') { + return ( + + ); + } - if (currentPage === 'explore') { - return ; - } + if (currentPage === 'explore') { + return ; + } - if (currentPage === 'signup') { - return ; - } + if (currentPage === 'signup') { + return ; + } - return setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />; + return setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />; + })()} + + ); } export default App; \ No newline at end of file diff --git a/frontend/src/components/CommentModal.tsx b/frontend/src/components/CommentModal.tsx index 503a07b..d6d1daa 100644 --- a/frontend/src/components/CommentModal.tsx +++ b/frontend/src/components/CommentModal.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from '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'; +import { useConfirm } from '@/hooks/useConfirm'; interface Comment { id: string; @@ -26,7 +26,7 @@ export const CommentModal: React.FC = ({ isOpen, onClose, loc const [comments, setComments] = useState([]); const [newComment, setNewComment] = useState(''); const [isLoading, setIsLoading] = useState(false); - const [confirmState, setConfirmState] = useState<{ open: boolean; commentId: string }>({ open: false, commentId: '' }); + const confirm = useConfirm(); const userRole = useTourStore(state => state.userRole); const currentUserId = React.useMemo(() => { diff --git a/frontend/src/hooks/useConfirm.tsx b/frontend/src/hooks/useConfirm.tsx new file mode 100644 index 0000000..e0e43c0 --- /dev/null +++ b/frontend/src/hooks/useConfirm.tsx @@ -0,0 +1,212 @@ +import React, { useState, useEffect } from 'react'; +import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react'; +import { io } from 'socket.io-client'; +import { useTourStore } from '@/store/useTourStore'; +import { useConfirm } from '@/hooks/useConfirm'; + +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 = ({ isOpen, onClose, locationId, locationName, onCommentAdded, onCommentDeleted, isPublicView = false }) => { + const [comments, setComments] = useState([]); + const [newComment, setNewComment] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const confirm = useConfirm(); + + 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 = {}; + 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 = 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 + }]; + }); + } + }); + + 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 ( +
+
+
+ {/* Header */} +
+
+

+ + Bình luận +

+

{locationName}

+
+ +
+ + {/* Comment List */} +
+ {isLoading ? ( +
+ ) : comments.length === 0 ? ( +
Chưa có bình luận nào.
+ ) : ( + comments.map((c) => ( +
+
+ +
+
+
+
+

{c.userName}

+ {(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && ( + + )} +
+

{c.content}

+
+

+ {new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +

+
+
+ )) + )} +
+ + {/* Input Area */} +
+
+ 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-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all" + /> + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/useConfirm.tsx b/useConfirm.tsx new file mode 100644 index 0000000..36d3c68 --- /dev/null +++ b/useConfirm.tsx @@ -0,0 +1,60 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; +import { ConfirmModal } from '../components/ConfirmModal'; + +interface ConfirmOptions { + title?: string; + message?: string; +} + +const ConfirmContext = createContext<((options: ConfirmOptions) => Promise) | undefined>(undefined); + +export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [state, setState] = useState<{ + isOpen: boolean; + title?: string; + message?: string; + resolve?: (value: boolean) => void; + }>({ isOpen: false }); + + const confirm = useCallback((options: ConfirmOptions) => { + return new Promise((resolve) => { + setState({ + isOpen: true, + title: options.title, + message: options.message, + resolve, + }); + }); + }, []); + + const handleConfirm = () => { + const resolve = state.resolve; + setState({ isOpen: false, resolve: undefined }); + resolve?.(true); + }; + + const handleCancel = () => { + const resolve = state.resolve; + setState({ isOpen: false, resolve: undefined }); + resolve?.(false); + }; + + return ( + + {children} + + + ); +}; + +export const useConfirm = () => { + const confirm = useContext(ConfirmContext); + if (!confirm) throw new Error('useConfirm must be used within a ConfirmProvider'); + return confirm; +}; \ No newline at end of file