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__/manual/KYCManual.html b/__tests__/manual/KYCManual.html new file mode 100644 index 00000000..70b2bab2 --- /dev/null +++ b/__tests__/manual/KYCManual.html @@ -0,0 +1,211 @@ + + + + + KYC Test Results + + + +

KYC Test Results

+
+

Tester: P. Renulucshmi

+ +

Browser: Chrome

+

Device: desktop

+

Date: 2025-07-17

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Test NameCategoryPriorityPreconditionsStepsExpected ResultActual ResultStatus
1Upload valid NIC + selfieCREATIONHIGHUser logged in; KYC form visible + 1. Enter full name
2. Enter valid NIC
3. Upload NIC file + (<2MB)
4. Upload selfie-with-NIC
5. Click Submit +
+ New submission appears in Admin KYC list with status “Not Reviewed” + new submission appearsPASS
2Reject blank-field submissionVALIDATIONHIGHKYC page loaded1. Leave all fields empty
2. Click Submit
+ Inline errors under each field (“Full Name is required”, etc.); form + not submitted + please fill out this field appearedPASS
3Reject invalid NIC formatVALIDATIONMEDIUMKYC form visible1. Enter “abc123” in NIC Number
2. Click Submit
+ Validation message “Enter a valid NIC” under NIC field; no + submission + + Red inline error was there until we enter correct format "Enter + either Old NIC (9 digits + V/X) or New NIC (12 digits)" + PASS
4Reject oversize file uploadVALIDATIONMEDIUMKYC form visible1. Select >2MB file for NIC Document
2. Click Submit
Error toast “File must be under 2MB”; input resetsfile must be less than 2MB inline errorPASS
5View pending submissionsFILTERINGLOWAt least one KYC exists1. Navigate to KYC tab
2. Observe table
+ Table lists NIC, Recipient, Date Submitted, Status, Reviewed, + Documents, Accept/Reject + yes all the columns were presentPASS
6Download NIC & selfie filesFUNCTIONALITYMEDIUMKYC list visible1. Click download NIC icon
2. Click download Person icon
Each file downloads with correct name and contentfile downloaded after a clickPASS
7Accept a KYC submissionWORKFLOWHIGHOne “Not Reviewed” row present1. Click green ✔
2. Confirm if prompted
Status → “Accepted”; Reviewed date = todayaccepted as expectedPASS
8Reject a KYC submissionWORKFLOWHIGHOne “Not Reviewed” row present1. Click red ✖
2. Confirm if prompted
Status → “Rejected”; Reviewed date = todayRejected as expectedPASS
9Search by recipient nameFILTERINGLOWMultiple names present1. Type partial name in search boxTable filters to matching Recipient rows-PASS
10Sort by date submittedSORTINGLOWTable visible1. Click Date Submitted header
2. Click again
Rows toggle ascending/descending by Date Submittedyes it works as expectedPASS
11Filter by statusFILTERINGLOWVarious statuses exist1. Open status dropdown
2. Select “Accepted”
Table shows only rows with Status = Acceptedonly showed acccepted recordsPASS
+ + diff --git a/__tests__/manual/ReportingManual.html b/__tests__/manual/ReportingManual.html new file mode 100644 index 00000000..dba4d0b1 --- /dev/null +++ b/__tests__/manual/ReportingManual.html @@ -0,0 +1,170 @@ + + + + + Reporting Test Execution Results + + + + +
+

Reporting Page Test Results

+

Tester: P. Renulucshmi

+

Date: 2025-07-17

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Test NameCategoryPriorityPreconditionsTest StepsExpected ResultActual ResultStatus
RP01Reporting List RendersVIEWHIGHLogged in as Admin; ≥1 report exists + 1. Navigate to Reporting
+ 2. Wait for table to load +
Table displays all columns and rows correctlyAll reports appeared as expectedPASS
RP02Search ReportsFUNCTIONALITYMEDIUM≥3 reports with distinct data + 1. Enter “Not Following” in search
+ 2. Press Enter +
Only matching rows remain visibleSearch filtered correctlyPASS
RP03Filter by StatusFUNCTIONALITYMEDIUMReports in multiple statuses exist + 1. Open Status dropdown
+ 2. Select “Under Review” +
Only “Under Review” rows remainFilter worked as expectedPASS
RP04Download Report DataUTILITYLOWTable loaded + 1. Click the download (↓) icon on a row + Report data downloads successfullyDownload triggered with correct payloadPASS
RP05Email Reporting UserWORKFLOW–EMAILHIGHA pending report exists + 1. Click ✉️ next to Reporting User
+ 2. Send “Investigating…” email +
Status changes to “Under Review”Email sent; status updatedPASS
RP06Email Reported UserWORKFLOW–EMAILHIGHReport is “Under Review” + 1. Click ✉️ next to Reported User
+ 2. Send “Please provide details” email +
Email confirmation shownEmail sent successfullyPASS
RP07Issue Warning on False ReportWORKFLOW–ACTIONMEDIUMStatus “Under Review” + 1. Click ❌ (reject) icon
+ 2. Choose “Warn Reporter” +
Reporter warned; status = “Resolved – Reporter Warned”Warning email sent; status updatedPASS
RP08Issue Warning to Reported UserWORKFLOW–ACTIONMEDIUMStatus “Under Review” + 1. Click 🛡️ (warn) icon
+ 2. Confirm +
Reported user warned; status = “Resolved – Reported Warned”Warning email sent; status updatedPASS
RP09Resolve ReportWORKFLOW–COMPLETEHIGHAny non-resolved status + 1. Click ✔️ (resolve) icon
+ 2. Confirm +
Status changes to “Resolved”Report marked ResolvedPASS
+ + diff --git a/__tests__/manual/adminDashboardManual.html b/__tests__/manual/adminDashboardManual.html new file mode 100644 index 00000000..562000b8 --- /dev/null +++ b/__tests__/manual/adminDashboardManual.html @@ -0,0 +1,146 @@ + + + + + Admin Dashboard Results + + + + +

