From b6a2b4e275e00577e8f6b619836da03d124cc3e9 Mon Sep 17 00:00:00 2001 From: RENULUCSHMI PRAKASAN Date: Tue, 15 Jul 2025 19:42:03 +0530 Subject: [PATCH 01/15] feat: Enhance text color for better readability in KYCContent and BadgeForm components --- src/components/Admin/dashboardContent/KYCContent.tsx | 2 +- src/components/Admin/dashboardContent/badge/BadgeForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Admin/dashboardContent/KYCContent.tsx b/src/components/Admin/dashboardContent/KYCContent.tsx index 07f58fa5..fc514f53 100644 --- a/src/components/Admin/dashboardContent/KYCContent.tsx +++ b/src/components/Admin/dashboardContent/KYCContent.tsx @@ -347,7 +347,7 @@ export default function KYCContent() { } return ( -
+

Admin Dashboard - KYC

diff --git a/src/components/Admin/dashboardContent/badge/BadgeForm.tsx b/src/components/Admin/dashboardContent/badge/BadgeForm.tsx index 1905fe75..47120714 100644 --- a/src/components/Admin/dashboardContent/badge/BadgeForm.tsx +++ b/src/components/Admin/dashboardContent/badge/BadgeForm.tsx @@ -203,7 +203,7 @@ const BadgeForm = ({ onBadgeAdded }: BadgeFormProps) => { }; return ( -
+

Add New Badge

{formError && ( From 455a32083d6e4a6f730b46a524365f2e4ecbb6f3 Mon Sep 17 00:00:00 2001 From: RENULUCSHMI PRAKASAN Date: Wed, 16 Jul 2025 13:21:42 +0530 Subject: [PATCH 02/15] feat: Add admin management features including create, edit, and filter modals - Implemented AdminCreateModal for creating new admin users with validation. - Added AdminEditModal for editing existing admin details with email validation. - Introduced AdminFilters component for searching and filtering admins by role. - Created AdminTable component to display admin details with action buttons for edit and delete. - Added Toast component for displaying success, error, and warning messages. - Updated DashboardContent to include suspended users metric. --- src/app/api/admin/dashboard/route.ts | 6 +- .../AdminManagementContent.tsx | 1295 +++-------------- .../dashboardContent/DashboardContent.tsx | 11 +- .../adminManager/AdminCreateModal.tsx | 268 ++++ .../adminManager/AdminEditModal.tsx | 279 ++++ .../adminManager/AdminFilters.tsx | 67 + .../adminManager/AdminTable.tsx | 172 +++ .../dashboardContent/adminManager/Toast.tsx | 70 + 8 files changed, 1105 insertions(+), 1063 deletions(-) create mode 100644 src/components/Admin/dashboardContent/adminManager/AdminCreateModal.tsx create mode 100644 src/components/Admin/dashboardContent/adminManager/AdminEditModal.tsx create mode 100644 src/components/Admin/dashboardContent/adminManager/AdminFilters.tsx create mode 100644 src/components/Admin/dashboardContent/adminManager/AdminTable.tsx create mode 100644 src/components/Admin/dashboardContent/adminManager/Toast.tsx diff --git a/src/app/api/admin/dashboard/route.ts b/src/app/api/admin/dashboard/route.ts index 0a3f9649..83ac4935 100644 --- a/src/app/api/admin/dashboard/route.ts +++ b/src/app/api/admin/dashboard/route.ts @@ -4,13 +4,14 @@ import Session from "@/lib/models/sessionSchema"; import SkillList from "@/lib/models/skillListing"; import SkillListing from "@/lib/models/skillListing"; import SkillMatch from "@/lib/models/skillMatch"; +import SuspendedUser from "@/lib/models/suspendedUserSchema"; import { NextResponse } from "next/server"; export async function GET() { try { await connect(); // Connect to your MongoDB - const totalUsers = await User.countDocuments(); + const totalUsers = await User.countDocuments({ isDeleted: false }); const sessions = await Session.countDocuments(); const skillsRequested = await SkillList.countDocuments(); const skillsOffered = await SkillListing.countDocuments(); @@ -20,6 +21,8 @@ export async function GET() { const userIds = await Session.distinct("userId"); const activeUsers = userIds.length; + const suspendedUsers = await SuspendedUser.countDocuments(); + const popularSkillDoc = await SkillList.aggregate([ { $group: { _id: "$skillName", count: { $sum: 1 } } }, { $sort: { count: -1 } }, @@ -88,6 +91,7 @@ export async function GET() { totalUsers, activeUsers, sessions, + suspendedUsers, popularSkill, skillsOffered, skillsRequested, diff --git a/src/components/Admin/dashboardContent/AdminManagementContent.tsx b/src/components/Admin/dashboardContent/AdminManagementContent.tsx index debb1221..0436c61c 100644 --- a/src/components/Admin/dashboardContent/AdminManagementContent.tsx +++ b/src/components/Admin/dashboardContent/AdminManagementContent.tsx @@ -1,34 +1,39 @@ "use client"; -import React, { useState, useEffect } from "react"; -import { - Users, - Plus, - Edit, - Trash2, - Shield, - ShieldCheck, - Eye, - EyeOff, - Search, - Filter, - AlertCircle, - CheckCircle, - XCircle, -} from "lucide-react"; - -// Define interfaces +// ─── TOP-LEVEL CONSTANTS (no magic strings) ──────────────────────────────────── + +// API endpoints +const API_CREATE_ADMIN = "/api/admin/create-admin"; +const API_MANAGE_ADMIN = "/api/admin/manage-admin"; + +// Role identifiers +const ROLE_SUPER_ADMIN = "super_admin"; +const ROLE_ADMIN = "admin"; +const ROLE_ALL = "all"; + +// Confirmation prompts +const CONFIRM_DELETE_ADMIN_MESSAGE = + "Are you sure you want to delete this admin? This action cannot be undone."; + +// ─── IMPORTS ────────────────────────────────────────────────────────────────── +import React, { useEffect, useState } from "react"; +import Toast, { ToastType } from "./adminManager/Toast"; +import AdminFilters from "./adminManager/AdminFilters"; +import AdminTable from "./adminManager/AdminTable"; +import AdminCreateModal from "./adminManager/AdminCreateModal"; +import AdminEditModal from "./adminManager/AdminEditModal"; +import { Plus, Shield, ShieldCheck } from "lucide-react"; + +// ─── DATA SHAPES ────────────────────────────────────────────────────────────── +// TypeScript interfaces to enforce structure of our data interface Admin { _id: string; username: string; email: string; role: string; permissions: string[]; - status: string; createdAt: string; - createdBy?: { - username: string; - }; + createdBy?: { username: string }; } interface CreateAdminData { @@ -41,15 +46,25 @@ interface CreateAdminData { interface UpdateAdminData { adminId: string; - username?: string; - email?: string; - password?: string; - role?: string; - permissions?: string[]; - status?: string; + username: string; + email: string; + password: string; + role: string; + permissions: string[]; } -// Available permissions +interface EmailValidation { + isValid: boolean; + message: string; + isChecking: boolean; +} + +interface AdminManagementContentProps { + currentAdminRole?: string; +} + +// ─── PERMISSIONS LIST ───────────────────────────────────────────────────────── +// Central list of all assignable permissions const AVAILABLE_PERMISSIONS = [ { key: "manage_admins", @@ -76,6 +91,11 @@ const AVAILABLE_PERMISSIONS = [ label: "Manage System", description: "System configuration and settings", }, + { + key: "manage_suspended_users", + label: "Manage Suspended Users", + description: "View, suspend or reactivate user accounts", + }, { key: "manage_verification", label: "Manage Verification", @@ -91,6 +111,11 @@ const AVAILABLE_PERMISSIONS = [ label: "Manage Forum Reports", description: "Review and moderate forum post reports", }, + { + key: "manage_success_stories", + label: "Manage Success Stories", + description: "Create, edit, and delete success stories", + }, { key: "view_dashboard", label: "View Dashboard", @@ -98,94 +123,22 @@ const AVAILABLE_PERMISSIONS = [ }, ]; -// Toast notification component -const Toast = ({ - message, - type, - onClose, -}: { - message: string; - type: "success" | "error" | "warning"; - onClose: () => void; -}) => { - const bgColor = - type === "success" - ? "bg-green-500" - : type === "error" - ? "bg-red-500" - : "bg-yellow-500"; - const icon = - type === "success" ? ( - - ) : type === "error" ? ( - - ) : ( - - ); - - useEffect(() => { - const timer = setTimeout(onClose, 5000); - return () => clearTimeout(timer); - }, [onClose]); - - return ( -
- {icon} - {message} - -
- ); -}; - -interface AdminManagementContentProps { - currentAdminRole?: string; -} - -const AdminManagementContent: React.FC = ({ +// ─── COMPONENT ──────────────────────────────────────────────────────────────── +export default function AdminManagementContent({ currentAdminRole, -}) => { +}: AdminManagementContentProps) { + // ─── STATE HOOKS ──────────────────────────────────────────────────────────── const [admins, setAdmins] = useState([]); const [loading, setLoading] = useState(true); const [showCreateForm, setShowCreateForm] = useState(false); const [showEditForm, setShowEditForm] = useState(false); const [selectedAdmin, setSelectedAdmin] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); - const [filterRole, setFilterRole] = useState("all"); - const [filterStatus, setFilterStatus] = useState("all"); - const [toast, setToast] = useState<{ - message: string; - type: "success" | "error" | "warning"; - } | null>(null); - - // Email validation states - const [emailValidation, setEmailValidation] = useState({ - isValid: false, - message: "", - isChecking: false, - }); - const [updateEmailValidation, setUpdateEmailValidation] = useState({ - isValid: false, - message: "", - isChecking: false, - }); - // Check if current admin is a super admin - const isCurrentSuperAdmin = currentAdminRole === "super_admin"; - - // Form states const [createForm, setCreateForm] = useState({ username: "", email: "", password: "", - role: "admin", + role: ROLE_ADMIN, permissions: [], }); @@ -194,53 +147,75 @@ const AdminManagementContent: React.FC = ({ username: "", email: "", password: "", - role: "", + role: ROLE_ADMIN, permissions: [], - status: "", }); + const [searchTerm, setSearchTerm] = useState(""); + const [filterRole, setFilterRole] = useState(ROLE_ALL); const [showPassword, setShowPassword] = useState(false); - // Email validation functions - const validateEmailFormat = ( - email: string - ): { isValid: boolean; message: string } => { - if (!email.trim()) { - return { isValid: false, message: "Email is required" }; - } + // For displaying temporary feedback to the user + const [toast, setToast] = useState<{ message: string; type: ToastType } | null>( + null + ); - // Check email length - if (email.length > 254) { - return { - isValid: false, - message: "Email is too long (max 254 characters)", - }; + // Email validation states for create vs. update flows + const [emailValidation, setEmailValidation] = useState({ + isValid: false, + message: "", + isChecking: false, + }); + const [updateEmailValidation, setUpdateEmailValidation] = + useState({ + isValid: false, + message: "", + isChecking: false, + }); + + // Debounced search term to avoid rapid filtering on every keystroke + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + // Shortcut boolean for super‐admin checks + const isCurrentSuperAdmin = currentAdminRole === ROLE_SUPER_ADMIN; + + // ─── DATA FETCHING ─────────────────────────────────────────────────────────── + const fetchAdmins = async () => { + try { + const response = await fetch(API_CREATE_ADMIN, { + method: "GET", + credentials: "include", + }); + if (!response.ok) throw new Error("Failed to fetch admins"); + const data = await response.json(); + setAdmins(data.admins || []); + } catch { + setToast({ message: "Failed to load admins", type: "error" }); + } finally { + setLoading(false); } + }; - // Enhanced email regex pattern + // ─── EMAIL VALIDATION HELPERS ──────────────────────────────────────────────── + const validateEmailFormat = (email: string) => { + if (!email.trim()) return { isValid: false, message: "Email is required" }; + if (email.length > 254) + return { isValid: false, message: "Email is too long (max 254 characters)" }; + + // Standard RFC-compatible regex const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; - - if (!emailRegex.test(email.trim())) { + if (!emailRegex.test(email.trim())) return { isValid: false, message: "Please enter a valid email address" }; - } - // Check for common domains and format issues - const domain = email.split("@")[1]; - if (domain) { - // Check if domain has at least one dot - if (!domain.includes(".")) { - return { - isValid: false, - message: "Email domain must contain a valid extension", - }; - } - - // Check domain length - if (domain.length > 253) { - return { isValid: false, message: "Email domain is too long" }; - } - } + const domain = email.split("@")[1] || ""; + if (!domain.includes(".")) + return { + isValid: false, + message: "Email domain must contain a valid extension", + }; + if (domain.length > 253) + return { isValid: false, message: "Email domain is too long" }; return { isValid: true, message: "Email format is valid" }; }; @@ -248,69 +223,40 @@ const AdminManagementContent: React.FC = ({ const checkEmailUniqueness = async ( email: string, excludeAdminId?: string - ): Promise<{ isUnique: boolean; message: string }> => { + ) => { try { - // Check against existing admins - const existingAdmin = admins.find( - (admin) => - admin.email.toLowerCase() === email.toLowerCase() && - admin._id !== excludeAdminId + const existing = admins.find( + (a) => + a.email.toLowerCase() === email.toLowerCase() && + a._id !== excludeAdminId ); - - if (existingAdmin) { - return { - isUnique: false, - message: "This email is already used by another admin", - }; - } - - // You could also check against user collection here if needed - // For now, we'll rely on server-side validation for complete check - + if (existing) + return { isUnique: false, message: "This email is already used by another admin" }; return { isUnique: true, message: "Email is available" }; - } catch (error) { + } catch { return { isUnique: false, message: "Unable to verify email uniqueness" }; } }; + // ─── CREATE / UPDATE EMAIL VALIDATION FLOWS ───────────────────────────────── const validateEmailForCreate = async (email: string) => { setEmailValidation({ isValid: false, message: "", isChecking: true }); - - // First check format const formatCheck = validateEmailFormat(email); if (!formatCheck.isValid) { - setEmailValidation({ - isValid: false, - message: formatCheck.message, - isChecking: false, - }); + setEmailValidation({ ...formatCheck, isChecking: false }); return; } - - // Then check uniqueness const uniquenessCheck = await checkEmailUniqueness(email); - setEmailValidation({ - isValid: uniquenessCheck.isUnique, - message: uniquenessCheck.message, - isChecking: false, - }); + setEmailValidation({ isValid: uniquenessCheck.isUnique, message: uniquenessCheck.message, isChecking: false }); }; const validateEmailForUpdate = async (email: string, adminId: string) => { setUpdateEmailValidation({ isValid: false, message: "", isChecking: true }); - - // First check format const formatCheck = validateEmailFormat(email); if (!formatCheck.isValid) { - setUpdateEmailValidation({ - isValid: false, - message: formatCheck.message, - isChecking: false, - }); + setUpdateEmailValidation({ ...formatCheck, isChecking: false }); return; } - - // Then check uniqueness (excluding current admin) const uniquenessCheck = await checkEmailUniqueness(email, adminId); setUpdateEmailValidation({ isValid: uniquenessCheck.isUnique, @@ -319,212 +265,68 @@ const AdminManagementContent: React.FC = ({ }); }; - // Fetch all admins - const fetchAdmins = async () => { - try { - const response = await fetch("/api/admin/create-admin", { - method: "GET", - credentials: "include", - }); - - if (!response.ok) { - throw new Error("Failed to fetch admins"); - } - - const data = await response.json(); - setAdmins(data.admins || []); - } catch (error) { - console.error("Error fetching admins:", error); - setToast({ message: "Failed to load admins", type: "error" }); - } finally { - setLoading(false); - } - }; - - // Create new admin + // ─── CRUD OPERATIONS ───────────────────────────────────────────────────────── const createAdmin = async (e: React.FormEvent) => { e.preventDefault(); - - // Client-side validation - if (createForm.username.trim().length < 4) { - setToast({ - message: "Username must be at least 4 characters long", - type: "error", - }); - return; - } - - // Enhanced email validation - const emailFormatCheck = validateEmailFormat(createForm.email); - if (!emailFormatCheck.isValid) { - setToast({ - message: emailFormatCheck.message, - type: "error", - }); - return; - } - - // Check email uniqueness - const emailUniquenessCheck = await checkEmailUniqueness(createForm.email); - if (!emailUniquenessCheck.isUnique) { - setToast({ - message: emailUniquenessCheck.message, - type: "error", - }); - return; - } - - if (createForm.password.length < 8) { - setToast({ - message: "Password must be at least 8 characters long", - type: "error", - }); - return; - } - - const passwordRegex = /^(?=.*\d)/; - if (!passwordRegex.test(createForm.password)) { - setToast({ - message: "Password must contain at least one number", - type: "error", - }); - return; - } - try { - const response = await fetch("/api/admin/create-admin", { + const response = await fetch(API_CREATE_ADMIN, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(createForm), }); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || "Failed to create admin"); - } - + if (!response.ok) throw new Error(data.message || "Failed to create admin"); setToast({ message: "New admin created successfully", type: "success" }); setShowCreateForm(false); - setCreateForm({ - username: "", - email: "", - password: "", - role: "admin", - permissions: [], - }); - // Reset email validation state + setCreateForm({ username: "", email: "", password: "", role: ROLE_ADMIN, permissions: [] }); setEmailValidation({ isValid: false, message: "", isChecking: false }); fetchAdmins(); } catch (error: any) { - console.error("Error creating admin:", error); - setToast({ - message: error.message || "Failed to create admin", - type: "error", - }); + setToast({ message: error.message || "Failed to create admin", type: "error" }); } }; - // Update admin const updateAdmin = async (e: React.FormEvent) => { e.preventDefault(); - - // Email validation if email is being changed - if (updateForm.email && updateForm.email !== selectedAdmin?.email) { - const emailFormatCheck = validateEmailFormat(updateForm.email); - if (!emailFormatCheck.isValid) { - setToast({ - message: emailFormatCheck.message, - type: "error", - }); - return; - } - - const emailUniquenessCheck = await checkEmailUniqueness( - updateForm.email, - updateForm.adminId - ); - if (!emailUniquenessCheck.isUnique) { - setToast({ - message: emailUniquenessCheck.message, - type: "error", - }); - return; - } - } - try { - const response = await fetch("/api/admin/manage-admin", { + const response = await fetch(API_MANAGE_ADMIN, { method: "PUT", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(updateForm), }); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || "Failed to update admin"); - } - + if (!response.ok) throw new Error(data.message || "Failed to update admin"); setToast({ message: "Admin updated successfully", type: "success" }); setShowEditForm(false); setSelectedAdmin(null); - // Reset email validation state - setUpdateEmailValidation({ - isValid: false, - message: "", - isChecking: false, - }); + setUpdateEmailValidation({ isValid: false, message: "", isChecking: false }); fetchAdmins(); } catch (error: any) { - console.error("Error updating admin:", error); - setToast({ - message: error.message || "Failed to update admin", - type: "error", - }); + setToast({ message: error.message || "Failed to update admin", type: "error" }); } }; - // Delete admin const deleteAdmin = async (adminId: string) => { - if ( - !confirm( - "Are you sure you want to delete this admin? This action cannot be undone." - ) - ) { - return; - } + // Ask user for confirmation before destructive action + if (!confirm(CONFIRM_DELETE_ADMIN_MESSAGE)) return; try { - const response = await fetch(`/api/admin/manage-admin?id=${adminId}`, { + const response = await fetch(`${API_MANAGE_ADMIN}?id=${adminId}`, { method: "DELETE", credentials: "include", }); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || "Failed to delete admin"); - } - + if (!response.ok) throw new Error(data.message || "Failed to delete admin"); setToast({ message: "Admin deleted successfully", type: "success" }); fetchAdmins(); } catch (error: any) { - console.error("Error deleting admin:", error); - setToast({ - message: error.message || "Failed to delete admin", - type: "error", - }); + setToast({ message: error.message || "Failed to delete admin", type: "error" }); } }; - // Handle edit form + // ─── HANDLERS & HELPERS ───────────────────────────────────────────────────── const handleEdit = (admin: Admin) => { setSelectedAdmin(admin); setUpdateForm({ @@ -534,105 +336,81 @@ const AdminManagementContent: React.FC = ({ password: "", role: admin.role, permissions: admin.permissions, - status: admin.status, - }); - // Reset email validation state - setUpdateEmailValidation({ - isValid: false, - message: "", - isChecking: false, }); + setUpdateEmailValidation({ isValid: false, message: "", isChecking: false }); setShowEditForm(true); }; - // Handle permission toggle - const togglePermission = (permission: string, isCreate: boolean = false) => { - if (isCreate) { - const newPermissions = createForm.permissions.includes(permission) - ? createForm.permissions.filter((p) => p !== permission) - : [...createForm.permissions, permission]; - setCreateForm({ ...createForm, permissions: newPermissions }); - } else { - const newPermissions = updateForm.permissions!.includes(permission) - ? updateForm.permissions!.filter((p) => p !== permission) - : [...updateForm.permissions!, permission]; - setUpdateForm({ ...updateForm, permissions: newPermissions }); - } + // Toggle a permission on create vs update forms + const togglePermission = (permission: string, isCreate = false) => { + const form = isCreate ? createForm : updateForm; + const has = form.permissions.includes(permission); + const newPerms = has + ? form.permissions.filter((p) => p !== permission) + : [...form.permissions, permission]; + + if (isCreate) setCreateForm({ ...createForm, permissions: newPerms }); + else setUpdateForm({ ...updateForm, permissions: newPerms }); }; - // Set default permissions based on role - const setDefaultPermissions = (role: string, isCreate: boolean = false) => { - const defaultPermissions = - role === "super_admin" + // Auto-assign default permissions based on selected role + const setDefaultPermissions = (role: string, isCreate = false) => { + const defaultKeys = + role === ROLE_SUPER_ADMIN ? AVAILABLE_PERMISSIONS.map((p) => p.key) - : AVAILABLE_PERMISSIONS.filter((p) => p.key !== "manage_admins").map( - (p) => p.key - ); + : AVAILABLE_PERMISSIONS.filter((p) => p.key !== "manage_admins").map((p) => p.key); - if (isCreate) { - setCreateForm({ ...createForm, role, permissions: defaultPermissions }); - } else { - setUpdateForm({ ...updateForm, role, permissions: defaultPermissions }); - } + if (isCreate) setCreateForm({ ...createForm, role, permissions: defaultKeys }); + else setUpdateForm({ ...updateForm, role, permissions: defaultKeys }); }; - // Filter admins + // ─── FILTERED & DEBOUNCED SEARCH ───────────────────────────────────────────── const filteredAdmins = admins.filter((admin) => { + const text = debouncedSearchTerm.toLowerCase(); const matchesSearch = - admin.username - .toLowerCase() - .includes(debouncedSearchTerm.toLowerCase()) || - admin.email.toLowerCase().includes(debouncedSearchTerm.toLowerCase()); - const matchesRole = filterRole === "all" || admin.role === filterRole; - const matchesStatus = - filterStatus === "all" || admin.status === filterStatus; - return matchesSearch && matchesRole && matchesStatus; + admin.username.toLowerCase().includes(text) || + admin.email.toLowerCase().includes(text); + const matchesRole = filterRole === ROLE_ALL || admin.role === filterRole; + return matchesSearch && matchesRole; }); - // Debounce search term + // Debounce search input for smoother typing UX useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 300); - + const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300); return () => clearTimeout(timer); }, [searchTerm]); - // Debounce email validation for create form + // Validate email on create form with a slight delay useEffect(() => { if (createForm.email.trim()) { - const timer = setTimeout(() => { - validateEmailForCreate(createForm.email.trim()); - }, 500); - + const timer = setTimeout(() => validateEmailForCreate(createForm.email.trim()), 500); return () => clearTimeout(timer); - } else { - setEmailValidation({ isValid: false, message: "", isChecking: false }); } + setEmailValidation({ isValid: false, message: "", isChecking: false }); }, [createForm.email]); - // Debounce email validation for update form + // Validate email on update form when email or id changes useEffect(() => { - if (updateForm.email && updateForm.email.trim() && updateForm.adminId) { - const timer = setTimeout(() => { - validateEmailForUpdate(updateForm.email!.trim(), updateForm.adminId); - }, 500); - + if (updateForm.email && updateForm.adminId) { + const timer = setTimeout(() => validateEmailForUpdate(updateForm.email.trim(), updateForm.adminId), 500); return () => clearTimeout(timer); - } else { - setUpdateEmailValidation({ - isValid: false, - message: "", - isChecking: false, - }); } + setUpdateEmailValidation({ isValid: false, message: "", isChecking: false }); }, [updateForm.email, updateForm.adminId]); + // Initial data load useEffect(() => { fetchAdmins(); }, []); + // Reset create permissions whenever role changes + useEffect(() => { + setDefaultPermissions(createForm.role, true); + }, [createForm.role]); + + // ─── RENDER ────────────────────────────────────────────────────────────────── if (loading) { + // Show spinner while fetching return (
@@ -641,16 +419,14 @@ const AdminManagementContent: React.FC = ({ ); } - // Show unauthorized message if user is not a super admin if (!isCurrentSuperAdmin) { + // Restrict access for non-super admins return (
-

- Unauthorized -

+

Unauthorized

You don't have permission to access admin management.

@@ -665,30 +441,27 @@ const AdminManagementContent: React.FC = ({ return (
- {/* Toast notification */} + {/* Toast notifications */} {toast && (
- setToast(null)} - /> + setToast(null)} />
)} - {/* Header */} + {/* Header and Create button */}

Admin Management

-

- Manage administrator accounts and permissions -

+

Manage administrator accounts and permissions

- {/* Filters */} -
-
-
-
- - setSearchTerm(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -
-
-
- - - -
-
-
- - {/* Admins List */} -
-
- - - - - - - - - - - - {filteredAdmins.length === 0 ? ( - - - - ) : ( - filteredAdmins.map((admin) => ( - - - - - - - - )) - )} - -
- Admin Details - - Role & Status - - Permissions - - Created - - Actions -
- -

No admins found

-

- Try adjusting your search or filter criteria -

-
-
-
- {admin.username} -
-
- {admin.email} -
-
-
-
- {admin.role === "super_admin" ? ( - - ) : ( - - )} -
-
- {admin.role === "super_admin" - ? "Super Admin" - : "Admin"} -
-
- {admin.status.charAt(0).toUpperCase() + - admin.status.slice(1)} -
-
-
-
-
- {admin.permissions.slice(0, 3).map((permission) => ( - - {AVAILABLE_PERMISSIONS.find( - (p) => p.key === permission - )?.label || permission} - - ))} - {admin.permissions.length > 3 && ( - - +{admin.permissions.length - 3} more - - )} -
-
-
-
- {new Date(admin.createdAt).toLocaleDateString()} -
- {admin.createdBy && ( -
- by {admin.createdBy.username} -
- )} -
-
-
- - -
-
-
-
- - {/* Create Admin Modal */} + {/* Filters and table */} + + + + {/* Create and Edit modals */} {showCreateForm && ( -
-
-

Create New Admin

-
-
-
- - - setCreateForm({ ...createForm, username: e.target.value }) - } - placeholder="At least 4 characters" - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - required - minLength={4} - /> -

- Minimum 4 characters -

-
-
- -
- - setCreateForm({ ...createForm, email: e.target.value }) - } - placeholder="admin@example.com" - className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:border-transparent ${ - createForm.email.trim() === "" - ? "border-gray-300 focus:ring-blue-500" - : emailValidation.isChecking - ? "border-yellow-300 focus:ring-yellow-500" - : emailValidation.isValid - ? "border-green-300 focus:ring-green-500" - : "border-red-300 focus:ring-red-500" - }`} - required - /> - {emailValidation.isChecking && ( -
-
-
- )} - {!emailValidation.isChecking && createForm.email.trim() && ( -
- {emailValidation.isValid ? ( - - ) : ( - - )} -
- )} -
-

- {emailValidation.isChecking - ? "Validating email..." - : emailValidation.message || - "Valid email address required"} -

-
-
- -
- -
- - setCreateForm({ ...createForm, password: e.target.value }) - } - placeholder="Minimum 8 characters" - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - required - minLength={8} - /> - -
-

- Minimum 8 characters, at least 1 number -

-
- -
- - -

- Super admins have full system access -

-
- -
- -
- {createForm.role === "super_admin" ? ( - // Super Admin: Show all permissions as read-only list with checkmarks -
-

- Super admins have access to all system permissions: -

- {AVAILABLE_PERMISSIONS.map((permission) => ( -
- -
- - {permission.label} - - - {permission.description} - -
-
- ))} -
- ) : ( - // Regular Admin: Show permissions as read-only list (excluding manage_admins) -
-

- Regular admins have all permissions except admin - management: -

- {AVAILABLE_PERMISSIONS.filter( - (p) => p.key !== "manage_admins" - ).map((permission) => ( -
- -
- - {permission.label} - - - {permission.description} - -
-
- ))} -
- -
- - Manage Admins - - - Not available for regular admins - -
-
-
- )} -
-
- -
- - -
-
-
-
+ setShowCreateForm(false)} + availablePermissions={AVAILABLE_PERMISSIONS} + showPassword={showPassword} + setShowPassword={setShowPassword} + /> )} - - {/* Edit Admin Modal */} {showEditForm && selectedAdmin && ( -
-
-

- Edit Admin: {selectedAdmin.username} -

-
-
-
- - - setUpdateForm({ ...updateForm, username: e.target.value }) - } - placeholder="At least 4 characters" - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - required - minLength={4} - /> -
-
- -
- - setUpdateForm({ ...updateForm, email: e.target.value }) - } - placeholder="admin@example.com" - className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:border-transparent ${ - updateForm.email === selectedAdmin.email - ? "border-gray-300 focus:ring-blue-500" - : updateEmailValidation.isChecking - ? "border-yellow-300 focus:ring-yellow-500" - : updateEmailValidation.isValid - ? "border-green-300 focus:ring-green-500" - : "border-red-300 focus:ring-red-500" - }`} - required - /> - {updateEmailValidation.isChecking && ( -
-
-
- )} - {!updateEmailValidation.isChecking && - updateForm.email !== selectedAdmin.email && ( -
- {updateEmailValidation.isValid ? ( - - ) : ( - - )} -
- )} -
-

- {updateForm.email === selectedAdmin.email - ? "Current email address" - : updateEmailValidation.isChecking - ? "Validating email..." - : updateEmailValidation.message || - "Valid email address required"} -

-
-
- -
- -
- - setUpdateForm({ ...updateForm, password: e.target.value }) - } - placeholder="Leave blank to keep current password" - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> - -
-

- Minimum 8 characters, at least 1 number if provided -

-
- -
-
- - -
-
- - -
-
- -
- -
- {updateForm.role === "super_admin" ? ( - // Super Admin: Show all permissions as read-only list with checkmarks -
-

- Super admins have access to all system permissions: -

- {AVAILABLE_PERMISSIONS.map((permission) => ( -
- -
- - {permission.label} - - - {permission.description} - -
-
- ))} -
- ) : ( - // Regular Admin: Show permissions as read-only list (excluding manage_admins) -
-

- Regular admins have all permissions except admin - management: -

- {AVAILABLE_PERMISSIONS.filter( - (p) => p.key !== "manage_admins" - ).map((permission) => ( -
- -
- - {permission.label} - - - {permission.description} - -
-
- ))} -
- -
- - Manage Admins - - - Not available for regular admins - -
-
-
- )} -
-
- -
- - -
-
-
-
+ setShowEditForm(false)} + emailValidation={updateEmailValidation} + availablePermissions={AVAILABLE_PERMISSIONS} + showPassword={showPassword} + setShowPassword={setShowPassword} + /> )}
); -}; - -export default AdminManagementContent; +} diff --git a/src/components/Admin/dashboardContent/DashboardContent.tsx b/src/components/Admin/dashboardContent/DashboardContent.tsx index 2a2416a1..a7689102 100644 --- a/src/components/Admin/dashboardContent/DashboardContent.tsx +++ b/src/components/Admin/dashboardContent/DashboardContent.tsx @@ -97,6 +97,7 @@ const DASHBOARD_API_URL = "/api/admin/dashboard"; interface DashboardData { totalUsers: number; sessions: number; + suspendedUsers: number; newUsersThisWeek: number; skillsOffered: number; skillsRequested: number; @@ -661,12 +662,10 @@ export default function DashboardContent() { value={filteredMetrics.newUsers.toString()} /> 0 - ? `${Math.round((data.matches / data.skillsRequested) * 100)}%` - : "0%" - } + + title="No of Suspended Users" + value={data.suspendedUsers.toString()} + /> void; + onSubmit: (e: React.FormEvent) => void; + onClose: () => void; + availablePermissions: Permission[]; + showPassword: boolean; + setShowPassword: (val: boolean) => void; +} + +// ─── COMPONENT ──────────────────────────────────────────────────────────────── +export default function AdminCreateModal({ + form, + onChange, + onSubmit, + onClose, + availablePermissions, + showPassword, + setShowPassword, +}: AdminCreateModalProps) { + // Trimmed email for validation + const email = form.email.trim(); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + // null = untouched, true/false = validity + const isEmailValid = email === "" ? null : emailRegex.test(email); + + // Password strength: at least 8 chars, one uppercase, one digit + const pwd = form.password; + const pwdRegex = /^(?=.*[A-Z])(?=.*\d).{8,}$/; + const isPasswordValid = pwd === "" ? null : pwdRegex.test(pwd); + + return ( +
+ {/* Modal backdrop and container */} +
+

