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/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/components/Dashboard/listings/ListingCard.tsx b/src/components/Dashboard/listings/ListingCard.tsx index 0c9e4098..fe0aeca8 100644 --- a/src/components/Dashboard/listings/ListingCard.tsx +++ b/src/components/Dashboard/listings/ListingCard.tsx @@ -6,6 +6,7 @@ import Image from 'next/image'; import { SkillListing } from '@/types/skillListing'; import { BadgeCheck, Edit, Trash2, Eye, Users, Shield, CheckCircle, Clock, XCircle } from 'lucide-react'; import { useAuth } from '@/lib/context/AuthContext'; +import { processAvatarUrl } from '@/utils/avatarUtils'; interface ListingCardProps { listing: SkillListing & { @@ -60,6 +61,9 @@ const ListingCard: React.FC = ({ listing, onDelete, onEdit }) const statusConfig = getStatusConfig(listing.status); const StatusIcon = statusConfig.icon; const canModify = isOwner && !listing.isUsedInMatches; + + // Process avatar URL or provide fallback + const userAvatar = processAvatarUrl(listing.userDetails.avatar) || '/Avatar.png'; return ( <> @@ -70,11 +74,16 @@ const ListingCard: React.FC = ({ listing, onDelete, onEdit })
{`${listing.userDetails.firstName} { + const target = e.target as HTMLImageElement; + target.src = '/Avatar.png'; + target.onerror = null; + }} />
@@ -202,11 +211,16 @@ const ListingCard: React.FC = ({ listing, onDelete, onEdit })
{`${listing.userDetails.firstName} { + const target = e.target as HTMLImageElement; + target.src = '/Avatar.png'; + target.onerror = null; + }} />
diff --git a/src/components/Dashboard/matches/MatchCard.tsx b/src/components/Dashboard/matches/MatchCard.tsx index 793c5f98..e6ca2104 100644 --- a/src/components/Dashboard/matches/MatchCard.tsx +++ b/src/components/Dashboard/matches/MatchCard.tsx @@ -3,7 +3,8 @@ import React from 'react'; import Image from 'next/image'; import { SkillMatch } from '@/types/skillMatch'; -import { BadgeCheck, ArrowRightLeft, Eye } from 'lucide-react'; +import { BadgeCheck, ArrowRightLeft, Eye, MessageCircle, Clock, CheckCircle, XCircle, Award, Calendar } from 'lucide-react'; +import { processAvatarUrl } from '@/utils/avatarUtils'; interface MatchCardProps { match: SkillMatch; @@ -17,133 +18,259 @@ const MatchCard: React.FC = ({ match, onClick }) => { return new Date(dateString).toLocaleDateString('en-US', options); }; - // Status badge color - const getStatusColor = (status: string) => { + // Status badge color and icon + const getStatusConfig = (status: string) => { switch (status) { case 'pending': - return 'bg-yellow-100 text-yellow-800'; + return { color: 'bg-yellow-100 text-yellow-800 border-yellow-200', icon: Clock, text: 'Awaiting Response' }; case 'accepted': - return 'bg-green-100 text-green-800'; + return { color: 'bg-green-100 text-green-800 border-green-200', icon: CheckCircle, text: 'Active Match' }; case 'rejected': - return 'bg-red-100 text-red-800'; + return { color: 'bg-red-100 text-red-800 border-red-200', icon: XCircle, text: 'Declined' }; case 'completed': - return 'bg-blue-100 text-blue-800'; + return { color: 'bg-blue-100 text-blue-800 border-blue-200', icon: Award, text: 'Completed' }; default: - return 'bg-gray-100 text-gray-800'; + return { color: 'bg-gray-100 text-gray-800 border-gray-200', icon: Clock, text: 'Unknown' }; } }; + + const statusConfig = getStatusConfig(match.status); + const StatusIcon = statusConfig.icon; + + // Process avatar URLs with fallbacks + const otherUserAvatar = processAvatarUrl(match.otherUser.avatar) || '/Avatar.png'; + const myAvatar = processAvatarUrl(match.myDetails.avatar) || '/Avatar.png'; return (
{/* Match Percentage Banner */} -
+
+
+ {match.matchPercentage === 100 && ( +
+ )} +
{/* Card Header */} -
-
-
+
+
+
- {match.matchPercentage}% Match - - - - {match.status.charAt(0).toUpperCase() + match.status.slice(1)} + {match.matchPercentage}% {match.matchType === 'exact' ? '🎯' : '🔄'}
- - {formatDate(match.createdAt)} +
+ + + {formatDate(match.createdAt)} + +
+
+ + {/* Enhanced Status Badge */} +
+ + + {statusConfig.text}
{/* Card Body */} -
+
{/* Users and Skills */} -
+
{/* Your skills */} -
-
- You +
+
+ Your avatar { + const target = e.target as HTMLImageElement; + // Fallback to "You" text if image fails to load + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (ctx) { + canvas.width = 40; + canvas.height = 40; + ctx.fillStyle = '#3b82f6'; + ctx.fillRect(0, 0, 40, 40); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 12px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('You', 20, 20); + target.src = canvas.toDataURL(); + } else { + target.src = '/Avatar.png'; + } + target.onerror = null; + }} + /> +
+
+

+ {match.myDetails.offeringSkill} +

+

You Offer

-

- {match.myDetails.offeringSkill} -

-

Offering

{/* Exchange arrows */} -
- +
+
+ +
{/* Other user */} -
-
- {match.otherUser.avatar ? ( - {`${match.otherUser.firstName} - ) : ( -
- - {match.otherUser.firstName.charAt(0)} - {match.otherUser.lastName.charAt(0)} - -
- )} +
+
+ {`${match.otherUser.firstName} { + const target = e.target as HTMLImageElement; + // Fallback to initials if image fails to load + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (ctx) { + canvas.width = 40; + canvas.height = 40; + ctx.fillStyle = '#c084fc'; + ctx.fillRect(0, 0, 40, 40); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 14px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const initials = `${match.otherUser.firstName.charAt(0)}${match.otherUser.lastName.charAt(0)}`; + ctx.fillText(initials, 20, 20); + target.src = canvas.toDataURL(); + } else { + target.src = '/Avatar.png'; + } + target.onerror = null; + }} + /> +
+
+

+ {match.otherUser.offeringSkill} +

+

They Offer

-

- {match.otherUser.offeringSkill} -

-

Offering

{/* User Info */} -
-

- {match.otherUser.firstName} {match.otherUser.lastName} - -

-

- {match.matchPercentage === 100 - ? "Perfect match! You're seeking exactly what they're offering." - : "Partial match! They have the skill you're seeking in their skill set."} -

+
+
+

+ + {match.otherUser.firstName} {match.otherUser.lastName} + + +

+
+ +
+

+ {match.matchPercentage === 100 + ? "🎯 Perfect match! Mutual skill exchange opportunity." + : "🔄 Partial match! They can teach you what you're seeking."} +

+
+ + {/* Success indicators */} + {match.status === 'accepted' && ( +
+ + Chat available +
+ )}
{/* Card Footer */} -
- +
+
+ {/* Quick action based on status */} +
+
+ {match.status === 'pending' && '⏳ Awaiting your response'} + {match.status === 'accepted' && '🚀 Ready to collaborate'} + {match.status === 'completed' && '✅ Successfully completed'} + {match.status === 'rejected' && '❌ Match declined'} +
+
+ + +
); diff --git a/src/components/Dashboard/matches/MatchDetailsModal.tsx b/src/components/Dashboard/matches/MatchDetailsModal.tsx index fd514e8e..e6e09f99 100644 --- a/src/components/Dashboard/matches/MatchDetailsModal.tsx +++ b/src/components/Dashboard/matches/MatchDetailsModal.tsx @@ -6,7 +6,10 @@ import { useRouter } from 'next/navigation'; import { SkillMatch } from '@/types/skillMatch'; import { useToast } from '@/lib/context/ToastContext'; import { updateMatchStatus, acceptMatchAndCreateChatRoom } from '@/services/matchService'; -import { BadgeCheck, ArrowRight, MessageCircle, Calendar, XCircle, CheckCircle, Clock } from 'lucide-react'; +import { fetchUserChatRooms } from '@/services/chatApiServices'; +import { useAuth } from '@/lib/context/AuthContext'; +import { BadgeCheck, ArrowRight, MessageCircle, Calendar, XCircle, CheckCircle, Clock, Award, BarChart3, Target } from 'lucide-react'; +import { processAvatarUrl } from '@/utils/avatarUtils'; interface MatchDetailsModalProps { match: SkillMatch; @@ -83,10 +86,12 @@ const ConfirmationModal: React.FC = ({ const MatchDetailsModal: React.FC = ({ match, currentUserId, onClose }) => { const { showToast } = useToast(); + const { user } = useAuth(); const router = useRouter(); const [submitting, setSubmitting] = useState(false); const [showAcceptConfirmation, setShowAcceptConfirmation] = useState(false); const [showRejectConfirmation, setShowRejectConfirmation] = useState(false); + const [openingChat, setOpeningChat] = useState(false); // Format date const formatDate = (dateString: string) => { @@ -94,6 +99,48 @@ const MatchDetailsModal: React.FC = ({ match, currentUse return new Date(dateString).toLocaleDateString('en-US', options); }; + // Get status display configuration + const getStatusConfig = (status: string) => { + switch (status) { + case 'pending': + return { + color: 'bg-gradient-to-r from-yellow-50 to-orange-50 text-yellow-800 border-yellow-200', + icon: Clock, + text: 'Awaiting Response' + }; + case 'accepted': + return { + color: 'bg-gradient-to-r from-green-50 to-emerald-50 text-green-800 border-green-200', + icon: CheckCircle, + text: 'Active Match' + }; + case 'rejected': + return { + color: 'bg-gradient-to-r from-red-50 to-pink-50 text-red-800 border-red-200', + icon: XCircle, + text: 'Declined' + }; + case 'completed': + return { + color: 'bg-gradient-to-r from-blue-50 to-indigo-50 text-blue-800 border-blue-200', + icon: Award, + text: 'Completed' + }; + default: + return { + color: 'bg-gradient-to-r from-gray-50 to-gray-100 text-gray-800 border-gray-200', + icon: Clock, + text: 'Unknown' + }; + } + }; + + const statusConfig = getStatusConfig(match.status); + + // Process avatar URLs with fallbacks + const otherUserAvatar = processAvatarUrl(match.otherUser.avatar) || '/Avatar.png'; + const myAvatar = processAvatarUrl(match.myDetails.avatar) || '/Avatar.png'; + // Handle match acceptance with chat room creation const handleAcceptMatch = async () => { setSubmitting(true); @@ -103,6 +150,8 @@ const MatchDetailsModal: React.FC = ({ match, currentUse if (response.success) { const wasExistingRoom = response.data?.chatRoomExists; + const chatRoomId = response.data?.chatRoomId || response.data?.chatRoom?._id; + const message = wasExistingRoom ? 'Match accepted successfully! Using existing chat room.' : 'Match accepted successfully! Chat room created.'; @@ -113,9 +162,14 @@ const MatchDetailsModal: React.FC = ({ match, currentUse setShowAcceptConfirmation(false); onClose(); - // Redirect to chat page after a short delay + // Redirect to chat page with the specific room ID after a short delay setTimeout(() => { - router.push('user/chat'); + if (chatRoomId) { + router.push(`/user/chat?from=dashboard&roomId=${chatRoomId}`); + } else { + // Fallback: redirect without roomId and let user find the room manually + router.push('/user/chat?from=dashboard'); + } }, 1000); } else { showToast(response.message || 'Failed to accept match and create chat room', 'error'); @@ -152,22 +206,83 @@ const MatchDetailsModal: React.FC = ({ match, currentUse setSubmitting(false); } }; + + // Handle opening chat room + const handleOpenChat = async () => { + if (!user?._id) { + showToast('Authentication required to open chat', 'error'); + return; + } + + setOpeningChat(true); + try { + // Get all user's chat rooms with a small retry mechanism for newly created rooms + let chatRooms = await fetchUserChatRooms(user._id); + + // If no chat rooms found, wait a moment and try once more (in case room was just created) + if (chatRooms.length === 0) { + await new Promise(resolve => setTimeout(resolve, 1000)); + chatRooms = await fetchUserChatRooms(user._id); + } + + if (chatRooms.length === 0) { + showToast('No chat rooms found. The chat room may still be being created.', 'error'); + return; + } + + // Find the chat room that includes the other user from this match + const matchChatRoom = chatRooms.find(room => + room.participants && room.participants.includes(match.otherUser.userId) + ); + + if (matchChatRoom) { + // Navigate to chat page with the specific room selected + router.push(`/user/chat?from=dashboard&roomId=${matchChatRoom._id}`); + onClose(); + } else { + showToast('Chat room not found for this match. It may still be being created.', 'error'); + // Fallback: redirect to chat page without specific room + setTimeout(() => { + router.push('/user/chat?from=dashboard'); + onClose(); + }, 500); + } + } catch (error) { + console.error('Error opening chat room:', error); + showToast('Error opening chat room', 'error'); + } finally { + setOpeningChat(false); + } + }; + // Determine what actions are available based on match status const canAccept = match.status === 'pending'; - const canReject = match.status === 'pending' || match.status === 'accepted'; - const canComplete = match.status === 'accepted'; + const canReject = match.status === 'pending'; // Only allow reject when pending + const canOpenChat = match.status === 'accepted'; return ( <> -
+
- {/* Modal Header */} + {/* Simple Modal Header */}
-

- {match.matchPercentage}% Match Details -

+
+
+ {match.matchPercentage}% +
+
+

+ Match Details +

+

+ {match.matchType === 'exact' ? 'Perfect Match' : 'Partial Match'} • {formatDate(match.createdAt)} +

+
+
- {/* Modal Body */} + {/* Simple Modal Body */}
- {/* Status Badge */} + {/* Simple Status Badge */}
= ({ match, currentUse {match.status === 'accepted' && } {match.status === 'rejected' && } {match.status === 'completed' && } - {match.status.charAt(0).toUpperCase() + match.status.slice(1)} + {statusConfig.text}
- {/* Match information */} + {/* Match Information with Consistent Design */}
{/* Your Information */} -
-

You're Offering

-
-

- {match.myDetails.offeringSkill} -

-

- You'll teach or provide this skill -

+
+
+
+ Your avatar { + const target = e.target as HTMLImageElement; + // Fallback to "You" text if image fails to load + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (ctx) { + canvas.width = 48; + canvas.height = 48; + ctx.fillStyle = '#3b82f6'; + ctx.fillRect(0, 0, 48, 48); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 16px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('You', 24, 24); + target.src = canvas.toDataURL(); + } else { + target.src = '/Avatar.png'; + } + target.onerror = null; + }} + /> +
+
+

Your Profile

+

Skills Exchange

+
-

You're Seeking

-
-

- {match.myDetails.seekingSkill} -

-

- You want to learn this skill -

+
+
+ Offering +

+ {match.myDetails.offeringSkill} +

+

+ You'll teach this skill +

+
+ +
+ Seeking +

+ {match.myDetails.seekingSkill} +

+

+ You want to learn this skill +

+
{/* Other User's Information */} -
+
- {match.otherUser.avatar ? ( - {`${match.otherUser.firstName} - ) : ( -
- - {match.otherUser.firstName.charAt(0)} - {match.otherUser.lastName.charAt(0)} - -
- )} + {`${match.otherUser.firstName} { + const target = e.target as HTMLImageElement; + // Create a canvas for initials fallback + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (ctx) { + canvas.width = 48; + canvas.height = 48; + ctx.fillStyle = '#c084fc'; + ctx.fillRect(0, 0, 48, 48); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 18px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const initials = `${match.otherUser.firstName.charAt(0)}${match.otherUser.lastName.charAt(0)}`; + ctx.fillText(initials, 24, 24); + target.src = canvas.toDataURL(); + } else { + target.src = '/Avatar.png'; + } + target.onerror = null; + }} + />
-

+

{match.otherUser.firstName} {match.otherUser.lastName}

+

Partner Profile

-

They're Offering

-
-

- {match.otherUser.offeringSkill} -

-

- {match.otherUser.firstName} will teach you this skill -

-
- -

They're Seeking

-
-

- {match.otherUser.seekingSkill} -

-

- {match.otherUser.firstName} wants to learn this skill -

+
+
+ They Offer +

+ {match.otherUser.offeringSkill} +

+

+ {match.otherUser.firstName} will teach you this +

+
+ +
+ They Seek +

+ {match.otherUser.seekingSkill} +

+

+ {match.otherUser.firstName} wants to learn this +

+
- {/* Match Assessment */} + {/* Simple Match Assessment */}

Match Assessment

@@ -291,7 +461,7 @@ const MatchDetailsModal: React.FC = ({ match, currentUse

- {/* Match Details */} + {/* Simple Match Details */}
@@ -310,75 +480,139 @@ const MatchDetailsModal: React.FC = ({ match, currentUse
- {/* Match Benefits Section */} -
-

- - What happens when you accept? -

-
    -
  • • A private chat room will be created instantly
  • -
  • • You'll be redirected to the chat page to start talking
  • -
  • • You can coordinate your skill exchange sessions
  • -
  • • Schedule meetings and track your progress together
  • -
  • • Access file sharing and collaboration tools
  • -
-
+ {/* Status-specific information */} + {match.status === 'pending' && ( +
+

+ + What happens when you accept? +

+
    +
  • • A private chat room will be created instantly
  • +
  • • You'll be redirected to the chat page to start talking
  • +
  • • You can coordinate your skill exchange sessions
  • +
  • • Schedule meetings and track your progress together
  • +
  • • Access file sharing and collaboration tools
  • +
+
+ )} + + {match.status === 'accepted' && ( +
+

+ + Active Match - Ready to Collaborate! +

+
    +
  • • Use the chat to coordinate your skill exchange
  • +
  • • Schedule learning sessions and share resources
  • +
  • • Track your progress and help each other
  • +
  • • Continue collaborating and learning together
  • +
+
+ )} + + {match.status === 'completed' && ( +
+

+ + Completed Match - Congratulations! 🎉 +

+

+ This skill exchange has been successfully completed. You can still access the chat history + and continue conversations with {match.otherUser.firstName}. +

+
+ )} + + {match.status === 'rejected' && ( +
+

+ + Match Declined +

+

+ This match has been declined and is no longer active. You can find new matches + by using the "Find New Matches" feature. +

+
+ )}
- {/* Modal Footer Actions */} -
-
- {/* Reject Button */} - {canReject && ( - - )} + {/* Simple Modal Footer Actions */} +
+
+ {/* Left side - Destructive actions */} +
+ {/* Reject Button - Only show for pending matches */} + {canReject && ( + + )} +
+ + {/* Right side - Primary actions */} +
+ {/* Contact Button - show for accepted matches */} + {canOpenChat && ( + + )} + + {/* Accept Button - only for pending */} + {canAccept && ( + + )} +
-
- {/* Contact Button - only show if accepted */} - {match.status === 'accepted' && ( - + {/* Simple Status-specific help text */} +
+ {match.status === 'pending' && ( +

+ 💡 Tip: Accept this match to start collaborating with {match.otherUser.firstName}! +

)} - - {/* Accept Button */} - {canAccept && ( - + {match.status === 'accepted' && ( +

+ 🚀 Active Match: Use chat to coordinate your skill exchange sessions. +

)} - - {/* Complete Button */} - {canComplete && ( - + {match.status === 'completed' && ( +

+ ✅ Success: This skill exchange was completed successfully. +

)}
@@ -389,7 +623,7 @@ const MatchDetailsModal: React.FC = ({ match, currentUse { const { showToast } = useToast(); @@ -17,6 +34,12 @@ const MatchesPage = () => { const [matches, setMatches] = useState([]); // Currently displayed matches const [selectedMatch, setSelectedMatch] = useState(null); const [activeFilter, setActiveFilter] = useState<'all' | 'exact' | 'partial'>('all'); + const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'accepted' | 'rejected'>('all'); + const [searchTerm, setSearchTerm] = useState(''); + + // Custom dropdown states for mobile + const [showMatchTypeDropdown, setShowMatchTypeDropdown] = useState(false); + const [showStatusDropdown, setShowStatusDropdown] = useState(false); // Get current user ID const currentUserId = user?._id; @@ -85,14 +108,49 @@ const MatchesPage = () => { // Function to handle filter changes const handleFilterChange = (filter: 'all' | 'exact' | 'partial') => { setActiveFilter(filter); + applyAllFilters(filter, statusFilter, searchTerm); + }; + + // Function to handle status filter changes + const handleStatusFilterChange = (status: 'all' | 'pending' | 'accepted' | 'rejected') => { + setStatusFilter(status); + applyAllFilters(activeFilter, status, searchTerm); + }; + + // Function to handle search + const handleSearch = (term: string) => { + setSearchTerm(term); + applyAllFilters(activeFilter, statusFilter, term); + }; + + // Function to apply all filters + const applyAllFilters = (matchFilter: string, statusFilterValue: string, searchFilterValue: string) => { + let filtered = allMatches; - // Apply the filter - always keep allMatches intact for counting - if (filter === 'all') { - setMatches(allMatches); - } else { - const filteredMatches = allMatches.filter(match => match.matchType === filter); - setMatches(filteredMatches); + // Match type filter + if (matchFilter !== 'all') { + filtered = filtered.filter(match => match.matchType === matchFilter); } + + // Status filter + if (statusFilterValue !== 'all') { + filtered = filtered.filter(match => match.status === statusFilterValue); + } + + // Search filter + if (searchFilterValue.trim()) { + const searchLower = searchFilterValue.toLowerCase(); + filtered = filtered.filter(match => + match.otherUser.firstName.toLowerCase().includes(searchLower) || + match.otherUser.lastName.toLowerCase().includes(searchLower) || + match.myDetails.offeringSkill.toLowerCase().includes(searchLower) || + match.myDetails.seekingSkill.toLowerCase().includes(searchLower) || + match.otherUser.offeringSkill.toLowerCase().includes(searchLower) || + match.otherUser.seekingSkill.toLowerCase().includes(searchLower) + ); + } + + setMatches(filtered); }; // Function to view match details @@ -120,12 +178,104 @@ const MatchesPage = () => { refreshAndReapplyFilter(); }; - // Filter matches by type (for UI counting - using allMatches for accurate counts) - const exactMatches = allMatches.filter(match => match.matchType === 'exact'); - const partialMatches = allMatches.filter(match => match.matchType === 'partial'); + // Calculate statistics using useMemo for performance + const matchStats = useMemo(() => { + const total = allMatches.length; + const exactMatches = allMatches.filter(match => match.matchType === 'exact'); + const partialMatches = allMatches.filter(match => match.matchType === 'partial'); + const pendingMatches = allMatches.filter(match => match.status === 'pending'); + const acceptedMatches = allMatches.filter(match => match.status === 'accepted'); + // Removed completed matches from stats + const rejectedMatches = allMatches.filter(match => match.status === 'rejected'); + + return { + total, + exact: exactMatches.length, + partial: partialMatches.length, + pending: pendingMatches.length, + accepted: acceptedMatches.length, + // completed: removed from stats, + rejected: rejectedMatches.length, + successRate: total > 0 ? Math.round(acceptedMatches.length / total * 100) : 0 + }; + }, [allMatches]); // Determine which matches to show based on filter const matchesToShow = matches; + + // Close all dropdowns + const closeAllDropdowns = () => { + setShowMatchTypeDropdown(false); + setShowStatusDropdown(false); + }; + + // Close dropdowns when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (!target.closest('.custom-dropdown')) { + closeAllDropdowns(); + } + }; + + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + }, []); + + // Custom dropdown component for mobile + const CustomDropdown = ({ + value, + options, + onChange, + placeholder, + isOpen, + setIsOpen, + className = "" + }: { + value: string; + options: { value: string; label: string }[]; + onChange: (value: string) => void; + placeholder: string; + isOpen: boolean; + setIsOpen: (open: boolean) => void; + className?: string; + }) => ( +
+ + + {isOpen && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); // Don't render if user is not authenticated if (!currentUserId) { @@ -139,84 +289,252 @@ const MatchesPage = () => { } return ( -
-
+
+

Skill Matches

- {/* Filter tabs */} -
-
- - - + {/* Enhanced Statistics Cards - Centered */} + {!loading && allMatches.length > 0 && ( +
+
+
+
+ +
+
{matchStats.total}
+
Total Matches
+
+ +
+
+ +
+
{matchStats.exact}
+
Exact
+
+ +
+
+ +
+
{matchStats.partial}
+
Partial
+
+ +
+
+ +
+
{matchStats.pending}
+
Pending
+
+ +
+
+ +
+
{matchStats.accepted}
+
Accepted
+
+ +
+
+ +
+
{matchStats.successRate}%
+
Success Rate
+
+
+
+ )} + + {/* Search and Filters */} + {allMatches.length > 0 && ( +
+
+ {/* Search - Full width on mobile */} +
+ + handleSearch(e.target.value)} + className="w-full pl-7 pr-3 py-1.5 border border-gray-200 rounded text-gray-900 placeholder-gray-500 text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + /> +
+ + {/* Use custom dropdowns for mobile responsiveness */} +
+ handleFilterChange(value as 'all' | 'exact' | 'partial')} + placeholder="All Types" + isOpen={showMatchTypeDropdown} + setIsOpen={setShowMatchTypeDropdown} + /> +
+ +
+ handleStatusFilterChange(value as 'all' | 'pending' | 'accepted' | 'rejected')} + placeholder="All Status" + isOpen={showStatusDropdown} + setIsOpen={setShowStatusDropdown} + /> +
+ + {/* Desktop dropdowns */} +
+ +
+ +
+ +
+
+
+ )} + + {/* Compact Info Row */} +
+
+ {allMatches.length > 0 && ( + Showing {matchesToShow.length} of {allMatches.length} matches + )} +
+
+ {matchStats.accepted > 0 && ( +
+ + + {matchStats.accepted} active chat{matchStats.accepted > 1 ? 's' : ''} + +
+ )}
+ {loading ? (
- ) : matchesToShow.length === 0 ? ( + ) : matchesToShow.length === 0 && allMatches.length > 0 ? (
-

No matches found

-

- {activeFilter === 'all' - ? "You don't have any matches yet" - : `You don't have any ${activeFilter} matches yet`} -

+ +

No matches match your filters

+

Try adjusting your search or filter criteria

+
+ ) : allMatches.length === 0 ? ( +
+
+ +
+

No skill matches yet

+

Start by finding matches with other users who have complementary skills

+
) : ( -
- {matchesToShow.map(match => ( - viewMatchDetails(match)} - /> - ))} +
+ {/* Group matches by status for better organization */} + {['pending', 'accepted', 'rejected'].map(status => { + const statusMatches = matchesToShow.filter(match => match.status === status); + if (statusMatches.length === 0) return null; + + const statusConfig = { + pending: { title: 'Pending Matches', icon: Clock, color: 'text-yellow-600', bg: 'bg-yellow-50', border: 'border-yellow-200' }, + accepted: { title: 'Active Matches', icon: CheckCircle, color: 'text-green-600', bg: 'bg-green-50', border: 'border-green-200' }, + rejected: { title: 'Declined Matches', icon: XCircle, color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' } + }[status]; + + const StatusIcon = statusConfig.icon; + + return ( +
+
+ +

+ {statusConfig.title} ({statusMatches.length}) +

+
+
+ {statusMatches.map(match => ( + viewMatchDetails(match)} + /> + ))} +
+
+ ); + })}
)}