2025-11-14 19:22:23 +01:00
|
|
|
import { useState, useEffect } from 'react';
|
2025-11-14 22:48:30 +01:00
|
|
|
import { useNavigate, Link } from 'react-router-dom';
|
2025-11-14 19:22:23 +01:00
|
|
|
import Layout from '../components/layout/Layout';
|
|
|
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
|
|
import { matchesAPI } from '../services/api';
|
|
|
|
|
import { MessageCircle, Check, X, Loader2, Users, Calendar, MapPin } from 'lucide-react';
|
|
|
|
|
import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
|
|
|
|
|
|
|
|
|
|
const MatchesPage = () => {
|
|
|
|
|
const { user } = useAuth();
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const [matches, setMatches] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [filter, setFilter] = useState('all'); // 'all', 'pending', 'accepted'
|
|
|
|
|
const [processingMatchId, setProcessingMatchId] = useState(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadMatches();
|
|
|
|
|
|
|
|
|
|
// Connect to socket for real-time updates
|
|
|
|
|
const token = localStorage.getItem('token');
|
|
|
|
|
if (token && user) {
|
|
|
|
|
connectSocket(token, user.id);
|
|
|
|
|
|
|
|
|
|
const socket = getSocket();
|
|
|
|
|
if (socket) {
|
|
|
|
|
// Listen for match notifications
|
|
|
|
|
socket.on('match_request_received', handleMatchRequestReceived);
|
|
|
|
|
socket.on('match_accepted', handleMatchAccepted);
|
|
|
|
|
socket.on('match_cancelled', handleMatchCancelled);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
const socket = getSocket();
|
|
|
|
|
if (socket) {
|
|
|
|
|
socket.off('match_request_received', handleMatchRequestReceived);
|
|
|
|
|
socket.off('match_accepted', handleMatchAccepted);
|
|
|
|
|
socket.off('match_cancelled', handleMatchCancelled);
|
|
|
|
|
}
|
|
|
|
|
disconnectSocket();
|
|
|
|
|
};
|
|
|
|
|
}, [user]);
|
|
|
|
|
|
|
|
|
|
const loadMatches = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
const result = await matchesAPI.getMatches();
|
|
|
|
|
setMatches(result.data || []);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to load matches:', error);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMatchRequestReceived = (data) => {
|
|
|
|
|
// Reload matches to show new request
|
|
|
|
|
loadMatches();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMatchAccepted = (data) => {
|
|
|
|
|
// Reload matches to update status
|
|
|
|
|
loadMatches();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMatchCancelled = (data) => {
|
|
|
|
|
// Remove cancelled match from list
|
2025-11-14 22:22:11 +01:00
|
|
|
setMatches(prev => prev.filter(m => m.slug !== data.matchSlug));
|
2025-11-14 19:22:23 +01:00
|
|
|
};
|
|
|
|
|
|
2025-11-14 22:22:11 +01:00
|
|
|
const handleAccept = async (matchSlug) => {
|
2025-11-14 19:22:23 +01:00
|
|
|
try {
|
2025-11-14 22:22:11 +01:00
|
|
|
setProcessingMatchId(matchSlug);
|
|
|
|
|
await matchesAPI.acceptMatch(matchSlug);
|
2025-11-14 19:22:23 +01:00
|
|
|
|
|
|
|
|
// Reload matches
|
|
|
|
|
await loadMatches();
|
|
|
|
|
|
|
|
|
|
// Show success message
|
|
|
|
|
alert('Match accepted! You can now chat with your partner.');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to accept match:', error);
|
|
|
|
|
alert('Failed to accept match. Please try again.');
|
|
|
|
|
} finally {
|
|
|
|
|
setProcessingMatchId(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-14 22:22:11 +01:00
|
|
|
const handleReject = async (matchSlug) => {
|
2025-11-14 19:22:23 +01:00
|
|
|
if (!confirm('Are you sure you want to reject this match request?')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-11-14 22:22:11 +01:00
|
|
|
setProcessingMatchId(matchSlug);
|
|
|
|
|
await matchesAPI.deleteMatch(matchSlug);
|
2025-11-14 19:22:23 +01:00
|
|
|
|
|
|
|
|
// Remove from list
|
2025-11-14 22:22:11 +01:00
|
|
|
setMatches(prev => prev.filter(m => m.slug !== matchSlug));
|
2025-11-14 19:22:23 +01:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to reject match:', error);
|
|
|
|
|
alert('Failed to reject match. Please try again.');
|
|
|
|
|
} finally {
|
|
|
|
|
setProcessingMatchId(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleOpenChat = (match) => {
|
|
|
|
|
if (match.status === 'accepted' && match.roomId) {
|
2025-11-14 22:22:11 +01:00
|
|
|
navigate(`/matches/${match.slug}/chat`);
|
2025-11-14 19:22:23 +01:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Filter matches based on selected filter
|
|
|
|
|
const filteredMatches = matches.filter(match => {
|
|
|
|
|
if (filter === 'all') return true;
|
|
|
|
|
return match.status === filter;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Separate pending incoming matches (where user is recipient)
|
|
|
|
|
const pendingIncoming = filteredMatches.filter(m => m.status === 'pending' && !m.isInitiator);
|
|
|
|
|
const otherMatches = filteredMatches.filter(m => !(m.status === 'pending' && !m.isInitiator));
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Layout>
|
|
|
|
|
<div className="max-w-4xl mx-auto">
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
<h1 className="text-3xl font-bold text-gray-900">Match Requests</h1>
|
|
|
|
|
<p className="text-gray-600 mt-2">
|
|
|
|
|
Manage your dance partner connections
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Filter Tabs */}
|
|
|
|
|
<div className="bg-white rounded-lg shadow-sm p-1 mb-6 flex gap-1">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setFilter('all')}
|
|
|
|
|
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
|
|
|
filter === 'all'
|
|
|
|
|
? 'bg-primary-600 text-white'
|
|
|
|
|
: 'text-gray-600 hover:bg-gray-100'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
All ({matches.length})
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setFilter('pending')}
|
|
|
|
|
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
|
|
|
filter === 'pending'
|
|
|
|
|
? 'bg-primary-600 text-white'
|
|
|
|
|
: 'text-gray-600 hover:bg-gray-100'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
Pending ({matches.filter(m => m.status === 'pending').length})
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setFilter('accepted')}
|
|
|
|
|
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
|
|
|
filter === 'accepted'
|
|
|
|
|
? 'bg-primary-600 text-white'
|
|
|
|
|
: 'text-gray-600 hover:bg-gray-100'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
Active ({matches.filter(m => m.status === 'accepted').length})
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex justify-center items-center py-12">
|
|
|
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{/* Pending Incoming Requests Section */}
|
|
|
|
|
{pendingIncoming.length > 0 && (
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
<h2 className="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
|
|
|
|
<Users className="w-5 h-5 text-amber-600" />
|
|
|
|
|
Incoming Requests
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{pendingIncoming.map(match => (
|
|
|
|
|
<MatchCard
|
|
|
|
|
key={match.id}
|
|
|
|
|
match={match}
|
|
|
|
|
onAccept={handleAccept}
|
|
|
|
|
onReject={handleReject}
|
|
|
|
|
onOpenChat={handleOpenChat}
|
2025-11-14 22:22:11 +01:00
|
|
|
processing={processingMatchId === match.slug}
|
2025-11-14 19:22:23 +01:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Other Matches */}
|
|
|
|
|
{otherMatches.length > 0 && (
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-lg font-semibold text-gray-900 mb-3">
|
|
|
|
|
{pendingIncoming.length > 0 ? 'Other Matches' : 'Your Matches'}
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{otherMatches.map(match => (
|
|
|
|
|
<MatchCard
|
|
|
|
|
key={match.id}
|
|
|
|
|
match={match}
|
|
|
|
|
onAccept={handleAccept}
|
|
|
|
|
onReject={handleReject}
|
|
|
|
|
onOpenChat={handleOpenChat}
|
2025-11-14 22:22:11 +01:00
|
|
|
processing={processingMatchId === match.slug}
|
2025-11-14 19:22:23 +01:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Empty State */}
|
|
|
|
|
{filteredMatches.length === 0 && (
|
|
|
|
|
<div className="text-center py-12">
|
|
|
|
|
<Users className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
|
|
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
|
|
|
No matches found
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="text-gray-600">
|
|
|
|
|
{filter === 'all'
|
|
|
|
|
? 'You have no match requests yet. Connect with other dancers at events!'
|
|
|
|
|
: `You have no ${filter} matches.`}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</Layout>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
|
|
|
|
|
const isIncoming = !match.isInitiator && match.status === 'pending';
|
|
|
|
|
const isOutgoing = match.isInitiator && match.status === 'pending';
|
|
|
|
|
const isAccepted = match.status === 'accepted';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow">
|
|
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="flex items-center gap-3 mb-2">
|
2025-11-14 22:48:30 +01:00
|
|
|
<Link to={`/${match.partner.username}`} className="flex-shrink-0">
|
|
|
|
|
<img
|
|
|
|
|
src={match.partner.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${match.partner.username}`}
|
|
|
|
|
alt={match.partner.username}
|
|
|
|
|
className="w-12 h-12 rounded-full hover:ring-2 hover:ring-primary-500 transition-all"
|
|
|
|
|
/>
|
|
|
|
|
</Link>
|
2025-11-14 19:22:23 +01:00
|
|
|
<div>
|
2025-11-14 22:48:30 +01:00
|
|
|
<Link to={`/${match.partner.username}`}>
|
|
|
|
|
<h3 className="font-semibold text-gray-900 hover:text-primary-600 transition-colors">
|
|
|
|
|
{match.partner.firstName && match.partner.lastName
|
|
|
|
|
? `${match.partner.firstName} ${match.partner.lastName}`
|
|
|
|
|
: match.partner.username}
|
|
|
|
|
</h3>
|
|
|
|
|
</Link>
|
2025-11-14 19:22:23 +01:00
|
|
|
<p className="text-sm text-gray-600">@{match.partner.username}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-4 text-sm text-gray-600 mb-2">
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Calendar className="w-4 h-4" />
|
|
|
|
|
<span>{match.event.name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<MapPin className="w-4 h-4" />
|
|
|
|
|
<span>{match.event.location}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{isIncoming && (
|
|
|
|
|
<span className="text-xs px-2 py-1 bg-amber-100 text-amber-700 rounded-full font-medium">
|
|
|
|
|
Incoming Request
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{isOutgoing && (
|
|
|
|
|
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded-full font-medium">
|
|
|
|
|
Sent Request
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{isAccepted && (
|
|
|
|
|
<span className="text-xs px-2 py-1 bg-green-100 text-green-700 rounded-full font-medium">
|
|
|
|
|
Active Match
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2 ml-4">
|
|
|
|
|
{isIncoming && (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
2025-11-14 22:22:11 +01:00
|
|
|
onClick={() => onAccept(match.slug)}
|
2025-11-14 19:22:23 +01:00
|
|
|
disabled={processing}
|
|
|
|
|
className="p-2 bg-green-600 text-white rounded-full hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
|
|
title="Accept"
|
|
|
|
|
>
|
|
|
|
|
{processing ? (
|
|
|
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Check className="w-5 h-5" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
2025-11-14 22:22:11 +01:00
|
|
|
onClick={() => onReject(match.slug)}
|
2025-11-14 19:22:23 +01:00
|
|
|
disabled={processing}
|
|
|
|
|
className="p-2 bg-red-600 text-white rounded-full hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
|
|
title="Reject"
|
|
|
|
|
>
|
|
|
|
|
<X className="w-5 h-5" />
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{isOutgoing && (
|
|
|
|
|
<button
|
2025-11-14 22:22:11 +01:00
|
|
|
onClick={() => onReject(match.slug)}
|
2025-11-14 19:22:23 +01:00
|
|
|
disabled={processing}
|
|
|
|
|
className="px-3 py-1.5 text-sm border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Cancel Request
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{isAccepted && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onOpenChat(match)}
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<MessageCircle className="w-4 h-4" />
|
|
|
|
|
Open Chat
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default MatchesPage;
|