website refactor
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { DriverIdentity } from '../drivers/DriverIdentity';
|
||||
import { useEffectiveDriverId } from '@/lib/hooks/useEffectiveDriverId';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
// Migrated to useInject-based DI; legacy EntityMapper removed.
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { LeagueMemberTable } from '@/ui/LeagueMemberTable';
|
||||
import { LeagueMemberRow } from '@/ui/LeagueMemberRow';
|
||||
import { MinimalEmptyState } from '@/ui/EmptyState';
|
||||
|
||||
interface LeagueMembersProps {
|
||||
leagueId: string;
|
||||
@@ -18,7 +23,7 @@ interface LeagueMembersProps {
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
export default function LeagueMembers({
|
||||
export function LeagueMembers({
|
||||
leagueId,
|
||||
onRemoveMember,
|
||||
onUpdateRole,
|
||||
@@ -44,7 +49,8 @@ export default function LeagueMembers({
|
||||
const driverDtos = await driverService.findByIds(uniqueDriverIds);
|
||||
|
||||
const byId: Record<string, DriverViewModel> = {};
|
||||
for (const dto of driverDtos) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
for (const dto of driverDtos as any[]) {
|
||||
byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null });
|
||||
}
|
||||
setDriversById(byId);
|
||||
@@ -72,12 +78,11 @@ export default function LeagueMembers({
|
||||
return order[role];
|
||||
};
|
||||
|
||||
const getDriverStats = (driverId: string): { rating: number; wins: number; overallRank: number } | null => {
|
||||
// This would typically come from a driver stats service
|
||||
// For now, return null as the original implementation was missing
|
||||
const getDriverStats = (): { rating: number; wins: number; overallRank: number } | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
|
||||
const sortedMembers = [...members].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'role':
|
||||
@@ -87,15 +92,15 @@ export default function LeagueMembers({
|
||||
case 'date':
|
||||
return new Date(b.joinedAt).getTime() - new Date(a.joinedAt).getTime();
|
||||
case 'rating': {
|
||||
const statsA = getDriverStats(a.driverId);
|
||||
const statsB = getDriverStats(b.driverId);
|
||||
const statsA = getDriverStats();
|
||||
const statsB = getDriverStats();
|
||||
return (statsB?.rating || 0) - (statsA?.rating || 0);
|
||||
}
|
||||
case 'points':
|
||||
return 0;
|
||||
case 'wins': {
|
||||
const statsA = getDriverStats(a.driverId);
|
||||
const statsB = getDriverStats(b.driverId);
|
||||
const statsA = getDriverStats();
|
||||
const statsB = getDriverStats();
|
||||
return (statsB?.wins || 0) - (statsA?.wins || 0);
|
||||
}
|
||||
default:
|
||||
@@ -103,180 +108,120 @@ export default function LeagueMembers({
|
||||
}
|
||||
});
|
||||
|
||||
const getRoleBadgeColor = (role: MembershipRole): string => {
|
||||
const getRoleVariant = (role: MembershipRole): 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30';
|
||||
case 'admin':
|
||||
return 'bg-purple-500/10 text-purple-400 border-purple-500/30';
|
||||
case 'steward':
|
||||
return 'bg-blue-500/10 text-blue-400 border-blue-500/30';
|
||||
case 'member':
|
||||
return 'bg-primary-blue/10 text-primary-blue border-primary-blue/30';
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-400 border-gray-500/30';
|
||||
case 'owner': return 'warning';
|
||||
case 'admin': return 'primary';
|
||||
case 'steward': return 'info';
|
||||
case 'member': return 'primary';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
Loading members...
|
||||
</div>
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="text-gray-400">Loading members...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
No members found
|
||||
</div>
|
||||
<MinimalEmptyState
|
||||
title="No members found"
|
||||
description="This league doesn't have any members yet."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Box>
|
||||
{/* Sort Controls */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-400">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={4}>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{members.length} {members.length === 1 ? 'member' : 'members'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-400">Sort by:</label>
|
||||
<select
|
||||
</Text>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Text as="label" size="sm" color="text-gray-400">Sort by:</Text>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="rating">Rating</option>
|
||||
<option value="points">Points</option>
|
||||
<option value="wins">Wins</option>
|
||||
<option value="role">Role</option>
|
||||
<option value="name">Name</option>
|
||||
<option value="date">Join Date</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSortBy(e.target.value as typeof sortBy)}
|
||||
options={[
|
||||
{ value: 'rating', label: 'Rating' },
|
||||
{ value: 'points', label: 'Points' },
|
||||
{ value: 'wins', label: 'Wins' },
|
||||
{ value: 'role', label: 'Role' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'date', label: 'Join Date' },
|
||||
]}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Members Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Rating</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Rank</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Wins</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Role</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Joined</th>
|
||||
{showActions && <th className="text-right py-3 px-4 text-sm font-semibold text-gray-400">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedMembers.map((member, index) => {
|
||||
const isCurrentUser = member.driverId === currentDriverId;
|
||||
const cannotModify = member.role === 'owner';
|
||||
const driverStats = getDriverStats(member.driverId);
|
||||
const isTopPerformer = index < 3 && sortBy === 'rating';
|
||||
const driver = driversById[member.driverId];
|
||||
const roleLabel =
|
||||
member.role.charAt(0).toUpperCase() + member.role.slice(1);
|
||||
const ratingAndWinsMeta =
|
||||
driverStats && typeof driverStats.rating === 'number'
|
||||
? `Rating ${driverStats.rating} • ${driverStats.wins ?? 0} wins`
|
||||
: null;
|
||||
<Box overflow="auto">
|
||||
<LeagueMemberTable showActions={showActions}>
|
||||
{sortedMembers.map((member, index) => {
|
||||
const isCurrentUser = member.driverId === currentDriverId;
|
||||
const cannotModify = member.role === 'owner';
|
||||
const driverStats = getDriverStats();
|
||||
const isTopPerformer = index < 3 && sortBy === 'rating';
|
||||
const driver = driversById[member.driverId];
|
||||
const ratingAndWinsMeta =
|
||||
driverStats && typeof driverStats.rating === 'number'
|
||||
? `Rating ${driverStats.rating} • ${driverStats.wins ?? 0} wins`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={member.driverId}
|
||||
className={`border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors ${isTopPerformer ? 'bg-primary-blue/5' : ''}`}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{driver ? (
|
||||
<DriverIdentity
|
||||
driver={driver}
|
||||
href={`/drivers/${member.driverId}?from=league-members&leagueId=${leagueId}`}
|
||||
contextLabel={roleLabel}
|
||||
meta={ratingAndWinsMeta}
|
||||
size="md"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white">Unknown Driver</span>
|
||||
)}
|
||||
{isCurrentUser && (
|
||||
<span className="text-xs text-gray-500">(You)</span>
|
||||
)}
|
||||
{isTopPerformer && (
|
||||
<span className="text-xs">⭐</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-primary-blue font-medium">
|
||||
{driverStats?.rating || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-gray-300">
|
||||
#{driverStats?.overallRank || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-green-400 font-medium">
|
||||
{driverStats?.wins || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${getRoleBadgeColor(member.role)}`}>
|
||||
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white text-sm">
|
||||
{new Date(member.joinedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</td>
|
||||
{showActions && (
|
||||
<td className="py-3 px-4 text-right">
|
||||
{!cannotModify && !isCurrentUser && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{onUpdateRole && (
|
||||
<select
|
||||
value={member.role}
|
||||
onChange={(e) => onUpdateRole(member.driverId, e.target.value as MembershipRole)}
|
||||
className="px-2 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-xs focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="steward">Steward</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
)}
|
||||
{onRemoveMember && (
|
||||
<button
|
||||
onClick={() => onRemoveMember(member.driverId)}
|
||||
className="px-2 py-1 text-xs font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{cannotModify && (
|
||||
<span className="text-xs text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<LeagueMemberRow
|
||||
key={member.driverId}
|
||||
driver={driver}
|
||||
driverId={member.driverId}
|
||||
isCurrentUser={isCurrentUser}
|
||||
isTopPerformer={isTopPerformer}
|
||||
role={member.role}
|
||||
roleVariant={getRoleVariant(member.role)}
|
||||
joinedAt={member.joinedAt}
|
||||
rating={driverStats?.rating}
|
||||
rank={driverStats?.overallRank}
|
||||
wins={driverStats?.wins}
|
||||
href={routes.driver.detail(member.driverId)}
|
||||
meta={ratingAndWinsMeta}
|
||||
actions={showActions && !cannotModify && !isCurrentUser ? (
|
||||
<Box display="flex" alignItems="center" justifyContent="end" gap={2}>
|
||||
{onUpdateRole && (
|
||||
<Select
|
||||
value={member.role}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onUpdateRole(member.driverId, e.target.value as MembershipRole)}
|
||||
options={[
|
||||
{ value: 'member', label: 'Member' },
|
||||
{ value: 'steward', label: 'Steward' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
]}
|
||||
fullWidth={false}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="text-xs py-1 px-2"
|
||||
/>
|
||||
)}
|
||||
{onRemoveMember && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onRemoveMember(member.driverId)}
|
||||
size="sm"
|
||||
color="text-error-red"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
) : (showActions && cannotModify ? <Text size="xs" color="text-gray-500">—</Text> : undefined)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LeagueMemberTable>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user