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:
Radosław Gierwiało
2025-12-05 17:15:25 +01:00
parent f90945aa47
commit 34f18b3b50
8 changed files with 997 additions and 16 deletions

View File

@@ -77,6 +77,7 @@ model User {
heats EventUserHeat[] heats EventUserHeat[]
recordingAssignments RecordingSuggestion[] @relation("RecorderAssignments") recordingAssignments RecordingSuggestion[] @relation("RecorderAssignments")
activityLogs ActivityLog[] activityLogs ActivityLog[]
contactMessages ContactMessage[]
@@map("users") @@map("users")
} }
@@ -365,3 +366,37 @@ model ActivityLog {
@@map("activity_logs") @@map("activity_logs")
} }
// Contact messages from users
model ContactMessage {
id Int @id @default(autoincrement())
// User identification (either logged in or anonymous)
userId Int? @map("user_id") // NULL for non-logged-in users
username String? @db.VarChar(50) // Username if logged in
firstName String? @map("first_name") @db.VarChar(100) // For non-logged-in users
lastName String? @map("last_name") @db.VarChar(100) // For non-logged-in users
email String @db.VarChar(255) // Always required
// Message content
subject String @db.VarChar(255)
message String @db.Text
// Admin tracking
status String @default("new") @db.VarChar(20) // 'new', 'read', 'resolved'
ipAddress String? @map("ip_address") @db.VarChar(45)
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
// Indexes
@@index([status, createdAt(sort: Desc)])
@@index([createdAt(sort: Desc)])
@@index([userId])
@@map("contact_messages")
}

View File

