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:
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
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"
|
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>
|
<Shield className="w-4 h-4" />
|
||||||
</Link>
|
<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">
|
<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 && (
|
||||||
<Link
|
<>
|
||||||
to="/admin/activity-logs"
|
<button
|
||||||
onClick={() => setMenuOpen(false)}
|
onClick={() => setMobileAdminOpen(!mobileAdminOpen)}
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100"
|
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"
|
||||||
>
|
>
|
||||||
<Shield className="w-4 h-4" /> Admin
|
<span className="flex items-center gap-2">
|
||||||
</Link>
|
<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
|
<button
|
||||||
onClick={() => {
|
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;
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user