Create New Admin

+
+ {/* ── Username & Email ───────────────────────────────────────── */} +
+ {/* Username input */} +
+ + onChange({ ...form, username: e.target.value })} + placeholder="At least 4 characters" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" + required + minLength={4} + /> +
+ {/* Email input with live validation */} +
+ +
+ onChange({ ...form, email: e.target.value })} + placeholder="admin@example.com" + className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 ${ + email === "" + ? "border-gray-300" + : isEmailValid + ? "border-green-300" + : "border-red-300" + }`} + required + /> + {email && ( +
+ {isEmailValid ? ( + + ) : ( + + )} +
+ )} +
+ {email && ( +

+ {isEmailValid + ? "Valid email format" + : "Please enter a valid email address"} +

+ )} +
+
+ + {/* ── Password Input with Visibility Toggle ──────────────────────── */} +
+ +
+ onChange({ ...form, password: e.target.value })} + placeholder="Min 8 chars, 1 uppercase, 1 digit" + className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 ${ + pwd === "" + ? "border-gray-300" + : isPasswordValid + ? "border-green-300" + : "border-red-300" + }`} + required + minLength={8} + /> + +
+ {pwd && ( +

+ {isPasswordValid + ? "Strong password" + : "Use 8+ chars, including uppercase & digit"} +

+ )} +
+ + {/* ── Role Selector ──────────────────────────────────────────────── */} +
+ + +
+ + {/* ── Permissions Display ───────────────────────────────────────── */} +
+ +
+ {form.role === ROLE_SUPER_ADMIN ? ( + // Super admins have all permissions granted +
+ {availablePermissions.map(perm => ( +
+ +
+
+ {perm.label} +
+
+ {perm.description} +
+
+
+ ))} +
+ ) : ( + // Regular admins see all except the manage_admins permission +
+ {availablePermissions + .filter(p => p.key !== "manage_admins") + .map(perm => ( +
+ +
+
+ {perm.label} +
+
+ {perm.description} +
+
+
+ ))} + {/* Explicitly indicate that manage_admins is unavailable */} +
+ +
+
+ Manage Admins +
+
+ Not available for regular admins +
+
+
+
+ )} +
+
+ + {/* ── Action Buttons ──────────────────────────────────────────────── */} +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/Admin/dashboardContent/adminManager/AdminEditModal.tsx b/src/components/Admin/dashboardContent/adminManager/AdminEditModal.tsx new file mode 100644 index 00000000..c3990fd6 --- /dev/null +++ b/src/components/Admin/dashboardContent/adminManager/AdminEditModal.tsx @@ -0,0 +1,279 @@ +"use client"; + +// ─── TOP-LEVEL CONSTANTS ────────────────────────────────────────────────────── +// Role identifiers used throughout the modal +const ROLE_SUPER_ADMIN = "super_admin"; +const ROLE_ADMIN = "admin"; + +// ─── IMPORTS ────────────────────────────────────────────────────────────────── +import { CheckCircle, Eye, EyeOff, XCircle } from "lucide-react"; + +// ─── TYPE DEFINITIONS ───────────────────────────────────────────────────────── +// Defines the structure of a permission item +interface Permission { + key: string; + label: string; + description: string; +} + +// Payload for updating an admin +interface UpdateAdminData { + adminId: string; + username: string; + email: string; + password: string; + role: string; + permissions: string[]; +} + +// Tracks email validation state during edit +interface EmailValidation { + isValid: boolean; + message: string; + isChecking: boolean; +} + +// Props accepted by the AdminEditModal component +interface AdminEditModalProps { + selectedAdmin: UpdateAdminData; + form: UpdateAdminData; + onChange: (data: UpdateAdminData) => void; + onSubmit: (e: React.FormEvent) => void; + onClose: () => void; + emailValidation: EmailValidation; + availablePermissions: Permission[]; + showPassword: boolean; + setShowPassword: (val: boolean) => void; +} + +// ─── COMPONENT ──────────────────────────────────────────────────────────────── +export default function AdminEditModal({ + selectedAdmin, + form, + onChange, + onSubmit, + onClose, + emailValidation, + availablePermissions, + showPassword, + setShowPassword, +}: AdminEditModalProps) { + return ( +
+ {/* Modal container */} +
+ {/* Header */} +

+ Edit Admin: {selectedAdmin.username} +