@@ -332,3 +332,167 @@ router.get('/activity-logs/stats', authenticate, requireAdmin, async (req, res,
next(error); next(error);
} }
}); });
// GET /api/admin/contact-messages - Get all contact messages with filtering
router.get('/contact-messages', authenticate, requireAdmin, async (req, res, next) => {
try {
const { status, limit = 50, offset = 0 } = req.query;
const where = {};
if (status && status !== 'all') {
where.status = status;
}
const [messages, total] = await Promise.all([
prisma.contactMessage.findMany({
where,
include: {
user: {
select: {
id: true,
username: true,
email: true,
firstName: true,
lastName: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: parseInt(limit),
skip: parseInt(offset),
}),
prisma.contactMessage.count({ where }),
]);
res.json({
success: true,
data: {
messages,
total,
limit: parseInt(limit),
offset: parseInt(offset),
},
});
} catch (error) {
next(error);
}
});
// GET /api/admin/contact-messages/:id - Get single contact message
router.get('/contact-messages/:id', authenticate, requireAdmin, async (req, res, next) => {
try {
const { id } = req.params;
const message = await prisma.contactMessage.findUnique({
where: { id: parseInt(id) },
include: {
user: {
select: {
id: true,
username: true,
email: true,
firstName: true,
lastName: true,
},
},
},
});
if (!message) {
return res.status(404).json({
success: false,
error: 'Contact message not found',
});
}
// Mark as read if it's new
if (message.status === 'new') {
await prisma.contactMessage.update({
where: { id: parseInt(id) },
data: { status: 'read' },
});
}
res.json({
success: true,
data: message,
});
} catch (error) {
next(error);
}
});
// PATCH /api/admin/contact-messages/:id/status - Update contact message status
router.patch('/contact-messages/:id/status', authenticate, requireAdmin, async (req, res, next) => {
try {
const { id } = req.params;
const { status } = req.body;
if (!['new', 'read', 'resolved'].includes(status)) {
return res.status(400).json({
success: false,
error: 'Invalid status. Must be one of: new, read, resolved',
});
}
const message = await prisma.contactMessage.update({
where: { id: parseInt(id) },
data: { status },
});
// Log the action
activityLog({
userId: req.user.id,
username: req.user.username,
ipAddress: getClientIP(req),
action: 'contact.update_status',
category: 'admin',
resource: `contact:${id}`,
method: 'PATCH',
path: `/api/admin/contact-messages/${id}/status`,
metadata: { status },
success: true,
});
res.json({
success: true,
data: message,
});
} catch (error) {
next(error);
}
});
// DELETE /api/admin/contact-messages/:id - Delete contact message
router.delete('/contact-messages/:id', authenticate, requireAdmin, async (req, res, next) => {
try {
const { id } = req.params;
await prisma.contactMessage.delete({
where: { id: parseInt(id) },
});
// Log the action
activityLog({
userId: req.user.id,
username: req.user.username,
ipAddress: getClientIP(req),
action: 'contact.delete',
category: 'admin',
resource: `contact:${id}`,
method: 'DELETE',
path: `/api/admin/contact-messages/${id}`,
metadata: {},
success: true,
});
res.json({
success: true,
message: 'Contact message deleted successfully',
});
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -1,8 +1,11 @@
const express = require('express'); const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { body, validationResult } = require('express-validator');
const { ACTIONS, log: activityLog } = require('../services/activityLog'); const { ACTIONS, log: activityLog } = require('../services/activityLog');
const { getClientIP } = require('../utils/request'); const { getClientIP } = require('../utils/request');
const router = express.Router(); const router = express.Router();
const prisma = new PrismaClient();
/** /**
* POST /api/public/log-404 * POST /api/public/log-404
@@ -46,4 +49,90 @@ router.post('/log-404', async (req, res) => {
} }
}); });
/**
* POST /api/public/contact
* Submit contact form (works for both authenticated and non-authenticated users)
*/
router.post('/contact', [
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('subject').trim().isLength({ min: 3, max: 255 }).withMessage('Subject must be between 3 and 255 characters'),
body('message').trim().isLength({ min: 10, max: 5000 }).withMessage('Message must be between 10 and 5000 characters'),
// For non-logged-in users
body('firstName').optional().trim().isLength({ min: 1, max: 100 }).withMessage('First name must be between 1 and 100 characters'),
body('lastName').optional().trim().isLength({ min: 1, max: 100 }).withMessage('Last name must be between 1 and 100 characters'),
// TODO: Add CAPTCHA validation here
], async (req, res) => {
try {
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: 'Validation failed',
details: errors.array(),
});
}
const { email, subject, message, firstName, lastName } = req.body;
// Check if user is authenticated
const userId = req.user?.id || null;
const username = req.user?.username || null;
// For non-logged-in users, firstName and lastName are required
if (!userId && (!firstName || !lastName)) {
return res.status(400).json({
success: false,
error: 'First name and last name are required for non-logged-in users',
});
}
// Create contact message
const contactMessage = await prisma.contactMessage.create({
data: {
userId,
username,
firstName: userId ? null : firstName, // Only store for non-logged-in users
lastName: userId ? null : lastName, // Only store for non-logged-in users
email,
subject,
message,
status: 'new',
ipAddress: getClientIP(req),
},
});
// Log to activity logs
activityLog({
userId,
username: username || 'anonymous',
ipAddress: getClientIP(req),
action: 'contact.submit',
category: 'system',
resource: `contact:${contactMessage.id}`,
method: 'POST',
path: '/api/public/contact',
metadata: {
subject,
messageLength: message.length,
},
success: true,
});
res.status(201).json({
success: true,
message: 'Contact form submitted successfully',
data: {
id: contactMessage.id,
},
});
} catch (error) {
console.error('Error submitting contact form:', error);
res.status(500).json({
success: false,
error: 'Failed to submit contact form',
});
}
});
module.exports = router; module.exports = router;

View File

@@ -19,6 +19,8 @@ import HistoryPage from './pages/HistoryPage';
import ProfilePage from './pages/ProfilePage'; import ProfilePage from './pages/ProfilePage';
import PublicProfilePage from './pages/PublicProfilePage'; import PublicProfilePage from './pages/PublicProfilePage';
import ActivityLogsPage from './pages/admin/ActivityLogsPage'; import ActivityLogsPage from './pages/admin/ActivityLogsPage';
import ContactMessagesPage from './pages/admin/ContactMessagesPage';
import ContactPage from './pages/ContactPage';
import NotFoundPage from './pages/NotFoundPage'; import NotFoundPage from './pages/NotFoundPage';
import VerificationBanner from './components/common/VerificationBanner'; import VerificationBanner from './components/common/VerificationBanner';
import InstallPWA from './components/pwa/InstallPWA'; import InstallPWA from './components/pwa/InstallPWA';
@@ -210,11 +212,22 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/admin/contact-messages"
element={
<ProtectedRoute>
<ContactMessagesPage />
</ProtectedRoute>
}
/>
{/* Home Page */} {/* Home Page */}
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
{/* Contact Page - Public route */}
<Route path="/contact" element={<ContactPage />} />
{/* Public Profile - /u/username format (no auth required) */} {/* Public Profile - /u/username format (no auth required) */}
<Route path="/u/:username" element={<PublicProfilePage />} /> <Route path="/u/:username" element={<PublicProfilePage />} />

View File

@@ -1,8 +1,8 @@
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; 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 Avatar from '../common/Avatar';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { matchesAPI } from '../../services/api'; import { matchesAPI } from '../../services/api';
import { connectSocket, disconnectSocket, getSocket } from '../../services/socket'; import { connectSocket, disconnectSocket, getSocket } from '../../services/socket';
import { MATCH_STATUS } from '../../constants'; import { MATCH_STATUS } from '../../constants';
@@ -12,6 +12,9 @@ const Navbar = ({ pageTitle = null }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [pendingMatchesCount, setPendingMatchesCount] = useState(0); const [pendingMatchesCount, setPendingMatchesCount] = useState(0);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [adminDropdownOpen, setAdminDropdownOpen] = useState(false);
const [mobileAdminOpen, setMobileAdminOpen] = useState(false);
const adminDropdownRef = useRef(null);
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
@@ -47,6 +50,20 @@ const Navbar = ({ pageTitle = null }) => {
} }
}, [user]); }, [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 () => { const loadPendingMatches = async () => {
try { try {
const result = await matchesAPI.getMatches(null, MATCH_STATUS.PENDING); const result = await matchesAPI.getMatches(null, MATCH_STATUS.PENDING);
@@ -123,13 +140,37 @@ const Navbar = ({ pageTitle = null }) => {
</Link> </Link>
{user?.isAdmin && ( {user?.isAdmin && (
<Link <div className="relative" ref={adminDropdownRef}>
to="/admin/activity-logs" <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" 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" /> <Shield className="w-4 h-4" />
<span>Admin</span> <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>
<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"> <div className="flex items-center space-x-3">
@@ -212,13 +253,41 @@ const Navbar = ({ pageTitle = null }) => {
<User className="w-4 h-4" /> Profile <User className="w-4 h-4" /> Profile
</Link> </Link>
{user?.isAdmin && ( {user?.isAdmin && (
<>
<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 <Link
to="/admin/activity-logs" to="/admin/activity-logs"
onClick={() => setMenuOpen(false)} onClick={() => {
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100" 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"
> >
<Shield className="w-4 h-4" /> Admin <Activity className="w-4 h-4" /> Activity Logs
</Link> </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 <button
onClick={() => { onClick={() => {

View 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>
);
}

View 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>
);
}

View File

@@ -505,6 +505,15 @@ export const publicAPI = {
}); });
return data; 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 }; export { ApiError };