Complete the match lifecycle with partner rating functionality. Backend changes: - Add POST /api/matches/:slug/ratings endpoint to create ratings * Validate score range (1-5) * Prevent duplicate ratings (unique constraint per match+rater+rated) * Auto-complete match when both users have rated * Return detailed rating data with user and event info - Add GET /api/users/:username/ratings endpoint to fetch user ratings * Calculate and return average rating * Include rater details and event context for each rating * Limit to last 50 ratings - Add hasRated field to GET /api/matches/:slug response * Check if current user has already rated the match * Enable frontend to prevent duplicate rating attempts Frontend changes: - Update RatePartnerPage to use real API instead of mocks * Load match data and partner info * Submit ratings with score, comment, and wouldCollaborateAgain * Check hasRated flag and redirect if already rated * Validate match status before allowing rating * Show loading state and proper error handling - Update MatchChatPage to show rating status * Replace "Rate Partner" button with "✓ Rated" badge when user has rated * Improve button text from "End & rate" to "Rate Partner" - Add ratings API functions * matchesAPI.createRating(slug, ratingData) * ratingsAPI.getUserRatings(username) User flow: 1. After match is accepted, users can rate each other 2. Click "Rate Partner" in chat to navigate to rating page 3. Submit 1-5 star rating with optional comment 4. Rating saved and user redirected to matches list 5. Chat shows "✓ Rated" badge instead of rating button 6. Match marked as 'completed' when both users have rated 7. Users cannot rate the same match twice
197 lines
6.7 KiB
JavaScript
197 lines
6.7 KiB
JavaScript
import { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import Layout from '../components/layout/Layout';
|
|
import { matchesAPI } from '../services/api';
|
|
import { Star, Loader2 } from 'lucide-react';
|
|
|
|
const RatePartnerPage = () => {
|
|
const { slug } = useParams();
|
|
const navigate = useNavigate();
|
|
const [match, setMatch] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [rating, setRating] = useState(0);
|
|
const [hoveredRating, setHoveredRating] = useState(0);
|
|
const [comment, setComment] = useState('');
|
|
const [wouldCollaborateAgain, setWouldCollaborateAgain] = useState(true);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
// Load match data and check if already rated
|
|
useEffect(() => {
|
|
const loadMatch = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const result = await matchesAPI.getMatch(slug);
|
|
setMatch(result.data);
|
|
|
|
// Check if this match can be rated
|
|
if (result.data.status !== 'accepted' && result.data.status !== 'completed') {
|
|
alert('This match must be accepted before rating.');
|
|
navigate('/matches');
|
|
return;
|
|
}
|
|
|
|
// Check if user has already rated this match
|
|
if (result.data.hasRated) {
|
|
alert('You have already rated this match.');
|
|
navigate('/matches');
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load match:', error);
|
|
alert('Failed to load match. Redirecting to matches page.');
|
|
navigate('/matches');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
loadMatch();
|
|
}, [slug, navigate]);
|
|
|
|
const partner = match?.partner;
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
if (rating === 0) {
|
|
alert('Please select a rating (1-5 stars)');
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
|
|
try {
|
|
await matchesAPI.createRating(slug, {
|
|
score: rating,
|
|
comment: comment.trim() || null,
|
|
wouldCollaborateAgain,
|
|
});
|
|
|
|
alert('Rating submitted successfully!');
|
|
navigate('/matches');
|
|
} catch (error) {
|
|
console.error('Failed to submit rating:', error);
|
|
if (error.message?.includes('already rated')) {
|
|
alert('You have already rated this match.');
|
|
} else {
|
|
alert('Failed to submit rating. Please try again.');
|
|
}
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (loading || !match || !partner) {
|
|
return (
|
|
<Layout>
|
|
<div className="flex justify-center items-center py-12">
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="max-w-2xl mx-auto">
|
|
<div className="bg-white rounded-lg shadow-md p-8">
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-6 text-center">
|
|
Rate the collaboration
|
|
</h1>
|
|
|
|
{/* Partner Info */}
|
|
<div className="flex items-center justify-center space-x-4 mb-8 p-6 bg-gray-50 rounded-lg">
|
|
<img
|
|
src={partner.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${partner.username}`}
|
|
alt={partner.username}
|
|
className="w-16 h-16 rounded-full"
|
|
/>
|
|
<div>
|
|
<h3 className="text-xl font-bold text-gray-900">
|
|
{partner.firstName && partner.lastName
|
|
? `${partner.firstName} ${partner.lastName}`
|
|
: partner.username}
|
|
</h3>
|
|
<p className="text-gray-600">@{partner.username}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Rating Stars */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-3 text-center">
|
|
How would you rate the collaboration?
|
|
</label>
|
|
<div className="flex justify-center space-x-2">
|
|
{[1, 2, 3, 4, 5].map((star) => (
|
|
<button
|
|
key={star}
|
|
type="button"
|
|
onClick={() => setRating(star)}
|
|
onMouseEnter={() => setHoveredRating(star)}
|
|
onMouseLeave={() => setHoveredRating(0)}
|
|
className="focus:outline-none transition-transform hover:scale-110"
|
|
>
|
|
<Star
|
|
className={`w-12 h-12 ${
|
|
star <= (hoveredRating || rating)
|
|
? 'fill-yellow-400 text-yellow-400'
|
|
: 'text-gray-300'
|
|
}`}
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="text-center text-sm text-gray-500 mt-2">
|
|
{rating === 0 && 'Click to rate'}
|
|
{rating === 1 && 'Poor'}
|
|
{rating === 2 && 'Fair'}
|
|
{rating === 3 && 'Good'}
|
|
{rating === 4 && 'Very good'}
|
|
{rating === 5 && 'Excellent!'}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Comment */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Comment (optional)
|
|
</label>
|
|
<textarea
|
|
value={comment}
|
|
onChange={(e) => setComment(e.target.value)}
|
|
rows={4}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
|
placeholder="Share your thoughts about the collaboration..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Would Collaborate Again */}
|
|
<div className="flex items-center space-x-3 p-4 bg-gray-50 rounded-lg">
|
|
<input
|
|
type="checkbox"
|
|
id="collaborate"
|
|
checked={wouldCollaborateAgain}
|
|
onChange={(e) => setWouldCollaborateAgain(e.target.checked)}
|
|
className="w-5 h-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
|
|
/>
|
|
<label htmlFor="collaborate" className="text-sm font-medium text-gray-700 cursor-pointer">
|
|
I would like to collaborate again
|
|
</label>
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<button
|
|
type="submit"
|
|
disabled={submitting || rating === 0}
|
|
className="w-full px-6 py-3 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
|
>
|
|
{submitting ? 'Saving...' : 'Save rating'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default RatePartnerPage;
|