Admin Dashboard Test Results

+

Tester: P. Renulucshmi

+

Date: 2025-07-17

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Test NameCategoryPriorityPreconditionsTest StepsExpected ResultActual ResultStatus
1Dashboard Cards LoadVIEWHIGHLogged in as Admin/SuperAdmin1. Navigate to /admin/dashboard
2. Wait for page load
All five metric cards appear with correct valuesits loading as expectedPASS
2Cards AccuracyVERIFICATIONHIGHDashboard loaded; known backend data1. Compare card values to API/DB
2. Refresh page
Each card value matches the source of truthit matches with database count as wellPASS
3Registration Chart PlotVISUALIZATIONMEDIUM≥1 registration record exists1. Observe line chart
2. Hover data points
Chart shows daily points; tooltip shows date & count + when hover points in shows no of users registered on a specific day + PASS
4Chart Date Range FilterFUNCTIONALITYMEDIUMDate‑range dropdown visible1. Select Last 7 Days
2. Select All Time
Chart re‑renders to selected rangechart re renders when we change the drop downPASS
5Skill Distribution DonutVISUALIZATIONMEDIUM≥1 skill assignment exists1. Observe donut chart
2. Hover slices
Each slice shows category & %; total sums to 100%each hover shows no of skills in each cateogoryPASS
6Loading SkeletonsUXLOWNetwork throttled1. Throttle to Slow 3G
2. Reload page
Skeleton placeholders displayed until data arrivesuntil data arrives it shows loading signPASS
7Network Calls VerificationDEBUGLOWDevTools open1. Monitor Network tab
2. Reload page
GET /api/dashboard fires once; no 404/500 errors + A single GET /api/dashboard request fired on load (200 OK), followed + by two GET /api/skillLists requests; no 404/500 errors. + PASS
+ + diff --git a/__tests__/manual/adminLoginManual.html b/__tests__/manual/adminLoginManual.html new file mode 100644 index 00000000..f6be63e8 --- /dev/null +++ b/__tests__/manual/adminLoginManual.html @@ -0,0 +1,108 @@ + + + + + Admin Login Test Results + + + + +

Admin Login Test Results

+

Tester: P. Renulucshmi

+

Date: 2025-07-17

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Test NamePriorityPreconditionsStepsExpectedActualStatus
1Page Load & UI ElementsHIGHBrowser open; Admin login page reachableNavigate to /admin/login
Observe form fields and buttons
Username, Password fields and Login button are visibleyes can view the pagePASS
2Empty Fields ValidationMEDIUMLogin page openLeave both fields blank
Click Login
Inline errors: “Username required” and “Password required”yes validatedPASS
3Invalid CredentialsHIGHLogin page openEnter wrong username/password
Click Login
Error toast: “Invalid credentials”Invalid username or password inline errorPASS
4Successful LoginHIGHValid admin credentialsFill in correct username & password
Click Login
Redirect to /admin/dashboardit redirect to dashboardPASS
5Password Mask ToggleLOWLogin form visibleEnter password
Click show/hide icon
Password toggles between masked and plain textwhen click the toggle password is visiblePASS
+ + diff --git a/__tests__/manual/adminManagementManual.html b/__tests__/manual/adminManagementManual.html new file mode 100644 index 00000000..5fe5cb73 --- /dev/null +++ b/__tests__/manual/adminManagementManual.html @@ -0,0 +1,201 @@ + + + + + Admin Management Results + + + +

Admin Management Test Results

+
+

Tester: P. Renulucshmi

+

Browser: Chrome

+

Device: desktop

+

Date: 2025-07-17

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Test NameCategoryPriorityPreconditionsTest StepsExpected ResultActual ResultStatus
1View Admin ListVIEWINGMEDIUMLogged in as Admin or Super Admin; Admin Management page open + 1. Navigate to Admin Management
2. Wait for table to load +
+ Table shows Username, Email, Role, Permissions, Created, Actions; ≥1 + row present + table with the all columns showedPASS
2Search by Username or EmailFILTERINGLOWAdmin Mgmt page loaded with ≥2 entries1. Type part of a username or email into search boxOnly matching rows remain visibleyes search box is functioning as expectedPASS
3Filter by RoleFILTERINGLOWMixed Admin & Super Admin rows present1. Select Admin then Super Admin from dropdownTable filters to show only rows for the selected rolefilter show only respected columnPASS
4Edit Admin DetailsUPDATEMEDIUMAt least one row present; Edit icon visible + 1. Click ✏️ on a row
2. Change email or role
3. Click Save +
That row updates to reflect the new email/rolethe row update after editPASS
5Delete Another AdminDELETIONHIGHAnother user’s row exists1. Click 🗑️ on that row
2. Confirm deletion
Row is removed; success message shownsuccessfully deletedPASS
6Prevent Self‑DeletionVALIDATIONHIGHYour own row present1. Attempt to click 🗑️ on your own rowDelete button disabled or error “Cannot delete yourself.”its not allowing to deleted myselfPASS
7Create New Admin (Super only)CREATIONHIGHLogged in as Super Admin; Create Admin button visible + 1. Click Create Admin
2. Fill Username, Email, Password, + Role=Admin, Permissions
3. Click Create Admin +
New admin appears in the table with correct detailsyes superadmin can create new adminPASS
8Deny Access to Admin Mgmt (Admin only)VALIDATIONHIGHLogged in as normal Admin1. Look for Admin Management tab/menu itemNo Admin Management entry is visible to normal Admin + admin admin management tab is not visible. only visible for + superAdmin + PASS
9Validate Create‑FormVALIDATIONMEDIUMCreate Admin modal open + 1. Leave required fields blank or invalid
2. Click Create Admin +
Inline validation errors appear; form not submittedusername ,password ,email validation are includedPASS
10Permission PersistenceFUNCTIONALITYMEDIUMAfter Create or Edit1. Reload Admin Management pageCreated/edited admins & permissions persist correctlyafter reload also its persistedPASS
+ + diff --git a/__tests__/manual/badgeManagerManual.html b/__tests__/manual/badgeManagerManual.html new file mode 100644 index 00000000..02030f71 --- /dev/null +++ b/__tests__/manual/badgeManagerManual.html @@ -0,0 +1,189 @@ + + + + + BadgeManager Results + + + +