+ + {/* Form starts here */} +
+ {/* Username & Email fields */} +
+ {/* Username input */} +
+ + + onChange({ ...form, username: e.target.value }) + } + placeholder="At least 4 characters" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + minLength={4} + /> +
+ + {/* Email input with validation UI */} +
+ +
+ onChange({ ...form, email: e.target.value })} + placeholder="admin@example.com" + className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:border-transparent ${ + form.email === selectedAdmin.email + ? "border-gray-300 focus:ring-blue-500" + : emailValidation.isChecking + ? "border-yellow-300 focus:ring-yellow-500" + : emailValidation.isValid + ? "border-green-300 focus:ring-green-500" + : "border-red-300 focus:ring-red-500" + }`} + required + /> + {/* Spinner while checking email */} + {emailValidation.isChecking && ( +
+
+
+ )} + {/* Success or error icon once checking completes */} + {!emailValidation.isChecking && + form.email !== selectedAdmin.email && ( +
+ {emailValidation.isValid ? ( + + ) : ( + + )} +
+ )} +
+ {/* Validation message below email */} +

+ {form.email === selectedAdmin.email + ? "Current email address" + : emailValidation.isChecking + ? "Validating email..." + : emailValidation.message || + "Valid email address required"} +

+
+
+ + {/* Password field with visibility toggle */} +
+ +
+ + onChange({ ...form, password: e.target.value }) + } + placeholder="Leave blank to keep current password" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + +
+
+ + {/* Role selector */} +
+
+ + +
+
+ + {/* Permissions list */} +
+ +
+ {form.role === ROLE_SUPER_ADMIN ? ( + // Super admin sees all permissions pre-checked +
+ {availablePermissions.map(permission => ( +
+ +
+ + {permission.label} + + + {permission.description} + +
+
+ ))} +
+ ) : ( + // Regular admin can toggle each permission +
+ {availablePermissions.map(permission => ( +
+ { + const has = form.permissions.includes(permission.key); + const updated = has + ? form.permissions.filter(p => p !== permission.key) + : [...form.permissions, permission.key]; + onChange({ ...form, permissions: updated }); + }} + className="mr-2" + /> +
+ + {permission.label} + + + {permission.description} + +
+
+ ))} +
+ )} +
+
+ + {/* Action buttons */} +
+ + +
+
+
+
+ ); +} diff --git a/src/components/Admin/dashboardContent/adminManager/AdminFilters.tsx b/src/components/Admin/dashboardContent/adminManager/AdminFilters.tsx new file mode 100644 index 00000000..69a68b13 --- /dev/null +++ b/src/components/Admin/dashboardContent/adminManager/AdminFilters.tsx @@ -0,0 +1,67 @@ +"use client"; + +// ─── TOP-LEVEL CONSTANTS ──────────────────────────────────────────────── +// Role identifiers used in filters +const ROLE_SUPER_ADMIN = "super_admin"; +const ROLE_ADMIN = "admin"; +const ROLE_ALL = "all"; + +// ─── IMPORTS ──────────────────────────────────────────────────────────── +import { Filter, Search } from "lucide-react"; + +// ─── PROPS DEFINITION ──────────────────────────────────────────────────── +// Defines the shape of props passed into the filter component +interface AdminFiltersProps { + searchTerm: string; + onSearchChange: (value: string) => void; + filterRole: string; + onRoleChange: (value: string) => void; +} + +// ─── COMPONENT ──────────────────────────────────────────────────────────── +export default function AdminFilters({ + searchTerm, + onSearchChange, + filterRole, + onRoleChange, +}: AdminFiltersProps) { + return ( +
+ {/* Wrapper for search input and role dropdown */} +
+ {/* Search box */} +
+
+ {/* Search icon inside input */} + + onSearchChange(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ + {/* Role filter dropdown */} +
+ {/* Filter icon next to dropdown */} + + +
+
+
+ ); +} diff --git a/src/components/Admin/dashboardContent/adminManager/AdminTable.tsx b/src/components/Admin/dashboardContent/adminManager/AdminTable.tsx new file mode 100644 index 00000000..1bf40495 --- /dev/null +++ b/src/components/Admin/dashboardContent/adminManager/AdminTable.tsx @@ -0,0 +1,172 @@ +"use client"; + +// ─── TOP-LEVEL CONSTANTS ────────────────────────────────────────────────────── +// Role identifier for Super Admin checks +const ROLE_SUPER_ADMIN = "super_admin"; + +// ─── IMPORTS ────────────────────────────────────────────────────────────────── +import { Edit, Shield, ShieldCheck, Trash2 } from "lucide-react"; + +// ─── TYPE DEFINITIONS ───────────────────────────────────────────────────────── +// Represents an administrator account +interface Admin { + _id: string; + username: string; + email: string; + role: string; + permissions: string[]; + createdAt: string; + createdBy?: { username: string }; +} + +// Represents a single permission option +interface Permission { + key: string; + label: string; + description: string; +} + +// Props accepted by the AdminTable component +interface AdminTableProps { + admins: Admin[]; + availablePermissions: Permission[]; + onEdit: (admin: Admin) => void; + onDelete: (id: string) => void; +} + +// ─── COMPONENT ──────────────────────────────────────────────────────────────── +export default function AdminTable({ + admins, + availablePermissions, + onEdit, + onDelete, +}: AdminTableProps) { + return ( +
+ {/* Responsive wrapper for horizontal scrolling */} +
+ + {/* Table headings */} + + + + + + + + + + {/* Table body with data rows */} + + {admins.map(admin => ( + + {/* Username and email column */} + + + {/* Role column with icon */} + + + {/* Permissions column: show first 3 with “+N more” */} + + + {/* Created date and creator */} + + + {/* Action buttons for edit and delete */} + + + ))} + + {/* Fallback row when there are no admins */} + {admins.length === 0 && ( + + + + )} + +
+ Admin Details + + Role + + Permissions + + Created + + Actions +
+
+
+ {admin.username} +
+
{admin.email}
+
+
+
+ {admin.role === ROLE_SUPER_ADMIN ? ( + + ) : ( + + )} + + {admin.role === ROLE_SUPER_ADMIN ? "Super Admin" : "Admin"} + +
+
+
+ {admin.permissions.slice(0, 3).map(permissionKey => ( + + { + availablePermissions.find(p => p.key === permissionKey) + ?.label || permissionKey + } + + ))} + {admin.permissions.length > 3 && ( + + +{admin.permissions.length - 3} more + + )} +
+
+
+
+ {new Date(admin.createdAt).toLocaleDateString()} +
+ {admin.createdBy && ( +
+ by {admin.createdBy.username} +
+ )} +
+
+
+ + +
+
+ No admins found +
+
+
+ ); +} diff --git a/src/components/Admin/dashboardContent/adminManager/Toast.tsx b/src/components/Admin/dashboardContent/adminManager/Toast.tsx new file mode 100644 index 00000000..40fbb051 --- /dev/null +++ b/src/components/Admin/dashboardContent/adminManager/Toast.tsx @@ -0,0 +1,70 @@ +"use client"; + +// ─── CONSTANTS ─────────────────────────────────────────────────────────────── +// Toast types +const TOAST_SUCCESS: ToastType = "success"; +const TOAST_ERROR: ToastType = "error"; +const TOAST_WARNING: ToastType = "warning"; + +// Duration before auto-dismiss (in milliseconds) +const TOAST_DURATION_MS = 5000; + +// ─── IMPORTS ───────────────────────────────────────────────────────────────── +import { useEffect } from "react"; +import { AlertCircle, CheckCircle, XCircle } from "lucide-react"; + +// ─── TYPES ─────────────────────────────────────────────────────────────────── +export type ToastType = "success" | "error" | "warning"; + +interface ToastProps { + message: string; + type: ToastType; + onClose: () => void; +} + +// ─── COMPONENT ──────────────────────────────────────────────────────────────── +export default function Toast({ message, type, onClose }: ToastProps) { + // Determine background color based on toast type + const bgColor = + type === TOAST_SUCCESS + ? "bg-green-500" + : type === TOAST_ERROR + ? "bg-red-500" + : "bg-yellow-500"; + + // Select appropriate icon for each toast type + const icon = + type === TOAST_SUCCESS ? ( + + ) : type === TOAST_ERROR ? ( + + ) : ( + + ); + + // Automatically dismiss toast after TOAST_DURATION_MS + useEffect(() => { + const timer = setTimeout(onClose, TOAST_DURATION_MS); + return () => clearTimeout(timer); + }, [onClose]); + + return ( +
+ {/* Toast icon */} + {icon} + + {/* Toast message */} + {message} + + {/* Manual close button */} + +
+ ); +} From 30ef38796ce0365693d5eac0ce48f77e6bb72bc0 Mon Sep 17 00:00:00 2001 From: RENULUCSHMI PRAKASAN Date: Wed, 16 Jul 2025 16:02:14 +0530 Subject: [PATCH 03/15] feat: Refactor SuspendedUsersContent component to improve structure and add constants --- .../dashboardContent/SuspendedUsersContent.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/Admin/dashboardContent/SuspendedUsersContent.tsx b/src/components/Admin/dashboardContent/SuspendedUsersContent.tsx index da5f599d..4a36b67e 100644 --- a/src/components/Admin/dashboardContent/SuspendedUsersContent.tsx +++ b/src/components/Admin/dashboardContent/SuspendedUsersContent.tsx @@ -1,6 +1,17 @@ -// Suspended Users page in admin dashboard -// This page displays a list of suspended users with search and unsuspend functionality -import React, { useEffect, useState, useMemo, useCallback } from "react"; +"use client"; + +// ─── TOP-LEVEL CONSTANTS ───────────────────────────────────────────────────── +// Number of users to show per page +const USERS_PER_PAGE = 10; +// Delay for debounced search (milliseconds) +const DEBOUNCE_DELAY = 300; + +import React, { + useEffect, + useState, + useMemo, + useCallback, +} from "react"; import { debounce } from "lodash-es"; import { ToastContainer, toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; From 40d481a6d97a9f838dadf8c5b75027dcb453188e Mon Sep 17 00:00:00 2001 From: RENULUCSHMI PRAKASAN Date: Wed, 16 Jul 2025 16:04:35 +0530 Subject: [PATCH 04/15] feat: Refactor SuspendedUsersContent component for improved readability and structure --- .../SuspendedUsersContent.tsx | 531 +++++++----------- 1 file changed, 193 insertions(+), 338 deletions(-) diff --git a/src/components/Admin/dashboardContent/SuspendedUsersContent.tsx b/src/components/Admin/dashboardContent/SuspendedUsersContent.tsx index 4a36b67e..e0eaa53f 100644 --- a/src/components/Admin/dashboardContent/SuspendedUsersContent.tsx +++ b/src/components/Admin/dashboardContent/SuspendedUsersContent.tsx @@ -16,7 +16,7 @@ import { debounce } from "lodash-es"; import { ToastContainer, toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; -// Types +// ─── TYPE DEFINITIONS ───────────────────────────────────────────────────────── interface SuspendedUser { _id: string; firstName?: string; @@ -27,7 +27,6 @@ interface SuspendedUser { avatar?: string; originalCreatedAt?: string; suspendedAt?: string; - suspendedBy?: string; suspensionReason?: string; suspensionNotes?: string; originalUserId?: string; @@ -65,85 +64,74 @@ interface DeleteModalProps { userName?: string; } -// Constants -const USERS_PER_PAGE = 10; -const DEBOUNCE_DELAY = 300; +// ─── HELPER FUNCTIONS ───────────────────────────────────────────────────────── +// Get initials from first and last name +const getInitials = (first: string, last: string): string => + `${(first[0] || "").toUpperCase()}${(last[0] || "").toUpperCase()}`; -// Helper functions -const getInitials = (firstName: string, lastName: string): string => { - const first = firstName || ""; - const last = lastName || ""; - return `${first[0] || ""}${last[0] || ""}`.toUpperCase(); -}; +// Format a 10-digit phone number as (123) 456-7890 +const formatPhoneNumber = (phone: string): string => + phone.replace(/(\d{3})(\d{3})(\d{4})/, "($1) $2-$3"); -const formatPhoneNumber = (phone: string): string => { - if (!phone) return ""; - return phone.replace(/(\d{3})(\d{3})(\d{4})/, "($1) $2-$3"); -}; +// Convert ISO date string to locale date +const formatDate = (iso: string): string => + new Date(iso).toLocaleDateString(); -const formatDate = (dateString: string): string => { - if (!dateString) return ""; - return new Date(dateString).toLocaleDateString(); -}; +// Convert ISO date string to locale date and time +const formatDateTime = (iso: string): string => + new Date(iso).toLocaleString(); -const formatDateTime = (dateString: string): string => { - if (!dateString) return ""; - return new Date(dateString).toLocaleString(); -}; +// ─── SUBCOMPONENTS ──────────────────────────────────────────────────────────── -// Components -const LoadingSkeleton: React.FC<{ count?: number }> = ({ - count = USERS_PER_PAGE, -}) => ( +// Skeleton loader while data is fetching +const LoadingSkeleton: React.FC<{ count?: number }> = ({ count = USERS_PER_PAGE }) => (
{[...Array(count)].map((_, i) => (
-
+
-
-
+
+
-
+
))}
); +// Error message display const ErrorMessage: React.FC<{ message: string }> = ({ message }) => ( -
- {message} +
+ {message}
); +// Empty state when no suspended users const EmptyState: React.FC = () => ( -
- 🚫 No suspended users found. +
+ No suspended users found.
); -const SearchInput: React.FC = ({ - value, - onChange, - onClear, -}) => ( +// Search input with clear button +const SearchInput: React.FC = ({ value, onChange, onClear }) => (
onChange(e.target.value)} + className="w-full pl-10 pr-10 py-2 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 transition" value={value} + onChange={e => onChange(e.target.value)} aria-label="Search suspended users" /> )}
); -const Pagination: React.FC = ({ - currentPage, - totalPages, - onPageChange, -}) => { +// Pagination controls with previous/next and page numbers +const Pagination: React.FC = ({ currentPage, totalPages, onPageChange }) => { const getPageNumbers = () => { - const pages = []; - const maxVisiblePages = 5; - let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2)); - let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); - - if (endPage - startPage + 1 < maxVisiblePages) { - startPage = Math.max(1, endPage - maxVisiblePages + 1); + const pages: number[] = []; + const maxVisible = 5; + let start = Math.max(1, currentPage - Math.floor(maxVisible / 2)); + let end = Math.min(totalPages, start + maxVisible - 1); + if (end - start + 1 < maxVisible) { + start = Math.max(1, end - maxVisible + 1); } - - for (let i = startPage; i <= endPage; i++) { - pages.push(i); - } - + for (let i = start; i <= end; i++) pages.push(i); return pages; }; @@ -203,13 +178,12 @@ const Pagination: React.FC = ({ - - {getPageNumbers().map((page) => ( + {getPageNumbers().map(page => ( ))} - ); +// Button to delete permanently const DeleteButton: React.FC<{ onClick: () => void; label: string }> = ({ onClick, label, }) => ( ); +// Card layout for mobile view const SuspendedUserCard: React.FC = ({ user, onUnsuspend, onDelete, }) => ( -
+

- {user.firstName || "Unknown"} {user.lastName || "User"} + {user.firstName || "Unknown"} {user.lastName || ""} Suspended

- Email: {user.email || "N/A"} + Email: {user.email || "N/A"}

- Phone:{" "} - {user.phone ? formatPhoneNumber(user.phone) : "N/A"} + Phone: {user.phone ? formatPhoneNumber(user.phone) : "N/A"}

- Title: {user.title || "N/A"} -

-

- Reason:{" "} - {user.suspensionReason || "N/A"} -

-

- Originally joined:{" "} - {user.originalCreatedAt ? formatDate(user.originalCreatedAt) : "N/A"} + Reason: {user.suspensionReason || "N/A"}

- Suspended:{" "} - {user.suspendedAt ? formatDateTime(user.suspendedAt) : "N/A"} + Suspended at: {user.suspendedAt ? formatDateTime(user.suspendedAt) : "N/A"}

- {user.suspensionNotes && ( -

- Notes: {user.suspensionNotes} -

- )}
-
+
onUnsuspend(user._id)} - label={`Unsuspend ${user.firstName || "Unknown"} ${user.lastName || "User"}`} + label="Unsuspend user" /> onDelete(user._id)} - label={`Permanently delete ${user.firstName || "Unknown"} ${user.lastName || "User"}`} + label="Delete user" />
); +// Table row for desktop view const SuspendedUserTableRow: React.FC<{ user: SuspendedUser; - onUnsuspend: (userId: string) => void; - onDelete: (userId: string) => void; + onUnsuspend: (id: string) => void; + onDelete: (id: string) => void; }> = ({ user, onUnsuspend, onDelete }) => ( - - + + - - {user.firstName || "Unknown"} {user.lastName || "User"} + + {user.firstName || ""} {user.lastName || ""} Suspended - {user.email || "N/A"} - - {user.phone ? formatPhoneNumber(user.phone) : "N/A"} - - {user.title || "N/A"} - {user.suspensionReason || "N/A"} - - {user.originalCreatedAt ? formatDate(user.originalCreatedAt) : "N/A"} - - - {user.suspendedAt ? formatDateTime(user.suspendedAt) : "N/A"} - - -
- onUnsuspend(user._id)} - label={`Unsuspend ${user.firstName || "Unknown"} ${user.lastName || "User"}`} - /> - onDelete(user._id)} - label={`Permanently delete ${user.firstName || "Unknown"} ${user.lastName || "User"}`} - /> -
+ {user.email || "N/A"} + {user.phone ? formatPhoneNumber(user.phone) : "N/A"} + {user.title || "N/A"} + {user.suspensionReason || "N/A"} + {user.originalCreatedAt ? formatDate(user.originalCreatedAt) : "N/A"} + {user.suspendedAt ? formatDateTime(user.suspendedAt) : "N/A"} + + onUnsuspend(user._id)} label="Unsuspend" /> + onDelete(user._id)} label="Delete" /> ); +// Table layout for desktop const SuspendedUserTable: React.FC = ({ users, onUnsuspend, @@ -422,39 +344,21 @@ const SuspendedUserTable: React.FC = ({ return (
- + - - - - - - - - - + + + + + + + + + - {users.map((user) => ( + {users.map(user => ( = ({ ); }; +// Delete confirmation modal const DeleteModal: React.FC = ({ isOpen, onClose, @@ -475,32 +380,27 @@ const DeleteModal: React.FC = ({ userName, }) => { if (!isOpen) return null; - return ( -
+
-

+

Confirm Permanent Deletion

- Are you sure you want to permanently delete{" "} - {userName ? `suspended user ${userName}` : "this suspended user"}? - This action cannot be undone and all their data will be lost forever. + Permanently delete {userName || "this user"}? This cannot be undone.

@@ -508,182 +408,139 @@ const DeleteModal: React.FC = ({ ); }; +// ─── MAIN COMPONENT ─────────────────────────────────────────────────────────── const SuspendedUsersContent: React.FC = () => { - const [suspendedUsers, setSuspendedUsers] = useState([]); + const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const [showModal, setShowModal] = useState(false); - const [userToDelete, setUserToDelete] = useState(null); + const [toDelete, setToDelete] = useState(null); - // Fetch suspended users + // Fetch data on mount useEffect(() => { - const fetchSuspendedUsers = async () => { + async function fetchData() { setLoading(true); setError(null); try { const res = await fetch("/api/suspended-users"); if (!res.ok) throw new Error("Failed to fetch suspended users"); const data = await res.json(); - setSuspendedUsers(data.data || []); + setUsers(data.data || []); } catch (err) { - const error = err as Error; - setError(error.message); - toast.error(error.message, { position: "top-right" }); + setError((err as Error).message); + toast.error((err as Error).message); } finally { setLoading(false); } - }; - - fetchSuspendedUsers(); + } + fetchData(); }, []); - // Handle unsuspend user - const handleUnsuspend = async (userId: string) => { - const user = suspendedUsers.find((u) => u._id === userId); + // Unsuspend handler + const handleUnsuspend = async (id: string) => { + const user = users.find(u => u._id === id); if (!user) return; - - const confirmUnsuspend = window.confirm( - `Are you sure you want to unsuspend ${user.firstName || "Unknown"} ${user.lastName || "User"}? They will be able to access their account again.` - ); - - if (!confirmUnsuspend) return; - + if (!confirm(`Unsuspend ${user.firstName} ${user.lastName}?`)) return; try { - const res = await fetch(`/api/suspended-users?userId=${userId}`, { - method: "PATCH", - }); - - if (!res.ok) throw new Error("Failed to unsuspend user"); - - setSuspendedUsers((prev) => prev.filter((u) => u._id !== userId)); - toast.success( - `User ${user.firstName || "Unknown"} ${user.lastName || "User"} has been unsuspended successfully and can now login with their original credentials.`, - { - position: "top-right", - autoClose: 5000, // Show for 5 seconds - } - ); + const res = await fetch(`/api/suspended-users?userId=${id}`, { method: "PATCH" }); + if (!res.ok) throw new Error("Failed to unsuspend"); + setUsers(prev => prev.filter(u => u._id !== id)); + toast.success("User unsuspended"); } catch (err) { - const error = err as Error; - toast.error(error.message, { position: "top-right" }); + toast.error((err as Error).message); } }; - // Handle delete suspended user + // Delete handler const handleDelete = async () => { - if (!userToDelete) return; - + if (!toDelete) return; try { - const res = await fetch( - `/api/suspended-users?userId=${userToDelete._id}`, - { - method: "DELETE", - } - ); - - if (!res.ok) throw new Error("Failed to delete suspended user"); - - setSuspendedUsers((prev) => - prev.filter((u) => u._id !== userToDelete._id) - ); - toast.success( - `Suspended user ${userToDelete.firstName || "Unknown"} ${userToDelete.lastName || "User"} deleted permanently`, - { - position: "top-right", - } - ); + const res = await fetch(`/api/suspended-users?userId=${toDelete._id}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to delete"); + setUsers(prev => prev.filter(u => u._id !== toDelete._id)); + toast.success("User deleted"); } catch (err) { - const error = err as Error; - toast.error(error.message, { position: "top-right" }); + toast.error((err as Error).message); } finally { setShowModal(false); - setUserToDelete(null); + setToDelete(null); } }; - // Debounced search - const debouncedSearchHandler = useMemo( + // Open delete modal + const requestDelete = useCallback( + (id: string) => { + const user = users.find(u => u._id === id); + if (user) { + setToDelete(user); + setShowModal(true); + } + }, + [users] + ); + + // Debounced search setup + const debouncedSearch = useMemo( () => debounce((value: string) => { - setSearch(value); + setSearch(value.toLowerCase()); setPage(1); }, DEBOUNCE_DELAY), [] ); const handleSearchChange = useCallback( - (value: string) => { - debouncedSearchHandler(value); - }, - [debouncedSearchHandler] + (value: string) => debouncedSearch(value), + [debouncedSearch] ); const clearSearch = useCallback(() => { + debouncedSearch.cancel(); setSearch(""); setPage(1); - debouncedSearchHandler.cancel(); - }, [debouncedSearchHandler]); + }, [debouncedSearch]); // Filter and paginate users - const filteredUsers = useMemo(() => { - const searchTerm = search.toLowerCase(); - return suspendedUsers.filter( - (user) => - (user.firstName || "").toLowerCase().includes(searchTerm) || - (user.lastName || "").toLowerCase().includes(searchTerm) || - (user.email || "").toLowerCase().includes(searchTerm) || - (user.title || "").toLowerCase().includes(searchTerm) || - (user.suspensionReason || "").toLowerCase().includes(searchTerm) - ); - }, [suspendedUsers, search]); - - const totalPages = Math.ceil(filteredUsers.length / USERS_PER_PAGE) || 1; - const paginatedUsers = useMemo(() => { - return filteredUsers.slice( - (page - 1) * USERS_PER_PAGE, - page * USERS_PER_PAGE - ); - }, [filteredUsers, page]); - - // Reset page if it's out of bounds after filtering + const filtered = useMemo( + () => + users.filter(u => + [u.firstName, u.lastName, u.email, u.title, u.suspensionReason] + .join(" ") + .toLowerCase() + .includes(search) + ), + [users, search] + ); + + const totalPages = Math.max(1, Math.ceil(filtered.length / USERS_PER_PAGE)); + + const paginated = useMemo( + () => filtered.slice((page - 1) * USERS_PER_PAGE, page * USERS_PER_PAGE), + [filtered, page] + ); + + // Reset page if out of range useEffect(() => { if (page > totalPages) setPage(1); - }, [totalPages, page]); + }, [page, totalPages]); // Clean up debounce on unmount - useEffect(() => { - return () => { - debouncedSearchHandler.cancel(); - }; - }, [debouncedSearchHandler]); - - const handleDeleteClick = useCallback( - (userId: string) => { - const user = suspendedUsers.find((u) => u._id === userId); - if (user) { - setUserToDelete(user); - setShowModal(true); - } - }, - [suspendedUsers] - ); + useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]); return (
- {/* Header */} -
+ {/* Header with count and search */} +
-

