543 lines
20 KiB
JavaScript
543 lines
20 KiB
JavaScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
|
import toast from 'react-hot-toast';
|
|
import { useAuth } from '../AuthContext';
|
|
import { useSocket } from '../SocketContext';
|
|
import api from '../api';
|
|
import { useClickOutside } from '../hooks/useClickOutside';
|
|
|
|
export default function ChallengeDetail() {
|
|
const { id } = useParams();
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
const { socket, joinChallenge, leaveChallenge } = useSocket();
|
|
const [challenge, setChallenge] = useState(null);
|
|
const [predictions, setPredictions] = useState([]);
|
|
const [leaderboard, setLeaderboard] = useState([]);
|
|
const [newPrediction, setNewPrediction] = useState('');
|
|
const [inviteQuery, setInviteQuery] = useState('');
|
|
const [searchResults, setSearchResults] = useState([]);
|
|
const [showInvite, setShowInvite] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchTimeout, setSearchTimeout] = useState(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [validating, setValidating] = useState(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [friends, setFriends] = useState([]);
|
|
const [inviting, setInviting] = useState(null);
|
|
const [leaving, setLeaving] = useState(false);
|
|
const searchRef = useRef(null);
|
|
|
|
useClickOutside(searchRef, () => setSearchResults([]));
|
|
|
|
const loadChallenge = useCallback(async () => {
|
|
try {
|
|
const data = await api.getChallenge(id);
|
|
setChallenge(data);
|
|
} catch (err) {
|
|
console.error('Failed to load challenge:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [id]);
|
|
|
|
const loadPredictions = useCallback(async () => {
|
|
try {
|
|
const data = await api.getPredictions(id);
|
|
setPredictions(data.predictions);
|
|
} catch (err) {
|
|
console.error('Failed to load predictions:', err);
|
|
}
|
|
}, [id]);
|
|
|
|
const loadLeaderboard = useCallback(async () => {
|
|
try {
|
|
const data = await api.getChallengeLeaderboard(id);
|
|
setLeaderboard(data.leaderboard);
|
|
} catch (err) {
|
|
console.error('Failed to load leaderboard:', err);
|
|
}
|
|
}, [id]);
|
|
|
|
const loadFriends = useCallback(async () => {
|
|
try {
|
|
const data = await api.getFriends();
|
|
// Combine regular friends and challenge friends
|
|
const allFriends = [...(data.friends || []), ...(data.challenge_friends || [])];
|
|
setFriends(allFriends);
|
|
} catch (err) {
|
|
console.error('Failed to load friends:', err);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadChallenge();
|
|
loadPredictions();
|
|
loadLeaderboard();
|
|
loadFriends();
|
|
}, [loadChallenge, loadPredictions, loadLeaderboard, loadFriends]);
|
|
|
|
// Join challenge room for real-time updates
|
|
useEffect(() => {
|
|
if (socket && id) {
|
|
joinChallenge(id);
|
|
|
|
return () => {
|
|
leaveChallenge(id);
|
|
};
|
|
}
|
|
}, [socket, id]);
|
|
|
|
// Listen for real-time prediction events
|
|
useEffect(() => {
|
|
if (!socket) return;
|
|
|
|
const handlePredictionCreated = (prediction) => {
|
|
console.log('📥 Received prediction:created event', prediction);
|
|
setPredictions(prev => {
|
|
// Avoid duplicates
|
|
if (prev.some(p => p.id === prediction.id)) {
|
|
return prev;
|
|
}
|
|
return [prediction, ...prev];
|
|
});
|
|
|
|
// Don't show toast for your own predictions
|
|
if (prediction.user_id !== user.id) {
|
|
toast.success(`New prediction from ${prediction.username}`);
|
|
}
|
|
};
|
|
|
|
const handlePredictionValidated = (prediction) => {
|
|
console.log('📥 Received prediction:validated event', prediction);
|
|
setPredictions(prev =>
|
|
prev.map(p => p.id === prediction.id ? prediction : p)
|
|
);
|
|
loadLeaderboard(); // Refresh leaderboard when points change
|
|
|
|
if (prediction.user_id === user.id) {
|
|
toast.success(
|
|
prediction.status === 'validated'
|
|
? '🎉 Your prediction was validated!'
|
|
: '❌ Your prediction was invalidated'
|
|
);
|
|
} else {
|
|
toast(`${prediction.username}'s prediction ${prediction.status}`);
|
|
}
|
|
};
|
|
|
|
const handleInvitationResponse = (response) => {
|
|
console.log('📥 Received invitation response', response);
|
|
if (response.status === 'accepted') {
|
|
toast.success(`${response.username} joined the challenge!`);
|
|
loadChallenge(); // Refresh participant list
|
|
}
|
|
};
|
|
|
|
socket.on('prediction:created', handlePredictionCreated);
|
|
socket.on('prediction:validated', handlePredictionValidated);
|
|
socket.on('challenge:invitation_response', handleInvitationResponse);
|
|
|
|
return () => {
|
|
socket.off('prediction:created', handlePredictionCreated);
|
|
socket.off('prediction:validated', handlePredictionValidated);
|
|
socket.off('challenge:invitation_response', handleInvitationResponse);
|
|
};
|
|
}, [socket, user.id, loadLeaderboard, loadChallenge]);
|
|
|
|
const handleCreatePrediction = async (e) => {
|
|
e.preventDefault();
|
|
if (!newPrediction.trim()) return;
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
await api.createPrediction({
|
|
challenge_id: id,
|
|
content: newPrediction
|
|
});
|
|
toast.success('Prediction submitted!');
|
|
setNewPrediction('');
|
|
await loadPredictions();
|
|
} catch (err) {
|
|
toast.error('Failed to create prediction: ' + err.message);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleValidate = async (predictionId, status) => {
|
|
setValidating(predictionId);
|
|
try {
|
|
await api.validatePrediction(predictionId, status);
|
|
toast.success(status === 'validated' ? 'Prediction validated!' : 'Prediction invalidated');
|
|
await loadPredictions();
|
|
await loadLeaderboard();
|
|
} catch (err) {
|
|
toast.error('Failed to validate: ' + err.message);
|
|
} finally {
|
|
setValidating(null);
|
|
}
|
|
};
|
|
|
|
const handleSearchUsers = (query) => {
|
|
setInviteQuery(query);
|
|
|
|
// Clear previous timeout
|
|
if (searchTimeout) {
|
|
clearTimeout(searchTimeout);
|
|
}
|
|
|
|
if (query.trim().length < 2) {
|
|
setSearchResults([]);
|
|
return;
|
|
}
|
|
|
|
// Debounce search by 0.5 second
|
|
const timeout = setTimeout(async () => {
|
|
try {
|
|
const data = await api.searchUsers(query);
|
|
setSearchResults(data.users);
|
|
} catch (err) {
|
|
console.error('Search failed:', err);
|
|
}
|
|
}, 500);
|
|
|
|
setSearchTimeout(timeout);
|
|
};
|
|
|
|
const handleInvite = async (userId, userName) => {
|
|
setInviting(userId);
|
|
try {
|
|
await api.inviteToChallenge(id, { user_ids: [userId] });
|
|
toast.success(`Invitation sent to ${userName || 'user'}!`);
|
|
setInviteQuery('');
|
|
setSearchResults([]);
|
|
await loadChallenge(); // Refresh to update participant list
|
|
} catch (err) {
|
|
toast.error('Failed to send invite: ' + err.message);
|
|
} finally {
|
|
setInviting(null);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!confirm('Are you sure you want to delete this challenge? This action cannot be undone.')) {
|
|
return;
|
|
}
|
|
|
|
setDeleting(true);
|
|
try {
|
|
await api.deleteChallenge(id);
|
|
toast.success('Challenge deleted successfully');
|
|
navigate('/challenges');
|
|
} catch (err) {
|
|
toast.error('Failed to delete challenge: ' + err.message);
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const handleLeave = async () => {
|
|
if (!confirm('Are you sure you want to leave this challenge? Your predictions will be removed.')) {
|
|
return;
|
|
}
|
|
|
|
setLeaving(true);
|
|
try {
|
|
await api.leaveChallenge(id);
|
|
toast.success('Left challenge successfully');
|
|
navigate('/challenges');
|
|
} catch (err) {
|
|
toast.error('Failed to leave challenge: ' + err.message);
|
|
setLeaving(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return <div className="loading">Loading challenge...</div>;
|
|
}
|
|
|
|
if (!challenge) {
|
|
return <div className="loading">Challenge not found</div>;
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: '2rem 0' }}>
|
|
<div className="container">
|
|
{/* Header */}
|
|
<div style={{ marginBottom: '2rem' }}>
|
|
<Link to="/challenges" style={{ color: 'var(--primary)', textDecoration: 'none' }}>← Back to Challenges</Link>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '2rem', marginBottom: '2rem', flexWrap: 'wrap' }}>
|
|
{challenge.challenge.cover_image_url && (
|
|
<img
|
|
src={challenge.challenge.cover_image_url}
|
|
alt={challenge.challenge.title}
|
|
style={{ width: '150px', height: '225px', objectFit: 'cover', borderRadius: '0.5rem' }}
|
|
/>
|
|
)}
|
|
<div style={{ flex: 1 }}>
|
|
<h1 style={{ marginBottom: '0.5rem' }}>{challenge.challenge.title}</h1>
|
|
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
|
Created by {challenge.challenge.creator_username}
|
|
</p>
|
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
<button
|
|
className="btn btn-secondary btn-sm"
|
|
onClick={() => setShowInvite(!showInvite)}
|
|
>
|
|
{showInvite ? 'Cancel' : 'Invite Friends'}
|
|
</button>
|
|
{challenge.challenge.created_by === user.id ? (
|
|
<button
|
|
className="btn btn-danger btn-sm"
|
|
onClick={handleDelete}
|
|
disabled={deleting}
|
|
>
|
|
{deleting ? 'Deleting...' : 'Delete Challenge'}
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="btn btn-danger btn-sm"
|
|
onClick={handleLeave}
|
|
disabled={leaving}
|
|
>
|
|
{leaving ? 'Leaving...' : 'Leave Challenge'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Invite Section */}
|
|
{showInvite && (
|
|
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }} ref={searchRef}>
|
|
<h3 style={{ marginBottom: '1rem' }}>Invite Someone</h3>
|
|
|
|
{/* Friends List */}
|
|
{friends.length > 0 && (
|
|
<div style={{ marginBottom: '1.5rem' }}>
|
|
<h4 style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>Your Friends</h4>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
{friends.filter(friend => {
|
|
// Filter out friends who are already participants
|
|
const isParticipant = challenge.participants?.some(p => p.id === friend.id);
|
|
const isCreator = challenge.challenge.created_by === friend.id;
|
|
return !isParticipant && !isCreator;
|
|
}).map(friend => (
|
|
<div
|
|
key={friend.id}
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
gap: '1rem',
|
|
padding: '0.75rem',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '0.5rem'
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontWeight: 500 }}>{friend.username}</div>
|
|
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{friend.email}</div>
|
|
</div>
|
|
<button
|
|
className="btn btn-primary btn-sm"
|
|
onClick={() => handleInvite(friend.id, friend.username)}
|
|
disabled={inviting === friend.id}
|
|
style={{ flexShrink: 0, width: 'auto' }}
|
|
>
|
|
{inviting === friend.id ? 'Inviting...' : 'Invite'}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Search for others */}
|
|
<div>
|
|
<h4 style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>Search for Others</h4>
|
|
<input
|
|
type="text"
|
|
className="input"
|
|
placeholder="Search by username or email..."
|
|
value={inviteQuery}
|
|
onChange={(e) => handleSearchUsers(e.target.value)}
|
|
/>
|
|
</div>
|
|
{searchResults.length > 0 && (
|
|
<div style={{
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
transform: 'translateY(100%)',
|
|
background: 'var(--bg)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '0.5rem',
|
|
marginTop: '0.5rem',
|
|
maxHeight: '300px',
|
|
overflowY: 'auto',
|
|
zIndex: 10
|
|
}}>
|
|
{searchResults.map(user => (
|
|
<div
|
|
key={user.id}
|
|
style={{
|
|
padding: '1rem',
|
|
borderBottom: '1px solid var(--border)',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
gap: '1rem'
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontWeight: 500 }}>{user.username}</div>
|
|
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{user.email}</div>
|
|
</div>
|
|
<button
|
|
className="btn btn-primary btn-sm"
|
|
onClick={() => handleInvite(user.id, user.username)}
|
|
disabled={inviting === user.id}
|
|
style={{ flexShrink: 0, width: 'auto' }}
|
|
>
|
|
{inviting === user.id ? 'Inviting...' : 'Invite'}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Leaderboard - Desktop: Sidebar, Mobile: Top */}
|
|
<div className="card" style={{ marginBottom: '2rem' }} id="leaderboard-mobile">
|
|
<h3 style={{ marginBottom: '1rem' }}>Leaderboard</h3>
|
|
{leaderboard.length === 0 ? (
|
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>No points yet</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
{leaderboard.map((entry, index) => (
|
|
<div key={entry.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<div>
|
|
<span style={{ marginRight: '0.5rem', color: 'var(--text-muted)' }}>{index + 1}.</span>
|
|
<strong>{entry.username}</strong>
|
|
</div>
|
|
<span style={{ color: 'var(--primary)', fontWeight: 600 }}>
|
|
{entry.validated_points} pts
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="challenge-detail-layout">
|
|
{/* Main Content */}
|
|
<div className="challenge-main">
|
|
{/* New Prediction */}
|
|
<div className="card" style={{ marginBottom: '2rem' }}>
|
|
<h3 style={{ marginBottom: '1rem' }}>Make a Prediction</h3>
|
|
<form onSubmit={handleCreatePrediction}>
|
|
<textarea
|
|
className="textarea"
|
|
placeholder="I predict that..."
|
|
value={newPrediction}
|
|
onChange={(e) => setNewPrediction(e.target.value)}
|
|
style={{ minHeight: '80px' }}
|
|
/>
|
|
<button type="submit" className="btn btn-primary" style={{ marginTop: '1rem' }} disabled={submitting}>
|
|
{submitting ? 'Submitting...' : 'Submit Prediction'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Predictions Feed */}
|
|
<h3 style={{ marginBottom: '1rem' }}>Predictions</h3>
|
|
{predictions.length === 0 ? (
|
|
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
|
|
<p style={{ color: 'var(--text-muted)' }}>No predictions yet. Be the first!</p>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
{predictions.map(pred => (
|
|
<div key={pred.id} className="card">
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
|
<strong>{pred.username}</strong>
|
|
<span style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
|
{new Date(pred.created_at).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
<p style={{ marginBottom: '1rem' }}>{pred.content}</p>
|
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
{pred.status === 'pending' && pred.user_id !== user.id && (
|
|
<>
|
|
<button
|
|
className="btn btn-success btn-sm"
|
|
onClick={() => handleValidate(pred.id, 'validated')}
|
|
disabled={validating === pred.id}
|
|
>
|
|
{validating === pred.id ? '...' : '✓ Validate'}
|
|
</button>
|
|
<button
|
|
className="btn btn-danger btn-sm"
|
|
onClick={() => handleValidate(pred.id, 'invalidated')}
|
|
disabled={validating === pred.id}
|
|
>
|
|
{validating === pred.id ? '...' : '✗ Invalidate'}
|
|
</button>
|
|
</>
|
|
)}
|
|
{pred.status === 'validated' && (
|
|
<span style={{ color: 'var(--success)', fontSize: '0.875rem' }}>
|
|
✓ Validated by {pred.validated_by_username}
|
|
</span>
|
|
)}
|
|
{pred.status === 'invalidated' && (
|
|
<span style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>
|
|
✗ Invalidated by {pred.validated_by_username}
|
|
</span>
|
|
)}
|
|
{pred.status === 'pending' && (
|
|
<span style={{ color: 'var(--warning)', fontSize: '0.875rem' }}>
|
|
⏳ Pending validation
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sidebar - Leaderboard (Desktop only) */}
|
|
<div className="challenge-sidebar">
|
|
<div className="card">
|
|
<h3 style={{ marginBottom: '1rem' }}>Leaderboard</h3>
|
|
{leaderboard.length === 0 ? (
|
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>No points yet</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
{leaderboard.map((entry, index) => (
|
|
<div key={entry.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<div>
|
|
<span style={{ marginRight: '0.5rem', color: 'var(--text-muted)' }}>{index + 1}.</span>
|
|
<strong>{entry.username}</strong>
|
|
</div>
|
|
<span style={{ color: 'var(--primary)', fontWeight: 600 }}>
|
|
{entry.validated_points} pts
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|