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

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