- Suspended Users -

-

- {filteredUsers.length}{" "} - {filteredUsers.length === 1 ? "suspended user" : "suspended users"}{" "} - found +

Suspended Users

+

+ {filtered.length} user{filtered.length !== 1 && "s"} found

{ />
- {/* Content */} + {/* Content area */}
- {/* Desktop: Table */} + {/* Desktop table */}
{error ? ( ) : ( )}
- {/* Mobile: Cards */} + {/* Mobile cards */}
{loading ? ( ) : error ? ( - ) : paginatedUsers.length === 0 ? ( + ) : paginated.length === 0 ? ( ) : ( - paginatedUsers.map((user) => ( + paginated.map(user => ( )) )}
- {/* Pagination */} - {filteredUsers.length > USERS_PER_PAGE && ( -
+ {/* Pagination controls */} + {filtered.length > USERS_PER_PAGE && ( +
{
)} - {/* Delete Confirmation Modal */} + {/* Delete confirmation modal */} setShowModal(false)} onConfirm={handleDelete} userName={ - userToDelete - ? `${userToDelete.firstName} ${userToDelete.lastName}` - : undefined + toDelete ? `${toDelete.firstName} ${toDelete.lastName}` : undefined } />
From 078fc22107bed039a8ffb9fe257262fc42483ea6 Mon Sep 17 00:00:00 2001 From: RENULUCSHMI PRAKASAN Date: Wed, 16 Jul 2025 16:16:23 +0530 Subject: [PATCH 05/15] feat: Update AdminSidebar to use NotebookTabs icon for Forum Reports --- src/components/Admin/AdminSidebar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Admin/AdminSidebar.tsx b/src/components/Admin/AdminSidebar.tsx index 9d3788f9..101b6aa2 100644 --- a/src/components/Admin/AdminSidebar.tsx +++ b/src/components/Admin/AdminSidebar.tsx @@ -17,6 +17,7 @@ import { Shield, Star, UserX, + NotebookTabs, } from "lucide-react"; import clsx from "clsx"; // Utility for conditional class names @@ -88,7 +89,7 @@ const navItems = [ { id: "forum-reports", label: "Forum Reports", - icon: Flag, + icon: NotebookTabs, permission: "manage_forum_reports", }, ]; From fbade216292d5aa7a65f21119f4cacb9f8feeff5 Mon Sep 17 00:00:00 2001 From: RENULUCSHMI PRAKASAN Date: Wed, 16 Jul 2025 22:01:44 +0530 Subject: [PATCH 06/15] feat: Refactor reporting system with enhanced filtering and email handling - Updated GET request in reports route to use .lean() for better performance. - Improved AdminReports component by integrating Filters and ReportsTable components for better UI organization. - Enhanced error handling in fetchReports function. - Added userId field to KYCSchema for better user tracking. - Created Filters component for unified search, status filtering, and sorting. - Developed ReportsTable component to handle report display and actions. - Introduced types for AdminReport and related entities for better type safety. - Implemented format utility functions for consistent data formatting across the application. --- src/app/api/admin/reports/route.ts | 2 +- .../dashboardContent/ReportingContent.tsx | 671 +++++------------- .../dashboardContent/reporting/Filters.tsx | 69 ++ .../reporting/ReportDetailsModal.tsx | 0 .../reporting/ReportsTable.tsx | 296 ++++++++ .../Admin/dashboardContent/reporting/types.ts | 119 ++++ src/lib/models/KYCSchema.ts | 6 + src/utils/format.ts | 26 + 8 files changed, 677 insertions(+), 512 deletions(-) create mode 100644 src/components/Admin/dashboardContent/reporting/Filters.tsx create mode 100644 src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx create mode 100644 src/components/Admin/dashboardContent/reporting/ReportsTable.tsx create mode 100644 src/components/Admin/dashboardContent/reporting/types.ts create mode 100644 src/utils/format.ts diff --git a/src/app/api/admin/reports/route.ts b/src/app/api/admin/reports/route.ts index f2f09615..b046aaa2 100644 --- a/src/app/api/admin/reports/route.ts +++ b/src/app/api/admin/reports/route.ts @@ -20,7 +20,7 @@ export async function GET(req: Request) { } // Check if we can fetch without population first - const reportsBasic = await ReportInSession.find({}).limit(1); + const reportsBasic = await ReportInSession.find({}).limit(1).lean(); console.log( "Basic report sample:", JSON.stringify(reportsBasic[0], null, 2) diff --git a/src/components/Admin/dashboardContent/ReportingContent.tsx b/src/components/Admin/dashboardContent/ReportingContent.tsx index f7138aec..4b13fbe5 100644 --- a/src/components/Admin/dashboardContent/ReportingContent.tsx +++ b/src/components/Admin/dashboardContent/ReportingContent.tsx @@ -3,6 +3,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import toast from "react-hot-toast"; +import { Filters } from "./reporting/Filters"; +import { ReportsTable } from "./reporting/ReportsTable"; +import type { EmailFlow } from "./reporting/types"; + import { Mail, @@ -241,44 +245,23 @@ export default function AdminReports() { }; const fetchReports = async () => { - try { - setLoading(true); - const response = await fetch("/api/admin/reports"); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - console.log("Fetched reports data:", data); // Debug log - console.log("Sample report structure:", data[0]); // Debug log - - // More detailed debugging - if (data.length > 0) { - const sample = data[0]; - console.log("Report ID:", sample._id); - console.log("Reported By:", sample.reportedBy); - console.log("Reported User:", sample.reportedUser); - console.log("Session ID:", sample.sessionId); - console.log("Reason:", sample.reason); - console.log("Description:", sample.description); - console.log("Status:", sample.status); - console.log("Created At:", sample.createdAt); - } - - setReports(Array.isArray(data) ? data : []); - setError(null); - } catch (err) { - console.error("Error fetching reports:", err); - setError( - err instanceof Error - ? err.message - : "An error occurred while fetching reports" - ); - } finally { - setLoading(false); + try { + setLoading(true); + const res = await fetch("/api/admin/reports"); + if (!res.ok) { + const errorText = await res.text(); + console.error("🔴 API /admin/reports error body:", errorText); + throw new Error(`HTTP error! status: ${res.status}`); } - }; + const data = await res.json(); + setReports(data); + } catch (err) { + console.error("Error fetching reports:", err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } +}; const handleSendNotification = async (reportId: string) => { if ( @@ -561,23 +544,31 @@ SkillSwapHub Admin Team`; reportId: string, resolution: "warn_reported" | "warn_reporter" | "dismiss" ) => { - // For warning actions, we use the email client instead of backend API if (resolution === "warn_reported" || resolution === "warn_reporter") { - const report = reports.find((r) => r._id === reportId); - if (report) { - const userType = - resolution === "warn_reported" ? "reported" : "reporter"; - openWarningEmailClient(report, userType); - - const message = - resolution === "warn_reported" - ? "Warning email opened for reported user. Please send the email and then mark the report as resolved." - : "Warning email opened for reporting user. Please send the email and then mark the report as resolved."; - - alert(message); - } - return; - } + // map your underscore‐style resolution to the hyphen‐style EmailFlow + const flow: EmailFlow = + resolution === "warn_reported" ? "warn-reported" : "warn-reporter"; + + // 1) Look up the full AdminReport object by its ID +const rpt = reports.find((r) => r._id === reportId); +if (!rpt) { + console.warn("Could not find report with id", reportId); + return; +} + +// 2) Call the email‐flow helper with the real report object +openWarningEmailClient(rpt, flow); + +// 3) Let the admin know which warning was opened +alert( + flow === "warn-reported" + ? "Warning email opened for reported user…" + : "Warning email opened for reporting user…" +); +return; + +} + // Only dismiss action calls the backend API const resolutionMessages = { @@ -640,41 +631,45 @@ SkillSwapHub Admin Team`; } }; - const openWarningEmailClient = ( - report: AdminReport, - userType: "reporter" | "reported" - ) => { - const email = - userType === "reporter" - ? report.reportedBy?.email - : report.reportedUser?.email; - - if (!email) { - alert( - `No email address available for the ${userType === "reporter" ? "reporting" : "reported"} user.` - ); - return; - } - - const reporterName = formatName( - report.reportedBy?.firstName, - report.reportedBy?.lastName - ); - const reportedName = formatName( - report.reportedUser?.firstName, - report.reportedUser?.lastName - ); - const sessionTitle = getSessionTitle(report.sessionId); - const reason = formatReason(report.reason); - - let subject = ""; - let body = ""; - - if (userType === "reported") { - subject = `Warning: Platform Rules Violation - Report #${report._id.slice(-8)}`; + /** Kick off any of the four email flows: + * - initial-reporter + * - initial-reported + * - warn-reporter + * - warn-reported + */ +const openWarningEmailClient = ( + report: AdminReport, + flow: EmailFlow +) => { + const reporterEmail = report.reportedBy?.email; + const reportedEmail = report.reportedUser?.email; + const reporterName = formatName( + report.reportedBy?.firstName, + report.reportedBy?.lastName + ); + const reportedName = formatName( + report.reportedUser?.firstName, + report.reportedUser?.lastName + ); + const sessionTitle = getSessionTitle(report.sessionId); + const reason = formatReason(report.reason); + + let email: string | undefined; + let subject = ""; + let body = ""; + + switch (flow) { + case "initial-reporter": + email = reporterEmail; + subject = `Investigation Required – Report #${report._id.slice(-8)}`; + body = `Dear ${reporterName},\n\nThank you for your report about ${reportedName}. We’ve started investigating the reported user. Please reply with any additional details or evidence you might have.\n\nBest,\nAdmin Team`; + break; + case "initial-reported": + email = reportedEmail; + subject = `Investigation Notice – Report #${report._id.slice(-8)}`; body = `Dear ${reportedName}, -Following our investigation of a report filed against you, we have determined that your behavior during a session violated our platform guidelines. +We are writing to inform you that a report has been filed regarding your interaction on our platform. Report Details: - Session: ${sessionTitle} @@ -683,45 +678,40 @@ Report Details: - Report ID: ${report._id} - Date: ${formatDate(report.createdAt)} -This serves as an official warning. Please review our community guidelines and ensure your future interactions comply with our platform standards. - -Repeated violations may result in account restrictions or suspension. - -If you believe this warning was issued in error, please reply to this email with your explanation within 7 days. - -Best regards, -SkillSwapHub Admin Team`; - } else { - subject = `Warning: False Complaint Filed - Report #${report._id.slice(-8)}`; - body = `Dear ${reporterName}, - -Following our investigation of the report you filed, we have determined that your complaint appears to be unfounded or exaggerated. - -Report Details: -- Session: ${sessionTitle} -- Reported user: ${reportedName} -- Reason claimed: ${reason} -- Report ID: ${report._id} -- Date: ${formatDate(report.createdAt)} - -Our investigation found insufficient evidence to support your claims. Filing false or misleading reports undermines our platform's integrity and wastes administrative resources. - -This serves as an official warning. Please ensure that future reports are accurate and made in good faith. +As part of our investigation process, we would like to hear your side of the story. Please reply to this email with: -Repeated false reporting may result in account restrictions. +1. Your account of what happened during the session +2. Any relevant context or explanations +3. Any evidence or screenshots that support your version of events -If you have additional evidence to support your original claim, please reply to this email within 7 days. +You have 3 business days to respond to this email. We are committed to conducting a fair and thorough investigation. Best regards, SkillSwapHub Admin Team`; - } + break; + case "warn-reporter": + email = reporterEmail; + subject = `Warning: False Complaint – Report #${report._id.slice(-8)}`; + body = `Dear ${reporterName},\n\nOur investigation found insufficient evidence to support your report. Please refrain from filing false reports.\n\nBest,\nAdmin Team`; + break; + case "warn-reported": + email = reportedEmail; + subject = `Warning: Violation – Report #${report._id.slice(-8)}`; + body = `Dear ${reportedName},\n\nWe confirmed your behavior violated our guidelines. This is an official warning.\n\nBest,\nAdmin Team`; + break; + } - // Create mailto link - const mailtoLink = `mailto:${email}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + if (!email) { + alert("No email address available for this user."); + return; + } - // Open the default email client - window.location.href = mailtoLink; - }; + window.location.href = [ + `mailto:${encodeURIComponent(email)}`, + `?subject=${encodeURIComponent(subject)}`, + `&body=${encodeURIComponent(body)}`, + ].join(""); +}; useEffect(() => { fetchReports(); @@ -871,6 +861,15 @@ SkillSwapHub Admin Team`; const statusCounts = getStatusCounts(); + // new: options for our Filters dropdown + const statusOptions = [ + { value: "all", label: "All", count: statusCounts.all }, + { value: "pending", label: "Pending", count: statusCounts.pending }, + { value: "under_review", label: "Under Review", count: statusCounts.under_review }, + { value: "resolved", label: "Resolved", count: statusCounts.resolved }, + { value: "dismissed", label: "Dismissed", count: statusCounts.dismissed }, + ]; + // Toggle sort direction const toggleSortDirection = () => { setSortDirection(sortDirection === "asc" ? "desc" : "asc"); @@ -971,397 +970,47 @@ SkillSwapHub Admin Team`;
- {/* Search Bar */} -
-
- - setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" - /> - {searchQuery && ( - - )} -
-
- - {/* Filter Dropdown */} -
-
-
-
-

- Filter by Status: -

- -
-
- -
- {/* Sort direction toggle button */} - -
-
-
- - {filteredReports.length === 0 ? ( -
- -

- {reports.length === 0 - ? "No Reports Found" - : searchQuery - ? `No Results Found` - : `No ${formatStatus(statusFilter)} Reports`} -

-

- {reports.length === 0 - ? "All user reports will appear here when submitted" - : searchQuery - ? `No reports match your search "${searchQuery}". Try adjusting your search terms or filters.` - : `No reports with status "${formatStatus(statusFilter)}" found. Try selecting a different filter.`} -

- {reports.length === 0 && ( -
- - -
- )} -
- ) : ( -
-
- Avatar - - Name - - Email - - Phone - - Title - - Reason - - Originally Joined - - Suspended - - Actions - AvatarNameEmailPhoneTitleReasonJoinedSuspendedActions
- - - - - - - - - - - - {filteredReports.map((report) => ( - - - - - - - - - ))} - -
Reporting UserReported UserReasonStatus -
- Created At - -
-
Actions
-
-
-
- {formatName( - report.reportedBy?.firstName, - report.reportedBy?.lastName - )} -
-
- {report.reportedBy?.email || "No email available"} -
-
- - - {/* download evidence */} - {report.evidenceFiles.length > 0 && ( -
- - {report.evidenceFiles.length > 1 && ( - - {report.evidenceFiles.length} - - )} -
- )} -
-
-
-
-
- {formatName( - report.reportedUser?.firstName, - report.reportedUser?.lastName - )} -
-
- {report.reportedUser?.email || - "No email available"} -
-
- -
-
-
- {formatReason(report.reason)} -
-
-
- - {formatStatus(report.status)} - -
-
-
- {formatDate(report.createdAt)} -
-
-
- - - {report.status === "pending" && ( - - )} - - {report.status === "under_review" && ( - <> - - - - - - )} - - {report.status === "resolved" && ( - - )} -
-
-
- )} + + {/* unified search + status + sort UI */} + + + +{filteredReports.length === 0 ? ( +
+ {/* …your existing “no results” UI… */} +
+) : ( + +)} @@ -1565,7 +1214,7 @@ SkillSwapHub Admin Team`; +
+ ); +} diff --git a/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx b/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/Admin/dashboardContent/reporting/ReportsTable.tsx b/src/components/Admin/dashboardContent/reporting/ReportsTable.tsx new file mode 100644 index 00000000..70125b15 --- /dev/null +++ b/src/components/Admin/dashboardContent/reporting/ReportsTable.tsx @@ -0,0 +1,296 @@ +// src/components/Admin/dashboardContent/reporting/ReportsTable.tsx + +import React from "react"; +import { + Mail, + Loader2, + AlertOctagon, + ShieldX, + X, + CheckCircle, + Download, + Eye, + SortAsc, + SortDesc, +} from "lucide-react"; +import type { ReportsTableProps } from "./types"; + +export function ReportsTable({ + reports, + sortDirection, + onToggleSort, + downloading, + onDownloadEvidence, + sendingEmails, + onSendNotification, + onSendNotificationToReporter, + onSendNotificationToReported, + onOpenWarningEmail, + resolvingReport, + onResolve, + onMarkResolved, + onViewDetails, + formatName, + formatReason, + formatDate, + getStatusColor, +}: ReportsTableProps) { + return ( +
+ + + + + + + + + + + + + {reports.map((report) => ( + + {/* Reporting User */} + + + {/* Reported User */} + + + {/* Reason */} + + + {/* Status */} + + + {/* Created At */} + + + {/* Actions */} + + + ))} + +
Reporting UserReported UserReasonStatus +
+ Created At + +
+
Actions
+
+
+
+ {formatName( + report.reportedBy?.firstName, + report.reportedBy?.lastName + )} +
+
+ {report.reportedBy?.email ?? "No email"} +
+
+ {/* ask the reporter for more info */} + +
+
+
+
+
+ {formatName( + report.reportedUser?.firstName, + report.reportedUser?.lastName + )} +
+
+ {report.reportedUser?.email ?? "No email"} +
+
+ {/* ask the reported user for their side */} + +
+
+
+ {formatReason(report.reason)} +
+
+ + {report.status + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase())} + + +
+ {formatDate(report.createdAt)} +
+
+
+ {/* view details */} + + + {report.status === "pending" && ( + + )} + + {report.status === "under_review" && ( + <> + {/* warn reported user */} + + {/* warn reporting user */} + + {/* dismiss */} + + {/* mark solved */} + + + )} + + {report.status === "resolved" && ( + + )} + + {/* download evidence */} + {report.evidenceFiles.length > 0 && ( +
+ + {report.evidenceFiles.length > 1 && ( + + {report.evidenceFiles.length} + + )} +
+ )} +
+
+
+ ); +} diff --git a/src/components/Admin/dashboardContent/reporting/types.ts b/src/components/Admin/dashboardContent/reporting/types.ts new file mode 100644 index 00000000..5611dec6 --- /dev/null +++ b/src/components/Admin/dashboardContent/reporting/types.ts @@ -0,0 +1,119 @@ +// src/components/Admin/dashboardContent/reporting/types.ts + +/** + * A user who either reported or was reported. + */ +export interface AdminReportUser { + _id: string; + firstName: string; + lastName: string; + email: string; +} + +/** + * The session in which the report was filed. + */ +export interface AdminReportSession { + _id: string; + user1Id: string; + user2Id: string; + skill1Id: string; + skill2Id: string; + descriptionOfService1: string; + descriptionOfService2: string; + startDate: string; + status: string; +} + +/** + * The shape of a report. + */ +export interface AdminReport { + _id: string; + sessionId: AdminReportSession | null; + reportedBy: AdminReportUser | null; + reportedUser: AdminReportUser | null; + + reason: + | "not_submitting_work" + | "not_responsive" + | "poor_quality_work" + | "inappropriate_behavior" + | "not_following_session_terms" + | "other"; + + description: string; + evidenceFiles: string[]; + + // Auto‐collected + reportedUserLastActive?: string; + reportedUserWorksCount: number; + reportingUserWorksCount: number; + reportedUserWorksDetails: { + workId: string; + submissionDate: string; + status: "pending" | "accepted" | "rejected"; + }[]; + reportingUserWorksDetails: { + workId: string; + submissionDate: string; + status: "pending" | "accepted" | "rejected"; + }[]; + + status: "pending" | "under_review" | "resolved" | "dismissed"; + adminResponse?: string; + adminId?: string; + resolvedAt?: string; + createdAt: string; + updatedAt: string; +} + +/** + * All four email‐flow modes: + * • initial-reporter – ask the reporter for more info + * • initial-reported – ask the reported user for their side + * • warn-reporter – warn the reporter (“false complaint”) + * • warn-reported – warn the reported user (“violation”) + */ +export type EmailFlow = + | "initial-reporter" + | "initial-reported" + | "warn-reporter" + | "warn-reported"; + +/** + * Props for the ReportsTable component. + */ +export interface ReportsTableProps { + /** Which reports to render */ + reports: AdminReport[]; + + /** "asc" or "desc" */ + sortDirection: "asc" | "desc"; + onToggleSort: () => void; + + downloading: Record; + onDownloadEvidence: (fileUrl: string, reportId: string) => Promise; + + sendingEmails: string | null; + onSendNotification: (reportId: string) => Promise; + onSendNotificationToReporter: (reportId: string) => Promise; + onSendNotificationToReported: (reportId: string) => Promise; + + /** Kick off any of the four email flows */ + onOpenWarningEmail: (report: AdminReport, flow: EmailFlow) => void; + + resolvingReport: string | null; + onResolve: ( + reportId: string, + resolution: "dismiss" + ) => Promise; + onMarkResolved: (reportId: string) => Promise; + + onViewDetails: (report: AdminReport) => void; + + formatName: (first: string | undefined, last: string | undefined) => string; + formatReason: (reason: string) => string; + formatDate: (dateString: string) => string; + getStatusColor: (status: AdminReport["status"]) => string; +} diff --git a/src/lib/models/KYCSchema.ts b/src/lib/models/KYCSchema.ts index 3dfec83c..b429f27c 100644 --- a/src/lib/models/KYCSchema.ts +++ b/src/lib/models/KYCSchema.ts @@ -8,6 +8,12 @@ const KYCSchema = new mongoose.Schema({ // National Identity Card number nic: { type: String, required: true }, + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + // User ID or email of the person submitting KYC recipient: { type: String, required: true }, diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 00000000..d39c1272 --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,26 @@ +// src/utils/format.ts + +export function formatName(firstName?: string, lastName?: string): string { + if (!firstName && !lastName) return "(Unknown)"; + return [firstName, lastName].filter(Boolean).join(" "); +} + +export function formatStatus(status?: string): string { + if (!status) return "Unknown"; + switch (status) { + case "pending": return "Pending"; + case "under_review": return "Under Review"; + case "resolved": return "Resolved"; + default: return status[0].toUpperCase() + status.slice(1); + } +} + +export function formatDate(date?: string): string { + if (!date) return ""; + return new Date(date).toLocaleString(); +} + +export function formatReason(reason?: string): string { + if (!reason) return "N/A"; + return reason[0].toUpperCase() + reason.slice(1); +} From d32d23936597080de5ede1bb9a1c099759c1098e Mon Sep 17 00:00:00 2001 From: RENULUCSHMI PRAKASAN Date: Wed, 16 Jul 2025 22:14:44 +0530 Subject: [PATCH 07/15] feat: Add hasInstallScript property to package-lock.json --- package-lock.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package-lock.json b/package-lock.json index f0b71a79..2f679b9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "skill-swap-hub", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { "@aws-sdk/client-s3": "^3.744.0", "@aws-sdk/s3-request-presigner": "^3.744.0", From d4127a99d0ddd408854fd6191effdf6286105207 Mon Sep 17 00:00:00 2001 From: RENULUCSHMI PRAKASAN Date: Wed, 16 Jul 2025 23:51:11 +0530 Subject: [PATCH 08/15] feat: Add ReportDetailsModal component and enhance report filtering functionality --- .../dashboardContent/ReportingContent.tsx | 651 +++++------------- .../dashboardContent/reporting/Filters.tsx | 2 +- .../reporting/ReportDetailsModal.tsx | 203 ++++++ 3 files changed, 362 insertions(+), 494 deletions(-) diff --git a/src/components/Admin/dashboardContent/ReportingContent.tsx b/src/components/Admin/dashboardContent/ReportingContent.tsx index 4b13fbe5..cb966d86 100644 --- a/src/components/Admin/dashboardContent/ReportingContent.tsx +++ b/src/components/Admin/dashboardContent/ReportingContent.tsx @@ -1,12 +1,11 @@ import React, { useState, useEffect, useMemo } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import toast from "react-hot-toast"; import { Filters } from "./reporting/Filters"; import { ReportsTable } from "./reporting/ReportsTable"; import type { EmailFlow } from "./reporting/types"; - +import { ReportDetailsModal } from "./reporting/ReportDetailsModal"; import { Mail, @@ -245,23 +244,23 @@ export default function AdminReports() { }; const fetchReports = async () => { - try { - setLoading(true); - const res = await fetch("/api/admin/reports"); - if (!res.ok) { - const errorText = await res.text(); - console.error("🔴 API /admin/reports error body:", errorText); - throw new Error(`HTTP error! status: ${res.status}`); + try { + setLoading(true); + const res = await fetch("/api/admin/reports"); + if (!res.ok) { + const errorText = await res.text(); + console.error("🔴 API /admin/reports error body:", errorText); + throw new Error(`HTTP error! status: ${res.status}`); + } + const data = await res.json(); + setReports(data); + } catch (err) { + console.error("Error fetching reports:", err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); } - const data = await res.json(); - setReports(data); - } catch (err) { - console.error("Error fetching reports:", err); - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } -}; + }; const handleSendNotification = async (reportId: string) => { if ( @@ -404,84 +403,6 @@ export default function AdminReports() { } }; - const openEmailClient = ( - email: string | undefined, - userType: "reporter" | "reported", - report: AdminReport - ) => { - if (!email) { - alert("No email address available for this user."); - return; - } - - const reporterName = formatName( - report.reportedBy?.firstName, - report.reportedBy?.lastName - ); - const reportedName = formatName( - report.reportedUser?.firstName, - report.reportedUser?.lastName - ); - const sessionTitle = getSessionTitle(report.sessionId); - const reason = formatReason(report.reason); - - let subject = ""; - let body = ""; - - if (userType === "reporter") { - subject = `Investigation Required - Report #${report._id.slice(-8)}`; - body = `Dear ${reporterName}, - -Thank you for reporting an incident on our platform. We are following up on your report regarding ${reportedName}. - -Report Details: -- Session: ${sessionTitle} -- Reason: ${reason} -- Report ID: ${report._id} -- Date: ${formatDate(report.createdAt)} - -We would appreciate if you could provide any additional information that might help us investigate this matter thoroughly. Please reply to this email with: - -1. Any additional details about the incident -2. Screenshots or evidence if available -3. Any other relevant information - -We take all reports seriously and will investigate this matter within 3 business days. - -Best regards, -SkillSwapHub Admin Team`; - } else { - subject = `Investigation Notice - Report #${report._id.slice(-8)}`; - body = `Dear ${reportedName}, - -We are writing to inform you that a report has been filed regarding your interaction on our platform. - -Report Details: -- Session: ${sessionTitle} -- Reported by: ${reporterName} -- Reason: ${reason} -- Report ID: ${report._id} -- Date: ${formatDate(report.createdAt)} - -As part of our investigation process, we would like to hear your side of the story. Please reply to this email with: - -1. Your account of what happened during the session -2. Any relevant context or explanations -3. Any evidence or screenshots that support your version of events - -You have 3 business days to respond to this email. We are committed to conducting a fair and thorough investigation. - -Best regards, -SkillSwapHub Admin Team`; - } - - // Create mailto link - const mailtoLink = `mailto:${email}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; - - // Open the default email client - window.location.href = mailtoLink; - }; - const markAsResolved = async (reportId: string) => { if ( !confirm( @@ -545,30 +466,28 @@ SkillSwapHub Admin Team`; resolution: "warn_reported" | "warn_reporter" | "dismiss" ) => { if (resolution === "warn_reported" || resolution === "warn_reporter") { - // map your underscore‐style resolution to the hyphen‐style EmailFlow - const flow: EmailFlow = - resolution === "warn_reported" ? "warn-reported" : "warn-reporter"; - - // 1) Look up the full AdminReport object by its ID -const rpt = reports.find((r) => r._id === reportId); -if (!rpt) { - console.warn("Could not find report with id", reportId); - return; -} - -// 2) Call the email‐flow helper with the real report object -openWarningEmailClient(rpt, flow); - -// 3) Let the admin know which warning was opened -alert( - flow === "warn-reported" - ? "Warning email opened for reported user…" - : "Warning email opened for reporting user…" -); -return; + // map your underscore‐style resolution to the hyphen‐style EmailFlow + const flow: EmailFlow = + resolution === "warn_reported" ? "warn-reported" : "warn-reporter"; + + // 1) Look up the full AdminReport object by its ID + const rpt = reports.find((r) => r._id === reportId); + if (!rpt) { + console.warn("Could not find report with id", reportId); + return; + } -} + // 2) Call the email‐flow helper with the real report object + openWarningEmailClient(rpt, flow); + // 3) Let the admin know which warning was opened + alert( + flow === "warn-reported" + ? "Warning email opened for reported user…" + : "Warning email opened for reporting user…" + ); + return; + } // Only dismiss action calls the backend API const resolutionMessages = { @@ -613,7 +532,10 @@ return; setReports((prevReports) => prevReports.map((report) => report._id === reportId - ? { ...report, status: "resolved" as const } + ? { + ...report, + status: resolution === "dismiss" ? "dismissed" : "resolved", + } : report ) ); @@ -632,42 +554,39 @@ return; }; /** Kick off any of the four email flows: - * - initial-reporter - * - initial-reported - * - warn-reporter - * - warn-reported - */ -const openWarningEmailClient = ( - report: AdminReport, - flow: EmailFlow -) => { - const reporterEmail = report.reportedBy?.email; - const reportedEmail = report.reportedUser?.email; - const reporterName = formatName( - report.reportedBy?.firstName, - report.reportedBy?.lastName - ); - const reportedName = formatName( - report.reportedUser?.firstName, - report.reportedUser?.lastName - ); - const sessionTitle = getSessionTitle(report.sessionId); - const reason = formatReason(report.reason); - - let email: string | undefined; - let subject = ""; - let body = ""; - - switch (flow) { - case "initial-reporter": - email = reporterEmail; - subject = `Investigation Required – Report #${report._id.slice(-8)}`; - body = `Dear ${reporterName},\n\nThank you for your report about ${reportedName}. We’ve started investigating the reported user. Please reply with any additional details or evidence you might have.\n\nBest,\nAdmin Team`; - break; - case "initial-reported": - email = reportedEmail; - subject = `Investigation Notice – Report #${report._id.slice(-8)}`; - body = `Dear ${reportedName}, + * - initial-reporter + * - initial-reported + * - warn-reporter + * - warn-reported + */ + const openWarningEmailClient = (report: AdminReport, flow: EmailFlow) => { + const reporterEmail = report.reportedBy?.email; + const reportedEmail = report.reportedUser?.email; + const reporterName = formatName( + report.reportedBy?.firstName, + report.reportedBy?.lastName + ); + const reportedName = formatName( + report.reportedUser?.firstName, + report.reportedUser?.lastName + ); + const sessionTitle = getSessionTitle(report.sessionId); + const reason = formatReason(report.reason); + + let email: string | undefined; + let subject = ""; + let body = ""; + + switch (flow) { + case "initial-reporter": + email = reporterEmail; + subject = `Investigation Required – Report #${report._id.slice(-8)}`; + body = `Dear ${reporterName},\n\nThank you for your report about ${reportedName}. We’ve started investigating the reported user. Please reply with any additional details or evidence you might have.\n\nBest,\nAdmin Team`; + break; + case "initial-reported": + email = reportedEmail; + subject = `Investigation Notice – Report #${report._id.slice(-8)}`; + body = `Dear ${reportedName}, We are writing to inform you that a report has been filed regarding your interaction on our platform. @@ -688,30 +607,30 @@ You have 3 business days to respond to this email. We are committed to conductin Best regards, SkillSwapHub Admin Team`; - break; - case "warn-reporter": - email = reporterEmail; - subject = `Warning: False Complaint – Report #${report._id.slice(-8)}`; - body = `Dear ${reporterName},\n\nOur investigation found insufficient evidence to support your report. Please refrain from filing false reports.\n\nBest,\nAdmin Team`; - break; - case "warn-reported": - email = reportedEmail; - subject = `Warning: Violation – Report #${report._id.slice(-8)}`; - body = `Dear ${reportedName},\n\nWe confirmed your behavior violated our guidelines. This is an official warning.\n\nBest,\nAdmin Team`; - break; - } + break; + case "warn-reporter": + email = reporterEmail; + subject = `Warning: False Complaint – Report #${report._id.slice(-8)}`; + body = `Dear ${reporterName},\n\nOur investigation found insufficient evidence to support your report. Please refrain from filing false reports.\n\nBest,\nAdmin Team`; + break; + case "warn-reported": + email = reportedEmail; + subject = `Warning: Violation – Report #${report._id.slice(-8)}`; + body = `Dear ${reportedName},\n\nWe confirmed your behavior violated our guidelines. This is an official warning.\n\nBest,\nAdmin Team`; + break; + } - if (!email) { - alert("No email address available for this user."); - return; - } + if (!email) { + alert("No email address available for this user."); + return; + } - window.location.href = [ - `mailto:${encodeURIComponent(email)}`, - `?subject=${encodeURIComponent(subject)}`, - `&body=${encodeURIComponent(body)}`, - ].join(""); -}; + window.location.href = [ + `mailto:${encodeURIComponent(email)}`, + `?subject=${encodeURIComponent(subject)}`, + `&body=${encodeURIComponent(body)}`, + ].join(""); + }; useEffect(() => { fetchReports(); @@ -861,13 +780,17 @@ SkillSwapHub Admin Team`; const statusCounts = getStatusCounts(); - // new: options for our Filters dropdown - const statusOptions = [ - { value: "all", label: "All", count: statusCounts.all }, - { value: "pending", label: "Pending", count: statusCounts.pending }, - { value: "under_review", label: "Under Review", count: statusCounts.under_review }, - { value: "resolved", label: "Resolved", count: statusCounts.resolved }, - { value: "dismissed", label: "Dismissed", count: statusCounts.dismissed }, + // new: options for our Filters dropdown + const statusOptions = [ + { value: "all", label: "All", count: statusCounts.all }, + { value: "pending", label: "Pending", count: statusCounts.pending }, + { + value: "under_review", + label: "Under Review", + count: statusCounts.under_review, + }, + { value: "resolved", label: "Resolved", count: statusCounts.resolved }, + { value: "dismissed", label: "Dismissed", count: statusCounts.dismissed }, ]; // Toggle sort direction @@ -970,319 +893,61 @@ SkillSwapHub Admin Team`;
- - {/* unified search + status + sort UI */} - - - -{filteredReports.length === 0 ? ( -
- {/* …your existing “no results” UI… */} -
-) : ( - -)} + {/* unified search + status + sort UI */} + + + {filteredReports.length === 0 ? ( +
+ {/* …your existing “no results” UI… */} +
+ ) : ( + + )}
- {/* Report Details Modal */} {selectedReport && ( -
-
-
-
-

- Report Details -

- -
-
- -
-
-
-

- 👤 Reporting User -

-
-

- {formatName( - selectedReport.reportedBy?.firstName, - selectedReport.reportedBy?.lastName - )} -

-

- 📧{" "} - {selectedReport.reportedBy?.email || "No email available"} -

-
-
- -
-

- 🚨 Reported User -

-
-

- {formatName( - selectedReport.reportedUser?.firstName, - selectedReport.reportedUser?.lastName - )} -

-

- 📧{" "} - {selectedReport.reportedUser?.email || - "No email available"} -

-
-
-
- -
-

- 🔄 Session Details -

-
-

- {getSessionTitle(selectedReport.sessionId)} -

-
-

- Session ID:{" "} - {selectedReport.sessionId?._id || "Not Available"} -

- {selectedReport.sessionId?.descriptionOfService1 && ( -

- Service 1:{" "} - {selectedReport.sessionId.descriptionOfService1} -

- )} - {selectedReport.sessionId?.descriptionOfService2 && ( -

- Service 2:{" "} - {selectedReport.sessionId.descriptionOfService2} -

- )} - {!selectedReport.sessionId && ( -

- ⚠️ Session data not available or not populated -

- )} -
-
-
- -
-

- ⚠️ Report Reason -

-

- {formatReason(selectedReport.reason)} -

-
- -
-

- 📝 Detailed Description -

-
-

- {selectedReport.description} -

-
-
- - {/* Evidence Files Section */} - {selectedReport.evidenceFiles && - selectedReport.evidenceFiles.length > 0 && ( -
-

- 📎 Evidence Files -

-
- {selectedReport.evidenceFiles.map((fileUrl, index) => ( -
-
-
- -
-
-

- Evidence File {index + 1} -

-

- {fileUrl.split("/").pop() || "Unknown file"} -

-
-
- -
- ))} -
-
- )} - -
-
-

- Current Status -

- - {formatStatus(selectedReport.status)} - -
-
-

- 📅 Report Submitted -

-

- {formatDate(selectedReport.createdAt)} -

-
-
- - {/* Resolution Actions */} - {selectedReport.status === "under_review" && ( -
-

- 🎯 Resolve Report -

-

- Choose the appropriate resolution based on your - investigation: -

-
- - - -
- -
-
- )} - - {selectedReport.status === "resolved" && ( -
-

- - Report Resolved -

-

- This report has been successfully resolved and appropriate - action has been taken. -

-
- )} -
-
-
+ setSelectedReport(null)} + onResolve={resolveReport} + onMarkResolved={markAsResolved} + openWarningEmail={openWarningEmailClient} + downloadEvidence={downloadEvidence} + downloading={downloading} + formatName={formatName} + formatDate={formatDate} + formatReason={formatReason} + getSessionTitle={getSessionTitle} + getStatusColor={getStatusColor} + /> )}
); diff --git a/src/components/Admin/dashboardContent/reporting/Filters.tsx b/src/components/Admin/dashboardContent/reporting/Filters.tsx index c4ef3d5d..4ed4fcd6 100644 --- a/src/components/Admin/dashboardContent/reporting/Filters.tsx +++ b/src/components/Admin/dashboardContent/reporting/Filters.tsx @@ -29,7 +29,7 @@ export function Filters({ onSearchChange(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" diff --git a/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx b/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx index e69de29b..ec07536d 100644 --- a/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx +++ b/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx @@ -0,0 +1,203 @@ +// src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + AlertOctagon, + ShieldX, + X, + CheckCircle, + Download, + Loader2, +} from "lucide-react"; +import type { AdminReport, EmailFlow } from "./types"; + +interface ReportDetailsModalProps { + report: AdminReport; + onClose: () => void; + onResolve: ( + id: string, + resolution: "dismiss" | "warn_reported" | "warn_reporter" + ) => void; + onMarkResolved: (id: string) => void; + openWarningEmail: (r: AdminReport, flow: EmailFlow) => void; + downloadEvidence: (url: string, id: string) => Promise; + downloading: Record; + formatName: (first?: string, last?: string) => string; + formatDate: (s: string) => string; + formatReason: (r: string) => string; + getSessionTitle: (s: AdminReport["sessionId"]) => string; + getStatusColor: (status: AdminReport["status"]) => string; +} +export function ReportDetailsModal({ + report, + onClose, + onResolve, + onMarkResolved, + openWarningEmail, + downloadEvidence, + downloading, + formatName, + formatDate, + formatReason, + getSessionTitle, + getStatusColor, +}: ReportDetailsModalProps) { + return ( +
+
+
+
+

Report Details

+

+ Report ID: {report._id} +

+
+ +
+ +
+ {/* Reporting & Reported users */} +
+ {/* Reporting User */} +
+

👤 Reporting User

+

+ {formatName( + report.reportedBy?.firstName, + report.reportedBy?.lastName + )} +

+

+ 📧 {report.reportedBy?.email || "No email"} +

+
+ {/* Reported User */} +
+

🚨 Reported User

+

+ {formatName( + report.reportedUser?.firstName, + report.reportedUser?.lastName + )} +

+

+ 📧 {report.reportedUser?.email || "No email"} +

+
+
+ + {/* Session Details */} +
+

🔄 Session Details

+

{getSessionTitle(report.sessionId)}

+
+

+ Session ID: {report.sessionId?._id || "N/A"} +

+ {report.sessionId?.descriptionOfService1 && ( +

+ Service 1:{" "} + {report.sessionId.descriptionOfService1} +

+ )} + {report.sessionId?.descriptionOfService2 && ( +

+ Service 2:{" "} + {report.sessionId.descriptionOfService2} +

+ )} +
+
+ + {/* Reason, Description, Evidence */} +
+

⚠️ Report Reason

+

{formatReason(report.reason)}

+
+
+

📝 Detailed Description

+
{report.description}
+
+ {report.evidenceFiles.length > 0 && ( +
+

📎 Evidence Files

+
    + {report.evidenceFiles.map((url, i) => ( +
  • +
    +

    Evidence File {i + 1}

    +

    + {url.split("/").pop()} +

    +
    + +
  • + ))} +
+
+ )} + + {/* Status + Resolve Actions */} +
+ + {report.status.replace(/_/g, " ")} + + {report.status === "under_review" ? ( +
+ + + + +
+ ) : ( + + Submitted {formatDate(report.createdAt)} + + )} +
+
+
+
+ ); +} From c96d763e0cee69f1584e2590982d778af1f90ef9 Mon Sep 17 00:00:00 2001 From: RENULUCSHMI PRAKASAN Date: Thu, 17 Jul 2025 11:35:42 +0530 Subject: [PATCH 09/15] feat: Refactor ReportDetailsModal to use string constants for user-visible text --- .../dashboardContent/ReportingContent.tsx | 16 +--- .../reporting/ReportDetailsModal.tsx | 80 +++++++++++++------ 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/src/components/Admin/dashboardContent/ReportingContent.tsx b/src/components/Admin/dashboardContent/ReportingContent.tsx index cb966d86..2fc9a7f6 100644 --- a/src/components/Admin/dashboardContent/ReportingContent.tsx +++ b/src/components/Admin/dashboardContent/ReportingContent.tsx @@ -7,21 +7,7 @@ import { ReportsTable } from "./reporting/ReportsTable"; import type { EmailFlow } from "./reporting/types"; import { ReportDetailsModal } from "./reporting/ReportDetailsModal"; -import { - Mail, - Loader2, - AlertCircle, - RefreshCw, - Eye, - CheckCircle, - Download, - X, - ShieldX, - AlertOctagon, - Search, - SortAsc, - SortDesc, -} from "lucide-react"; +import { Mail, Loader2, AlertCircle, RefreshCw } from "lucide-react"; // Local interfaces to ensure TypeScript compatibility interface AdminReportUser { _id: string; diff --git a/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx b/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx index ec07536d..81ed148e 100644 --- a/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx +++ b/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx @@ -12,6 +12,29 @@ import { } from "lucide-react"; import type { AdminReport, EmailFlow } from "./types"; +// String constants for all user-visible text +const MODAL_TITLE = "Report Details"; +const REPORT_ID_LABEL = "Report ID:"; +const CLOSE_BUTTON_TEXT = "✕ Close"; +const REPORTING_USER_TITLE = "👤 Reporting User"; +const REPORTED_USER_TITLE = "🚨 Reported User"; +const SESSION_DETAILS_TITLE = "🔄 Session Details"; +const REPORT_REASON_TITLE = "⚠️ Report Reason"; +const DESCRIPTION_TITLE = "📝 Detailed Description"; +const EVIDENCE_FILES_TITLE = "📎 Evidence Files"; +const NO_EMAIL_FALLBACK = "No email"; +const SESSION_ID_LABEL = "Session ID:"; +const SERVICE_1_LABEL = "Service 1:"; +const SERVICE_2_LABEL = "Service 2:"; +const SESSION_ID_FALLBACK = "N/A"; +const EVIDENCE_FILE_PREFIX = "Evidence File "; +const WARN_REPORTED_BUTTON = "Warn Reported"; +const WARN_REPORTER_BUTTON = "Warn Reporter"; +const DISMISS_BUTTON = "Dismiss"; +const RESOLVE_BUTTON = "Resolve"; +const SUBMITTED_PREFIX = "Submitted "; +const EMAIL_ICON = "📧 "; + interface ReportDetailsModalProps { report: AdminReport; onClose: () => void; @@ -29,6 +52,7 @@ interface ReportDetailsModalProps { getSessionTitle: (s: AdminReport["sessionId"]) => string; getStatusColor: (status: AdminReport["status"]) => string; } + export function ReportDetailsModal({ report, onClose, @@ -46,24 +70,27 @@ export function ReportDetailsModal({ return (
+ + {/* HEADER - Modal title and close button */}
-

Report Details

+

{MODAL_TITLE}

- Report ID: {report._id} + {REPORT_ID_LABEL} {report._id}

- {/* Reporting & Reported users */} + + {/* USERS SECTION - Information about reporting and reported users */}
{/* Reporting User */}
-

👤 Reporting User

+

{REPORTING_USER_TITLE}

{formatName( report.reportedBy?.firstName, @@ -71,12 +98,13 @@ export function ReportDetailsModal({ )}

- 📧 {report.reportedBy?.email || "No email"} + {EMAIL_ICON}{report.reportedBy?.email || NO_EMAIL_FALLBACK}

+ {/* Reported User */}
-

🚨 Reported User

+

{REPORTED_USER_TITLE}

{formatName( report.reportedUser?.firstName, @@ -84,46 +112,48 @@ export function ReportDetailsModal({ )}

- 📧 {report.reportedUser?.email || "No email"} + {EMAIL_ICON}{report.reportedUser?.email || NO_EMAIL_FALLBACK}

- {/* Session Details */} + {/* SESSION DETAILS - Information about the session where the report occurred */}
-

🔄 Session Details

+

{SESSION_DETAILS_TITLE}

{getSessionTitle(report.sessionId)}

- Session ID: {report.sessionId?._id || "N/A"} + {SESSION_ID_LABEL} {report.sessionId?._id || SESSION_ID_FALLBACK}

{report.sessionId?.descriptionOfService1 && (

- Service 1:{" "} + {SERVICE_1_LABEL}{" "} {report.sessionId.descriptionOfService1}

)} {report.sessionId?.descriptionOfService2 && (

- Service 2:{" "} + {SERVICE_2_LABEL}{" "} {report.sessionId.descriptionOfService2}

)}
- {/* Reason, Description, Evidence */} + {/* REPORT CONTENT - Reason, description, and evidence files */}
-

⚠️ Report Reason

+

{REPORT_REASON_TITLE}

{formatReason(report.reason)}

+
-

📝 Detailed Description

+

{DESCRIPTION_TITLE}

{report.description}
+ {report.evidenceFiles.length > 0 && (
-

📎 Evidence Files

+

{EVIDENCE_FILES_TITLE}

    {report.evidenceFiles.map((url, i) => (
  • -

    Evidence File {i + 1}

    +

    {EVIDENCE_FILE_PREFIX}{i + 1}

    {url.split("/").pop()}

    @@ -154,7 +184,7 @@ export function ReportDetailsModal({
)} - {/* Status + Resolve Actions */} + {/* FOOTER - Status badge and action buttons */}
{report.status.replace(/_/g, " ")} @@ -166,33 +196,33 @@ export function ReportDetailsModal({ size="sm" variant="outline" > - Warn Reported + {WARN_REPORTED_BUTTON}
) : ( - Submitted {formatDate(report.createdAt)} + {SUBMITTED_PREFIX}{formatDate(report.createdAt)} )}
@@ -200,4 +230,4 @@ export function ReportDetailsModal({
); -} +} \ No newline at end of file From ef0cd49d5d826a6c2a8c8a657d38bd9755430f16 Mon Sep 17 00:00:00 2001 From: RENULUCSHMI PRAKASAN Date: Thu, 17 Jul 2025 13:13:20 +0530 Subject: [PATCH 10/15] feat: Remove "Engagement and Activity Badges" from badge categories and criteria options --- src/app/badge/page.tsx | 1 - src/components/Admin/dashboardContent/badge/BadgeForm.tsx | 1 - src/components/Admin/dashboardContent/badge/badgeHelpers.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/app/badge/page.tsx b/src/app/badge/page.tsx index 9cb6592c..e29bd3da 100644 --- a/src/app/badge/page.tsx +++ b/src/app/badge/page.tsx @@ -20,7 +20,6 @@ const CATEGORIES = [ "All", "Achievement Milestone Badges", "Specific Badges", - "Engagement and Activity Badges", "Exclusive Recognition Badges", ]; diff --git a/src/components/Admin/dashboardContent/badge/BadgeForm.tsx b/src/components/Admin/dashboardContent/badge/BadgeForm.tsx index 47120714..5d1035f6 100644 --- a/src/components/Admin/dashboardContent/badge/BadgeForm.tsx +++ b/src/components/Admin/dashboardContent/badge/BadgeForm.tsx @@ -5,7 +5,6 @@ import Image from "next/image"; import { criteriaOptions, validateBadgeInput, - handleImageFileChange, uploadBadgeImage, API_ENDPOINTS, validateImageFile, diff --git a/src/components/Admin/dashboardContent/badge/badgeHelpers.ts b/src/components/Admin/dashboardContent/badge/badgeHelpers.ts index 89099fb2..29788e44 100644 --- a/src/components/Admin/dashboardContent/badge/badgeHelpers.ts +++ b/src/components/Admin/dashboardContent/badge/badgeHelpers.ts @@ -27,7 +27,6 @@ export interface Badge { export const criteriaOptions = [ "Achievement Milestone Badges", "Specific Badges", - "Engagement and Activity Badges", "Exclusive Recognition Badges", ]; From f0f5ac67a072063e11fa10a328d082ddf6e7be1e Mon Sep 17 00:00:00 2001 From: RENULUCSHMI PRAKASAN Date: Thu, 17 Jul 2025 21:16:55 +0530 Subject: [PATCH 11/15] feat: Enhance badge name validation to require a minimum length of 3 characters --- .../Admin/dashboardContent/badge/badgeHelpers.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/Admin/dashboardContent/badge/badgeHelpers.ts b/src/components/Admin/dashboardContent/badge/badgeHelpers.ts index 29788e44..bdf61dba 100644 --- a/src/components/Admin/dashboardContent/badge/badgeHelpers.ts +++ b/src/components/Admin/dashboardContent/badge/badgeHelpers.ts @@ -125,6 +125,13 @@ export const validateBadgeInput = ( errorMessage: "Badge name must be less than 50 characters", }; } + + if (name.trim().length < 3) { + return { + isValid: false, + errorMessage: "Badge name must be at least 3 characters long.", + }; + } if (!description || description.trim().length === 0) { return { isValid: false, errorMessage: "Description is required" }; } From 138c0e0705e1e21dec880371e492e1855081b05e Mon Sep 17 00:00:00 2001 From: AdeepaK2 Date: Fri, 18 Jul 2025 21:23:30 +0530 Subject: [PATCH 12/15] refactor: remove MediaDeviceTips and MediaDeviceWarning components refactor: simplify MeetingBox component by removing framer-motion animations fix: update Notification component styles for better responsiveness fix: update CreateSessionModal labels for clarity test: add comprehensive tests for SessionWorkspace page component --- __tests__/components/MeetingBox.test.tsx | 851 ++++++------------ __tests__/components/SessionBox.test.tsx | 96 +- __tests__/pages/SessionPage.test..tsx | 0 __tests__/pages/SessionWorkspace.test.tsx | 603 +++++++++++++ src/app/meeting/[id]/page.tsx | 56 +- src/app/user/notification/page.tsx | 23 +- src/components/meetingSystem/DailyMeeting.tsx | 142 ++- .../meetingSystem/MediaDeviceTips.tsx | 106 --- .../meetingSystem/MediaDeviceWarning.tsx | 0 src/components/messageSystem/MeetingBox.tsx | 395 ++------ .../notificationSystem/Notification.tsx | 26 +- .../sessionSystem/CreateSessionModal.tsx | 4 +- 12 files changed, 1195 insertions(+), 1107 deletions(-) delete mode 100644 __tests__/pages/SessionPage.test..tsx create mode 100644 __tests__/pages/SessionWorkspace.test.tsx delete mode 100644 src/components/meetingSystem/MediaDeviceTips.tsx delete mode 100644 src/components/meetingSystem/MediaDeviceWarning.tsx diff --git a/__tests__/components/MeetingBox.test.tsx b/__tests__/components/MeetingBox.test.tsx index 32bd7d65..c6b223ba 100644 --- a/__tests__/components/MeetingBox.test.tsx +++ b/__tests__/components/MeetingBox.test.tsx @@ -1,722 +1,471 @@ /** * MeetingBox Component Tests - * Tests the meeting management functionality including scheduling, accepting, and cancelling meetings */ import React from 'react'; -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import MeetingBox from '@/components/messageSystem/MeetingBox'; -// Create shared mocks for router -const mockPush = jest.fn(); -const mockRefresh = jest.fn(); - -// Mock the router -jest.mock('next/navigation', () => ({ - useRouter: () => ({ - push: mockPush, - refresh: mockRefresh - }) -})); - -// Mock meeting API services -jest.mock('@/services/meetingApiServices', () => ({ - fetchMeetings: jest.fn(), - createMeeting: jest.fn(), - updateMeeting: jest.fn() -})); - -// Mock session API services for cache invalidation -jest.mock('@/services/sessionApiServices', () => ({ - invalidateUsersCaches: jest.fn() -})); - -// Mock debounced API service -jest.mock('@/services/debouncedApiService', () => { - const mockService = { - makeRequest: jest.fn(), - invalidate: jest.fn() - }; - return { - debouncedApiService: mockService, - makeRequest: mockService.makeRequest, - invalidate: mockService.invalidate - }; -}); - -// Mock utility functions -jest.mock('@/utils/avatarUtils', () => ({ - processAvatarUrl: jest.fn((url) => url), - getFirstLetter: jest.fn((firstName, userId) => firstName?.[0] || 'U'), - createFallbackAvatar: jest.fn(() => 'data:image/svg+xml;base64,mock') -})); - -// Mock OptimizedAvatar component -jest.mock('@/components/ui/OptimizedAvatar', () => { - return function MockOptimizedAvatar({ userId, firstName, lastName }: any) { +// Mock child components +jest.mock('@/components/meetingSystem/CreateMeetingModal', () => { + return function MockCreateMeetingModal({ onClose, onCreate, receiverName }: any) { return ( -
- Avatar for {firstName} {lastName} ({userId}) +
+
Create Meeting Modal for {receiverName}
+ +
); }; }); -// Mock Alert component -jest.mock('@/components/ui/Alert', () => { - return function MockAlert({ type, title, message, isOpen, onClose }: any) { - if (!isOpen) return null; +jest.mock('@/components/meetingSystem/CancelMeetingModal', () => { + return function MockCancelMeetingModal({ meetingId, onClose, onCancel, userName }: any) { return ( -
- {title &&
{title}
} -
{message}
- +
+
Cancel Meeting Modal for {userName}
+ +
); }; }); -// Mock ConfirmationDialog component -jest.mock('@/components/ui/ConfirmationDialog', () => { - return function MockConfirmationDialog({ - isOpen, - onClose, - onConfirm, - title, - message, - type, - confirmText +jest.mock('@/components/meetingSystem/MeetingList', () => { + return function MockMeetingList({ + pendingRequests, + upcomingMeetings, + onScheduleMeeting, + onMeetingAction, + onCancelMeeting }: any) { - if (!isOpen) return null; return ( -
-
{title}
-
{message}
-
{type}
- - +
+
{pendingRequests.length}
+
{upcomingMeetings.length}
+ + + + {pendingRequests.map((meeting: any) => ( +
+ + +
+ ))} + + {upcomingMeetings.map((meeting: any) => ( +
+ +
+ ))}
); }; }); -// Mock CreateMeetingModal component -jest.mock('@/components/meetingSystem/CreateMeetingModal', () => { - return function MockCreateMeetingModal({ onClose, onCreate, receiverName }: any) { +jest.mock('@/components/meetingSystem/SavedNotesList', () => { + return function MockSavedNotesList({ notes }: any) { return ( -
-
Creating meeting with {receiverName}
- - +
+
{notes.length}
); }; }); -// Mock CancelMeetingModal component -jest.mock('@/components/meetingSystem/CancelMeetingModal', () => { - return function MockCancelMeetingModal({ meetingId, onClose, onCancel, userName }: any) { - return ( -
-
Cancelling meeting for {userName}
- - +jest.mock('@/components/ui/Alert', () => { + return function MockAlert({ isOpen, type, message, onClose }: any) { + return isOpen ? ( +
+
{message}
+
- ); + ) : null; }; }); -// Mock fetch for API calls -global.fetch = jest.fn(); +jest.mock('@/components/ui/ConfirmationDialog', () => { + return function MockConfirmationDialog({ isOpen, title, onConfirm, onClose }: any) { + return isOpen ? ( +
+
{title}
+ + +
+ ) : null; + }; +}); -// Mock URL.createObjectURL for notes download -global.URL.createObjectURL = jest.fn(() => 'mock-blob-url'); -global.URL.revokeObjectURL = jest.fn(); +// Mock API Services +const mockFetchMeetings = jest.fn(); +const mockCreateMeeting = jest.fn(); +const mockUpdateMeeting = jest.fn(); +const mockCancelMeetingWithReason = jest.fn(); +const mockFetchAllUserMeetingNotes = jest.fn(); +const mockFilterMeetingsByType = jest.fn(); +const mockCheckMeetingLimit = jest.fn(); +const mockCanCancelMeeting = jest.fn(); -describe('MeetingBox Component', () => { - const mockProps = { - chatRoomId: '6873cdd898892472c9621cf1', - userId: '6873cc50ac4e1d6e1cddf33f', - onClose: jest.fn(), - onMeetingUpdate: jest.fn() - }; +jest.mock('@/services/meetingApiServices', () => ({ + fetchMeetings: (...args: any[]) => mockFetchMeetings(...args), + createMeeting: (...args: any[]) => mockCreateMeeting(...args), + updateMeeting: (...args: any[]) => mockUpdateMeeting(...args), + cancelMeetingWithReason: (...args: any[]) => mockCancelMeetingWithReason(...args), + fetchMeetingCancellation: jest.fn(), + acknowledgeMeetingCancellation: jest.fn(), + checkMeetingNotesExist: jest.fn().mockResolvedValue(false), + fetchAllUserMeetingNotes: (...args: any[]) => mockFetchAllUserMeetingNotes(...args), + downloadMeetingNotesFile: jest.fn(), + filterMeetingsByType: (...args: any[]) => mockFilterMeetingsByType(...args), + checkMeetingLimit: (...args: any[]) => mockCheckMeetingLimit(...args), + canCancelMeeting: (...args: any[]) => mockCanCancelMeeting(...args), +})); - const mockMeetings = [ - { - _id: 'meeting-1', - senderId: 'other-user-id', // Other user is sender - receiverId: '6873cc50ac4e1d6e1cddf33f', // Test user is receiver - description: 'Test meeting 1', - meetingTime: '2025-07-20T10:00:00Z', - state: 'pending', - acceptStatus: false, - meetingLink: null - }, - { - _id: 'meeting-2', - senderId: 'other-user-id', - receiverId: '6873cc50ac4e1d6e1cddf33f', - description: 'Test meeting 2', - meetingTime: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // Tomorrow - state: 'accepted', - acceptStatus: true, - meetingLink: 'https://meet.example.com/room123' - }, - { - _id: 'meeting-3', - senderId: '6873cc50ac4e1d6e1cddf33f', - receiverId: 'other-user-id', - description: 'Past meeting', - meetingTime: '2025-07-10T10:00:00Z', - state: 'completed', - acceptStatus: true, - meetingLink: null - } - ]; - - const mockChatRoom = { - _id: '6873cdd898892472c9621cf1', - participants: ['6873cc50ac4e1d6e1cddf33f', 'other-user-id'] - }; +const mockFetchChatRoom = jest.fn(); +const mockFetchUserProfile = jest.fn(); - const mockUserProfile = { - _id: 'other-user-id', - firstName: 'John', - lastName: 'Doe', - avatar: 'https://example.com/avatar.jpg' - }; +jest.mock('@/services/chatApiServices', () => ({ + fetchChatRoom: (...args: any[]) => mockFetchChatRoom(...args), + fetchUserProfile: (...args: any[]) => mockFetchUserProfile(...args), +})); + +jest.mock('@/services/sessionApiServices', () => ({ + invalidateUsersCaches: jest.fn(), +})); + +jest.mock('@/services/debouncedApiService', () => ({ + debouncedApiService: { + fetchUserProfile: jest.fn(), + makeRequest: jest.fn(), + }, +})); - // Get the mocked functions - const mockFetchMeetings = require('@/services/meetingApiServices').fetchMeetings as jest.MockedFunction; - const mockCreateMeeting = require('@/services/meetingApiServices').createMeeting as jest.MockedFunction; - const mockUpdateMeeting = require('@/services/meetingApiServices').updateMeeting as jest.MockedFunction; - const mockInvalidateUsersCaches = require('@/services/sessionApiServices').invalidateUsersCaches as jest.MockedFunction; - const mockMakeRequest = require('@/services/debouncedApiService').debouncedApiService.makeRequest as jest.MockedFunction; - const mockInvalidate = require('@/services/debouncedApiService').debouncedApiService.invalidate as jest.MockedFunction; - const mockRouter = require('next/navigation').useRouter(); - const mockPush = mockRouter.push as jest.MockedFunction; +// Mock data +const mockChatRoom = { + _id: 'chat-room-1', + participants: ['user1', 'user2'], + messages: [] +}; + +const mockUserProfile = { + _id: 'user2', + firstName: 'John', + lastName: 'Doe', + avatar: '/avatar.png' +}; + +const mockMeetings = [ + { + _id: 'meeting-1', + description: 'Frontend Development Discussion', + meetingTime: new Date('2025-07-20T14:00:00Z').toISOString(), + state: 'pending', + senderId: 'user2', + receiverId: 'user1', + createdAt: new Date('2025-07-18T10:00:00Z').toISOString() + }, + { + _id: 'meeting-2', + description: 'Project Planning', + meetingTime: new Date('2025-07-22T16:00:00Z').toISOString(), + state: 'accepted', + senderId: 'user1', + receiverId: 'user2', + createdAt: new Date('2025-07-17T10:00:00Z').toISOString() + } +]; + +const mockFilteredMeetings = { + pendingRequests: [mockMeetings[0]], + upcomingMeetings: [mockMeetings[1]], + pastMeetings: [], + cancelledMeetings: [] +}; + +const mockSavedNotes = [ + { + _id: 'note-1', + meetingId: 'meeting-3', + title: 'Meeting Notes 1', + content: 'Content of the first note', + tags: ['development'], + wordCount: 100, + lastModified: new Date('2025-07-15T15:00:00Z').toISOString(), + createdAt: new Date('2025-07-15T15:00:00Z').toISOString(), + isPrivate: false + } +]; + +// Default props +const defaultProps = { + chatRoomId: 'chat-room-1', + userId: 'user1', + onClose: jest.fn(), + onMeetingUpdate: jest.fn() +}; +describe('MeetingBox Component', () => { beforeEach(() => { jest.clearAllMocks(); - // Mock chat room fetch - (global.fetch as jest.Mock).mockImplementation((url: string) => { - if (url.includes('/api/chatrooms')) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ - success: true, - chatRooms: [mockChatRoom] - }) - }); - } - - if (url.includes('/api/users/profile')) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ - success: true, - user: mockUserProfile - }) - }); - } - - if (url.includes('/api/meeting/cancel')) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ - meeting: { ...mockMeetings[0], state: 'cancelled' } - }) - }); - } - - if (url.includes('/api/meeting-notes')) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ - _id: 'notes-1', - content: 'Test meeting notes content', - title: 'Meeting Notes', - userName: 'Test User', - createdAt: '2025-07-10T10:00:00Z' - }) - }); - } - - return Promise.resolve({ - ok: false, - json: () => Promise.resolve({ error: 'Not found' }) - }); - }); - - // Mock API services + // Setup default mocks + mockFetchChatRoom.mockResolvedValue(mockChatRoom); + mockFetchUserProfile.mockResolvedValue(mockUserProfile); mockFetchMeetings.mockResolvedValue(mockMeetings); - mockCreateMeeting.mockResolvedValue({ - _id: 'new-meeting-id', - senderId: '6873cc50ac4e1d6e1cddf33f', - receiverId: 'other-user-id', - description: 'New test meeting', - meetingTime: '2025-07-30T15:00:00Z', - state: 'pending' - }); - mockUpdateMeeting.mockResolvedValue({ - ...mockMeetings[0], - state: 'accepted' - }); - - // Mock debounced API service - mockMakeRequest.mockImplementation((key: string, fn: () => any) => fn()); + mockFilterMeetingsByType.mockReturnValue(mockFilteredMeetings); + mockFetchAllUserMeetingNotes.mockResolvedValue(mockSavedNotes); + mockCanCancelMeeting.mockReturnValue({ canCancel: true, reason: null }); + mockCheckMeetingLimit.mockReturnValue(0); }); - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('Initial Rendering', () => { - it('should render the meeting box with header and schedule button', async () => { - render(); + describe('Rendering', () => { + it('should render loading state initially', async () => { + mockFetchMeetings.mockImplementation(() => new Promise(() => {})); // Never resolve - await waitFor(() => { - expect(screen.getByText('Meetings')).toBeInTheDocument(); - expect(screen.getByText('New')).toBeInTheDocument(); - }); - }); - - it('should show loading state initially', () => { - render(); + render(); expect(screen.getByText('Loading meetings...')).toBeInTheDocument(); }); - it('should fetch chat room data and meetings on mount', async () => { - render(); + it('should render the main meeting interface after loading', async () => { + render(); await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/chatrooms?chatRoomId=6873cdd898892472c9621cf1') - ); - expect(mockFetchMeetings).toHaveBeenCalledWith( - '6873cc50ac4e1d6e1cddf33f', - 'other-user-id' - ); + expect(screen.getByText('Meetings')).toBeInTheDocument(); }); - }); - }); - - describe('Meeting Display and Categorization', () => { - it('should categorize and display meetings correctly', async () => { - render(); - await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); - }); - - // Should show different meeting categories - expect(screen.getByText('Pending Requests (1)')).toBeInTheDocument(); - expect(screen.getByText('Upcoming Meetings (1)')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument(); + expect(screen.getByTestId('meeting-list')).toBeInTheDocument(); }); - it('should show empty state when no meetings exist', async () => { - mockFetchMeetings.mockResolvedValueOnce([]); - - render(); + it('should render saved notes section', async () => { + render(); await waitFor(() => { - expect(screen.getByText('No meetings scheduled')).toBeInTheDocument(); - expect(screen.getByText('Schedule Meeting')).toBeInTheDocument(); + expect(screen.getByText(/saved meeting notes/i)).toBeInTheDocument(); }); }); }); - describe('Meeting Creation', () => { - it('should open create meeting modal when schedule button is clicked', async () => { - // Use meetings with no active count to allow modal opening - mockFetchMeetings.mockResolvedValueOnce([mockMeetings[2]]); // Only past meeting - - render(); + describe('Data Fetching', () => { + it('should fetch chat room and meetings on mount', async () => { + render(); await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); - }); - - const scheduleButton = screen.getByText('New'); - - act(() => { - fireEvent.click(scheduleButton); + expect(mockFetchChatRoom).toHaveBeenCalledWith(defaultProps.chatRoomId); }); + // Give more time for fetchMeetings to be called await waitFor(() => { - expect(screen.getByTestId('create-meeting-modal')).toBeInTheDocument(); - }); + expect(mockFetchMeetings).toHaveBeenCalled(); + }, { timeout: 3000 }); }); - it('should create a new meeting successfully', async () => { - // Use meetings with no active count to allow modal opening - mockFetchMeetings.mockResolvedValueOnce([mockMeetings[2]]); // Only past meeting - - render(); + it('should fetch saved notes when component loads', async () => { + render(); await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); - }); - - // Open create modal - const scheduleButton = screen.getByText('New'); - - act(() => { - fireEvent.click(scheduleButton); - }); - - await waitFor(() => { - expect(screen.getByTestId('create-meeting-modal')).toBeInTheDocument(); - }); - - // Submit meeting creation - const createButton = screen.getByTestId('create-meeting-submit'); - fireEvent.click(createButton); - - await waitFor(() => { - expect(mockCreateMeeting).toHaveBeenCalledWith({ - senderId: '6873cc50ac4e1d6e1cddf33f', - receiverId: 'other-user-id', - description: 'Test meeting description', - meetingTime: new Date('2025-07-15T10:00') - }); + expect(mockFetchAllUserMeetingNotes).toHaveBeenCalled(); }); }); + }); - it('should show error when meeting limit is reached', async () => { - // Mock meetings with 2 active meetings - const activeMeetings = [ - { ...mockMeetings[0], state: 'pending' }, - { ...mockMeetings[1], state: 'accepted', meetingTime: '2025-07-30T10:00:00Z' } - ]; - mockFetchMeetings.mockResolvedValueOnce(activeMeetings); - - render(); + describe('Meeting Management', () => { + it('should open create meeting modal when new button is clicked', async () => { + render(); await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument(); }); - - const scheduleButton = screen.getByText('New'); - fireEvent.click(scheduleButton); - await waitFor(() => { - expect(screen.getByTestId('alert')).toBeInTheDocument(); - expect(screen.getByText('Meeting Limit Reached')).toBeInTheDocument(); - }); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + + expect(screen.getByTestId('create-meeting-modal')).toBeInTheDocument(); }); - }); - describe('Meeting Actions', () => { - it('should accept a meeting request', async () => { - render(); + it('should handle meeting creation', async () => { + const newMeeting = { + _id: 'new-meeting-id', + description: 'Test Meeting', + meetingTime: new Date('2025-07-20T14:00:00Z').toISOString(), + state: 'pending', + senderId: 'user1', + receiverId: 'user2' + }; - await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); - }); + mockCreateMeeting.mockResolvedValue(newMeeting); - // Find and click accept button for pending meeting - const acceptButton = screen.getByText('Accept'); - fireEvent.click(acceptButton); + render(); - // Confirm the action await waitFor(() => { - expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); }); - const confirmButton = screen.getByTestId('confirmation-confirm'); - fireEvent.click(confirmButton); + fireEvent.click(screen.getByText('Create Meeting')); await waitFor(() => { - expect(mockUpdateMeeting).toHaveBeenCalledWith('meeting-1', 'accept'); + expect(mockCreateMeeting).toHaveBeenCalledWith({ + senderId: 'user1', + receiverId: 'user2', + description: 'Test Meeting', + meetingTime: expect.any(Date) + }); }); }); - it('should decline a meeting request', async () => { - render(); + it('should handle meeting acceptance with confirmation', async () => { + const updatedMeeting = { + ...mockMeetings[0], + state: 'accepted' + }; - await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); - }); - - const declineButton = screen.getByText('Decline'); - fireEvent.click(declineButton); - - await waitFor(() => { - expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); - expect(screen.getByText('Decline Meeting')).toBeInTheDocument(); - }); + mockUpdateMeeting.mockResolvedValue(updatedMeeting); - const confirmButton = screen.getByTestId('confirmation-confirm'); - fireEvent.click(confirmButton); + render(); await waitFor(() => { - expect(mockUpdateMeeting).toHaveBeenCalledWith('meeting-1', 'reject'); + expect(screen.getByTestId('meeting-list')).toBeInTheDocument(); }); - }); - - it('should cancel a meeting with reason', async () => { - render(); - await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); - }); - - // Find cancel button (should be on upcoming meeting) - const cancelButton = screen.getByText('Cancel'); - fireEvent.click(cancelButton); + fireEvent.click(screen.getByText('Accept')); - await waitFor(() => { - expect(screen.getByTestId('cancel-meeting-modal')).toBeInTheDocument(); - }); + // Should show confirmation dialog + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + expect(screen.getByText('Accept Meeting')).toBeInTheDocument(); - const submitCancelButton = screen.getByTestId('cancel-meeting-submit'); - fireEvent.click(submitCancelButton); + fireEvent.click(screen.getByTestId('confirm-button')); await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith('/api/meeting/cancel', - expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: expect.stringContaining('meeting-2') - }) - ); + expect(mockUpdateMeeting).toHaveBeenCalledWith('meeting-1', 'accept'); }); }); - }); - describe('Meeting Navigation', () => { - it('should navigate to meeting room when join button is clicked', async () => { - render(); + it('should handle meeting cancellation', async () => { + const cancelledMeeting = { + ...mockMeetings[1], + state: 'cancelled' + }; - await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); - }); - - const joinButton = screen.getByText('Join Meeting'); - expect(joinButton).toBeInTheDocument(); - - fireEvent.click(joinButton); + mockCancelMeetingWithReason.mockResolvedValue(cancelledMeeting); - expect(mockPush).toHaveBeenCalledWith('/meeting/meeting-2'); - }); - }); - - describe('Meeting Notes Functionality', () => { - it('should download meeting notes for completed meetings', async () => { - render(); + render(); await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); + expect(screen.getByTestId('meeting-list')).toBeInTheDocument(); }); - - // Click on past meetings to see download option - const pastMeetingsButton = screen.getByText('Past Meetings (1)'); - fireEvent.click(pastMeetingsButton); - await waitFor(() => { - const downloadButton = screen.getByText('Download Notes'); - fireEvent.click(downloadButton); - }); - - // Verify notes API was called - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/meeting-notes?meetingId=meeting-3') - ); - }); - }); - }); - - describe('View Toggles', () => { - it('should toggle past meetings view', async () => { - render(); + fireEvent.click(screen.getByText('Cancel')); - await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); - }); - - const pastMeetingsButton = screen.getByText('Past Meetings (1)'); - fireEvent.click(pastMeetingsButton); + expect(screen.getByTestId('cancel-meeting-modal')).toBeInTheDocument(); - // Should show past meetings section - expect(screen.getByText('Past meeting')).toBeInTheDocument(); - }); - - it('should toggle cancelled meetings view', async () => { - // Add a cancelled meeting to the mock data - const meetingsWithCancelled = [ - ...mockMeetings, - { - _id: 'meeting-4', - senderId: '6873cc50ac4e1d6e1cddf33f', - receiverId: 'other-user-id', - description: 'Cancelled meeting', - meetingTime: '2025-07-18T10:00:00Z', - state: 'cancelled', - acceptStatus: false, - meetingLink: null - } - ]; - mockFetchMeetings.mockResolvedValueOnce(meetingsWithCancelled); - - render(); + fireEvent.click(screen.getByText('Cancel Meeting')); await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); + expect(mockCancelMeetingWithReason).toHaveBeenCalledWith('meeting-2', 'user1', 'Test cancellation reason'); }); - - const cancelledMeetingsButton = screen.getByText('Cancelled Meetings (1)'); - fireEvent.click(cancelledMeetingsButton); - - expect(screen.getByText('Cancelled meeting')).toBeInTheDocument(); }); }); describe('Error Handling', () => { - it('should handle meeting creation errors', async () => { - mockCreateMeeting.mockRejectedValueOnce(new Error('Failed to create meeting')); - // Use meetings with no active count to allow modal opening - mockFetchMeetings.mockResolvedValueOnce([mockMeetings[2]]); // Only past meeting + it('should show error alert when meeting creation fails', async () => { + mockCreateMeeting.mockRejectedValue(new Error('Failed to create meeting')); - render(); + render(); await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); }); - - const scheduleButton = screen.getByText('New'); - act(() => { - fireEvent.click(scheduleButton); - }); - - await waitFor(() => { - expect(screen.getByTestId('create-meeting-modal')).toBeInTheDocument(); - }); - - const createButton = screen.getByTestId('create-meeting-submit'); - fireEvent.click(createButton); + fireEvent.click(screen.getByText('Create Meeting')); await waitFor(() => { - expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByTestId('alert-error')).toBeInTheDocument(); expect(screen.getByText('Failed to create meeting')).toBeInTheDocument(); }); }); + }); - it('should handle meeting update errors', async () => { - mockUpdateMeeting.mockRejectedValueOnce(new Error('Update failed')); - - render(); - - await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); - }); - - const acceptButton = screen.getByText('Accept'); - fireEvent.click(acceptButton); + describe('Saved Notes', () => { + it('should toggle saved notes section', async () => { + render(); await waitFor(() => { - expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + expect(screen.getByText(/saved meeting notes/i)).toBeInTheDocument(); }); - const confirmButton = screen.getByTestId('confirmation-confirm'); - fireEvent.click(confirmButton); + const toggleButton = screen.getByText(/saved meeting notes/i); + fireEvent.click(toggleButton); await waitFor(() => { - expect(screen.getByTestId('alert')).toBeInTheDocument(); - expect(screen.getByText('Failed to accept meeting')).toBeInTheDocument(); + expect(screen.getByTestId('saved-notes-list')).toBeInTheDocument(); }); }); }); - describe('Component Cleanup', () => { - it('should call onMeetingUpdate when meetings change', async () => { - render(); + describe('Callback Functions', () => { + it('should call onMeetingUpdate when meetings are loaded', async () => { + render(); await waitFor(() => { - expect(mockProps.onMeetingUpdate).toHaveBeenCalled(); + expect(defaultProps.onMeetingUpdate).toHaveBeenCalled(); }); }); }); - describe('Alert and Confirmation Management', () => { - it('should close alert when close button is clicked', async () => { - mockCreateMeeting.mockRejectedValueOnce(new Error('Test error')); - // Use meetings with no active count to allow modal opening - mockFetchMeetings.mockResolvedValueOnce([mockMeetings[2]]); // Only past meeting - - render(); - - await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); + describe('Edge Cases', () => { + it('should handle empty meetings list', async () => { + mockFetchMeetings.mockResolvedValue([]); + mockFilterMeetingsByType.mockReturnValue({ + pendingRequests: [], + upcomingMeetings: [], + pastMeetings: [], + cancelledMeetings: [] }); - - // Trigger an error to show alert - const scheduleButton = screen.getByText('New'); - act(() => { - fireEvent.click(scheduleButton); - }); + render(); await waitFor(() => { - expect(screen.getByTestId('create-meeting-modal')).toBeInTheDocument(); + expect(screen.getByTestId('meeting-list')).toBeInTheDocument(); + expect(screen.getByTestId('pending-requests-count')).toHaveTextContent('0'); + expect(screen.getByTestId('upcoming-meetings-count')).toHaveTextContent('0'); }); + }); - const createButton = screen.getByTestId('create-meeting-submit'); - fireEvent.click(createButton); + it('should handle meeting limit restriction', async () => { + mockCheckMeetingLimit.mockReturnValue(2); // At limit - await waitFor(() => { - expect(screen.getByTestId('alert')).toBeInTheDocument(); - }); - - const closeAlertButton = screen.getByTestId('alert-close'); - fireEvent.click(closeAlertButton); + render(); await waitFor(() => { - expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); }); - }); - - it('should close confirmation dialog when cancel is clicked', async () => { - render(); await waitFor(() => { - expect(screen.queryByText('Loading meetings...')).not.toBeInTheDocument(); + expect(screen.getByTestId('alert-warning')).toBeInTheDocument(); + // The exact text from the component + expect(screen.getByText(/maximum of 2 active meetings/i)).toBeInTheDocument(); }); + }); - const acceptButton = screen.getByText('Accept'); - fireEvent.click(acceptButton); - - await waitFor(() => { - expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + it('should handle cancellation when meeting cannot be cancelled', async () => { + mockCanCancelMeeting.mockReturnValue({ + canCancel: false, + reason: 'Meeting is too close to start time' }); - const cancelButton = screen.getByTestId('confirmation-cancel'); - fireEvent.click(cancelButton); + render(); await waitFor(() => { - expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Cancel')); }); + + expect(screen.getByTestId('alert-warning')).toBeInTheDocument(); + expect(screen.getByText('Meeting is too close to start time')).toBeInTheDocument(); }); }); -}); +}); \ No newline at end of file diff --git a/__tests__/components/SessionBox.test.tsx b/__tests__/components/SessionBox.test.tsx index b0d6880a..6e978068 100644 --- a/__tests__/components/SessionBox.test.tsx +++ b/__tests__/components/SessionBox.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import '@testing-library/jest-dom'; import SessionBox from '@/components/messageSystem/SessionBox'; +import { useSessionActions } from '@/hooks/useSessionActions'; // Mock Next.js router const mockPush = jest.fn(); @@ -87,6 +88,11 @@ jest.mock('@/utils/avatarUtils', () => ({ processAvatarUrl: jest.fn((url) => url || '/default-avatar.png'), })); +// Mock the useSessionActions hook +jest.mock('@/hooks/useSessionActions', () => ({ + useSessionActions: jest.fn(), +})); + // Mock data const mockUser = { _id: '6873cc50ac4e1d6e1cddf33f', @@ -110,8 +116,8 @@ const mockSession = { _id: 'session-id-1', user1Id: mockUser, user2Id: mockOtherUser, - skill1Id: { _id: 'skill1', name: 'React' }, - skill2Id: { _id: 'skill2', name: 'Node.js' }, + skill1Id: { _id: 'skill1', skillName: 'React' }, + skill2Id: { _id: 'skill2', skillName: 'Node.js' }, descriptionOfService1: 'I can teach React basics', descriptionOfService2: 'I want to learn Node.js', startDate: '2025-01-15', @@ -197,9 +203,33 @@ describe('SessionBox Component', () => { onSessionUpdate: jest.fn() }; + // Default mock implementation for useSessionActions + const defaultMockSessionActions = { + sessions: [], + counterOffers: {}, + loading: false, + processingSession: null, + pendingSessionCount: 0, + activeSessionCount: 0, + fetchSessions: jest.fn(), + handleAcceptReject: jest.fn(), + handleDeleteSession: jest.fn(), + handleCounterOfferResponse: jest.fn(), + handleRequestCompletion: jest.fn(), + handleCompletionResponse: jest.fn(), + handleRatingSubmit: jest.fn(), + setLoading: jest.fn(), + setSessions: jest.fn(), + setCounterOffers: jest.fn(), + setPendingSessionCount: jest.fn(), + setActiveSessionCount: jest.fn() + }; + beforeEach(() => { jest.clearAllMocks(); global.fetch = createMockFetch({}); + // Reset to default mock implementation + (useSessionActions as jest.MockedFunction).mockReturnValue(defaultMockSessionActions); }); afterEach(() => { @@ -210,6 +240,12 @@ describe('SessionBox Component', () => { it('should show New Session button', async () => { global.fetch = createMockFetch({ sessions: [] }); + // Mock the hook to return empty sessions + (useSessionActions as jest.MockedFunction).mockReturnValue({ + ...defaultMockSessionActions, + sessions: [] + }); + render(); await waitFor(() => { @@ -220,6 +256,12 @@ describe('SessionBox Component', () => { it('should show empty state when no sessions exist', async () => { global.fetch = createMockFetch({ sessions: [] }); + // Mock the hook to return empty sessions + (useSessionActions as jest.MockedFunction).mockReturnValue({ + ...defaultMockSessionActions, + sessions: [] + }); + render(); await waitFor(() => { @@ -236,11 +278,19 @@ describe('SessionBox Component', () => { counterOffers: [] }); + // Mock the hook to return the session + (useSessionActions as jest.MockedFunction).mockReturnValue({ + ...defaultMockSessionActions, + sessions: [mockSession], + pendingSessionCount: 1, + activeSessionCount: 1 + }); + render(); await waitFor(() => { - expect(screen.getByText('I can teach React basics')).toBeInTheDocument(); - expect(screen.getByText('I want to learn Node.js')).toBeInTheDocument(); + expect(screen.getByText('React')).toBeInTheDocument(); + expect(screen.getByText('Node.js')).toBeInTheDocument(); expect(screen.getByText('pending')).toBeInTheDocument(); }); }); @@ -257,6 +307,14 @@ describe('SessionBox Component', () => { counterOffers: [] }); + // Mock the hook to return multiple sessions + (useSessionActions as jest.MockedFunction).mockReturnValue({ + ...defaultMockSessionActions, + sessions: multiplePendingSessions, + pendingSessionCount: 3, + activeSessionCount: 3 + }); + render(); await waitFor(() => { @@ -269,6 +327,12 @@ describe('SessionBox Component', () => { it('should open create session modal when New Session button is clicked', async () => { global.fetch = createMockFetch({ sessions: [] }); + // Mock the hook to return empty sessions + (useSessionActions as jest.MockedFunction).mockReturnValue({ + ...defaultMockSessionActions, + sessions: [] + }); + render(); await waitFor(() => { @@ -291,6 +355,14 @@ describe('SessionBox Component', () => { counterOffers: [] }); + // Mock the hook to return 3 active sessions + (useSessionActions as jest.MockedFunction).mockReturnValue({ + ...defaultMockSessionActions, + sessions: multiplePendingSessions, + pendingSessionCount: 3, + activeSessionCount: 3 + }); + render(); await waitFor(() => { @@ -322,7 +394,7 @@ describe('SessionBox Component', () => { render(); await waitFor(() => { - expect(screen.getByText('Failed to load user information')).toBeInTheDocument(); + expect(screen.getByText('Failed to load user')).toBeInTheDocument(); }); }); @@ -351,6 +423,12 @@ describe('SessionBox Component', () => { it('should close create session modal when close button is clicked', async () => { global.fetch = createMockFetch({ sessions: [] }); + // Mock the hook to return empty sessions + (useSessionActions as jest.MockedFunction).mockReturnValue({ + ...defaultMockSessionActions, + sessions: [] + }); + render(); await waitFor(() => { @@ -374,6 +452,14 @@ describe('SessionBox Component', () => { counterOffers: [] }); + // Mock the hook to return the session + (useSessionActions as jest.MockedFunction).mockReturnValue({ + ...defaultMockSessionActions, + sessions: [mockSession], + pendingSessionCount: 1, + activeSessionCount: 1 + }); + render(); await waitFor(() => { diff --git a/__tests__/pages/SessionPage.test..tsx b/__tests__/pages/SessionPage.test..tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/__tests__/pages/SessionWorkspace.test.tsx b/__tests__/pages/SessionWorkspace.test.tsx new file mode 100644 index 00000000..86cdfa3d --- /dev/null +++ b/__tests__/pages/SessionWorkspace.test.tsx @@ -0,0 +1,603 @@ +/** + * SessionWorkspace Page Component Tests + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { useParams, useRouter } from 'next/navigation'; +import SessionWorkspace from '@/app/session/[sessionId]/page'; + +// Mock Next.js navigation +const mockPush = jest.fn(); +const mockBack = jest.fn(); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + back: mockBack, + replace: jest.fn(), + prefetch: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + }), + useParams: jest.fn(), +})); + +// Mock AuthContext +const mockUser = { + _id: 'user1', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + title: 'Developer' +}; + +jest.mock('@/lib/context/AuthContext', () => ({ + useAuth: () => ({ + user: mockUser, + loading: false, + logout: jest.fn(), + }), +})); + +// Mock tab components +jest.mock('@/components/sessionTabs/OverviewTab', () => { + return function MockOverviewTab({ + session, + setActiveTab, + onSessionUpdate, + showAlert + }: any) { + return ( +
+
Overview Tab
+
{session?.status}
+ + + +
+ ); + }; +}); + +jest.mock('@/components/sessionTabs/SubmitWorkTab', () => { + return function MockSubmitWorkTab({ session, showAlert }: any) { + return ( +
+
Submit Work Tab
+ +
+ ); + }; +}); + +jest.mock('@/components/sessionTabs/ViewWorksTab', () => { + return function MockViewWorksTab({ onWorkUpdate, showAlert }: any) { + return ( +
+
View Works Tab
+ + +
+ ); + }; +}); + +jest.mock('@/components/sessionTabs/ProgressTab', () => { + return function MockProgressTab({ showAlert }: any) { + return ( +
+
Progress Tab
+ +
+ ); + }; +}); + +jest.mock('@/components/sessionTabs/ReportTab', () => { + return function MockReportTab({ showAlert }: any) { + return ( +
+
Report Tab
+ +
+ ); + }; +}); + +// Mock UI Components +jest.mock('@/components/ui/Alert', () => { + return function MockAlert({ isOpen, type, message, title, onClose }: any) { + return isOpen ? ( +
+ {title &&
{title}
} +
{message}
+ +
+ ) : null; + }; +}); + +jest.mock('@/components/ui/ConfirmationDialog', () => { + return function MockConfirmationDialog({ + isOpen, + title, + message, + onConfirm, + onClose, + confirmText + }: any) { + return isOpen ? ( +
+
{title}
+
{message}
+ + +
+ ) : null; + }; +}); + +// Mock data +const mockActiveSession = { + _id: 'session-123', + status: 'active', + startDate: '2025-07-15T10:00:00.000Z', + expectedEndDate: '2025-07-25T10:00:00.000Z', + user1Id: { + _id: 'user1', + firstName: 'John', + lastName: 'Doe', + name: 'John Doe' + }, + user2Id: { + _id: 'user2', + firstName: 'Jane', + lastName: 'Smith', + name: 'Jane Smith' + }, + skill1Id: { + _id: 'skill1', + name: 'Frontend Development' + }, + skill2Id: { + _id: 'skill2', + name: 'Backend Development' + }, + descriptionOfService1: 'I will help with React and TypeScript development', + descriptionOfService2: 'I will help with Node.js and database design' +}; + +const mockCompletedSession = { + ...mockActiveSession, + status: 'completed' +}; + +const mockCancelledSession = { + ...mockActiveSession, + status: 'canceled' +}; + +const mockWorks = [ + { + _id: 'work-1', + submitUser: { _id: 'user1', firstName: 'John', lastName: 'Doe' }, + receiveUser: { _id: 'user2', firstName: 'Jane', lastName: 'Smith' }, + title: 'Frontend Component', + description: 'React component for user dashboard', + acceptanceStatus: 'accepted', + submittedAt: '2025-07-16T10:00:00.000Z' + }, + { + _id: 'work-2', + submitUser: { _id: 'user2', firstName: 'Jane', lastName: 'Smith' }, + receiveUser: { _id: 'user1', firstName: 'John', lastName: 'Doe' }, + title: 'API Endpoint', + description: 'User authentication API', + acceptanceStatus: 'pending', + submittedAt: '2025-07-17T10:00:00.000Z' + } +]; + +const mockProgress = [ + { + _id: 'progress-1', + userId: 'user1', + status: 'in_progress', + completionPercentage: 75, + notes: 'Working on the final components', + dueDate: '2025-07-22T10:00:00.000Z' + }, + { + _id: 'progress-2', + userId: 'user2', + status: 'in_progress', + completionPercentage: 60, + notes: 'Database schema complete', + dueDate: '2025-07-23T10:00:00.000Z' + } +]; + +const mockOtherUserDetails = { + _id: 'user2', + firstName: 'Jane', + lastName: 'Smith', + email: 'jane.smith@example.com', + title: 'Backend Developer' +}; + +// Global fetch mock +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +describe('SessionWorkspace Page', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Mock useParams to return sessionId + (useParams as jest.Mock).mockReturnValue({ sessionId: 'session-123' }); + + // Setup default fetch responses + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/session/session-123')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + success: true, + session: mockActiveSession + }) + }); + } + + if (url.includes('/api/work/session/session-123')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + success: true, + works: mockWorks + }) + }); + } + + if (url.includes('/api/session-progress/session-123')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + success: true, + progress: mockProgress + }) + }); + } + + if (url.includes('/api/users/profile?id=user2')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + success: true, + user: mockOtherUserDetails + }) + }); + } + + if (url.includes('/api/notification')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true }) + }); + } + + // Default fallback + return Promise.resolve({ + ok: false, + json: () => Promise.resolve({ success: false, message: 'Not found' }) + }); + }); + }); + + describe('Rendering', () => { + it('should render session not found when session is null', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/session/session-123')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + success: false, + session: null + }) + }); + } + return Promise.reject(new Error('Unhandled fetch')); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Session Not Found')).toBeInTheDocument(); + }); + + expect(screen.getByText('Go Back')).toBeInTheDocument(); + }); + }); + + + + describe('Tab Navigation', () => { + beforeEach(async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('overview-tab')).toBeInTheDocument(); + }); + }); + + it('should render all tab buttons', () => { + expect(screen.getAllByText('Overview')).toHaveLength(2); // Desktop and mobile versions + expect(screen.getAllByText('Submit Work')).toHaveLength(2); + expect(screen.getAllByText('View Works')).toHaveLength(2); + expect(screen.getAllByText('Progress')).toHaveLength(2); + expect(screen.getAllByText('Report Issue')).toHaveLength(2); + }); + + it('should switch to submit work tab', async () => { + // Use getByRole to target the button specifically + const submitWorkButtons = screen.getAllByRole('button'); + const submitWorkTab = submitWorkButtons.find(button => + button.textContent?.includes('Submit Work') + ); + + if (submitWorkTab) { + fireEvent.click(submitWorkTab); + + await waitFor(() => { + expect(screen.getByTestId('submit-work-tab')).toBeInTheDocument(); + }); + } + }); + + it('should switch to view works tab', async () => { + const viewWorksButtons = screen.getAllByRole('button'); + const viewWorksTab = viewWorksButtons.find(button => + button.textContent?.includes('View Works') + ); + + if (viewWorksTab) { + fireEvent.click(viewWorksTab); + + await waitFor(() => { + expect(screen.getByTestId('view-works-tab')).toBeInTheDocument(); + }); + } + }); + + it('should switch to progress tab', async () => { + const progressButtons = screen.getAllByRole('button'); + const progressTab = progressButtons.find(button => + button.textContent?.includes('Progress') && !button.textContent?.includes('Update') + ); + + if (progressTab) { + fireEvent.click(progressTab); + + await waitFor(() => { + expect(screen.getByTestId('progress-tab')).toBeInTheDocument(); + }); + } + }); + + it('should switch to report tab', async () => { + const reportButtons = screen.getAllByRole('button'); + const reportTab = reportButtons.find(button => + button.textContent?.includes('Report Issue') + ); + + if (reportTab) { + fireEvent.click(reportTab); + + await waitFor(() => { + expect(screen.getByTestId('report-tab')).toBeInTheDocument(); + }); + } + }); + }); + + describe('Data Fetching', () => { + it('should fetch session data on mount', async () => { + render(); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith('/api/session/session-123'); + }); + }); + + it('should fetch works and progress when user is available', async () => { + render(); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith('/api/work/session/session-123'); + expect(mockFetch).toHaveBeenCalledWith('/api/session-progress/session-123'); + }); + }); + + it('should fetch other user details when session is loaded', async () => { + render(); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith('/api/users/profile?id=user2'); + }); + }); + + it('should handle session refresh', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('overview-tab')).toBeInTheDocument(); + }); + + const refreshButton = screen.getByText('Refresh Session'); + fireEvent.click(refreshButton); + + // Should call fetch again for session data + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith('/api/session/session-123'); + }); + }); + }); + + describe('Alert System', () => { + beforeEach(async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('overview-tab')).toBeInTheDocument(); + }); + }); + + it('should show alert from tab component', async () => { + const alertButton = screen.getByText('Test Alert'); + fireEvent.click(alertButton); + + await waitFor(() => { + expect(screen.getByTestId('alert-success')).toBeInTheDocument(); + expect(screen.getByText('Test alert from overview')).toBeInTheDocument(); + }); + }); + + it('should close alert when close button is clicked', async () => { + const alertButton = screen.getByText('Test Alert'); + fireEvent.click(alertButton); + + await waitFor(() => { + expect(screen.getByTestId('alert-success')).toBeInTheDocument(); + }); + + const closeButton = screen.getByText('Close Alert'); + fireEvent.click(closeButton); + + await waitFor(() => { + expect(screen.queryByTestId('alert-success')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Error Handling', () => { + it('should handle session fetch error gracefully', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + render(); + + // Should still render but may show loading state or handle error + await waitFor(() => { + // Component should not crash - check if main content exists + expect(document.body).toBeInTheDocument(); + }, { timeout: 2000 }); + }); + + it('should handle works fetch error gracefully', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/work/session/session-123')) { + return Promise.reject(new Error('Works fetch failed')); + } + if (url.includes('/api/session/session-123')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + success: true, + session: mockActiveSession + }) + }); + } + return mockFetch(url); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Session with Jane Smith')).toBeInTheDocument(); + }); + }); + + it('should handle progress fetch error gracefully', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('/api/session-progress/session-123')) { + return Promise.reject(new Error('Progress fetch failed')); + } + if (url.includes('/api/session/session-123')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + success: true, + session: mockActiveSession + }) + }); + } + return mockFetch(url); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Session with Jane Smith')).toBeInTheDocument(); + }); + }); + }); + + describe('Responsive Design', () => { + beforeEach(async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('overview-tab')).toBeInTheDocument(); + }); + }); + + it('should render mobile-friendly header', () => { + expect(screen.getByText('Session with Jane Smith')).toBeInTheDocument(); + expect(screen.getByText('Started:')).toBeInTheDocument(); + }); + + it('should have scrollable tab navigation', () => { + // Get the tab navigation container specifically + const allNavs = screen.getAllByRole('navigation'); + const tabNavigation = allNavs.find(nav => + nav.className.includes('mb-4') + ); + expect(tabNavigation).toBeInTheDocument(); + }); + }); + + + + describe('User Display Names', () => { + beforeEach(async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('overview-tab')).toBeInTheDocument(); + }); + }); + + it('should display correct other user name in header', () => { + expect(screen.getByText('Session with Jane Smith')).toBeInTheDocument(); + }); + + it('should display session start date', () => { + expect(screen.getByText('Started:')).toBeInTheDocument(); + expect(screen.getByText('Jul 15, 2025')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/meeting/[id]/page.tsx b/src/app/meeting/[id]/page.tsx index fd7200e0..8ac63f49 100644 --- a/src/app/meeting/[id]/page.tsx +++ b/src/app/meeting/[id]/page.tsx @@ -106,10 +106,10 @@ export default function MeetingPage() { if (authLoading || loading) { return ( -
+
-
-