BadgeManager Test Results

+
+

Tester: P. Renulucshmi

+ +

Browser: Chrome

+

Device: desktop

+

Date: 2025-07-17

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Test NameCategoryPriorityPreconditionsTest StepsExpected ResultActual ResultStatus
1Badge Name RequiredADDHIGH“Add New Badge” form visible; all fields empty + 1. Leave Badge Name blank
2. Fill other fields correctly
3. + Click Add Badge +
Inline error “Badge Name is required”; form not submittedBadge name is required inline errorPASS
2Badge Name Min‑Length (≥3 chars)ADDMEDIUM“Add New Badge” form visible + 1. Enter 1–2 chars in Badge Name
2. Fill others correctly
3. + Click Add Badge +
+ Inline error “Name must be at least 3 characters”; submit disabled + inline error Badge name must be at least 3 characters long.PASS
3Description Required & Min‑LengthADDHIGH“Add New Badge” form visible + 1. Leave Description blank or <10 chars
2. Fill other fields + correctly
3. Click Add Badge +
+ Inline error “Description must be at least 10 characters”; form not + submitted + inline error showedPASS
4Image Required & FormatADDHIGH“Add New Badge” form visible1. Leave Badge Image empty
2. Click Add Badge
+ Inline error “Please upload an image” or “Unsupported file format” + + Invalid image type. Please use JPEG, PNG, GIF or WEBP inline error + PASS
5Image Size Limit (≤2 MB)ADDMEDIUM“Add New Badge” form visible1. Upload file >2 MB
2. Click Add Badge
Inline error “Image must be smaller than 2 MB”Image size should be less than 2MB inline errorPASS
6Edit Badge Inline ValidationUPDATEHIGHAt least one badge card present + 1. Click Edit on a badge
2. Clear or shorten Badge + Name/Description +
Same inline errors as BM01–BM03; Save blocked until validinline error showedPASS
7Submit Edit Updates ListUPDATEMEDIUMEdit form open; fields valid1. Modify name or image
2. Click Save
Badge card updates immediately without full reloadits updated without reloading pagePASS
8Delete Badge ConfirmationDELETEMEDIUMAt least one badge card present1. Click Delete on a badge
2. Confirm deletion
+ Confirmation dialog appears; after confirm, badge removed and + success alert shown + Badge deleted successfully! alert shownPASS
+ + diff --git a/__tests__/manual/badgeManual.html b/__tests__/manual/badgeManual.html new file mode 100644 index 00000000..92485fa4 --- /dev/null +++ b/__tests__/manual/badgeManual.html @@ -0,0 +1,117 @@ + + + + + Badges Test Results + + + + +

Badges Test Results

+
+

Tester: P.Renulucshmi

+

Date: 17/07/2025

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Test NamePriorityPreconditionsTest StepsExpected ResultActual ResultStatus
1Page Load & Default List (BG01)HighUser authenticated; at least 1 badge exists + 1. Navigate to /badges
+ 2. Wait for page load to complete +
All badges rendered as cards; Category dropdown set to “All”yes worked as expectedPass
2Filter by Category (BG02)MediumMultiple badges across categories exist + 1. Select “Achievement Milestone” in Category dropdown
+ 2. Observe the displayed badge list +
Only “Achievement Milestone” badges remain visiblework as expectedPass
+ + diff --git a/__tests__/manual/suspendedUserManual.html b/__tests__/manual/suspendedUserManual.html new file mode 100644 index 00000000..ce16bcd8 --- /dev/null +++ b/__tests__/manual/suspendedUserManual.html @@ -0,0 +1,129 @@ + + + + + Suspended Users Test Results + + + +

Suspended Users Test Results

+

Tester: P.Renlucshmi

+

