feat(chat): add user status grouping and 'No heats' indicator
Improved participants sidebar UX by grouping users and clearly showing who can't be contacted for match requests and why. User Status Groups (in order): 1. Available - Online with declared heats (🟢 green dot) - Ready to receive match requests - Shows heat badges - Active match button with "Send match request" tooltip 2. Online - No Heats - Online but no heats declared (🟡 yellow dot) - Shows "No heats declared" gray badge - Match button disabled with "User has not declared heats yet" tooltip - Clear visual indicator of unavailability reason 3. Offline - Not currently online (⚪ gray dot) - Can still send requests if they have heats (button faded) - Shows "No heats declared" badge if no heats - Match button disabled if no heats Visual Improvements: - Color-coded status dots for quick scanning - Section headers with user counts per group - "No heats declared" badge for users without heats - Clear, contextual tooltips on disabled states - Better spacing between groups (space-y-4 vs space-y-2) Benefits: - Users immediately see who's available to match - No confusion about why buttons are disabled - Priority given to online users with heats - Reduced support questions - Better conversion (users know what to do) Applies to: - Desktop sidebar (visible on chat tab) - Mobile participants tab
This commit is contained in:
@@ -43,6 +43,21 @@ const ParticipantsSidebar = ({
|
|||||||
const participantCount = users.length;
|
const participantCount = users.length;
|
||||||
const onlineCount = activeUsers.length;
|
const onlineCount = activeUsers.length;
|
||||||
|
|
||||||
|
// Group users by status for better UX
|
||||||
|
const groupedUsers = users.reduce((groups, user) => {
|
||||||
|
const hasHeats = (userHeats.get(user.userId) || []).length > 0;
|
||||||
|
const isOnline = user.isOnline ?? false;
|
||||||
|
|
||||||
|
if (isOnline && hasHeats) {
|
||||||
|
groups.onlineWithHeats.push(user);
|
||||||
|
} else if (isOnline && !hasHeats) {
|
||||||
|
groups.onlineNoHeats.push(user);
|
||||||
|
} else {
|
||||||
|
groups.offline.push(user);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, { onlineWithHeats: [], onlineNoHeats: [], offline: [] });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${fullWidth ? 'w-full' : 'w-64 border-r'} bg-gray-50 p-4 overflow-y-auto ${className}`}>
|
<div className={`${fullWidth ? 'w-full' : 'w-64 border-r'} bg-gray-50 p-4 overflow-y-auto ${className}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -78,38 +93,129 @@ const ParticipantsSidebar = ({
|
|||||||
<p className="text-sm text-gray-500">No other participants</p>
|
<p className="text-sm text-gray-500">No other participants</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User List */}
|
{/* User List - Grouped by Status */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
{users.map((displayUser) => {
|
{/* Online with Heats - Ready to match */}
|
||||||
const thisUserHeats = userHeats.get(displayUser.userId) || [];
|
{groupedUsers.onlineWithHeats.length > 0 && (
|
||||||
const competitorNumber = userCompetitorNumbers.get(displayUser.userId);
|
<div>
|
||||||
const hasHeats = thisUserHeats.length > 0;
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
||||||
|
Available ({groupedUsers.onlineWithHeats.length})
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{groupedUsers.onlineWithHeats.map((displayUser) => {
|
||||||
|
const thisUserHeats = userHeats.get(displayUser.userId) || [];
|
||||||
|
const competitorNumber = userCompetitorNumbers.get(displayUser.userId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserListItem
|
<UserListItem
|
||||||
key={displayUser.userId}
|
key={displayUser.userId}
|
||||||
user={displayUser}
|
user={displayUser}
|
||||||
heats={thisUserHeats}
|
heats={thisUserHeats}
|
||||||
competitorNumber={competitorNumber}
|
competitorNumber={competitorNumber}
|
||||||
showHeats={true}
|
showHeats={true}
|
||||||
linkToProfile={true}
|
linkToProfile={true}
|
||||||
actionButton={
|
actionButton={
|
||||||
<button
|
<button
|
||||||
onClick={() => onMatchWith?.(displayUser.userId)}
|
onClick={() => onMatchWith?.(displayUser.userId)}
|
||||||
disabled={!hasHeats}
|
className="p-1 rounded flex-shrink-0 text-primary-600 hover:bg-primary-50"
|
||||||
className={`p-1 rounded flex-shrink-0 ${
|
title="Send match request"
|
||||||
hasHeats
|
>
|
||||||
? 'text-primary-600 hover:bg-primary-50'
|
<UserPlus className="w-4 h-4" />
|
||||||
: 'text-gray-300 cursor-not-allowed'
|
</button>
|
||||||
}`}
|
}
|
||||||
title={hasHeats ? 'Connect' : 'User has not declared heats'}
|
/>
|
||||||
>
|
);
|
||||||
<UserPlus className="w-4 h-4" />
|
})}
|
||||||
</button>
|
</div>
|
||||||
}
|
</div>
|
||||||
/>
|
)}
|
||||||
);
|
|
||||||
})}
|
{/* Online without Heats - Can't match yet */}
|
||||||
|
{groupedUsers.onlineNoHeats.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
|
||||||
|
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
||||||
|
Online - No Heats ({groupedUsers.onlineNoHeats.length})
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{groupedUsers.onlineNoHeats.map((displayUser) => {
|
||||||
|
const competitorNumber = userCompetitorNumbers.get(displayUser.userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserListItem
|
||||||
|
key={displayUser.userId}
|
||||||
|
user={displayUser}
|
||||||
|
heats={[]}
|
||||||
|
competitorNumber={competitorNumber}
|
||||||
|
showHeats={true}
|
||||||
|
linkToProfile={true}
|
||||||
|
actionButton={
|
||||||
|
<div
|
||||||
|
className="p-1 rounded flex-shrink-0 text-gray-300 cursor-not-allowed"
|
||||||
|
title="User has not declared heats yet"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Offline */}
|
||||||
|
{groupedUsers.offline.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||||
|
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
||||||
|
Offline ({groupedUsers.offline.length})
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{groupedUsers.offline.map((displayUser) => {
|
||||||
|
const thisUserHeats = userHeats.get(displayUser.userId) || [];
|
||||||
|
const competitorNumber = userCompetitorNumbers.get(displayUser.userId);
|
||||||
|
const hasHeats = thisUserHeats.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserListItem
|
||||||
|
key={displayUser.userId}
|
||||||
|
user={displayUser}
|
||||||
|
heats={thisUserHeats}
|
||||||
|
competitorNumber={competitorNumber}
|
||||||
|
showHeats={true}
|
||||||
|
linkToProfile={true}
|
||||||
|
actionButton={
|
||||||
|
hasHeats ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onMatchWith?.(displayUser.userId)}
|
||||||
|
className="p-1 rounded flex-shrink-0 text-primary-600 hover:bg-primary-50 opacity-60"
|
||||||
|
title="Send match request (user is offline)"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="p-1 rounded flex-shrink-0 text-gray-300 cursor-not-allowed"
|
||||||
|
title="User has not declared heats yet"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,12 +83,19 @@ const UserListItem = ({
|
|||||||
{user.firstName} {user.lastName}
|
{user.firstName} {user.lastName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{/* Heat Badges */}
|
{/* Heat Badges or No Heats indicator */}
|
||||||
{showHeats && hasHeats && (
|
{showHeats && hasHeats && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<HeatBadges heats={heats} maxVisible={3} />
|
<HeatBadges heats={heats} maxVisible={3} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showHeats && !hasHeats && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-600 rounded">
|
||||||
|
No heats declared
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{actionButton && (
|
{actionButton && (
|
||||||
|
|||||||
Reference in New Issue
Block a user