Loading meeting...

+
+

Loading meeting...

); @@ -117,16 +117,16 @@ export default function MeetingPage() { if (error || !meeting || !isAuthorized) { return ( -
-
+
+
- -

Meeting Not Available

-

+ +

Meeting Not Available

+

{error || 'You are not authorized to access this meeting.'}

-
@@ -143,28 +143,28 @@ export default function MeetingPage() { const isTooEarly = timeUntilMeeting > 30 * 60 * 1000; // More than 30 minutes before return ( -
-
+
+
- -

Meeting Not Available

+ +

Meeting Not Available

{meeting.state !== 'accepted' && ( -

+

This meeting has not been accepted yet.

)} {meeting.state === 'accepted' && !meeting.meetingLink && ( -

+

Meeting link is not available yet.

)} {meeting.state === 'accepted' && meeting.meetingLink && isTooEarly && ( -
-

Meeting starts in:

-

+

+

Meeting starts in:

+

{(() => { const minutesUntil = Math.floor(timeUntilMeeting / (1000 * 60)); if (minutesUntil >= 60) { @@ -174,34 +174,34 @@ export default function MeetingPage() { return `${minutesUntil} minutes`; })()}

-

+

You can join 30 minutes before the scheduled time.

)} {meeting.state === 'accepted' && meeting.meetingLink && isPastMeeting && ( -

+

This meeting has ended.

)}
-
-

Meeting Details

-

+

+

Meeting Details

+

With: {otherUser?.firstName} {otherUser?.lastName}

-

+

Scheduled: {meetingTime.toLocaleString()}

-

+

Description: {meeting.description}

-
diff --git a/src/app/user/notification/page.tsx b/src/app/user/notification/page.tsx index b8328a8e..694c93f7 100644 --- a/src/app/user/notification/page.tsx +++ b/src/app/user/notification/page.tsx @@ -215,7 +215,7 @@ const NotificationPage = () => {
-
+
{user && (

Hi {user.firstName}, here are your notifications:

@@ -223,23 +223,23 @@ const NotificationPage = () => { )} {/*UnReaded Notifications Badge*/}
-
- -

Notifications

+
+ +

Notifications

{unreadNotifications.length > 0 && ( - + {unreadNotifications.length} unread )}
-
+
{notifications.length > 0 && ( -
+