Date: 2025-07-17

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Test NamePriorityPreconditionsStepsExpected ResultExec DateActual ResultStatus
SU01Suspend a UserHighUser exists; Admin logged in + 1. Go to Users
+ 2. Click “Suspend”
+ 3. Enter reason
+ 4. Confirm +
User moves to Suspended list2025‑07‑17User moved from Users to Suspended UsersPass
SU02Search Suspended UsersMedium≥1 suspended user + 1. Open Suspended Users
+ 2. Type search +
Table filters to matching rows2025‑07‑17Search worksPass
SU03Prevent Suspended LoginHighUser is suspended1. Attempt loginLogin blocked with suspend message2025‑07‑17User blocked from loginPass
SU04Prevent Re‑registrationMediumEmail suspended1. Sign up with same emailRegistration rejected: Email in use2025‑07‑17Cannot create account with same emailPass
SU05Unsuspend a UserHighUser in Suspended list + 1. Click ✓
+ 2. Confirm +
User moves back to Users list2025‑07‑17User unsuspended and can log in againPass
SU06Reason ValidationMediumSuspend dialog open1. Click Confirm without reasonError: Reason is required2025‑07‑17User isn’t suspended without a reasonPass
+ + 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/package-lock.json b/package-lock.json index 1eb79eeb..72a0f643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ "mongodb": "^6.13.0", "mongoose": "^8.10.1", "natural": "^8.1.0", - "next": "^15.2.4", + "next": "^15.4.1", "next-auth": "^4.24.11", "next-connect": "^1.0.0", "next-themes": "^0.4.4", @@ -1719,8 +1719,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "dev": true, + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", "license": "MIT", "optional": true, "dependencies": { @@ -2091,8 +2092,409 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", + "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", + "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", + "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", + "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", + "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", + "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", + "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", + "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", + "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", + "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", + "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", + "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.4" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", + "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.1", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", + "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", "cpu": [ "x64" ], @@ -2726,7 +3128,9 @@ } }, "node_modules/@next/env": { - "version": "15.3.1", + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.1.tgz", + "integrity": "sha512-DXQwFGAE2VH+f2TJsKepRXpODPU+scf5fDbKOME8MMyeyswe4XwgRdiiIYmBfkXU+2ssliLYznajTrOQdnLR5A==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2738,12 +3142,13 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.1.tgz", - "integrity": "sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==", + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.1.tgz", + "integrity": "sha512-L+81yMsiHq82VRXS2RVq6OgDwjvA4kDksGU8hfiDHEXP+ncKIUhUsadAVB+MRIp2FErs/5hpXR0u2eluWPAhig==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2753,12 +3158,13 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.1.tgz", - "integrity": "sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==", + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.1.tgz", + "integrity": "sha512-jfz1RXu6SzL14lFl05/MNkcN35lTLMJWPbqt7Xaj35+ZWAX342aePIJrN6xBdGeKl6jPXJm0Yqo3Xvh3Gpo3Uw==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2768,12 +3174,13 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.1.tgz", - "integrity": "sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==", + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.1.tgz", + "integrity": "sha512-k0tOFn3dsnkaGfs6iQz8Ms6f1CyQe4GacXF979sL8PNQxjYS1swx9VsOyUQYaPoGV8nAZ7OX8cYaeiXGq9ahPQ==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -2783,12 +3190,13 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.1.tgz", - "integrity": "sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==", + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.1.tgz", + "integrity": "sha512-4ogGQ/3qDzbbK3IwV88ltihHFbQVq6Qr+uEapzXHXBH1KsVBZOB50sn6BWHPcFjwSoMX2Tj9eH/fZvQnSIgc3g==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -2798,12 +3206,13 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.1.tgz", - "integrity": "sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==", + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.1.tgz", + "integrity": "sha512-Jj0Rfw3wIgp+eahMz/tOGwlcYYEFjlBPKU7NqoOkTX0LY45i5W0WcDpgiDWSLrN8KFQq/LW7fZq46gxGCiOYlQ==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -2813,12 +3222,13 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.1.tgz", - "integrity": "sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==", + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.1.tgz", + "integrity": "sha512-9WlEZfnw1vFqkWsTMzZDgNL7AUI1aiBHi0S2m8jvycPyCq/fbZjtE/nDkhJRYbSjXbtRHYLDBlmP95kpjEmJbw==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -2828,12 +3238,13 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.1.tgz", - "integrity": "sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==", + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.1.tgz", + "integrity": "sha512-WodRbZ9g6CQLRZsG3gtrA9w7Qfa9BwDzhFVdlI6sV0OCPq9JrOrJSp9/ioLsezbV8w9RCJ8v55uzJuJ5RgWLZg==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -2843,7 +3254,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.3.1", + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.1.tgz", + "integrity": "sha512-y+wTBxelk2xiNofmDOVU7O5WxTHcvOoL3srOM0kxTzKDjQ57kPU0tpnPJ/BWrRnsOwXEv0+3QSbGR7hY4n9LkQ==", "cpu": [ "x64" ], @@ -6029,15 +6442,6 @@ "dev": true, "license": "MIT" }, - "node_modules/busboy": { - "version": "1.6.0", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/cacheable-lookup": { "version": "5.0.4", "license": "MIT", @@ -6387,6 +6791,8 @@ }, "node_modules/color": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", "license": "MIT", "optional": true, "dependencies": { @@ -6413,6 +6819,8 @@ }, "node_modules/color-string": { "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", "license": "MIT", "optional": true, "dependencies": { @@ -6951,6 +7359,8 @@ }, "node_modules/detect-libc": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "license": "Apache-2.0", "optional": true, "engines": { @@ -9217,6 +9627,8 @@ }, "node_modules/is-arrayish": { "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", "license": "MIT", "optional": true }, @@ -11525,13 +11937,13 @@ } }, "node_modules/next": { - "version": "15.3.1", + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/next/-/next-15.4.1.tgz", + "integrity": "sha512-eNKB1q8C7o9zXF8+jgJs2CzSLIU3T6bQtX6DcTnCq1sIR1CJ0GlSyRs1BubQi3/JgCnr9Vr+rS5mOMI38FFyQw==", "license": "MIT", "dependencies": { - "@next/env": "15.3.1", - "@swc/counter": "0.1.3", + "@next/env": "15.4.1", "@swc/helpers": "0.5.15", - "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -11543,19 +11955,19 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.3.1", - "@next/swc-darwin-x64": "15.3.1", - "@next/swc-linux-arm64-gnu": "15.3.1", - "@next/swc-linux-arm64-musl": "15.3.1", - "@next/swc-linux-x64-gnu": "15.3.1", - "@next/swc-linux-x64-musl": "15.3.1", - "@next/swc-win32-arm64-msvc": "15.3.1", - "@next/swc-win32-x64-msvc": "15.3.1", - "sharp": "^0.34.1" + "@next/swc-darwin-arm64": "15.4.1", + "@next/swc-darwin-x64": "15.4.1", + "@next/swc-linux-arm64-gnu": "15.4.1", + "@next/swc-linux-arm64-musl": "15.4.1", + "@next/swc-linux-x64-gnu": "15.4.1", + "@next/swc-linux-x64-musl": "15.4.1", + "@next/swc-win32-arm64-msvc": "15.4.1", + "@next/swc-win32-x64-msvc": "15.4.1", + "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", + "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", @@ -13395,14 +13807,16 @@ } }, "node_modules/sharp": { - "version": "0.34.1", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", + "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", "hasInstallScript": true, "license": "Apache-2.0", "optional": true, "dependencies": { "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.7.1" + "detect-libc": "^2.0.4", + "semver": "^7.7.2" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -13411,26 +13825,28 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.1", - "@img/sharp-darwin-x64": "0.34.1", - "@img/sharp-libvips-darwin-arm64": "1.1.0", - "@img/sharp-libvips-darwin-x64": "1.1.0", - "@img/sharp-libvips-linux-arm": "1.1.0", - "@img/sharp-libvips-linux-arm64": "1.1.0", - "@img/sharp-libvips-linux-ppc64": "1.1.0", - "@img/sharp-libvips-linux-s390x": "1.1.0", - "@img/sharp-libvips-linux-x64": "1.1.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", - "@img/sharp-libvips-linuxmusl-x64": "1.1.0", - "@img/sharp-linux-arm": "0.34.1", - "@img/sharp-linux-arm64": "0.34.1", - "@img/sharp-linux-s390x": "0.34.1", - "@img/sharp-linux-x64": "0.34.1", - "@img/sharp-linuxmusl-arm64": "0.34.1", - "@img/sharp-linuxmusl-x64": "0.34.1", - "@img/sharp-wasm32": "0.34.1", - "@img/sharp-win32-ia32": "0.34.1", - "@img/sharp-win32-x64": "0.34.1" + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-ppc64": "1.2.0", + "@img/sharp-libvips-linux-s390x": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm": "0.34.3", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-ppc64": "0.34.3", + "@img/sharp-linux-s390x": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-wasm32": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-ia32": "0.34.3", + "@img/sharp-win32-x64": "0.34.3" } }, "node_modules/shebang-command": { @@ -13524,6 +13940,8 @@ }, "node_modules/simple-swizzle": { "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", "license": "MIT", "optional": true, "dependencies": { @@ -13778,12 +14196,6 @@ "version": "1.0.3", "license": "MIT" }, - "node_modules/streamsearch": { - "version": "1.1.0", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "license": "MIT", diff --git a/package.json b/package.json index 9ed63573..db0467ce 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "mongodb": "^6.13.0", "mongoose": "^8.10.1", "natural": "^8.1.0", - "next": "^15.2.4", + "next": "^15.4.1", "next-auth": "^4.24.11", "next-connect": "^1.0.0", "next-themes": "^0.4.4", 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/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/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/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 && ( -
+
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()} + /> +

