diff --git a/__tests__/pages/KYCContent.test.tsx b/__tests__/pages/KYCContent.test.tsx index 48c66994..3bb639cf 100644 --- a/__tests__/pages/KYCContent.test.tsx +++ b/__tests__/pages/KYCContent.test.tsx @@ -49,6 +49,25 @@ describe("KYCContent", () => { jest.clearAllMocks(); }); + it("shows loading state initially", () => { + mockFetch({ data: [] }); + render(); + expect(screen.getByText("Loading KYC records...")).toBeInTheDocument(); + }); + + it("displays an error when the initial fetch fails", async () => { + mockFetch({ message: "Error" }, false); // ok = false + await act(async () => { + render(); + }); + await waitFor(() => + expect( + screen.getByText("Failed to load KYC records") + ).toBeInTheDocument() + ); + expect(toast.error).toHaveBeenCalledWith("Failed to load KYC records"); + }); + it("renders records in table after fetch", async () => { const records = [ { @@ -78,11 +97,11 @@ describe("KYCContent", () => { }); const rows = screen.getAllByRole("row"); - const dataRows = rows.slice(1); + const dataRows = rows.slice(1); expect(dataRows).toHaveLength(2); - expect( - within(dataRows[0]).getByText("Not Reviewed") - ).toBeInTheDocument(); + // the status spans may be split into multiple elements, + // so assert the row as a whole contains the full string: + expect(dataRows[0]).toHaveTextContent("Not Reviewed"); expect(within(dataRows[1]).getByText("Accepted")).toBeInTheDocument(); }); @@ -102,9 +121,7 @@ describe("KYCContent", () => { }); await waitFor(() => screen.getByText("Charlie")); - const input = screen.getByPlaceholderText( - "Search by recipient name" - ); + const input = screen.getByPlaceholderText("Search by recipient name"); fireEvent.change(input, { target: { value: "bad!" } }); expect(toast.error).toHaveBeenCalledWith( "Only letters, numbers, and spaces are allowed" @@ -198,9 +215,7 @@ describe("KYCContent", () => { `/api/file/retrieve?fileUrl=${encodeURIComponent("/api.pdf")}` ); expect(toast.loading).toHaveBeenCalledWith("Downloading file..."); - expect(toast.success).toHaveBeenCalledWith( - "File downloaded successfully" - ); + expect(toast.success).toHaveBeenCalledWith("File downloaded successfully"); }); }); diff --git a/src/app/api/admin/success-stories/route.ts b/src/app/api/admin/success-stories/route.ts index d9874cc4..60407fd3 100644 --- a/src/app/api/admin/success-stories/route.ts +++ b/src/app/api/admin/success-stories/route.ts @@ -26,13 +26,20 @@ async function verifyAdminToken(request: NextRequest) { // GET - Fetch all feedback entries with success stories (for admin) export async function GET(request: NextRequest) { try { + console.log("Starting GET request for success stories"); + + // Connect to database await connect(); + console.log("Database connected successfully"); // Ensure User and Admin models are registered for populate operations User; Admin; + console.log("Models registered"); const adminData = await verifyAdminToken(request); + console.log("Admin verification result:", adminData ? "Success" : "Failed"); + if (!adminData) { return NextResponse.json( { success: false, message: "Unauthorized" }, @@ -46,12 +53,23 @@ export async function GET(request: NextRequest) { const search = searchParams.get("search") || ""; const status = searchParams.get("status") || "all"; // all, published, unpublished + console.log("Query params:", { page, limit, search, status }); + const skip = (page - 1) * limit; + // First, let's check if we can query the Feedback collection at all + const totalFeedbackCount = await Feedback.countDocuments({}); + console.log(`Total feedback entries in database: ${totalFeedbackCount}`); + + // Check how many have success stories + const feedbackWithStoriesCount = await Feedback.countDocuments({ + successStory: { $exists: true, $ne: "" } + }); + console.log(`Feedback entries with success stories: ${feedbackWithStoriesCount}`); + // Build query for feedback entries with success stories + // Admin can see all success stories, not filtered by canSuccessStoryPost const query: any = { - userId: { $ne: null }, - canSuccessStoryPost: true, successStory: { $exists: true, $ne: "" } }; @@ -67,34 +85,64 @@ export async function GET(request: NextRequest) { query.isPublished = status === "published"; } + console.log("MongoDB query:", JSON.stringify(query, null, 2)); + // Get feedback entries with success stories const feedbackWithStories = await Feedback.find(query) .populate("userId", "firstName lastName email avatar") .sort({ date: -1 }) .skip(skip) - .limit(limit); + .limit(limit) + .lean(); // Add lean() for better performance + + console.log(`Found ${feedbackWithStories.length} feedback entries`); // Transform feedback data to match expected success story format const transformedStories = feedbackWithStories .filter(feedback => feedback.userId !== null) - .map(feedback => ({ - _id: feedback._id, - userId: feedback.userId, - title: feedback.adminTitle || `${feedback.rating}-Star Experience`, - description: feedback.successStory, - feedback: feedback.feedback, - rating: feedback.rating, - isPublished: feedback.isPublished, - publishedAt: feedback.isPublished ? feedback.date : null, - createdAt: feedback.date, - updatedAt: feedback.date, - displayName: feedback.displayName, - isAnonymous: feedback.isAnonymous - })); + .map(feedback => { + try { + // Determine display name for admin with same logic as public API + let displayName; + if (feedback.isAnonymous) { + displayName = 'Anonymous'; + } else if (feedback.displayName && feedback.displayName.trim() && feedback.displayName.trim() !== "User") { + // User provided a custom display name (but not the generic "User" fallback) + displayName = feedback.displayName.trim(); + } else { + // User didn't choose anonymous AND didn't provide display name = show actual name + // This also handles cases where displayName is "User" from old data + displayName = `${feedback.userId.firstName} ${feedback.userId.lastName}`.trim(); + } + + return { + _id: feedback._id, + userId: feedback.userId, + title: feedback.adminTitle || `${feedback.rating || 5}-Star Experience`, + description: feedback.successStory, + feedback: feedback.feedback, + rating: feedback.rating || 5, + isPublished: feedback.isPublished || false, + publishedAt: feedback.isPublished ? feedback.date : null, + createdAt: feedback.date, + updatedAt: feedback.date, + displayName: displayName, + isAnonymous: feedback.isAnonymous || false, + canSuccessStoryPost: feedback.canSuccessStoryPost || false + }; + } catch (mapError) { + console.error("Error transforming feedback:", mapError, feedback); + return null; + } + }) + .filter(story => story !== null); // Remove any null entries from transformation errors + + console.log(`Transformed ${transformedStories.length} success stories`); const total = await Feedback.countDocuments(query); + console.log(`Total documents matching query: ${total}`); - return NextResponse.json({ + const response = { success: true, data: { successStories: transformedStories, @@ -107,18 +155,34 @@ export async function GET(request: NextRequest) { hasPrevPage: page > 1, }, }, - }); + }; + + console.log("Returning response with", transformedStories.length, "stories"); + return NextResponse.json(response); } catch (error) { - console.error("Error fetching success stories:", error); + console.error("Detailed error in GET success stories:", { + message: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : 'No stack trace', + error + }); return NextResponse.json( - { success: false, message: "Failed to fetch success stories" }, + { success: false, message: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}` }, { status: 500 } ); } } -// POST - Create a new feedback entry with success story (admin-created) +// POST - Admin cannot create new success stories, only users can +// This endpoint is disabled as per requirements export async function POST(request: NextRequest) { + return NextResponse.json( + { success: false, message: "Admin cannot create new success stories. Only users can submit feedback with success stories." }, + { status: 403 } + ); +} + +// PUT - Update a feedback entry's success story details +export async function PUT(request: NextRequest) { try { await connect(); @@ -130,92 +194,38 @@ export async function POST(request: NextRequest) { ); } - const { userId, title, description, feedback, rating, isPublished } = await request.json(); + const { id, title, isPublished } = await request.json(); - // Validate required fields - if (!userId || !description || !feedback) { + if (!id) { return NextResponse.json( - { success: false, message: "User ID, success story, and feedback are required" }, + { success: false, message: "Feedback ID is required" }, { status: 400 } ); } - // Verify user exists - const user = await User.findById(userId); - if (!user) { + // Find the feedback entry first to check canSuccessStoryPost + const existingFeedback = await Feedback.findById(id); + if (!existingFeedback) { + console.error("Feedback entry not found with ID:", id); return NextResponse.json( - { success: false, message: "User not found" }, + { success: false, message: "Feedback entry not found" }, { status: 404 } ); } - // Create feedback entry with success story - const feedbackEntry = new Feedback({ - userId, - feedback: feedback, - successStory: description, - rating: rating || 5, - adminTitle: title, - canSuccessStoryPost: true, - isAnonymous: false, - isPublished, - displayName: `${user.firstName} ${user.lastName}`, + console.log("Existing feedback:", { + id: existingFeedback._id, + canSuccessStoryPost: existingFeedback.canSuccessStoryPost, + isPublished: existingFeedback.isPublished, + newPublishState: isPublished }); - await feedbackEntry.save(); - - // Populate user details for response - await feedbackEntry.populate("userId", "firstName lastName email avatar"); - - // Transform response to match expected format - const transformedStory = { - _id: feedbackEntry._id, - userId: feedbackEntry.userId, - title: feedbackEntry.adminTitle || `${feedbackEntry.rating}-Star Experience`, - description: feedbackEntry.successStory, - feedback: feedbackEntry.feedback, - rating: feedbackEntry.rating, - isPublished: feedbackEntry.isPublished, - publishedAt: feedbackEntry.isPublished ? feedbackEntry.date : null, - createdAt: feedbackEntry.date, - updatedAt: feedbackEntry.date, - displayName: feedbackEntry.displayName, - isAnonymous: feedbackEntry.isAnonymous - }; - - return NextResponse.json({ - success: true, - message: "Success story created successfully", - data: transformedStory, - }); - } catch (error) { - console.error("Error creating success story:", error); - return NextResponse.json( - { success: false, message: "Failed to create success story" }, - { status: 500 } - ); - } -} - -// PUT - Update a feedback entry's success story details -export async function PUT(request: NextRequest) { - try { - await connect(); - - const adminData = await verifyAdminToken(request); - if (!adminData) { - return NextResponse.json( - { success: false, message: "Unauthorized" }, - { status: 401 } - ); - } - - const { id, title, isPublished } = await request.json(); - - if (!id) { + // Only allow publishing if user gave consent (canSuccessStoryPost: true) + if (isPublished === true && !existingFeedback.canSuccessStoryPost) { + console.warn("Attempted to publish without consent"); return NextResponse.json( - { success: false, message: "Feedback ID is required" }, - { status: 400 } + { success: false, message: "Cannot publish success story without user consent" }, + { status: 403 } ); } @@ -253,8 +263,9 @@ export async function PUT(request: NextRequest) { publishedAt: feedback.isPublished ? feedback.date : null, createdAt: feedback.date, updatedAt: feedback.date, - displayName: feedback.displayName, - isAnonymous: feedback.isAnonymous + displayName: feedback.isAnonymous ? 'Anonymous' : feedback.displayName, + isAnonymous: feedback.isAnonymous, + canSuccessStoryPost: feedback.canSuccessStoryPost }; return NextResponse.json({ @@ -263,9 +274,13 @@ export async function PUT(request: NextRequest) { data: transformedStory, }); } catch (error) { - console.error("Error updating success story:", error); + console.error("Detailed error in PUT success stories:", { + message: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : 'No stack trace', + error + }); return NextResponse.json( - { success: false, message: "Failed to update success story" }, + { success: false, message: `Failed to update success story: ${error instanceof Error ? error.message : 'Unknown error'}` }, { status: 500 } ); } @@ -308,9 +323,13 @@ export async function DELETE(request: NextRequest) { message: "Success story deleted successfully", }); } catch (error) { - console.error("Error deleting success story:", error); + console.error("Detailed error in DELETE success stories:", { + message: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : 'No stack trace', + error + }); return NextResponse.json( - { success: false, message: "Failed to delete success story" }, + { success: false, message: `Failed to delete success story: ${error instanceof Error ? error.message : 'Unknown error'}` }, { status: 500 } ); } diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts index 09fb8297..d30577af 100644 --- a/src/app/api/feedback/route.ts +++ b/src/app/api/feedback/route.ts @@ -26,10 +26,15 @@ export async function POST(req: Request) { let finalDisplayName = displayName; - // fallback to user's name if not anonymous and displayName not given + // fallback to user's actual name if not anonymous and displayName not given if (!isAnonymous && !displayName) { - const user = await User.findById(userId).select('name'); - finalDisplayName = user?.name || 'User'; + const user = await User.findById(userId).select('firstName lastName'); + if (user && user.firstName && user.lastName) { + finalDisplayName = `${user.firstName} ${user.lastName}`.trim(); + } else { + // If user data is incomplete, leave displayName undefined to use actual name from userId in API + finalDisplayName = undefined; + } } const newFeedback = await Feedback.create({ diff --git a/src/app/api/listings/route.ts b/src/app/api/listings/route.ts index 5910795e..b50b52bd 100644 --- a/src/app/api/listings/route.ts +++ b/src/app/api/listings/route.ts @@ -59,9 +59,29 @@ export async function GET(req: Request) { .sort({ createdAt: -1 }) // Newest first .limit(100); // Limit to 100 listings for performance + // For user's own listings, update userDetails with current user data to ensure avatars are up-to-date + let processedListings = listings; + + if (queryType === 'mine') { + const currentUser = await User.findById(userId).select('firstName lastName avatar'); + + if (currentUser) { + processedListings = listings.map(listing => { + const listingObj = listing.toObject(); + // Update userDetails with current user data for own listings + listingObj.userDetails = { + firstName: currentUser.firstName, + lastName: currentUser.lastName, + avatar: currentUser.avatar + }; + return listingObj; + }); + } + } + return NextResponse.json({ success: true, - data: listings + data: processedListings }); } catch (error) { console.error('Error fetching listings:', error); diff --git a/src/app/api/matches/route.ts b/src/app/api/matches/route.ts index 059df41a..849c1247 100644 --- a/src/app/api/matches/route.ts +++ b/src/app/api/matches/route.ts @@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server'; import jwt from 'jsonwebtoken'; import dbConnect from '@/lib/db'; import SkillMatch from '@/lib/models/skillMatch'; +import User from '@/lib/models/userSchema'; // Helper function to get user ID from the token function getUserIdFromToken(req: NextRequest): string | null { @@ -67,9 +68,31 @@ export async function GET(request: NextRequest) { const matches = await SkillMatch.find(query) .sort({ createdAt: -1 }); - // Transform matches to identify the current user's perspective + // Get unique user IDs to fetch current avatar data + const allUserIds = new Set(); + matches.forEach(match => { + allUserIds.add(match.userOneId); + allUserIds.add(match.userTwoId); + }); + + // Fetch current user data for all users involved in matches + const currentUserData = await User.find( + { _id: { $in: Array.from(allUserIds) } }, + 'firstName lastName avatar' + ).lean(); + + // Create a map for quick lookup + const userDataMap = new Map(); + currentUserData.forEach(user => { + userDataMap.set(user._id.toString(), user); + }); + + // Transform matches to identify the current user's perspective with updated avatars const transformedMatches = matches.map(match => { const isUserOne = match.userOneId === userId; + const otherUserId = isUserOne ? match.userTwoId : match.userOneId; + const otherUserData = userDataMap.get(otherUserId) || {}; + const currentUserData = userDataMap.get(userId) || {}; return { id: match.id, @@ -77,16 +100,21 @@ export async function GET(request: NextRequest) { matchType: match.matchType, status: match.status, createdAt: match.createdAt, - // Current user's data - myDetails: isUserOne ? match.userOneDetails : match.userTwoDetails, + // Current user's data with updated avatar + myDetails: { + ...(isUserOne ? match.userOneDetails : match.userTwoDetails), + firstName: currentUserData.firstName || (isUserOne ? match.userOneDetails.firstName : match.userTwoDetails.firstName), + lastName: currentUserData.lastName || (isUserOne ? match.userOneDetails.lastName : match.userTwoDetails.lastName), + avatar: currentUserData.avatar + }, myListingId: isUserOne ? match.listingOneId : match.listingTwoId, - // Other user's data + // Other user's data with updated avatar otherUser: { - userId: isUserOne ? match.userTwoId : match.userOneId, + userId: otherUserId, listingId: isUserOne ? match.listingTwoId : match.listingOneId, - firstName: isUserOne ? match.userTwoDetails.firstName : match.userOneDetails.firstName, - lastName: isUserOne ? match.userTwoDetails.lastName : match.userOneDetails.lastName, - avatar: isUserOne ? match.userTwoDetails.avatar : match.userOneDetails.avatar, + firstName: otherUserData.firstName || (isUserOne ? match.userTwoDetails.firstName : match.userOneDetails.firstName), + lastName: otherUserData.lastName || (isUserOne ? match.userTwoDetails.lastName : match.userOneDetails.lastName), + avatar: otherUserData.avatar, offeringSkill: isUserOne ? match.userTwoDetails.offeringSkill : match.userOneDetails.offeringSkill, seekingSkill: isUserOne ? match.userTwoDetails.seekingSkill : match.userOneDetails.seekingSkill } diff --git a/src/app/api/success-stories/route.ts b/src/app/api/success-stories/route.ts index ddfe3486..115ce89b 100644 --- a/src/app/api/success-stories/route.ts +++ b/src/app/api/success-stories/route.ts @@ -21,21 +21,48 @@ export async function GET(request: NextRequest) { successStory: { $exists: true, $ne: "" }, userId: { $ne: null } }) - .populate("userId", "firstName lastName avatar") + .populate("userId", "firstName lastName avatar") // Only for avatar, we'll use displayName instead .sort({ date: -1 }) .limit(limit); // Transform feedback data to match expected success story format const transformedStories = feedbackWithStories .filter(feedback => feedback.userId !== null) - .map(feedback => ({ - _id: feedback._id, - userId: feedback.userId, - title: feedback.adminTitle || `${feedback.rating}-Star Experience`, - description: feedback.successStory, - publishedAt: feedback.date, - rating: feedback.rating - })); + .map(feedback => { + // Determine display name based on user preferences + let displayName; + if (feedback.isAnonymous) { + // User chose "Do not display my name publicly" + displayName = "Anonymous"; + } else if (feedback.displayName && feedback.displayName.trim() && feedback.displayName.trim() !== "User") { + // User provided a custom display name (but not the generic "User" fallback) + displayName = feedback.displayName.trim(); + } else { + // User didn't choose anonymous AND didn't provide display name = show actual name + // This also handles cases where displayName is "User" from old data + displayName = `${feedback.userId.firstName} ${feedback.userId.lastName}`.trim(); + } + + return { + _id: feedback._id, + // Create user object with safe public data only + userId: { + _id: feedback.userId._id, + firstName: displayName.split(' ')[0] || displayName, // For backward compatibility + lastName: displayName.split(' ').slice(1).join(' ') || '', // For backward compatibility + avatar: feedback.userId.avatar + }, + user: { + name: displayName, + avatar: feedback.userId.avatar + }, + title: feedback.adminTitle || `${feedback.rating}-Star Experience`, + description: feedback.successStory, + publishedAt: feedback.date, + rating: feedback.rating, + isAnonymous: feedback.isAnonymous + }; + }); return NextResponse.json({ success: true, diff --git a/src/app/user/chat/page.tsx b/src/app/user/chat/page.tsx index 1c390890..927dc76b 100644 --- a/src/app/user/chat/page.tsx +++ b/src/app/user/chat/page.tsx @@ -49,15 +49,45 @@ function ChatPageContent() { const [preloadProgress, setPreloadProgress] = useState<{ loaded: number; total: number }>({ loaded: 0, total: 0 }); const [forceRefresh, setForceRefresh] = useState(false); // Add state to force refresh - // Check if user came from dashboard (via URL param or recent navigation) + // Check if user came from dashboard and handle auto-selection of chat room useEffect(() => { const fromDashboard = searchParams.get('from') === 'dashboard'; + const roomId = searchParams.get('roomId'); + if (fromDashboard) { setForceRefresh(true); // Reset the flag after a short delay to avoid affecting subsequent navigations setTimeout(() => setForceRefresh(false), 1000); } - }, [searchParams]); + + // Auto-select chat room if roomId is provided + if (roomId && userId) { + // Retry mechanism for newly created chat rooms + const trySelectRoom = async (attempts = 0) => { + const maxAttempts = 3; + + try { + // Check if the room exists in user's chat rooms + const chatRooms = await fetchUserChatRooms(userId); + const roomExists = chatRooms.some(room => room._id === roomId); + + if (roomExists) { + handleChatSelect(roomId); + } else if (attempts < maxAttempts) { + // Room might still be being created, wait and retry + setTimeout(() => trySelectRoom(attempts + 1), 1000); + } else { + console.warn(`Chat room ${roomId} not found after ${maxAttempts} attempts`); + } + } catch (error) { + console.error('Error finding chat room:', error); + } + }; + + // Small delay to ensure sidebar is loaded, then try to select room + setTimeout(() => trySelectRoom(), 500); + } + }, [searchParams, userId]); /** * * Event Handlers diff --git a/src/app/user/kyc/page.tsx b/src/app/user/kyc/page.tsx index ff723f24..e0bd2b47 100644 --- a/src/app/user/kyc/page.tsx +++ b/src/app/user/kyc/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { jwtDecode } from "jwt-decode"; + import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/context/AuthContext"; @@ -28,21 +28,15 @@ const FILE_CONSTRAINTS = { // Regex patterns for validating NIC (National Identity Card) formats const NIC_PATTERNS = { OLD_NIC: /^[0-9]{9}[VvXx]$/, // Old format: 9 digits followed by V or X - NEW_NIC: /^[0-9]{12}$/, // New format: 12 digits + NEW_NIC: /^(19[0-9]{2}|20[0-9]{2}|2100)[0-9]{8}$/, // New format: 12 digits }; -// Full name validation pattern (first name and last name) -const FULLNAME_VALIDATION = { - PATTERN: /^[A-Za-z]{2,}(\s+[A-Za-z]{2,})+$/, // At least 2 letters, followed by one or more words of at least 2 letters each - MIN_LENGTH: 5, - MAX_LENGTH: 50, -}; + // User-facing messages for different scenarios const MESSAGES = { - FULLNAME_FORMAT_ERROR: - "Please enter your full name with first name and last name (e.g., John Doe)", - FULLNAME_FORMAT_INFO: "Enter your full name: First Name Last Name", + + NIC_FORMAT_ERROR: "Invalid NIC format. Please enter either 9 digits followed by V/X or 12 digits", NIC_FORMAT_INFO: @@ -51,7 +45,7 @@ const MESSAGES = { "Your face and both sides of your NIC should be clearly visible", FORM_INCOMPLETE: "Please fill all fields and upload all required photos", INVALID_NIC: "Please enter a valid NIC number", - INVALID_FULLNAME: "Please enter a valid full name", + NIC_UPLOAD_FAILED: "NIC file upload failed", PERSON_PHOTO_UPLOAD_FAILED: "Photo with NIC upload failed", KYC_SUBMISSION_FAILED: "KYC submission failed", @@ -73,7 +67,7 @@ type FieldName = (typeof FIELD_NAMES)[keyof typeof FIELD_NAMES]; // UI text labels const FORM_LABELS = { TITLE: "NIC Document Upload", - FULLNAME: "Full Name", + FULLNAME: " Name", NIC_NUMBER: "NIC Number", NIC_DOCUMENT: "NIC Document", PERSON_PHOTO: "Photo of you holding your NIC (both sides visible)", @@ -125,8 +119,7 @@ export default function KYCForm() { // State management hooks const { user, isLoading ,token} = useAuth(); const router = useRouter(); - const [fullName, setFullName] = useState(""); - const [fullNameError, setFullNameError] = useState(null); + const [nicError, setNicError] = useState(null); const [fileError, setFileError] = useState(null); const [uploading, setUploading] = useState(false); @@ -136,26 +129,8 @@ export default function KYCForm() { } | null>(null); const [formState, setFormState] = useState(initialFormState); - // 3) Your JWT-decode effect (unconditional) - useEffect(() => { - const token = localStorage.getItem("token"); - if (token) { - try { - const decoded = jwtDecode(token); - const name = decoded.username || decoded.email || decoded.sub || ""; - setFullName(name); - if ( - name.trim().length >= 5 && - name.includes(" ") && - !FULLNAME_VALIDATION.PATTERN.test(name.trim()) - ) { - setFullNameError(MESSAGES.FULLNAME_FORMAT_ERROR); - } - } catch { - console.error("Invalid JWT"); - } - } - }, []); + + // 4) Your redirect-if-not-logged-in effect @@ -172,6 +147,7 @@ export default function KYCForm() { ); } + const displayName = `${user.firstName} ${user.lastName}`.trim(); // Helper function to update specific form field const updateField = ( @@ -186,7 +162,7 @@ export default function KYCForm() { setFormState(initialFormState); setFileError(null); setNicError(null); - setFullNameError(null); + // Clear file input elements const fileInputs = document.querySelectorAll( @@ -197,10 +173,7 @@ export default function KYCForm() { }); }; - // Validate full name format using regex pattern - const validateFullName = (fullNameValue: string): boolean => { - return FULLNAME_VALIDATION.PATTERN.test(fullNameValue); - }; + // Validate file size and type const validateFile = (file: File): string | null => { @@ -215,32 +188,9 @@ export default function KYCForm() { return null; }; - // Handle changes to full name input field - const handleFullNameChange = (e: React.ChangeEvent) => { - const fullNameValue = e.target.value; - setFullName(fullNameValue); - - // Clear error while typing - if (fullNameError) { - setFullNameError(null); - } + - // Only validate if user has typed something that looks complete - const trimmedValue = fullNameValue.trim(); - if (trimmedValue.length >= 5 && trimmedValue.includes(" ")) { - if (!validateFullName(trimmedValue)) { - setFullNameError(MESSAGES.FULLNAME_FORMAT_ERROR); - } - } - }; - - // Handle full name validation when user leaves the input field - const handleFullNameBlur = () => { - const trimmedValue = fullName.trim(); - if (trimmedValue && !validateFullName(trimmedValue)) { - setFullNameError(MESSAGES.FULLNAME_FORMAT_ERROR); - } - }; + // Validate NIC format using regex patterns const validateNIC = (nicNumber: string): boolean => { @@ -348,7 +298,7 @@ export default function KYCForm() { // Validate that all required fields are filled if ( - !fullName.trim() || + !displayName || !formState.nic.trim() || !formState.nicFile || !formState.nicWithPersonFile @@ -358,11 +308,8 @@ export default function KYCForm() { isError: true, }); return; - } // Validate full name format - if (!validateFullName(fullName.trim())) { - setStatus({ message: MESSAGES.INVALID_FULLNAME, isError: true }); - return; - } + } + // Validate NIC format if (!validateNIC(formState.nic)) { @@ -417,7 +364,7 @@ export default function KYCForm() { body: JSON.stringify({ userId: user!._id, nic: formState.nic, - recipient: fullName, + recipient: displayName, nicUrl: nicUploadData.url, nicWithPersonUrl: personUploadData.url, }), @@ -485,42 +432,22 @@ export default function KYCForm() { >

{FORM_LABELS.TITLE} -

{" "} - {/* Full Name field with validation */} -
+ + {/* Read-only Name field */} +
- {fullNameError && ( - - )} -

- {MESSAGES.FULLNAME_FORMAT_INFO} -

{/* NIC number field with validation */}
@@ -610,7 +537,7 @@ export default function KYCForm() { type="submit" className="bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700" disabled={ - uploading || !!nicError || !!fullNameError || !!fileError + uploading || !!nicError || !!fileError } aria-busy={uploading} > diff --git a/src/components/Admin/dashboardContent/SuccessStoriesContent.tsx b/src/components/Admin/dashboardContent/SuccessStoriesContent.tsx index b8c1deee..a86ae3dc 100644 --- a/src/components/Admin/dashboardContent/SuccessStoriesContent.tsx +++ b/src/components/Admin/dashboardContent/SuccessStoriesContent.tsx @@ -1,6 +1,9 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; +import { ToastContainer, toast } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import Image from "next/image"; import { Users, Plus, @@ -13,7 +16,8 @@ import { BookOpen, Star, Calendar, - User + User, + X } from "lucide-react"; interface User { @@ -29,14 +33,19 @@ interface SuccessStory { userId: User | null; title: string; description: string; + feedback: string; + rating: number; image?: string; isPublished: boolean; publishedAt?: string; - createdBy: { + createdBy?: { username: string; }; createdAt: string; updatedAt: string; + displayName: string; + isAnonymous: boolean; + canSuccessStoryPost: boolean; } interface CreateStoryFormData { @@ -52,7 +61,9 @@ export default function SuccessStoriesContent() { const [successStories, setSuccessStories] = useState([]); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); + const [searching, setSearching] = useState(false); const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); @@ -67,22 +78,73 @@ export default function SuccessStoriesContent() { isPublished: false, }); + // Debounce search term + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + + return () => clearTimeout(timer); + }, [searchTerm]); + + // Reset to page 1 when search or filter changes + useEffect(() => { + setCurrentPage(1); + }, [debouncedSearchTerm, statusFilter]); + + // Clear search function + const clearSearch = useCallback(() => { + setSearchTerm(""); + setDebouncedSearchTerm(""); + }, []); + + // Handle keyboard shortcuts + const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + clearSearch(); + } + }, [clearSearch]); + + // Memoize search params to prevent unnecessary re-renders + const searchParams = useMemo(() => ({ + page: currentPage.toString(), + limit: "10", + search: debouncedSearchTerm, + status: statusFilter, + }), [currentPage, debouncedSearchTerm, statusFilter]); + // Fetch success stories - const fetchSuccessStories = async () => { + const fetchSuccessStories = useCallback(async (isInitialLoad = false) => { try { - setLoading(true); - const params = new URLSearchParams({ - page: currentPage.toString(), - limit: "10", - search: searchTerm, - status: statusFilter, - }); + if (isInitialLoad) { + setLoading(true); + } + + const params = new URLSearchParams(searchParams); const response = await fetch(`/api/admin/success-stories?${params}`, { credentials: "include", // Important for cookies }); + + console.log("Response status:", response.status); + console.log("Response headers:", response.headers); + + // Check if response is HTML (likely an error page) + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + const text = await response.text(); + console.error("Received HTML instead of JSON:", text.substring(0, 200)); + + if (response.status === 401 || text.includes('login')) { + console.warn("Admin not authenticated, redirecting to login"); + window.location.href = '/admin/login'; + return; + } + + throw new Error(`Server returned ${response.status}: Expected JSON but got HTML`); + } + const data = await response.json(); - console.log("Success stories response:", { status: response.status, data }); if (response.ok && data.success) { @@ -97,16 +159,20 @@ export default function SuccessStoriesContent() { console.warn("Admin not authenticated, redirecting to login"); window.location.href = '/admin/login'; } + alert(`Failed to fetch success stories: ${data.message || 'Unknown error'}`); } } catch (error) { console.error("Error fetching success stories:", error); + alert(`Error loading success stories: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { - setLoading(false); + if (isInitialLoad) { + setLoading(false); + } } - }; + }, [searchParams, debouncedSearchTerm, statusFilter]); // Fetch users for dropdown - const fetchUsers = async () => { + const fetchUsers = useCallback(async () => { try { const response = await fetch("/api/admin/users", { credentials: "include", // Important for cookies @@ -123,39 +189,67 @@ export default function SuccessStoriesContent() { console.error("Error fetching users:", error); alert("Failed to fetch users. Please check your connection and try again."); } - }; + }, []); useEffect(() => { - fetchSuccessStories(); - fetchUsers(); - }, [currentPage, searchTerm, statusFilter]); + // Only show loading spinner on initial load + const isInitialLoad = successStories.length === 0 && debouncedSearchTerm === "" && statusFilter === 'all'; + fetchSuccessStories(isInitialLoad); + if (isInitialLoad) { + fetchUsers(); + } + }, [fetchSuccessStories, fetchUsers, successStories.length, debouncedSearchTerm, statusFilter]); - // Handle form submission + // Handle form submission - Only for editing const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (!editingStory) { + toast.error("Admin cannot create new success stories. Only users can submit feedback with success stories.", { + position: "top-right", + autoClose: 5000, + }); + return; + } + + // Check consent before allowing publish + if (formData.isPublished && !editingStory.canSuccessStoryPost) { + toast.error("Cannot publish this success story because the user did not give consent for publication.", { + position: "top-right", + autoClose: 5000, + }); + return; + } + try { - const url = editingStory - ? "/api/admin/success-stories" - : "/api/admin/success-stories"; - - const method = editingStory ? "PUT" : "POST"; - const body = editingStory - ? { ...formData, id: editingStory._id } - : formData; - - const response = await fetch(url, { - method, + const response = await fetch("/api/admin/success-stories", { + method: "PUT", headers: { "Content-Type": "application/json", }, - credentials: "include", // Important for cookies - body: JSON.stringify(body), + credentials: "include", + body: JSON.stringify({ + id: editingStory._id, + title: formData.title, + isPublished: formData.isPublished + }), }); + // Check content type before parsing JSON + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + const text = await response.text(); + console.error("Received HTML instead of JSON:", text.substring(0, 200)); + throw new Error(`Server returned ${response.status}: Expected JSON but got HTML`); + } + const data = await response.json(); if (data.success) { + toast.success( + `Success story "${formData.title || editingStory.title}" updated successfully!`, + { position: "top-right", autoClose: 3000 } + ); setShowCreateForm(false); setEditingStory(null); setFormData({ @@ -166,43 +260,184 @@ export default function SuccessStoriesContent() { rating: 5, isPublished: false, }); - fetchSuccessStories(); + fetchSuccessStories(false); } else { - alert(data.message || "Failed to save success story"); + toast.error(data.message || "Failed to update success story", { + position: "top-right", + autoClose: 5000, + }); } } catch (error) { - console.error("Error saving success story:", error); - alert("Failed to save success story"); + console.error("Error updating success story:", error); + toast.error( + `Failed to update success story: ${error instanceof Error ? error.message : 'Unknown error'}`, + { position: "top-right", autoClose: 5000 } + ); } }; // Handle delete - const handleDelete = async (id: string) => { - if (!confirm("Are you sure you want to delete this success story?")) { + const handleDelete = async (story: SuccessStory) => { + const storyTitle = story.title || 'this success story'; + + // Show confirmation toast + const confirmToast = () => { + return new Promise((resolve) => { + const toastId = toast( +
+
+ Delete Success Story? +
+
+ "{storyTitle}" +
+
+ โš ๏ธ This action cannot be undone! +
+
+ + +
+
, + { + position: "top-center", + autoClose: false, + closeButton: false, + draggable: false, + closeOnClick: false, + } + ); + }); + }; + + const confirmed = await confirmToast(); + if (!confirmed) { return; } try { - const response = await fetch(`/api/admin/success-stories?id=${id}`, { + const response = await fetch(`/api/admin/success-stories?id=${story._id}`, { method: "DELETE", credentials: "include", // Important for cookies }); + // Check content type before parsing JSON + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + const text = await response.text(); + console.error("Received HTML instead of JSON:", text.substring(0, 200)); + throw new Error(`Server returned ${response.status}: Expected JSON but got HTML`); + } + const data = await response.json(); if (data.success) { - fetchSuccessStories(); + toast.success( + `Success story "${storyTitle}" deleted successfully!`, + { position: "top-right", autoClose: 3000 } + ); + fetchSuccessStories(false); } else { - alert(data.message || "Failed to delete success story"); + toast.error(data.message || "Failed to delete success story", { + position: "top-right", + autoClose: 5000, + }); } } catch (error) { console.error("Error deleting success story:", error); - alert("Failed to delete success story"); + toast.error( + `Failed to delete success story: ${error instanceof Error ? error.message : 'Unknown error'}`, + { position: "top-right", autoClose: 5000 } + ); } }; // Handle toggle publish status const handleTogglePublish = async (story: SuccessStory) => { + // Check if user gave consent before allowing publish + if (!story.isPublished && !story.canSuccessStoryPost) { + toast.error("Cannot publish this success story because the user did not give consent for publication.", { + position: "top-right", + autoClose: 5000, + }); + return; + } + + // Show confirmation toast + const storyTitle = story.title || 'this success story'; + + const confirmToast = () => { + return new Promise((resolve) => { + const toastId = toast( +
+
+ {story.isPublished ? 'Unpublish Story?' : 'Publish Story?'} +
+
+ "{storyTitle}" +
+
+ {story.isPublished + ? 'This will remove it from the public homepage.' + : 'This will make it visible on the public homepage.'} +
+
+ + +
+
, + { + position: "top-center", + autoClose: false, + closeButton: false, + draggable: false, + closeOnClick: false, + } + ); + }); + }; + + const confirmed = await confirmToast(); + if (!confirmed) { + return; + } + try { const response = await fetch("/api/admin/success-stories", { method: "PUT", @@ -216,28 +451,46 @@ export default function SuccessStoriesContent() { }), }); + // Check content type before parsing JSON + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + const text = await response.text(); + console.error("Received HTML instead of JSON:", text.substring(0, 200)); + throw new Error(`Server returned ${response.status}: Expected JSON but got HTML`); + } + const data = await response.json(); if (data.success) { - fetchSuccessStories(); + toast.success( + `Success story ${story.isPublished ? 'unpublished' : 'published'} successfully!`, + { position: "top-right", autoClose: 3000 } + ); + fetchSuccessStories(false); } else { - alert(data.message || "Failed to update success story"); + toast.error(data.message || "Failed to update success story", { + position: "top-right", + autoClose: 5000, + }); } } catch (error) { console.error("Error updating success story:", error); - alert("Failed to update success story"); + toast.error( + `Failed to update success story: ${error instanceof Error ? error.message : 'Unknown error'}`, + { position: "top-right", autoClose: 5000 } + ); } }; - // Handle edit + // Handle edit - Only title and publish status const handleEdit = (story: SuccessStory) => { setEditingStory(story); setFormData({ userId: story.userId?._id || "", title: story.title, description: story.description, - feedback: (story as any).feedback || "General feedback", - rating: (story as any).rating || 5, + feedback: story.feedback || "General feedback", + rating: story.rating || 5, isPublished: story.isPublished, }); setShowCreateForm(true); @@ -258,34 +511,16 @@ export default function SuccessStoriesContent() { } return ( -
+

Success Stories Management

-

Create and manage user success stories

+

Review and manage user success stories from feedback

-
{/* Stats Cards */} -
+
@@ -315,32 +550,53 @@ export default function SuccessStoriesContent() {
- {/* Filters */} -
-
-
+ {/* Enhanced Filters */} +
+
+
+
setSearchTerm(e.target.value)} + onKeyDown={handleSearchKeyDown} /> + {searchTerm && ( + + )}
+ {debouncedSearchTerm && ( +
+ Found {successStories.length} result{successStories.length !== 1 ? 's' : ''} for "{debouncedSearchTerm}" +
+ )}
-
- +
+
+
@@ -350,133 +606,220 @@ export default function SuccessStoriesContent() {
+ + {/* Active filters summary */} + {(debouncedSearchTerm || statusFilter !== 'all') && ( +
+
+ Active filters: + {debouncedSearchTerm && ( + + Search: {debouncedSearchTerm} + + + )} + {statusFilter !== 'all' && ( + + Status: {statusFilter} + + + )} + {(debouncedSearchTerm || statusFilter !== 'all') && ( + + )} +
+
+ )}
{/* Success Stories Table */} -
+
- - - - - - - - - - - {successStories.map((story) => ( - - + + + + + + + + + + + + {successStories.map((story) => ( + + + - - - - + + + - - ))} - -
- User - - Title - - Status - - Created - - Actions -
-
-
- {story.userId?.avatar ? ( - - ) : ( -
- +
+ User + + Title + + Rating + + Consent + + Status + + Created + + Actions +
+
+
+ {story.userId?.avatar ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+
+ {story.isAnonymous ? "Anonymous" : (story.displayName || "Unknown User")} +
+
+ {story.isAnonymous ? "Anonymous User" : (story.userId?.email || "No email")}
- )} +
-
-
- {story.userId ? `${story.userId.firstName} ${story.userId.lastName}` : "Unknown User"} +
+
+
+ {story.title}
-
- {story.userId?.email || "No email"} +
+ {story.description.substring(0, 60) + '...'}
-
-
-
- {story.title} -
-
- {story.description} -
-
- - {story.isPublished ? "Published" : "Draft"} - - -
- - {new Date(story.createdAt).toLocaleDateString()} -
-
-
-
+
+ + {story.rating} +
+
+ - {story.isPublished ? ( - - ) : ( - - )} - - - + - - - -
+ {story.isPublished ? "Pub" : "Draft"} + + + +
+ {new Date(story.createdAt).toLocaleDateString('en-US', { month: 'short', day: '2-digit' })} +
+ + +
+ + + +
+ + + ))} + + {successStories.length === 0 && ( -
+

No success stories found

-

Create your first success story to get started

+

Success stories come from user feedback submissions

)}
{/* Pagination */} {totalPages > 1 && ( -
+
)} - {/* Create/Edit Form Modal */} - {showCreateForm && ( + {/* Edit Form Modal - Only for editing titles and publish status */} + {showCreateForm && editingStory && (

- {editingStory ? "Edit Success Story" : "Add Success Story"} + Edit Success Story

-
-
- -
- -
- - - -
-
+ + {/* Read-only information */} +
+

Original Feedback Details (Read-only)

+
+

User: {editingStory.isAnonymous ? "Anonymous" : editingStory.displayName}

+

Rating: {editingStory.rating}/5 stars

+

User Consent: {editingStory.canSuccessStoryPost ? "Yes" : "No"}

+

Feedback: {editingStory.feedback}

+

Success Story: {editingStory.description}

+
+
setFormData({ ...formData, title: e.target.value })} - required maxLength={200} className="w-full border border-gray-300 rounded-lg px-3 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-900 bg-white placeholder-gray-400 hover:border-gray-400 transition-colors" - placeholder="Enter story title" + placeholder="Enter custom title for this story" />

{formData.title.length}/200 characters

-
- -