feat: integrate heats system into EventChatPage

- Add state management for heats (myHeats, userHeats Map, showHeatsBanner, hideMyHeats, showHeatsModal)
- Load user's heats and all users' heats on component mount
- Display HeatsBanner when user has no heats declared
- Add "Edit Heats" button in header for users with declared heats
- Add modal for editing heats via HeatsBanner component
- Display heat badges under usernames in sidebar (format: J&J NOV 1 L)
- Show max 3 badges per user, with "+N" indicator for more
- Add filter checkbox to hide users from same heats
- Implement filter logic (hide if ANY heat matches: division + competition_type + heat_number)
- Disable UserPlus (match) button for users without declared heats
- Add Socket.IO heats_updated listener for real-time updates
- Update todo list to mark EventChatPage integration as completed
This commit is contained in:
Radosław Gierwiało
2025-11-14 17:41:35 +01:00
parent 37d2a7c548
commit d88d972c03
2 changed files with 395 additions and 53 deletions

View File

@@ -2,9 +2,10 @@ import { useState, useRef, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import Layout from '../components/layout/Layout';
import { useAuth } from '../contexts/AuthContext';
import { Send, UserPlus, Loader2, LogOut, AlertTriangle, QrCode } from 'lucide-react';
import { Send, UserPlus, Loader2, LogOut, AlertTriangle, QrCode, Edit2, Filter, X } from 'lucide-react';
import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
import { eventsAPI } from '../services/api';
import { eventsAPI, heatsAPI } from '../services/api';
import HeatsBanner from '../components/heats/HeatsBanner';
const EventChatPage = () => {
const { slug } = useParams();
@@ -24,6 +25,13 @@ const EventChatPage = () => {
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
// Heats state
const [myHeats, setMyHeats] = useState([]);
const [userHeats, setUserHeats] = useState(new Map());
const [showHeatsBanner, setShowHeatsBanner] = useState(false);
const [hideMyHeats, setHideMyHeats] = useState(false);
const [showHeatsModal, setShowHeatsModal] = useState(false);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
@@ -57,6 +65,32 @@ const EventChatPage = () => {
fetchEvent();
}, [slug]);
// Load heats data
useEffect(() => {
if (!event || !isParticipant) return;
const loadHeats = async () => {
try {
// Load my heats
const myHeatsData = await heatsAPI.getMyHeats(slug);
setMyHeats(myHeatsData);
setShowHeatsBanner(myHeatsData.length === 0);
// Load all users' heats
const allHeatsData = await heatsAPI.getAllHeats(slug);
const heatsMap = new Map();
allHeatsData.forEach((userHeat) => {
heatsMap.set(userHeat.userId, userHeat.heats);
});
setUserHeats(heatsMap);
} catch (error) {
console.error('Failed to load heats:', error);
}
};
loadHeats();
}, [event, isParticipant, slug]);
useEffect(() => {
scrollToBottom();
}, [messages]);
@@ -115,6 +149,28 @@ const EventChatPage = () => {
console.log(`${userData.username} left the room`);
});
// Heats updated notification
socket.on('heats_updated', (data) => {
const { userId, heats } = data;
// Update userHeats map
setUserHeats((prev) => {
const newMap = new Map(prev);
if (heats && heats.length > 0) {
newMap.set(userId, heats);
} else {
newMap.delete(userId);
}
return newMap;
});
// If it's the current user, update myHeats
if (userId === user.id) {
setMyHeats(heats || []);
setShowHeatsBanner(heats.length === 0);
}
});
// Cleanup
return () => {
socket.emit('leave_event_room');
@@ -125,6 +181,7 @@ const EventChatPage = () => {
socket.off('active_users');
socket.off('user_joined');
socket.off('user_left');
socket.off('heats_updated');
};
}, [event, slug, user.id]);
@@ -187,6 +244,41 @@ const EventChatPage = () => {
}, 1000);
};
const handleHeatsSave = () => {
setShowHeatsBanner(false);
setShowHeatsModal(false);
// Heats will be updated via Socket.IO heats_updated event
};
const formatHeat = (heat) => {
const parts = [
heat.competitionType?.abbreviation || '',
heat.division?.abbreviation || '',
heat.heatNumber,
];
if (heat.role) {
parts.push(heat.role.charAt(0)); // L or F
}
return parts.join(' ');
};
const shouldHideUser = (userId) => {
if (!hideMyHeats || myHeats.length === 0) return false;
const targetUserHeats = userHeats.get(userId);
if (!targetUserHeats || targetUserHeats.length === 0) return false;
// Hide if ANY of their heats match ANY of my heats (same division + competition_type + heat_number)
return targetUserHeats.some((targetHeat) =>
myHeats.some(
(myHeat) =>
myHeat.divisionId === targetHeat.divisionId &&
myHeat.competitionTypeId === targetHeat.competitionTypeId &&
myHeat.heatNumber === targetHeat.heatNumber
)
);
};
const handleLeaveEvent = async () => {
try {
setIsLeaving(true);
@@ -304,51 +396,120 @@ const EventChatPage = () => {
<div className="bg-white rounded-lg shadow-md overflow-hidden">
{/* Header */}
<div className="bg-primary-600 text-white p-4">
<h2 className="text-2xl font-bold">{event.name}</h2>
<p className="text-primary-100 text-sm">{event.location}</p>
<div className="mt-2">
<span className={`text-xs px-2 py-1 rounded ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}>
{isConnected ? '● Connected' : '● Disconnected'}
</span>
<div className="flex items-start justify-between">
<div className="flex-1">
<h2 className="text-2xl font-bold">{event.name}</h2>
<p className="text-primary-100 text-sm">{event.location}</p>
<div className="mt-2">
<span className={`text-xs px-2 py-1 rounded ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}>
{isConnected ? '● Connected' : '● Disconnected'}
</span>
</div>
</div>
{myHeats.length > 0 && (
<button
onClick={() => setShowHeatsModal(true)}
className="flex items-center gap-2 px-3 py-2 bg-primary-700 hover:bg-primary-800 rounded-md transition-colors text-sm"
>
<Edit2 className="w-4 h-4" />
Edit Heats
</button>
)}
</div>
</div>
{/* Heats Banner */}
{showHeatsBanner && (
<HeatsBanner
slug={slug}
onSave={handleHeatsSave}
onDismiss={() => setShowHeatsBanner(false)}
/>
)}
<div className="flex h-[calc(100vh-280px)]">
{/* Active Users Sidebar */}
<div className="w-64 border-r bg-gray-50 p-4 overflow-y-auto">
<h3 className="font-semibold text-gray-900 mb-4">
Active users ({activeUsers.length})
</h3>
{activeUsers.length === 0 && (
<div className="mb-4">
<h3 className="font-semibold text-gray-900 mb-3">
Active users ({activeUsers.filter(u => !shouldHideUser(u.userId)).length})
</h3>
{/* Filter Checkbox */}
{myHeats.length > 0 && (
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer hover:text-gray-900">
<input
type="checkbox"
checked={hideMyHeats}
onChange={(e) => setHideMyHeats(e.target.checked)}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<Filter className="w-3 h-3" />
<span>Hide users from my heats</span>
</label>
)}
</div>
{activeUsers.filter(u => !shouldHideUser(u.userId)).length === 0 && (
<p className="text-sm text-gray-500">No other users online</p>
)}
<div className="space-y-2">
{activeUsers.map((activeUser) => (
<div
key={activeUser.userId}
className="flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg"
>
<div className="flex items-center space-x-2">
<img
src={activeUser.avatar}
alt={activeUser.username}
className="w-8 h-8 rounded-full"
/>
<div>
<p className="text-sm font-medium text-gray-900">
{activeUser.username}
</p>
{activeUsers
.filter((activeUser) => !shouldHideUser(activeUser.userId))
.map((activeUser) => {
const thisUserHeats = userHeats.get(activeUser.userId) || [];
const hasHeats = thisUserHeats.length > 0;
return (
<div
key={activeUser.userId}
className="flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg"
>
<div className="flex items-center space-x-2 flex-1 min-w-0">
<img
src={activeUser.avatar}
alt={activeUser.username}
className="w-8 h-8 rounded-full flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{activeUser.username}
</p>
{/* Heat Badges */}
{hasHeats && (
<div className="flex flex-wrap gap-1 mt-1">
{thisUserHeats.slice(0, 3).map((heat, idx) => (
<span
key={idx}
className="text-xs px-1.5 py-0.5 bg-amber-100 text-amber-800 rounded font-mono"
>
{formatHeat(heat)}
</span>
))}
{thisUserHeats.length > 3 && (
<span className="text-xs px-1.5 py-0.5 bg-gray-200 text-gray-700 rounded font-mono">
+{thisUserHeats.length - 3}
</span>
)}
</div>
)}
</div>
</div>
<button
onClick={() => handleMatchWith(activeUser.userId)}
disabled={!hasHeats}
className={`p-1 rounded flex-shrink-0 ${
hasHeats
? 'text-primary-600 hover:bg-primary-50'
: 'text-gray-300 cursor-not-allowed'
}`}
title={hasHeats ? 'Connect' : 'User has not declared heats'}
>
<UserPlus className="w-4 h-4" />
</button>
</div>
</div>
<button
onClick={() => handleMatchWith(activeUser.userId)}
className="p-1 text-primary-600 hover:bg-primary-50 rounded"
title="Connect"
>
<UserPlus className="w-4 h-4" />
</button>
</div>
))}
);
})}
</div>
</div>
@@ -490,6 +651,30 @@ const EventChatPage = () => {
</div>
</div>
)}
{/* Edit Heats Modal */}
{showHeatsModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Edit Your Competition Heats</h3>
<button
onClick={() => setShowHeatsModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6">
<HeatsBanner
slug={slug}
onSave={handleHeatsSave}
onDismiss={() => setShowHeatsModal(false)}
/>
</div>
</div>
</div>
)}
</div>
</Layout>
);