Admin Dashboard - KYC

diff --git a/src/components/Admin/dashboardContent/ReportingContent.tsx b/src/components/Admin/dashboardContent/ReportingContent.tsx index f7138aec..2fc9a7f6 100644 --- a/src/components/Admin/dashboardContent/ReportingContent.tsx +++ b/src/components/Admin/dashboardContent/ReportingContent.tsx @@ -1,24 +1,13 @@ 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, - 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; @@ -243,38 +232,17 @@ 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); + 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}`); } - - setReports(Array.isArray(data) ? data : []); - setError(null); + const data = await res.json(); + setReports(data); } catch (err) { console.error("Error fetching reports:", err); - setError( - err instanceof Error - ? err.message - : "An error occurred while fetching reports" - ); + setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } @@ -421,84 +389,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( @@ -561,21 +451,27 @@ 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); + // 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; } @@ -622,7 +518,10 @@ SkillSwapHub Admin Team`; setReports((prevReports) => prevReports.map((report) => report._id === reportId - ? { ...report, status: "resolved" as const } + ? { + ...report, + status: resolution === "dismiss" ? "dismissed" : "resolved", + } : report ) ); @@ -640,22 +539,15 @@ 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; - } - + /** 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 @@ -667,14 +559,22 @@ SkillSwapHub Admin Team`; const sessionTitle = getSessionTitle(report.sessionId); const reason = formatReason(report.reason); + let email: string | undefined; let subject = ""; let body = ""; - if (userType === "reported") { - subject = `Warning: Platform Rules Violation - Report #${report._id.slice(-8)}`; - body = `Dear ${reportedName}, + 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,44 +583,39 @@ 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(() => { @@ -871,6 +766,19 @@ 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,669 +879,61 @@ 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 */} - -
-
-
+ {/* unified search + status + sort UI */} + {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 && ( -
- - -
- )} + {/* …your existing “no results” UI… */}
) : ( -
- - - - - - - - - - - - - {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" && ( - - )} -
-
-
+ )}
- {/* 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/SuspendedUsersContent.tsx b/src/components/Admin/dashboardContent/SuspendedUsersContent.tsx index da5f599d..e0eaa53f 100644 --- a/src/components/Admin/dashboardContent/SuspendedUsersContent.tsx +++ b/src/components/Admin/dashboardContent/SuspendedUsersContent.tsx @@ -1,11 +1,22 @@ -// 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"; -// Types +// ─── TYPE DEFINITIONS ───────────────────────────────────────────────────────── interface SuspendedUser { _id: string; firstName?: string; @@ -16,7 +27,6 @@ interface SuspendedUser { avatar?: string; originalCreatedAt?: string; suspendedAt?: string; - suspendedBy?: string; suspensionReason?: string; suspensionNotes?: string; originalUserId?: string; @@ -54,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; }; @@ -192,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, @@ -411,39 +344,21 @@ const SuspendedUserTable: React.FC = ({ return (
- + - - - - - - - - - + + + + + + + + + - {users.map((user) => ( + {users.map(user => ( = ({ ); }; +// Delete confirmation modal const DeleteModal: React.FC = ({ isOpen, onClose, @@ -464,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.

@@ -497,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 } />
diff --git a/src/components/Admin/dashboardContent/adminManager/AdminCreateModal.tsx b/src/components/Admin/dashboardContent/adminManager/AdminCreateModal.tsx new file mode 100644 index 00000000..93eac1b6 --- /dev/null +++ b/src/components/Admin/dashboardContent/adminManager/AdminCreateModal.tsx @@ -0,0 +1,268 @@ +"use client"; + +// ─── TOP-LEVEL CONSTANTS ────────────────────────────────────────────────────── +const ROLE_SUPER_ADMIN = "super_admin"; +const ROLE_ADMIN = "admin"; + +// ─── IMPORTS ────────────────────────────────────────────────────────────────── +import React from "react"; +import { CheckCircle, XCircle, Eye, EyeOff } from "lucide-react"; + +// ─── TYPE DEFINITIONS ───────────────────────────────────────────────────────── +// Represents a single permission option +interface Permission { + key: string; + label: string; + description: string; +} + +// Data model for creating a new admin +interface CreateAdminData { + username: string; + email: string; + password: string; + role: string; + permissions: string[]; +} + +// Props accepted by the create-admin modal component +interface AdminCreateModalProps { + form: CreateAdminData; + onChange: (data: CreateAdminData) => 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 */} +
+
- Avatar - - Name - - Email - - Phone - - Title - - Reason - - Originally Joined - - Suspended - - Actions - AvatarNameEmailPhoneTitleReasonJoinedSuspendedActions
+ {/* 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 */} + +
+ ); +} diff --git a/src/components/Admin/dashboardContent/badge/BadgeForm.tsx b/src/components/Admin/dashboardContent/badge/BadgeForm.tsx index 1905fe75..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, @@ -203,7 +202,7 @@ const BadgeForm = ({ onBadgeAdded }: BadgeFormProps) => { }; return ( -
+

Add New Badge

{formError && ( diff --git a/src/components/Admin/dashboardContent/badge/badgeHelpers.ts b/src/components/Admin/dashboardContent/badge/badgeHelpers.ts index 89099fb2..bdf61dba 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", ]; @@ -126,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" }; } diff --git a/src/components/Admin/dashboardContent/reporting/Filters.tsx b/src/components/Admin/dashboardContent/reporting/Filters.tsx new file mode 100644 index 00000000..4ed4fcd6 --- /dev/null +++ b/src/components/Admin/dashboardContent/reporting/Filters.tsx @@ -0,0 +1,69 @@ +"use client"; +import React from "react"; +import { Search, SortAsc, SortDesc } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export interface FiltersProps { + searchQuery: string; + onSearchChange: (q: string) => void; + statusFilter: string; + onStatusChange: (s: string) => void; + sortDirection: "asc" | "desc"; + onToggleSort: () => void; + statusOptions: { value: string; label: string; count: number }[]; +} + +export function Filters({ + searchQuery, + onSearchChange, + statusFilter, + onStatusChange, + sortDirection, + onToggleSort, + statusOptions, +}: FiltersProps) { + return ( +
+ {/* Search */} +
+ + 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" + /> +
+ + {/* Status dropdown */} + + + {/* Sort toggle */} + +
+ ); +} diff --git a/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx b/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx new file mode 100644 index 00000000..81ed148e --- /dev/null +++ b/src/components/Admin/dashboardContent/reporting/ReportDetailsModal.tsx @@ -0,0 +1,233 @@ +// 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"; + +// 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; + 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 ( +
+
+ + {/* HEADER - Modal title and close button */} +
+
+

{MODAL_TITLE}

+

+ {REPORT_ID_LABEL} {report._id} +

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

{REPORTING_USER_TITLE}

+

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

+

+ {EMAIL_ICON}{report.reportedBy?.email || NO_EMAIL_FALLBACK} +

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

{REPORTED_USER_TITLE}

+

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

+

+ {EMAIL_ICON}{report.reportedUser?.email || NO_EMAIL_FALLBACK} +

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

{SESSION_DETAILS_TITLE}

+

{getSessionTitle(report.sessionId)}

+
+

+ {SESSION_ID_LABEL} {report.sessionId?._id || SESSION_ID_FALLBACK} +

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

+ {SERVICE_1_LABEL}{" "} + {report.sessionId.descriptionOfService1} +

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

+ {SERVICE_2_LABEL}{" "} + {report.sessionId.descriptionOfService2} +

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

{REPORT_REASON_TITLE}

+

{formatReason(report.reason)}

+
+ +
+

{DESCRIPTION_TITLE}

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

{EVIDENCE_FILES_TITLE}

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

    {EVIDENCE_FILE_PREFIX}{i + 1}

    +

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

    +
    + +
  • + ))} +
