feat: tạo hook useConfirm.tsx để dùng chung toàn hệ thống

This commit is contained in:
2026-06-16 12:29:09 +07:00
parent 047170d4be
commit 29d39ae7b0
4 changed files with 297 additions and 18 deletions
+23 -16
View File
@@ -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 (
<TourDetailPage
tourId={currentTourId!} // tourId được đảm bảo không null ở đây
onBack={handleBackFromTourDetail}
isPublicView={isPublicTourView}
/>
);
}
return (
<ConfirmProvider>
{(() => {
if (currentPage === 'tourDetail') {
return (
<TourDetailPage
tourId={currentTourId!}
onBack={handleBackFromTourDetail}
isPublicView={isPublicTourView}
/>
);
}
if (currentPage === 'explore') {
return <ExploreMap onBack={handleBackFromTourDetail} onLogout={handleLogout} user={user} onViewTour={handleViewTour} />;
}
if (currentPage === 'explore') {
return <ExploreMap onBack={handleBackFromTourDetail} onLogout={handleLogout} user={user} onViewTour={handleViewTour} />;
}
if (currentPage === 'signup') {
return <SignupPage onBack={handleBackFromSignup} onSuccess={handleSignupSuccess} />;
}
if (currentPage === 'signup') {
return <SignupPage onBack={handleBackFromSignup} onSuccess={handleSignupSuccess} />;
}
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
})()}
</ConfirmProvider>
);
}
export default App;
+2 -2
View File
@@ -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<CommentModalProps> = ({ isOpen, onClose, loc
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 confirm = useConfirm();
const userRole = useTourStore(state => state.userRole);
const currentUserId = React.useMemo(() => {
+212
View File
@@ -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<CommentModalProps> = ({ isOpen, onClose, locationId, locationName, onCommentAdded, onCommentDeleted, isPublicView = false }) => {
const [comments, setComments] = useState<Comment[]>([]);
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<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 = 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 (
<div className="fixed inset-0 z-[5000] flex items-center justify-center 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 max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-white sticky top-0 z-10">
<div>
<h3 className="text-xl font-black text-gray-900 flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-600" />
Bình luận
</h3>
<p className="text-xs text-gray-500 font-bold uppercase tracking-widest mt-1 truncate max-w-[250px]">{locationName}</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* Comment List */}
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-50/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-gray-400 italic text-sm">Chưa 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-white p-3 rounded-2xl rounded-tl-none border border-gray-100 shadow-sm">
<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={async () => {
const isConfirmed = await confirm({
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.'
});
if (isConfirmed) handleDelete(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">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
))
)}
</div>
{/* Input Area */}
<div className="p-4 bg-white border-t border-gray-100">
<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-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"
/>
<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>
</div>
);
};
+60
View File
@@ -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<boolean>) | 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<boolean>((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 (
<ConfirmContext.Provider value={confirm}>
{children}
<ConfirmModal
isOpen={state.isOpen}
title={state.title}
message={state.message}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</ConfirmContext.Provider>
);
};
export const useConfirm = () => {
const confirm = useContext(ConfirmContext);
if (!confirm) throw new Error('useConfirm must be used within a ConfirmProvider');
return confirm;
};