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:
@@ -19,6 +19,8 @@ import HistoryPage from './pages/HistoryPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import PublicProfilePage from './pages/PublicProfilePage';
|
||||
import ActivityLogsPage from './pages/admin/ActivityLogsPage';
|
||||
import ContactMessagesPage from './pages/admin/ContactMessagesPage';
|
||||
import ContactPage from './pages/ContactPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
import VerificationBanner from './components/common/VerificationBanner';
|
||||
import InstallPWA from './components/pwa/InstallPWA';
|
||||
@@ -210,11 +212,22 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/contact-messages"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ContactMessagesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
{/* Home Page */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
|
||||
{/* Contact Page - Public route */}
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
|
||||
{/* Public Profile - /u/username format (no auth required) */}
|
||||
<Route path="/u/:username" element={<PublicProfilePage />} />
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Video, LogOut, User, History, Users, Menu, X, LayoutDashboard, Calendar, Shield } from 'lucide-react';
|
||||
import { Video, LogOut, User, History, Users, Menu, X, LayoutDashboard, Calendar, Shield, Mail, Activity, ChevronDown } from 'lucide-react';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { matchesAPI } from '../../services/api';
|
||||
import { connectSocket, disconnectSocket, getSocket } from '../../services/socket';
|
||||
import { MATCH_STATUS } from '../../constants';
|
||||
@@ -12,6 +12,9 @@ const Navbar = ({ pageTitle = null }) => {
|
||||
const navigate = useNavigate();
|
||||
const [pendingMatchesCount, setPendingMatchesCount] = useState(0);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [adminDropdownOpen, setAdminDropdownOpen] = useState(false);
|
||||
const [mobileAdminOpen, setMobileAdminOpen] = useState(false);
|
||||
const adminDropdownRef = useRef(null);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
@@ -47,6 +50,20 @@ const Navbar = ({ pageTitle = null }) => {
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Close admin dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (adminDropdownRef.current && !adminDropdownRef.current.contains(event.target)) {
|
||||
setAdminDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (adminDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [adminDropdownOpen]);
|
||||
|
||||
const loadPendingMatches = async () => {
|
||||
try {
|
||||
const result = await matchesAPI.getMatches(null, MATCH_STATUS.PENDING);
|
||||
@@ -123,13 +140,37 @@ const Navbar = ({ pageTitle = null }) => {
|
||||
</Link>
|
||||
|
||||
{user?.isAdmin && (
|
||||
<Link
|
||||
to="/admin/activity-logs"
|
||||
className="flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-100"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Admin</span>
|
||||
</Link>
|
||||
<div className="relative" ref={adminDropdownRef}>
|
||||
<button
|
||||
onClick={() => setAdminDropdownOpen(!adminDropdownOpen)}
|
||||
className="flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-100"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Admin</span>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${adminDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{adminDropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white rounded-md shadow-lg border border-gray-200 py-1 z-50">
|
||||
<Link
|
||||
to="/admin/activity-logs"
|
||||
onClick={() => setAdminDropdownOpen(false)}
|
||||
className="flex items-center space-x-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
<span>Activity Logs</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/contact-messages"
|
||||
onClick={() => setAdminDropdownOpen(false)}
|
||||
className="flex items-center space-x-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
<span>Contact Messages</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -212,13 +253,41 @@ const Navbar = ({ pageTitle = null }) => {
|
||||
<User className="w-4 h-4" /> Profile
|
||||
</Link>
|
||||
{user?.isAdmin && (
|
||||
<Link
|
||||
to="/admin/activity-logs"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<Shield className="w-4 h-4" /> Admin
|
||||
</Link>
|
||||
<>
|
||||
<button
|
||||
onClick={() => setMobileAdminOpen(!mobileAdminOpen)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" /> Admin
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${mobileAdminOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{mobileAdminOpen && (
|
||||
<div className="ml-4 space-y-1">
|
||||
<Link
|
||||
to="/admin/activity-logs"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setMobileAdminOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<Activity className="w-4 h-4" /> Activity Logs
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/contact-messages"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setMobileAdminOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<Mail className="w-4 h-4" /> Contact Messages
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
254
frontend/src/pages/ContactPage.jsx
Normal file
254
frontend/src/pages/ContactPage.jsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Send, Mail, User, MessageSquare } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { publicAPI } from '../services/api';
|
||||
import Layout from '../components/layout/Layout';
|
||||
|
||||
export default function ContactPage() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: user?.email || '',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Prepare data based on authentication status
|
||||
const submitData = {
|
||||
email: formData.email,
|
||||
subject: formData.subject,
|
||||
message: formData.message,
|
||||
};
|
||||
|
||||
// Add firstName and lastName only for non-logged-in users
|
||||
if (!user) {
|
||||
submitData.firstName = formData.firstName;
|
||||
submitData.lastName = formData.lastName;
|
||||
}
|
||||
|
||||
await publicAPI.submitContact(submitData);
|
||||
setSuccess(true);
|
||||
|
||||
// Reset form after 3 seconds and redirect
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setError(err.data?.error || 'Failed to submit contact form. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-lg shadow-sm p-8 text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Send className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Message Sent!</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Thank you for contacting us. We'll get back to you as soon as possible.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Redirecting to homepage...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Contact Us</h1>
|
||||
<p className="text-gray-600">
|
||||
Have a question or feedback? We'd love to hear from you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact Form */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Non-logged-in users: First Name & Last Name */}
|
||||
{!user && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
First Name *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="pl-10 w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="John"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Last Name *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="pl-10 w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logged-in users: Show username */}
|
||||
{user && (
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-md p-4">
|
||||
<p className="text-sm text-primary-800">
|
||||
Sending as: <span className="font-semibold">@{user.username}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="pl-10 w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Subject *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<MessageSquare className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={255}
|
||||
className="pl-10 w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="How can we help?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Message *
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
rows={6}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
|
||||
placeholder="Tell us more about your question or feedback..."
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{formData.message.length} / 5000 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TODO: CAPTCHA will go here */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<p className="text-sm text-yellow-800">
|
||||
CAPTCHA verification coming soon
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-primary-600 text-white font-medium rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
Send Message
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
<p>We typically respond within 24-48 hours.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -505,6 +505,15 @@ export const publicAPI = {
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async submitContact(contactData) {
|
||||
const data = await fetchAPI('/public/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(contactData),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
Reference in New Issue
Block a user