feat(contact): add contact form with admin panel and navbar dropdown
Database changes: - Added ContactMessage model to Prisma schema - Fields: userId, username, firstName, lastName, email, subject, message, status, ipAddress - Status enum: new, read, resolved - Relation to User model Backend changes: - Added POST /api/public/contact endpoint for form submissions - Works for both authenticated and non-authenticated users - Validation for email, subject (3-255 chars), message (10-5000 chars) - Activity logging for submissions - Added admin endpoints: - GET /api/admin/contact-messages - list with filtering by status - GET /api/admin/contact-messages/:id - view single message (auto-marks as read) - PATCH /api/admin/contact-messages/:id/status - update status - DELETE /api/admin/contact-messages/:id - delete message Frontend changes: - Created ContactPage at /contact route - For non-logged-in users: firstName, lastName, email, subject, message fields - For logged-in users: auto-fills username, shows only email, subject, message - Character counter for message (max 5000) - Success screen with auto-redirect to homepage - Created ContactMessagesPage at /admin/contact-messages - Two-column layout: message list + detail view - Filter by status (all, new, read, resolved) - View message details with sender info and IP address - Update status and delete messages - Added admin dropdown menu to Navbar - Desktop: dropdown with Activity Logs and Contact Messages - Mobile: expandable submenu - Click outside to close on desktop - ChevronDown icon rotates when open Note: CAPTCHA integration planned for future enhancement
This commit is contained in:
348
frontend/src/pages/admin/ContactMessagesPage.jsx
Normal file
348
frontend/src/pages/admin/ContactMessagesPage.jsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Mail, User, Calendar, Trash2, Check, Eye, AlertCircle, Loader2, Filter } from 'lucide-react';
|
||||
import Layout from '../../components/layout/Layout';
|
||||
|
||||
// This would normally come from an admin API module, but for now we'll add it inline
|
||||
const adminContactAPI = {
|
||||
async getMessages(status = 'all', limit = 50, offset = 0) {
|
||||
const params = new URLSearchParams();
|
||||
if (status !== 'all') params.append('status', status);
|
||||
params.append('limit', limit);
|
||||
params.append('offset', offset);
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`/api/admin/contact-messages?${params}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch contact messages');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async updateStatus(id, status) {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`/api/admin/contact-messages/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update message status');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async deleteMessage(id) {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`/api/admin/contact-messages/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete message');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
|
||||
export default function ContactMessagesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [selectedMessage, setSelectedMessage] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const userData = localStorage.getItem('user');
|
||||
if (userData) {
|
||||
const parsedUser = JSON.parse(userData);
|
||||
setUser(parsedUser);
|
||||
if (!parsedUser.isAdmin) {
|
||||
navigate('/');
|
||||
}
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.isAdmin) {
|
||||
fetchMessages();
|
||||
}
|
||||
}, [user, statusFilter]);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await adminContactAPI.getMessages(statusFilter);
|
||||
setMessages(result.data.messages);
|
||||
setTotal(result.data.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch messages:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (id, newStatus) => {
|
||||
try {
|
||||
await adminContactAPI.updateStatus(id, newStatus);
|
||||
await fetchMessages();
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Are you sure you want to delete this message?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await adminContactAPI.deleteMessage(id);
|
||||
await fetchMessages();
|
||||
if (selectedMessage?.id === id) {
|
||||
setSelectedMessage(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'new':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'read':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'resolved':
|
||||
return 'bg-green-100 text-green-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'new':
|
||||
return <AlertCircle className="w-4 h-4" />;
|
||||
case 'read':
|
||||
return <Eye className="w-4 h-4" />;
|
||||
case 'resolved':
|
||||
return <Check className="w-4 h-4" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Contact Messages</h1>
|
||||
<p className="text-gray-600">Manage and respond to user contact form submissions</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Filter className="w-5 h-5 text-gray-500" />
|
||||
<div className="flex gap-2">
|
||||
{['all', 'new', 'read', 'resolved'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
statusFilter === status
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto text-sm text-gray-600">
|
||||
Total: {total} {total === 1 ? 'message' : 'messages'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left Column: Message List */}
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow-sm p-8 text-center">
|
||||
<Mail className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">No messages found</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
onClick={() => setSelectedMessage(message)}
|
||||
className={`bg-white rounded-lg shadow-sm p-6 cursor-pointer transition-all hover:shadow-md ${
|
||||
selectedMessage?.id === message.id ? 'ring-2 ring-primary-500' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(message.status)}`}>
|
||||
{getStatusIcon(message.status)}
|
||||
{message.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(message.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sender Info */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex-shrink-0">
|
||||
<User className="w-10 h-10 text-gray-400 bg-gray-100 rounded-full p-2" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{message.user
|
||||
? `@${message.user.username}`
|
||||
: `${message.firstName} ${message.lastName}`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 truncate">{message.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<h3 className="font-medium text-gray-900 mb-2 truncate">{message.subject}</h3>
|
||||
|
||||
{/* Message Preview */}
|
||||
<p className="text-sm text-gray-600 line-clamp-2">{message.message}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column: Message Detail */}
|
||||
<div className="lg:sticky lg:top-8 lg:self-start">
|
||||
{selectedMessage ? (
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
{/* Header with Actions */}
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b">
|
||||
<h2 className="text-xl font-bold text-gray-900">Message Details</h2>
|
||||
<button
|
||||
onClick={() => handleDelete(selectedMessage.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-md transition-colors"
|
||||
title="Delete message"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sender Info */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">From</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<User className="w-10 h-10 text-gray-400 bg-gray-100 rounded-full p-2" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{selectedMessage.user
|
||||
? `@${selectedMessage.user.username}`
|
||||
: `${selectedMessage.firstName} ${selectedMessage.lastName}`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">{selectedMessage.email}</p>
|
||||
{selectedMessage.ipAddress && (
|
||||
<p className="text-xs text-gray-500">IP: {selectedMessage.ipAddress}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Date</h3>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{new Date(selectedMessage.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Subject</h3>
|
||||
<p className="text-gray-900">{selectedMessage.subject}</p>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Message</h3>
|
||||
<div className="bg-gray-50 rounded-md p-4 text-gray-900 whitespace-pre-wrap">
|
||||
{selectedMessage.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Actions */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Status</h3>
|
||||
<div className="flex gap-2">
|
||||
{['new', 'read', 'resolved'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleStatusChange(selectedMessage.id, status)}
|
||||
disabled={selectedMessage.status === status}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
selectedMessage.status === status
|
||||
? 'bg-primary-600 text-white cursor-default'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm p-8 text-center">
|
||||
<Mail className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Select a message to view details</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user