+
+ )} + + {/* FOOTER - Status badge and action buttons */} +
+ + {report.status.replace(/_/g, " ")} + + {report.status === "under_review" ? ( +
+ + + + +
+ ) : ( + + {SUBMITTED_PREFIX}{formatDate(report.createdAt)} + + )} +
+
+
+
+ ); +} \ No newline at end of file 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/components/meetingSystem/DailyMeeting.tsx b/src/components/meetingSystem/DailyMeeting.tsx index c74ab422..7cecc9eb 100644 --- a/src/components/meetingSystem/DailyMeeting.tsx +++ b/src/components/meetingSystem/DailyMeeting.tsx @@ -26,11 +26,9 @@ import { Users, Maximize, Minimize, - StickyNote, - HelpCircle + StickyNote } from 'lucide-react'; import { MeetingNotesSidebar } from './MeetingNotesSidebar'; -import { MediaDeviceTips } from './MediaDeviceTips'; interface DailyMeetingProps { roomUrl: string; @@ -59,8 +57,22 @@ const videoStyles = ` } .daily-camera-box { - min-width: 128px; - min-height: 96px; + min-width: 100px; + min-height: 75px; + aspect-ratio: 4/3; + } + + @media (min-width: 768px) { + .daily-camera-box { + min-width: 128px; + min-height: 96px; + } + } + + @media (max-width: 767px) { + .daily-camera-box { + flex-shrink: 0; + } } `; @@ -96,7 +108,6 @@ function DailyMeetingInner({ // UI State const [isFullscreen, setIsFullscreen] = useState(false); const [showNotes, setShowNotes] = useState(false); - const [showTips, setShowTips] = useState(false); const videoContainerRef = useRef(null); // Meeting control functions with enhanced error handling @@ -224,9 +235,9 @@ function DailyMeetingInner({ const screenShareParticipant = screens[0]; return ( -
+
{/* Main screen share area */} -
+
{/* Camera videos sidebar */} -
+
{participantIds.map(id => ( ))} @@ -277,37 +288,30 @@ function DailyMeetingInner({ {/* Header */} -
-
-
-

+
+
+
+

{otherUserName ? `Meeting with ${otherUserName}` : 'Meeting Room'}

{meetingDescription && ( -

{meetingDescription}

+

{meetingDescription}

)}
-
- +
+ {participantIds.length} participant{participantIds.length !== 1 ? 's' : ''}
-
- +
@@ -317,17 +321,17 @@ function DailyMeetingInner({ {/* Video Area */}
{screens.length > 0 ? ( renderScreenShare() ) : (
{participantIds.map(renderParticipant)}
@@ -336,52 +340,52 @@ function DailyMeetingInner({
{/* Controls */} -
-
+
+
@@ -394,12 +398,6 @@ function DailyMeetingInner({ isVisible={showNotes} onToggle={() => setShowNotes(!showNotes)} /> - - {/* Media Device Tips Modal */} - setShowTips(false)} - />
); } @@ -427,14 +425,14 @@ function ParticipantTile({
{/* Participant info overlay */} -
+
{participant.user_name || 'Anonymous'} {isLocal && '(You)'}
{/* Audio muted indicator */} {!participant.audio && ( -
- +
+
)} @@ -442,10 +440,10 @@ function ParticipantTile({ {!participant.video && (
-
- +
+
-

Camera off

+

Camera off

)} @@ -462,7 +460,7 @@ function CameraTile({ participantId }: { participantId: string }) { if (!participant) return null; return ( -
@@ -470,22 +468,22 @@ function CameraTile({ participantId }: { participantId: string }) {
{/* Participant name */} -
+
{participant.user_name || 'Anonymous'} {isLocal && '(You)'}
{/* Audio muted indicator */} {!participant.audio && ( -
- +
+
)} {/* Video off indicator */} {!participant.video && (
-
- +
+
)} @@ -589,17 +587,17 @@ export default function DailyMeeting({ if (joinError) { return (
-
-
-
- +
+
+
+
-

Unable to Join Meeting

-

{joinError}

+

Unable to Join Meeting

+

{joinError}

-
-

💡 Quick Solutions:

-
    +
    +

    💡 Quick Solutions:

    +
    • • Close other browser tabs using your camera/microphone
    • • Close video calling apps (Zoom, Teams, Skype, etc.)
    • • Refresh this page and allow camera permissions
    • @@ -615,7 +613,7 @@ export default function DailyMeeting({ setIsJoining(true); window.location.reload(); }} - className="w-full bg-blue-600 hover:bg-blue-700" + className="w-full bg-blue-600 hover:bg-blue-700 text-sm md:text-base" > Try Again @@ -623,7 +621,7 @@ export default function DailyMeeting({ diff --git a/src/components/meetingSystem/MediaDeviceTips.tsx b/src/components/meetingSystem/MediaDeviceTips.tsx deleted file mode 100644 index 5b9a1674..00000000 --- a/src/components/meetingSystem/MediaDeviceTips.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { AlertTriangle, X, Camera, Mic, Monitor, Smartphone } from 'lucide-react'; - -interface MediaDeviceTipsProps { - isVisible: boolean; - onClose: () => void; -} - -export function MediaDeviceTips({ isVisible, onClose }: MediaDeviceTipsProps) { - if (!isVisible) return null; - - return ( -
      -
      -
      - {/* Header */} -
      -
      - -

      Camera & Microphone Tips

      -
      - -
      - - {/* Content */} -
      -
      -

      ⚠️ Multiple Browser Limitation

      -

      - Only one browser tab can access your camera and microphone at the same time. - This is a security feature built into web browsers. -

      -
      - -
      -

      If you can't access camera/microphone:

      - -
      -
      - -
      -

      Close Other Browser Tabs

      -

      Close any other tabs that might be using your camera (video calls, other meetings)

      -
      -
      - -
      - -
      -

      Close Video Applications

      -

      Close apps like Zoom, Teams, Skype, or any camera software

      -
      -
      - -
      - -
      -

      Check Browser Permissions

      -

      Make sure you've allowed camera and microphone access for this website

      -
      -
      - -
      - -
      -

      Try Different Browser

      -

      If issues persist, try opening the meeting in a different browser or incognito mode

      -
      -
      -
      -
      - -
      -

      💡 Pro Tips:

      -
        -
      • • Use one browser for video calls only
      • -
      • • Keep other video apps closed during meetings
      • -
      • • Refresh the page if you encounter permission issues
      • -
      • • Use headphones to prevent echo
      • -
      -
      -
      - - {/* Actions */} -
      - - -
      -
      -
      -
      - ); -} diff --git a/src/components/meetingSystem/MediaDeviceWarning.tsx b/src/components/meetingSystem/MediaDeviceWarning.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/messageSystem/MeetingBox.tsx b/src/components/messageSystem/MeetingBox.tsx index bf57c842..127ea1e1 100644 --- a/src/components/messageSystem/MeetingBox.tsx +++ b/src/components/messageSystem/MeetingBox.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; import { Calendar, Plus, FileText, ChevronDown, ChevronRight } from 'lucide-react'; -import { motion, AnimatePresence } from 'framer-motion'; import CreateMeetingModal from '@/components/meetingSystem/CreateMeetingModal'; import CancelMeetingModal from '@/components/meetingSystem/CancelMeetingModal'; import MeetingList from '@/components/meetingSystem/MeetingList'; @@ -75,80 +74,6 @@ interface MeetingBoxProps { export default function MeetingBox({ chatRoomId, userId, onClose, onMeetingUpdate }: MeetingBoxProps) { const [meetings, setMeetings] = useState([]); const [loading, setLoading] = useState(true); - - // Animation variants - const containerVariants = { - hidden: { - opacity: 0, - y: 20, - scale: 0.95 - }, - visible: { - opacity: 1, - y: 0, - scale: 1, - transition: { - duration: 0.4, - ease: "easeOut", - staggerChildren: 0.1 - } - }, - exit: { - opacity: 0, - y: -20, - scale: 0.95, - transition: { - duration: 0.3, - ease: "easeIn" - } - } - }; - - const itemVariants = { - hidden: { - opacity: 0, - x: -20 - }, - visible: { - opacity: 1, - x: 0, - transition: { - duration: 0.3, - ease: "easeOut" - } - } - }; - - const headerVariants = { - hidden: { - opacity: 0, - y: -10 - }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.4, - ease: "easeOut", - delay: 0.1 - } - } - }; - - const buttonHover = { - scale: 1.05, - transition: { - duration: 0.2, - ease: "easeInOut" - } - }; - - const buttonTap = { - scale: 0.95, - transition: { - duration: 0.1 - } - }; const [userProfiles, setUserProfiles] = useState({}); const [showCreateModal, setShowCreateModal] = useState(false); const [showCancelModal, setShowCancelModal] = useState(false); @@ -714,167 +639,45 @@ ${formattedContent} if (loading && meetings.length === 0) { return ( - +
      {/* Background pulse effect */} - +
      - - +
      +

      Loading meetings... - +

      -
      +
      ); } return ( <> - {/* Animated Background Elements */} - - {/* Floating circles */} - - - - - - +
      {/* Header */} - - - +
      +
      +
      - +

      Meetings

      - - + +
      {/* Meetings List */} - +
      setShowPastMeetings(!showPastMeetings)} onToggleCancelledMeetings={() => setShowCancelledMeetings(!showCancelledMeetings)} /> - +
      {/* Saved Meeting Notes - Collapsible */} - - - +
      + + {showSavedNotes && ( +
      + +
      + )} +
      +
      +
      {/* Modals */} - - {showCreateModal && ( - - setShowCreateModal(false)} - onCreate={handleCreateMeeting} - receiverName={otherUserId ? userProfiles[otherUserId]?.firstName || 'User' : 'this user'} - /> - - )} - - {showCancelModal && meetingToCancel && ( - - { - setShowCancelModal(false); - setMeetingToCancel(null); - }} - onCancel={handleCancelMeeting} - userName={otherUserId ? userProfiles[otherUserId]?.firstName || 'User' : 'User'} - /> - - )} - + {showCreateModal && ( +
      + setShowCreateModal(false)} + onCreate={handleCreateMeeting} + receiverName={otherUserId ? userProfiles[otherUserId]?.firstName || 'User' : 'this user'} + /> +
      + )} + + {showCancelModal && meetingToCancel && ( +
      + { + setShowCancelModal(false); + setMeetingToCancel(null); + }} + onCancel={handleCancelMeeting} + userName={otherUserId ? userProfiles[otherUserId]?.firstName || 'User' : 'User'} + /> +
      + )} {/* Alert Component */} {/* Notes View Modal */} - - {showNotesModal && selectedNote && ( - - { - setShowNotesModal(false); - setSelectedNote(null); - }} - onDownload={handleDownloadNotes} - /> - - )} - + {showNotesModal && selectedNote && ( +
      + { + setShowNotesModal(false); + setSelectedNote(null); + }} + onDownload={handleDownloadNotes} + /> +
      + )} ); } \ No newline at end of file diff --git a/src/components/notificationSystem/Notification.tsx b/src/components/notificationSystem/Notification.tsx index 1cb362b5..89ac5511 100644 --- a/src/components/notificationSystem/Notification.tsx +++ b/src/components/notificationSystem/Notification.tsx @@ -24,51 +24,53 @@ const Notification: React.FC = ({ notification, onMarkAsRead return (
      -
      +
      -
      -
      +
      +
      {typeName}
      -

      {notification.description}

      +

      {notification.description}

      {formattedDate}

      -
      +
      {!notification.isRead && ( )} {notification.targetDestination && ( )} diff --git a/src/components/sessionSystem/CreateSessionModal.tsx b/src/components/sessionSystem/CreateSessionModal.tsx index 6fa77c6f..b58b6145 100644 --- a/src/components/sessionSystem/CreateSessionModal.tsx +++ b/src/components/sessionSystem/CreateSessionModal.tsx @@ -308,7 +308,7 @@ export default function CreateSessionModal({ onChange={(e) => setSelectedOtherSkill(e.target.value)} className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" > - + {otherUserSkills.map((skill) => (