This commit is contained in:
2025-12-04 23:31:55 +01:00
parent 9fa21a488a
commit fb509607c1
96 changed files with 5839 additions and 1609 deletions

View File

@@ -3,20 +3,20 @@
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import UserPill from '@/components/profile/UserPill';
import { useAuth } from '@/lib/auth/AuthContext';
type AlphaNavProps = {
isAuthenticated?: boolean;
};
type AlphaNavProps = Record<string, never>;
const nonHomeLinks = [
{ href: '/profile', label: 'Profile' },
{ href: '/leagues', label: 'Leagues' },
{ href: '/teams', label: 'Teams' },
{ href: '/drivers', label: 'Drivers' },
] as const;
export function AlphaNav({ isAuthenticated }: AlphaNavProps) {
export function AlphaNav({}: AlphaNavProps) {
const pathname = usePathname();
const { session } = useAuth();
const isAuthenticated = !!session;
const navLinks = isAuthenticated
? ([{ href: '/dashboard', label: 'Dashboard' } as const, ...nonHomeLinks] as const)
@@ -64,24 +64,7 @@ export function AlphaNav({ isAuthenticated }: AlphaNavProps) {
</div>
<div className="hidden md:flex items-center space-x-3">
{!isAuthenticated && (
<Link
href={loginHref}
className="inline-flex items-center justify-center px-3 py-1.5 rounded-md bg-primary-blue text-xs font-medium text-white hover:bg-primary-blue/90 transition-colors"
>
Authenticate with iRacing
</Link>
)}
{isAuthenticated && (
<form action="/auth/logout" method="POST">
<button
type="submit"
className="inline-flex items-center justify-center px-3 py-1.5 rounded-md border border-gray-600 text-xs font-medium text-gray-200 hover:bg-gray-800 transition-colors"
>
Logout
</button>
</form>
)}
<UserPill />
</div>
<div className="md:hidden w-8" />

View File

@@ -1,7 +1,7 @@
import Image from 'next/image';
import Card from '@/components/ui/Card';
import RankBadge from '@/components/drivers/RankBadge';
import { getDriverAvatarUrl } from '@/lib/racingLegacyFacade';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
export interface DriverCardProps {
id: string;
@@ -29,6 +29,14 @@ export default function DriverCard(props: DriverCardProps) {
onClick,
} = props;
const driver: DriverDTO = {
id,
iracingId: '',
name,
country: nationality,
joinedAt: '',
};
return (
<Card
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
@@ -38,22 +46,12 @@ export default function DriverCard(props: DriverCardProps) {
<div className="flex items-center gap-4 flex-1">
<RankBadge rank={rank} size="lg" />
<div className="w-16 h-16 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center">
<Image
src={getDriverAvatarUrl(id)}
alt={name}
width={64}
height={64}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-white mb-1">{name}</h3>
<p className="text-sm text-gray-400">
{nationality} {racesCompleted} races
</p>
</div>
<DriverIdentity
driver={driver}
href={`/drivers/${id}`}
meta={`${nationality}${racesCompleted} races`}
size="md"
/>
</div>
<div className="flex items-center gap-8 text-center">

View File

@@ -0,0 +1,63 @@
import Link from 'next/link';
import Image from 'next/image';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { getImageService } from '@/lib/di-container';
export interface DriverIdentityProps {
driver: DriverDTO;
href?: string;
contextLabel?: React.ReactNode;
meta?: React.ReactNode;
size?: 'sm' | 'md';
}
export default function DriverIdentity(props: DriverIdentityProps) {
const { driver, href, contextLabel, meta, size = 'md' } = props;
const avatarSize = size === 'sm' ? 40 : 48;
const nameTextClasses =
size === 'sm'
? 'text-sm font-medium text-white'
: 'text-base md:text-lg font-semibold text-white';
const metaTextClasses = 'text-xs md:text-sm text-gray-400';
const content = (
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
<div
className={`rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0`}
style={{ width: avatarSize, height: avatarSize }}
>
<Image
src={getImageService().getDriverAvatar(driver.id)}
alt={driver.name}
width={avatarSize}
height={avatarSize}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className={`${nameTextClasses} truncate`}>{driver.name}</span>
{contextLabel ? (
<span className="inline-flex items-center rounded-full bg-charcoal-outline/60 px-2 py-0.5 text-[10px] md:text-xs font-medium text-gray-200">
{contextLabel}
</span>
) : null}
</div>
{meta ? <div className={`${metaTextClasses} mt-0.5 truncate`}>{meta}</div> : null}
</div>
</div>
);
if (href) {
return (
<Link href={href} className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
{content}
</Link>
);
}
return <div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">{content}</div>;
}

View File

@@ -7,16 +7,30 @@ import ProfileStats from './ProfileStats';
import CareerHighlights from './CareerHighlights';
import DriverRankings from './DriverRankings';
import PerformanceMetrics from './PerformanceMetrics';
import { getDriverTeam } from '@/lib/racingLegacyFacade';
import { getDriverStats, getLeagueRankings } from '@/lib/di-container';
import { useEffect, useState } from 'react';
import { getDriverStats, getLeagueRankings, getGetDriverTeamQuery, getAllDriverRankings } from '@/lib/di-container';
import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO';
interface DriverProfileProps {
driver: DriverDTO;
isOwnProfile?: boolean;
onEditClick?: () => void;
}
export default function DriverProfile({ driver }: DriverProfileProps) {
export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
const driverStats = getDriverStats(driver.id);
const leagueRank = getLeagueRankings(driver.id, 'league-1');
const allRankings = getAllDriverRankings();
const [teamData, setTeamData] = useState<GetDriverTeamQueryResultDTO | null>(null);
useEffect(() => {
const load = async () => {
const query = getGetDriverTeamQuery();
const result = await query.execute({ driverId: driver.id });
setTeamData(result);
};
void load();
}, [driver.id]);
const performanceStats = driverStats ? {
winRate: (driverStats.wins / driverStats.totalRaces) * 100,
@@ -33,7 +47,7 @@ export default function DriverProfile({ driver }: DriverProfileProps) {
type: 'overall' as const,
name: 'Overall Ranking',
rank: driverStats.overallRank,
totalDrivers: 850,
totalDrivers: allRankings.length,
percentile: driverStats.percentile,
rating: driverStats.rating,
},
@@ -50,7 +64,15 @@ export default function DriverProfile({ driver }: DriverProfileProps) {
return (
<div className="space-y-6">
<Card>
<ProfileHeader driver={driver} isOwnProfile={false} />
<ProfileHeader
driver={driver}
rating={driverStats?.rating ?? null}
rank={driverStats?.overallRank ?? null}
isOwnProfile={isOwnProfile}
onEditClick={isOwnProfile ? onEditClick : undefined}
teamName={teamData?.team.name ?? null}
teamTag={teamData?.team.tag ?? null}
/>
</Card>
{driver.bio && (
@@ -82,46 +104,13 @@ export default function DriverProfile({ driver }: DriverProfileProps) {
{!driverStats && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<Card className="lg:col-span-3">
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3>
<div className="grid grid-cols-2 gap-4">
<StatCard label="Rating" value="1450" color="text-primary-blue" />
<StatCard label="Total Races" value="147" color="text-white" />
<StatCard label="Wins" value="23" color="text-green-400" />
<StatCard label="Podiums" value="56" color="text-warning-amber" />
</div>
<p className="text-gray-400 text-sm">
No statistics available yet. Compete in races to start building your record.
</p>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Team</h3>
{(() => {
const teamData = getDriverTeam(driver.id);
if (teamData) {
const { team, membership } = teamData;
return (
<div className="flex items-center gap-4 p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
<div className="w-12 h-12 rounded-lg bg-primary-blue/20 flex items-center justify-center text-xl font-bold text-white">
{team.tag}
</div>
<div>
<div className="text-white font-medium">{team.name}</div>
<div className="text-sm text-gray-400">
{membership.role.charAt(0).toUpperCase() + membership.role.slice(1)} Joined {new Date(membership.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</div>
</div>
</div>
);
}
return (
<div className="text-center py-4 text-gray-400 text-sm">
Not on a team
</div>
);
})()}
</Card>
</div>
</div>
)}
<Card>

View File

@@ -20,25 +20,28 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
const driverStats = driverId ? getDriverStats(driverId) : null;
const allRankings = getAllDriverRankings();
const leagueRank = driverId ? getLeagueRankings(driverId, 'league-1') : null;
const defaultStats = stats || (driverStats ? {
totalRaces: driverStats.totalRaces,
wins: driverStats.wins,
podiums: driverStats.podiums,
dnfs: driverStats.dnfs,
avgFinish: driverStats.avgFinish,
completionRate: ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
} : {
totalRaces: 147,
wins: 23,
podiums: 56,
dnfs: 12,
avgFinish: 5.8,
completionRate: 91.8
});
const winRate = ((defaultStats.wins / defaultStats.totalRaces) * 100).toFixed(1);
const podiumRate = ((defaultStats.podiums / defaultStats.totalRaces) * 100).toFixed(1);
const defaultStats = stats || (driverStats
? {
totalRaces: driverStats.totalRaces,
wins: driverStats.wins,
podiums: driverStats.podiums,
dnfs: driverStats.dnfs,
avgFinish: driverStats.avgFinish,
completionRate:
((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) *
100,
}
: null);
const winRate =
defaultStats && defaultStats.totalRaces > 0
? ((defaultStats.wins / defaultStats.totalRaces) * 100).toFixed(1)
: '0.0';
const podiumRate =
defaultStats && defaultStats.totalRaces > 0
? ((defaultStats.podiums / defaultStats.totalRaces) * 100).toFixed(1)
: '0.0';
const getTrendIndicator = (value: number) => {
if (value > 0) return '↑';
@@ -131,41 +134,74 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
</Card>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: 'Total Races', value: defaultStats.totalRaces, color: 'text-primary-blue' },
{ label: 'Wins', value: defaultStats.wins, color: 'text-green-400' },
{ label: 'Podiums', value: defaultStats.podiums, color: 'text-warning-amber' },
{ label: 'DNFs', value: defaultStats.dnfs, color: 'text-red-400' },
{ label: 'Avg Finish', value: defaultStats.avgFinish.toFixed(1), color: 'text-white' },
{ label: 'Completion', value: `${defaultStats.completionRate.toFixed(1)}%`, color: 'text-green-400' },
{ label: 'Win Rate', value: `${winRate}%`, color: 'text-primary-blue' },
{ label: 'Podium Rate', value: `${podiumRate}%`, color: 'text-warning-amber' }
].map((stat, index) => (
<Card key={index} className="text-center">
<div className="text-sm text-gray-400 mb-1">{stat.label}</div>
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
</Card>
))}
</div>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Performance by Car Class</h3>
<div className="space-y-3 text-sm">
<PerformanceRow label="GT3" races={45} wins={12} podiums={23} avgFinish={4.2} />
<PerformanceRow label="Formula" races={38} wins={7} podiums={15} avgFinish={6.1} />
<PerformanceRow label="LMP2" races={32} wins={4} podiums={11} avgFinish={7.3} />
<PerformanceRow label="Other" races={32} wins={0} podiums={7} avgFinish={8.5} />
</div>
</Card>
{defaultStats ? (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{
label: 'Total Races',
value: defaultStats.totalRaces,
color: 'text-primary-blue',
},
{ label: 'Wins', value: defaultStats.wins, color: 'text-green-400' },
{
label: 'Podiums',
value: defaultStats.podiums,
color: 'text-warning-amber',
},
{ label: 'DNFs', value: defaultStats.dnfs, color: 'text-red-400' },
{
label: 'Avg Finish',
value: defaultStats.avgFinish.toFixed(1),
color: 'text-white',
},
{
label: 'Completion',
value: `${defaultStats.completionRate.toFixed(1)}%`,
color: 'text-green-400',
},
{ label: 'Win Rate', value: `${winRate}%`, color: 'text-primary-blue' },
{
label: 'Podium Rate',
value: `${podiumRate}%`,
color: 'text-warning-amber',
},
].map((stat, index) => (
<Card key={index} className="text-center">
<div className="text-sm text-gray-400 mb-1">{stat.label}</div>
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
</Card>
))}
</div>
</>
) : (
<Card>
<h3 className="text-lg font-semibold text-white mb-2">Career Statistics</h3>
<p className="text-sm text-gray-400">
No statistics available yet. Compete in races to start building your record.
</p>
</Card>
)}
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">📊</div>
<h3 className="text-lg font-semibold text-white">Performance by Car Class</h3>
</div>
<p className="text-gray-400 text-sm">
Detailed per-car and per-class performance breakdowns will be available in a future
version once more race history data is tracked.
</p>
</Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">📈</div>
<h3 className="text-lg font-semibold text-white">Coming Soon</h3>
</div>
<p className="text-gray-400 text-sm">
Performance trends, track-specific stats, head-to-head comparisons vs friends, and league member comparisons will be available in production.
Performance trends, track-specific stats, head-to-head comparisons vs friends, and
league member comparisons will be available in production.
</p>
</Card>
</div>

View File

@@ -2,14 +2,9 @@
import { useState } from 'react';
import Button from '../ui/Button';
import {
getMembership,
joinLeague,
leaveLeague,
requestToJoin,
getCurrentDriverId,
type MembershipStatus,
} from '@/lib/racingLegacyFacade';
import { getMembership, type MembershipStatus } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { getJoinLeagueUseCase, getLeagueMembershipRepository } from '@/lib/di-container';
interface JoinLeagueButtonProps {
leagueId: string;
@@ -22,9 +17,9 @@ export default function JoinLeagueButton({
isInviteOnly = false,
onMembershipChange,
}: JoinLeagueButtonProps) {
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
const membership = getMembership(leagueId, currentDriverId);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
@@ -34,11 +29,27 @@ export default function JoinLeagueButton({
setLoading(true);
setError(null);
try {
const membershipRepo = getLeagueMembershipRepository();
if (isInviteOnly) {
requestToJoin(leagueId, currentDriverId);
// For alpha, treat "request to join" as creating a pending membership
const pending = await membershipRepo.getMembership(leagueId, currentDriverId);
if (pending) {
throw new Error('Already a member or have a pending request');
}
await membershipRepo.saveMembership({
leagueId,
driverId: currentDriverId,
role: 'member',
status: 'pending',
joinedAt: new Date(),
});
} else {
joinLeague(leagueId, currentDriverId);
const useCase = getJoinLeagueUseCase();
await useCase.execute({ leagueId, driverId: currentDriverId });
}
onMembershipChange?.();
setShowConfirmDialog(false);
} catch (err) {
@@ -52,7 +63,16 @@ export default function JoinLeagueButton({
setLoading(true);
setError(null);
try {
leaveLeague(leagueId, currentDriverId);
const membershipRepo = getLeagueMembershipRepository();
const existing = await membershipRepo.getMembership(leagueId, currentDriverId);
if (!existing) {
throw new Error('Not a member of this league');
}
if (existing.role === 'owner') {
throw new Error('League owner cannot leave the league');
}
await membershipRepo.removeMembership(leagueId, currentDriverId);
onMembershipChange?.();
setShowConfirmDialog(false);
} catch (err) {

View File

@@ -1,23 +1,33 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import Button from '../ui/Button';
import Card from '../ui/Card';
import LeagueMembers from './LeagueMembers';
import ScheduleRaceForm from './ScheduleRaceForm';
import { League } from '@gridpilot/racing/domain/entities/League';
import {
getJoinRequests,
approveJoinRequest,
rejectJoinRequest,
removeMember,
updateMemberRole,
getCurrentDriverId,
type JoinRequest,
type MembershipRole,
} from '@/lib/racingLegacyFacade';
import { getDriverRepository } from '@/lib/di-container';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
getLeagueMembershipRepository,
getDriverStats,
getAllDriverRankings,
getDriverRepository,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { MembershipRole } from '@/lib/leagueMembership';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import Modal from '@/components/ui/Modal';
interface JoinRequest {
id: string;
leagueId: string;
driverId: string;
requestedAt: Date;
message?: string;
}
interface LeagueAdminProps {
league: League;
@@ -26,24 +36,38 @@ interface LeagueAdminProps {
export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps) {
const router = useRouter();
const currentDriverId = getCurrentDriverId();
const searchParams = useSearchParams();
const pathname = usePathname();
const currentDriverId = useEffectiveDriverId();
const [joinRequests, setJoinRequests] = useState<JoinRequest[]>([]);
const [requestDrivers, setRequestDrivers] = useState<Driver[]>([]);
const [requestDriversById, setRequestDriversById] = useState<Record<string, DriverDTO>>({});
const [ownerDriver, setOwnerDriver] = useState<DriverDTO | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings'>('members');
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'disputes'>('members');
const [rejectReason, setRejectReason] = useState('');
const loadJoinRequests = useCallback(async () => {
setLoading(true);
try {
const requests = getJoinRequests(league.id);
const membershipRepo = getLeagueMembershipRepository();
const requests = await membershipRepo.getJoinRequests(league.id);
setJoinRequests(requests);
const driverRepo = getDriverRepository();
const drivers = await Promise.all(
requests.map(r => driverRepo.findById(r.driverId))
const uniqueDriverIds = Array.from(new Set(requests.map((r) => r.driverId)));
const driverEntities = await Promise.all(
uniqueDriverIds.map((id) => driverRepo.findById(id)),
);
setRequestDrivers(drivers.filter((d): d is Driver => d !== null));
const driverDtos = driverEntities
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
.filter((dto): dto is DriverDTO => dto !== null);
const byId: Record<string, DriverDTO> = {};
for (const dto of driverDtos) {
byId[dto.id] = dto;
}
setRequestDriversById(byId);
} catch (err) {
console.error('Failed to load join requests:', err);
} finally {
@@ -55,52 +79,189 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
loadJoinRequests();
}, [loadJoinRequests]);
const handleApproveRequest = (requestId: string) => {
useEffect(() => {
async function loadOwner() {
try {
const driverRepo = getDriverRepository();
const entity = await driverRepo.findById(league.ownerId);
setOwnerDriver(EntityMappers.toDriverDTO(entity));
} catch (err) {
console.error('Failed to load league owner:', err);
}
}
loadOwner();
}, [league.ownerId]);
const handleApproveRequest = async (requestId: string) => {
try {
approveJoinRequest(requestId);
loadJoinRequests();
const membershipRepo = getLeagueMembershipRepository();
const requests = await membershipRepo.getJoinRequests(league.id);
const request = requests.find((r) => r.id === requestId);
if (!request) {
throw new Error('Join request not found');
}
await membershipRepo.saveMembership({
leagueId: request.leagueId,
driverId: request.driverId,
role: 'member',
status: 'active',
joinedAt: new Date(),
});
await membershipRepo.removeJoinRequest(requestId);
await loadJoinRequests();
onLeagueUpdate?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to approve request');
}
};
const handleRejectRequest = (requestId: string) => {
const handleRejectRequest = async (requestId: string, trimmedReason: string) => {
try {
rejectJoinRequest(requestId);
loadJoinRequests();
const membershipRepo = getLeagueMembershipRepository();
// Alpha-only: we do not persist the reason yet, but we at least log it.
console.log('Join request rejected with reason:', {
requestId,
reason: trimmedReason,
});
await membershipRepo.removeJoinRequest(requestId);
await loadJoinRequests();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reject request');
}
};
const handleRemoveMember = (driverId: string) => {
const handleRemoveMember = async (driverId: string) => {
if (!confirm('Are you sure you want to remove this member?')) {
return;
}
try {
removeMember(league.id, driverId, currentDriverId);
const membershipRepo = getLeagueMembershipRepository();
const performer = await membershipRepo.getMembership(league.id, currentDriverId);
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
throw new Error('Only owners or admins can remove members');
}
const membership = await membershipRepo.getMembership(league.id, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot remove the league owner');
}
await membershipRepo.removeMembership(league.id, driverId);
onLeagueUpdate?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove member');
}
};
const handleUpdateRole = (driverId: string, newRole: MembershipRole) => {
const handleUpdateRole = async (driverId: string, newRole: MembershipRole) => {
try {
updateMemberRole(league.id, driverId, newRole, currentDriverId);
const membershipRepo = getLeagueMembershipRepository();
const performer = await membershipRepo.getMembership(league.id, currentDriverId);
if (!performer || performer.role !== 'owner') {
throw new Error('Only the league owner can update roles');
}
const membership = await membershipRepo.getMembership(league.id, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot change the owner role');
}
await membershipRepo.saveMembership({
...membership,
role: newRole,
});
onLeagueUpdate?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
const getDriverName = (driverId: string): string => {
const driver = requestDrivers.find(d => d.id === driverId);
return driver?.name || 'Unknown Driver';
const modal = searchParams?.get('modal');
const modalRequestId = searchParams?.get('requestId');
const activeRejectRequest =
modal === 'reject-request'
? joinRequests.find((r) => r.id === modalRequestId) ?? null
: null;
useEffect(() => {
if (!activeRejectRequest) {
setRejectReason('');
}
}, [activeRejectRequest?.id]);
const isRejectModalOpen = modal === 'reject-request' && !!activeRejectRequest;
const openRejectModal = (requestId: string) => {
const params = new URLSearchParams(searchParams?.toString());
params.set('modal', 'reject-request');
params.set('requestId', requestId);
const query = params.toString();
const url = query ? `${pathname}?${query}` : pathname;
router.push(url, { scroll: false });
};
const closeModal = () => {
const params = new URLSearchParams(searchParams?.toString());
params.delete('modal');
params.delete('requestId');
const query = params.toString();
const url = query ? `${pathname}?${query}` : pathname;
router.push(url, { scroll: false });
};
const ownerSummary = useMemo(() => {
if (!ownerDriver) {
return null;
}
const stats = getDriverStats(ownerDriver.id);
const allRankings = getAllDriverRankings();
let rating: number | null = stats?.rating ?? null;
let rank: number | null = null;
if (stats) {
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
rank = stats.overallRank;
} else {
const indexInGlobal = allRankings.findIndex(
(stat) => stat.driverId === stats.driverId,
);
if (indexInGlobal !== -1) {
rank = indexInGlobal + 1;
}
}
if (rating === null) {
const globalEntry = allRankings.find(
(stat) => stat.driverId === stats.driverId,
);
if (globalEntry) {
rating = globalEntry.rating;
}
}
}
return {
driver: ownerDriver,
rating,
rank,
};
}, [ownerDriver]);
return (
<div>
{error && (
@@ -153,6 +314,16 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
>
Create Race
</button>
<button
onClick={() => setActiveTab('disputes')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'disputes'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Disputes
</button>
<button
onClick={() => setActiveTab('settings')}
className={`pb-3 px-1 font-medium transition-colors ${
@@ -191,48 +362,60 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
</div>
) : (
<div className="space-y-4">
{joinRequests.map((request) => (
<div
key={request.id}
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-white font-medium">
{getDriverName(request.driverId)}
</h3>
<p className="text-sm text-gray-400 mt-1">
Requested {new Date(request.requestedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
{request.message && (
<p className="text-sm text-gray-400 mt-2 italic">
&ldquo;{request.message}&rdquo;
</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => handleApproveRequest(request.id)}
className="px-4"
>
Approve
</Button>
<Button
variant="secondary"
onClick={() => handleRejectRequest(request.id)}
className="px-4"
>
Reject
</Button>
{joinRequests.map((request) => {
const driver = requestDriversById[request.driverId];
const requestedOn = new Date(request.requestedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const metaPieces = [
`Requested ${requestedOn}`,
request.message ? `Message: "${request.message}"` : null,
].filter(Boolean);
const meta = metaPieces.join(' • ');
return (
<div
key={request.id}
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
{driver ? (
<DriverIdentity
driver={driver}
href={`/drivers/${request.driverId}?from=league-join-requests&leagueId=${league.id}`}
meta={meta}
size="sm"
/>
) : (
<div>
<h3 className="text-white font-medium">Unknown Driver</h3>
<p className="text-sm text-gray-400 mt-1">Unable to load driver details</p>
</div>
)}
</div>
<div className="flex gap-2 shrink-0">
<Button
variant="primary"
onClick={() => handleApproveRequest(request.id)}
className="px-4"
>
Approve
</Button>
<Button
variant="secondary"
onClick={() => openRejectModal(request.id)}
className="px-4"
>
Reject
</Button>
</div>
</div>
</div>
</div>
))}
);
})}
</div>
)}
</Card>
@@ -240,16 +423,40 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
{activeTab === 'races' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Create New Race</h2>
<p className="text-gray-400 mb-4">
Schedule a new race for this league
<h2 className="text-xl font-semibold text-white mb-4">Schedule Race</h2>
<p className="text-gray-400 mb-6">
Create a new race for this league; this is an alpha-only in-memory scheduler.
</p>
<Button
variant="primary"
onClick={() => router.push(`/races?leagueId=${league.id}`)}
>
Go to Race Scheduler
</Button>
<ScheduleRaceForm
preSelectedLeagueId={league.id}
onSuccess={(race) => {
router.push(`/leagues/${league.id}/races/${race.id}`);
}}
/>
</Card>
)}
{activeTab === 'disputes' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Disputes (Alpha)</h2>
<div className="space-y-4">
<p className="text-sm text-gray-400">
Demo-only view of potential protest and dispute workflow for this league.
</p>
<div className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4">
<h3 className="text-sm font-semibold text-white mb-1">Sample Protest</h3>
<p className="text-xs text-gray-400 mb-2">
Driver contact in Turn 3, Lap 12. Protest submitted by a driver against another
competitor for avoidable contact.
</p>
<p className="text-xs text-gray-500">
In the full product, this area would show protests, steward reviews, penalties, and appeals.
</p>
</div>
<p className="text-xs text-gray-500">
For the alpha, this tab is static and read-only and does not affect any race or league state.
</p>
</div>
</Card>
)}
@@ -257,50 +464,165 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
<Card>
<h2 className="text-xl font-semibold text-white mb-4">League Settings</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
League Name
</label>
<p className="text-white">{league.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<p className="text-white">{league.description}</p>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">Racing Settings</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div>
<label className="text-sm text-gray-500">Points System</label>
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
<label className="block text-sm font-medium text-gray-300 mb-2">
League Name
</label>
<p className="text-white">{league.name}</p>
</div>
<div>
<label className="text-sm text-gray-500">Session Duration</label>
<p className="text-white">{league.settings.sessionDuration} minutes</p>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<p className="text-white">{league.description}</p>
</div>
<div>
<label className="text-sm text-gray-500">Qualifying Format</label>
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2 border-t border-charcoal-outline">
<div>
<label className="text-sm text-gray-500">Season / Series</label>
<p className="text-white">Alpha Demo Season</p>
</div>
<div>
<label className="text-sm text-gray-500">Points System</label>
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
</div>
<div>
<label className="text-sm text-gray-500">Qualifying Format</label>
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
</div>
</div>
{league.socialLinks && (
<div className="pt-4 border-t border-charcoal-outline space-y-2">
<h3 className="text-sm font-medium text-gray-300">Social Links</h3>
<div className="space-y-1 text-sm">
{league.socialLinks.discordUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">Discord</span>
<a
href={league.socialLinks.discordUrl}
target="_blank"
rel="noreferrer"
className="text-primary-blue hover:underline break-all"
>
{league.socialLinks.discordUrl}
</a>
</div>
)}
{league.socialLinks.youtubeUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">YouTube</span>
<a
href={league.socialLinks.youtubeUrl}
target="_blank"
rel="noreferrer"
className="text-red-400 hover:underline break-all"
>
{league.socialLinks.youtubeUrl}
</a>
</div>
)}
{league.socialLinks.websiteUrl && (
<div className="flex items-center justify-between gap-3">
<span className="text-gray-400">Website</span>
<a
href={league.socialLinks.websiteUrl}
target="_blank"
rel="noreferrer"
className="text-gray-100 hover:underline break-all"
>
{league.socialLinks.websiteUrl}
</a>
</div>
)}
{!league.socialLinks.discordUrl &&
!league.socialLinks.youtubeUrl &&
!league.socialLinks.websiteUrl && (
<p className="text-gray-500">
No social links configured for this league in the alpha demo.
</p>
)}
</div>
</div>
)}
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-300">League Owner</h3>
{ownerSummary ? (
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
)}
</div>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<p className="text-sm text-gray-400">
League settings editing will be available in a future update
League settings editing is alpha-only and changes are not persisted yet.
</p>
</div>
</div>
</Card>
)}
<Modal
title="Reject join request"
description={
activeRejectRequest
? `Provide a reason for rejecting ${requestDriversById[activeRejectRequest.driverId]?.name ?? 'this driver'}.`
: 'Provide a reason for rejecting this join request.'
}
primaryActionLabel="Reject"
secondaryActionLabel="Cancel"
onPrimaryAction={async () => {
const trimmed = rejectReason.trim();
if (!trimmed) {
setError('A rejection reason is required to reject a join request.');
return;
}
if (!activeRejectRequest) {
return;
}
await handleRejectRequest(activeRejectRequest.id, trimmed);
closeModal();
}}
onSecondaryAction={() => {
setRejectReason('');
}}
onOpenChange={(open) => {
if (!open) {
closeModal();
}
}}
isOpen={isRejectModalOpen}
>
<div className="space-y-3">
<p className="text-sm text-gray-300">
This will remove the join request and the driver will not be added to the league.
</p>
<div>
<label className="block text-sm font-medium text-gray-200 mb-1">
Rejection reason
</label>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={4}
className="w-full rounded-lg border border-charcoal-outline bg-iron-gray/80 px-3 py-2 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue"
placeholder="Let the driver know why this request was rejected..."
/>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,16 +1,22 @@
'use client';
import Link from 'next/link';
import { League } from '@gridpilot/racing/domain/entities/League';
import Image from 'next/image';
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
import Card from '../ui/Card';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import { getImageService } from '@/lib/di-container';
interface LeagueCardProps {
league: League;
league: LeagueDTO;
onClick?: () => void;
}
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
const imageService = getImageService();
const coverUrl = imageService.getLeagueCover(league.id);
const logoUrl = imageService.getLeagueLogo(league.id);
return (
<div
className="cursor-pointer hover:scale-[1.03] transition-transform duration-150"
@@ -18,7 +24,28 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
>
<Card>
<div className="space-y-3">
<div className={getLeagueCoverClasses(league.id)} aria-hidden="true" />
<div className={getLeagueCoverClasses(league.id)} aria-hidden="true">
<div className="relative w-full h-full">
<Image
src={coverUrl}
alt={`${league.name} cover`}
fill
className="object-cover opacity-80"
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
/>
<div className="absolute left-4 bottom-4 flex items-center">
<div className="w-10 h-10 rounded-full overflow-hidden border border-charcoal-outline/80 bg-deep-graphite/80 shadow-[0_0_10px_rgba(0,0,0,0.6)]">
<Image
src={logoUrl}
alt={`${league.name} logo`}
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
</div>
</div>
</div>
<div className="flex items-start justify-between">
<h3 className="text-xl font-semibold text-white">{league.name}</h3>
@@ -32,14 +59,27 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
</p>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<div className="text-xs text-gray-500">
Owner:{' '}
<Link
href={`/drivers/${league.ownerId}?from=league&leagueId=${league.id}`}
className="text-primary-blue hover:underline"
>
{league.ownerId.slice(0, 8)}...
</Link>
<div className="flex flex-col text-xs text-gray-500">
<span>
Owner:{' '}
<Link
href={`/drivers/${league.ownerId}?from=league&leagueId=${league.id}`}
className="text-primary-blue hover:underline"
>
{league.ownerId.slice(0, 8)}...
</Link>
</span>
<span className="mt-1 text-gray-400">
Slots:{' '}
<span className="text-white font-medium">
{typeof league.usedSlots === 'number' ? league.usedSlots : '—'}
</span>
{' / '}
<span className="text-gray-300">
{league.settings.maxDrivers ?? '—'}
</span>{' '}
used
</span>
</div>
<div className="text-xs text-primary-blue font-medium">
{league.settings.pointsSystem.toUpperCase()}

View File

@@ -1,11 +1,20 @@
'use client';
import React from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import MembershipStatus from '@/components/leagues/MembershipStatus';
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import {
getDriverRepository,
getDriverStats,
getAllDriverRankings,
getImageService,
} from '@/lib/di-container';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
interface LeagueHeaderProps {
leagueId: string;
@@ -22,7 +31,72 @@ export default function LeagueHeader({
ownerId,
ownerName,
}: LeagueHeaderProps) {
const coverUrl = `https://picsum.photos/seed/${leagueId}/1200/280?blur=2`;
const imageService = getImageService();
const coverUrl = imageService.getLeagueCover(leagueId);
const logoUrl = imageService.getLeagueLogo(leagueId);
const [ownerDriver, setOwnerDriver] = useState<DriverDTO | null>(null);
useEffect(() => {
let isMounted = true;
async function loadOwner() {
try {
const driverRepo = getDriverRepository();
const entity = await driverRepo.findById(ownerId);
if (!entity || !isMounted) return;
setOwnerDriver(EntityMappers.toDriverDTO(entity));
} catch (err) {
console.error('Failed to load league owner for header:', err);
}
}
loadOwner();
return () => {
isMounted = false;
};
}, [ownerId]);
const ownerSummary = useMemo(() => {
if (!ownerDriver) {
return null;
}
const stats = getDriverStats(ownerDriver.id);
const allRankings = getAllDriverRankings();
let rating: number | null = stats?.rating ?? null;
let rank: number | null = null;
if (stats) {
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
rank = stats.overallRank;
} else {
const indexInGlobal = allRankings.findIndex(
(stat) => stat.driverId === stats.driverId,
);
if (indexInGlobal !== -1) {
rank = indexInGlobal + 1;
}
}
if (rating === null) {
const globalEntry = allRankings.find(
(stat) => stat.driverId === stats.driverId,
);
if (globalEntry) {
rating = globalEntry.rating;
}
}
}
return {
driver: ownerDriver,
rating,
rank,
};
}, [ownerDriver]);
return (
<div className="mb-8">
@@ -36,6 +110,17 @@ export default function LeagueHeader({
className="object-cover opacity-80"
sizes="100vw"
/>
<div className="absolute left-6 bottom-4 flex items-center">
<div className="h-16 w-16 rounded-full overflow-hidden border-2 border-charcoal-outline bg-deep-graphite/95 shadow-[0_0_18px_rgba(0,0,0,0.7)]">
<Image
src={logoUrl}
alt={`${leagueName} logo`}
width={64}
height={64}
className="w-full h-full object-cover"
/>
</div>
</div>
</div>
</div>
</div>
@@ -56,14 +141,27 @@ export default function LeagueHeader({
<p className="text-gray-400 mb-2">{description}</p>
)}
<div className="text-sm text-gray-400 mb-6">
<span className="mr-2">Owner:</span>
<Link
href={`/drivers/${ownerId}?from=league&leagueId=${leagueId}`}
className="text-primary-blue hover:underline"
>
{ownerName}
</Link>
<div className="mb-6 flex flex-col gap-2">
<span className="text-sm text-gray-400">Owner</span>
{ownerSummary ? (
<div className="inline-flex items-center gap-3">
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
href={`/drivers/${ownerSummary.driver.id}?from=league&leagueId=${leagueId}`}
/>
</div>
) : (
<div className="text-sm text-gray-500">
<Link
href={`/drivers/${ownerId}?from=league&leagueId=${leagueId}`}
className="text-primary-blue hover:underline"
>
{ownerName}
</Link>
</div>
)}
</div>
</div>
);

View File

@@ -1,15 +1,16 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
import {
getLeagueMembers,
getCurrentDriverId,
type LeagueMembership,
type MembershipRole,
} from '@/lib/racingLegacyFacade';
} from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
interface LeagueMembersProps {
leagueId: string;
@@ -18,17 +19,17 @@ interface LeagueMembersProps {
showActions?: boolean;
}
export default function LeagueMembers({
leagueId,
onRemoveMember,
export default function LeagueMembers({
leagueId,
onRemoveMember,
onUpdateRole,
showActions = false
showActions = false
}: LeagueMembersProps) {
const [members, setMembers] = useState<LeagueMembership[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({});
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<'role' | 'name' | 'date' | 'rating' | 'points' | 'wins'>('rating');
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
const loadMembers = useCallback(async () => {
setLoading(true);
@@ -37,10 +38,18 @@ export default function LeagueMembers({
setMembers(membershipData);
const driverRepo = getDriverRepository();
const driverData = await Promise.all(
membershipData.map(m => driverRepo.findById(m.driverId))
const driverEntities = await Promise.all(
membershipData.map((m) => driverRepo.findById(m.driverId))
);
setDrivers(driverData.filter((d): d is Driver => d !== null));
const driverDtos = driverEntities
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
.filter((dto): dto is DriverDTO => dto !== null);
const byId: Record<string, DriverDTO> = {};
for (const dto of driverDtos) {
byId[dto.id] = dto;
}
setDriversById(byId);
} catch (error) {
console.error('Failed to load members:', error);
} finally {
@@ -53,7 +62,7 @@ export default function LeagueMembers({
}, [loadMembers]);
const getDriverName = (driverId: string): string => {
const driver = drivers.find(d => d.id === driverId);
const driver = driversById[driverId];
return driver?.name || 'Unknown Driver';
};
@@ -160,6 +169,13 @@ export default function LeagueMembers({
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;
return (
<tr
@@ -168,12 +184,17 @@ export default function LeagueMembers({
>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<Link
href={`/drivers/${member.driverId}?from=league&leagueId=${leagueId}`}
className="text-white font-medium hover:text-primary-blue transition-colors"
>
{getDriverName(member.driverId)}
</Link>
{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>
)}

View File

@@ -3,13 +3,13 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Race } from '@gridpilot/racing/domain/entities/Race';
import { getRaceRepository } from '@/lib/di-container';
import {
getCurrentDriverId,
isRegistered,
registerForRace,
withdrawFromRace,
} from '@/lib/racingLegacyFacade';
getRaceRepository,
getIsDriverRegisteredForRaceQuery,
getRegisterForRaceUseCase,
getWithdrawFromRaceUseCase,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
interface LeagueScheduleProps {
leagueId: string;
@@ -23,7 +23,7 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const [registrationStates, setRegistrationStates] = useState<Record<string, boolean>>({});
const [processingRace, setProcessingRace] = useState<string | null>(null);
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
useEffect(() => {
loadRaces();
@@ -35,15 +35,24 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const raceRepo = getRaceRepository();
const allRaces = await raceRepo.findAll();
const leagueRaces = allRaces
.filter(race => race.leagueId === leagueId)
.sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime());
.filter((race) => race.leagueId === leagueId)
.sort(
(a, b) =>
new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(),
);
setRaces(leagueRaces);
// Load registration states
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
const states: Record<string, boolean> = {};
leagueRaces.forEach(race => {
states[race.id] = isRegistered(race.id, currentDriverId);
});
await Promise.all(
leagueRaces.map(async (race) => {
const registered = await isRegisteredQuery.execute({
raceId: race.id,
driverId: currentDriverId,
});
states[race.id] = registered;
}),
);
setRegistrationStates(states);
} catch (error) {
console.error('Failed to load races:', error);
@@ -63,8 +72,13 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
setProcessingRace(race.id);
try {
registerForRace(race.id, currentDriverId, leagueId);
setRegistrationStates(prev => ({ ...prev, [race.id]: true }));
const useCase = getRegisterForRaceUseCase();
await useCase.execute({
raceId: race.id,
leagueId,
driverId: currentDriverId,
});
setRegistrationStates((prev) => ({ ...prev, [race.id]: true }));
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register');
} finally {
@@ -83,8 +97,12 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
setProcessingRace(race.id);
try {
withdrawFromRace(race.id, currentDriverId);
setRegistrationStates(prev => ({ ...prev, [race.id]: false }));
const useCase = getWithdrawFromRaceUseCase();
await useCase.execute({
raceId: race.id,
driverId: currentDriverId,
});
setRegistrationStates((prev) => ({ ...prev, [race.id]: false }));
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw');
} finally {

View File

@@ -1,6 +1,7 @@
'use client';
import { getMembership, getCurrentDriverId, type MembershipRole } from '@/lib/racingLegacyFacade';
import { getMembership, type MembershipRole } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
interface MembershipStatusProps {
leagueId: string;
@@ -8,7 +9,7 @@ interface MembershipStatusProps {
}
export default function MembershipStatus({ leagueId, className = '' }: MembershipStatusProps) {
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
const membership = getMembership(leagueId, currentDriverId);
if (!membership) {

View File

@@ -1,19 +1,18 @@
'use client';
import Link from 'next/link';
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
interface StandingsTableProps {
standings: Standing[];
drivers: Driver[];
standings: LeagueDriverSeasonStatsDTO[];
drivers: DriverDTO[];
leagueId: string;
}
export default function StandingsTable({ standings, drivers, leagueId }: StandingsTableProps) {
const getDriverName = (driverId: string): string => {
const driver = drivers.find((d) => d.id === driverId);
return driver?.name || 'Unknown Driver';
const getDriver = (driverId: string): DriverDTO | undefined => {
return drivers.find((d) => d.id === driverId);
};
if (standings.length === 0) {
@@ -26,50 +25,121 @@ export default function StandingsTable({ standings, drivers, leagueId }: Standin
return (
<div className="overflow-x-auto">
<table className="w-full">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Pos</th>
<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">Points</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">Races</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Pos</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Driver</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Team</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Total Pts</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Pts / Race</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Started</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Finished</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">DNF</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">NoShows</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Penalty</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Bonus</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Avg Finish</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Rating Δ</th>
</tr>
</thead>
<tbody>
{standings.map((standing) => {
const isLeader = standing.position === 1;
{standings.map((row) => {
const isLeader = row.position === 1;
const driver = getDriver(row.driverId);
const totalPointsLine =
row.penaltyPoints > 0
? `Total Points: ${row.totalPoints} (-${row.penaltyPoints} penalty)`
: `Total Points: ${row.totalPoints}`;
const ratingDelta =
row.ratingChange === null || row.ratingChange === 0
? '—'
: row.ratingChange > 0
? `+${row.ratingChange}`
: `${row.ratingChange}`;
return (
<tr
key={`${standing.leagueId}-${standing.driverId}`}
key={`${row.leagueId}-${row.driverId}`}
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
>
<td className="py-3 px-4">
<span className={`font-semibold ${isLeader ? 'text-yellow-500' : 'text-white'}`}>
{standing.position}
{row.position}
</span>
</td>
<td className="py-3 px-4">
<Link
href={`/drivers/${standing.driverId}?from=league&leagueId=${leagueId}`}
className={
isLeader
? 'text-white font-semibold hover:text-primary-blue transition-colors'
: 'text-white hover:text-primary-blue transition-colors'
}
>
{getDriverName(standing.driverId)}
</Link>
{driver ? (
<DriverIdentity
driver={driver}
href={`/drivers/${row.driverId}?from=league-standings&leagueId=${leagueId}`}
contextLabel={`P${row.position}`}
size="sm"
meta={totalPointsLine}
/>
) : (
<span className="text-white">Unknown Driver</span>
)}
</td>
<td className="py-3 px-4">
<span className="text-white font-medium">{standing.points}</span>
<span className="text-gray-300">
{row.teamName ?? '—'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{standing.wins}</span>
<div className="flex flex-col">
<span className="text-white font-medium">{row.totalPoints}</span>
{row.penaltyPoints > 0 || row.bonusPoints !== 0 ? (
<span className="text-xs text-gray-400">
base {row.basePoints}
{row.penaltyPoints > 0 && (
<span className="text-red-400"> {row.penaltyPoints}</span>
)}
{row.bonusPoints !== 0 && (
<span className="text-green-400"> +{row.bonusPoints}</span>
)}
</span>
) : null}
</div>
</td>
<td className="py-3 px-4">
<span className="text-white">{standing.racesCompleted}</span>
<span className="text-white">
{row.pointsPerRace.toFixed(2)}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{row.racesStarted}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{row.racesFinished}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{row.dnfs}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{row.noShows}</span>
</td>
<td className="py-3 px-4">
<span className={row.penaltyPoints > 0 ? 'text-red-400' : 'text-gray-300'}>
{row.penaltyPoints > 0 ? `-${row.penaltyPoints}` : '—'}
</span>
</td>
<td className="py-3 px-4">
<span className={row.bonusPoints !== 0 ? 'text-green-400' : 'text-gray-300'}>
{row.bonusPoints !== 0 ? `+${row.bonusPoints}` : '—'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white">
{row.avgFinish !== null ? row.avgFinish.toFixed(2) : '—'}
</span>
</td>
<td className="py-3 px-4">
<span className={row.ratingChange && row.ratingChange > 0 ? 'text-green-400' : row.ratingChange && row.ratingChange < 0 ? 'text-red-400' : 'text-gray-300'}>
{ratingDelta}
</span>
</td>
</tr>
);

View File

@@ -0,0 +1,28 @@
'use client';
import { Star, Trophy } from 'lucide-react';
interface DriverRatingProps {
rating: number | null;
rank: number | null;
}
export default function DriverRating({ rating, rank }: DriverRatingProps) {
return (
<div className="mt-0.5 flex items-center gap-2 text-[11px]">
<span className="inline-flex items-center gap-1 text-amber-300">
<Star className="h-3 w-3" />
<span className="tabular-nums">
{rating !== null ? rating : '—'}
</span>
</span>
{rank !== null && (
<span className="inline-flex items-center gap-1 text-primary-blue">
<Trophy className="h-3 w-3" />
<span className="tabular-nums">#{rank}</span>
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import DriverRating from '@/components/profile/DriverRatingPill';
import { getImageService } from '@/lib/di-container';
export interface DriverSummaryPillProps {
driver: DriverDTO;
rating: number | null;
rank: number | null;
avatarSrc?: string;
onClick?: () => void;
href?: string;
}
export default function DriverSummaryPill(props: DriverSummaryPillProps) {
const { driver, rating, rank, avatarSrc, onClick, href } = props;
const resolvedAvatar =
avatarSrc ?? getImageService().getDriverAvatar(driver.id);
const content = (
<>
<div className="w-8 h-8 rounded-full overflow-hidden bg-charcoal-outline flex items-center justify-center border border-charcoal-outline/80">
<Image
src={resolvedAvatar}
alt={driver.name}
width={32}
height={32}
className="w-full h-full object-cover"
/>
</div>
<div className="flex flex-col leading-tight text-left">
<span className="text-xs font-semibold text-white truncate max-w-[140px]">
{driver.name}
</span>
<DriverRating rating={rating} rank={rank} />
</div>
</>
);
if (href) {
return (
<Link
href={href}
className="flex items-center gap-3 rounded-full bg-iron-gray/70 px-3 py-1.5 border border-charcoal-outline/80 shadow-[0_0_18px_rgba(0,0,0,0.45)] hover:border-primary-blue/60 hover:bg-iron-gray transition-colors"
>
{content}
</Link>
);
}
if (onClick) {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-3 rounded-full bg-iron-gray/70 px-3 py-1.5 border border-charcoal-outline/80 shadow-[0_0_18px_rgba(0,0,0,0.45)] hover:border-primary-blue/60 hover:bg-iron-gray transition-colors"
>
{content}
</button>
);
}
return (
<div className="flex items-center gap-3 rounded-full bg-iron-gray/70 px-3 py-1.5 border border-charcoal-outline/80">
{content}
</div>
);
}

View File

@@ -3,21 +3,34 @@
import Image from 'next/image';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import Button from '../ui/Button';
import { getDriverTeam, getDriverAvatarUrl } from '@/lib/racingLegacyFacade';
import { getImageService } from '@/lib/di-container';
import DriverRatingPill from '@/components/profile/DriverRatingPill';
interface ProfileHeaderProps {
driver: DriverDTO;
rating?: number | null;
rank?: number | null;
isOwnProfile?: boolean;
onEditClick?: () => void;
teamName?: string | null;
teamTag?: string | null;
}
export default function ProfileHeader({ driver, isOwnProfile = false, onEditClick }: ProfileHeaderProps) {
export default function ProfileHeader({
driver,
rating,
rank,
isOwnProfile = false,
onEditClick,
teamName,
teamTag,
}: ProfileHeaderProps) {
return (
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-blue to-purple-600 overflow-hidden flex items-center justify-center">
<Image
src={getDriverAvatarUrl(driver.id)}
src={getImageService().getDriverAvatar(driver.id)}
alt={driver.name}
width={80}
height={80}
@@ -31,36 +44,30 @@ export default function ProfileHeader({ driver, isOwnProfile = false, onEditClic
<span className="text-3xl" aria-label={`Country: ${driver.country}`}>
{getCountryFlag(driver.country)}
</span>
{(() => {
const teamData = getDriverTeam(driver.id);
if (teamData) {
return (
<span className="px-3 py-1 bg-primary-blue/20 text-primary-blue rounded-full text-sm font-medium">
{teamData.team.tag}
</span>
);
}
return null;
})()}
{teamTag && (
<span className="px-3 py-1 bg-primary-blue/20 text-primary-blue rounded-full text-sm font-medium">
{teamTag}
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span>iRacing ID: {driver.iracingId}</span>
<span></span>
<span>Rating: 1450</span>
{(() => {
const teamData = getDriverTeam(driver.id);
if (teamData) {
return (
<>
<span></span>
<span className="text-primary-blue">{teamData.team.name}</span>
</>
);
}
return null;
})()}
{teamName && (
<>
<span></span>
<span className="text-primary-blue">
{teamTag ? `[${teamTag}] ${teamName}` : teamName}
</span>
</>
)}
</div>
{(typeof rating === 'number' || typeof rank === 'number') && (
<div className="mt-2">
<DriverRatingPill rating={rating ?? null} rank={rank ?? null} />
</div>
)}
</div>
</div>

View File

@@ -0,0 +1,171 @@
'use client';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { LogOut, Star } from 'lucide-react';
import { useAuth } from '@/lib/auth/AuthContext';
import {
getDriverStats,
getLeagueRankings,
getAllDriverRankings,
getDriverRepository,
getImageService,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
export default function UserPill() {
const { session, login } = useAuth();
const [driver, setDriver] = useState<DriverDTO | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const user = session?.user as
| {
id: string;
displayName?: string;
primaryDriverId?: string | null;
avatarUrl?: string | null;
}
| undefined;
const primaryDriverId = useEffectiveDriverId();
useEffect(() => {
let cancelled = false;
async function loadDriver() {
if (!primaryDriverId) {
if (!cancelled) {
setDriver(null);
}
return;
}
const repo = getDriverRepository();
const entity = await repo.findById(primaryDriverId);
if (!cancelled) {
setDriver(EntityMappers.toDriverDTO(entity));
}
}
loadDriver();
return () => {
cancelled = true;
};
}, [primaryDriverId]);
const data = useMemo(() => {
if (!session?.user || !primaryDriverId || !driver) {
return null;
}
const driverStats = getDriverStats(primaryDriverId);
const allRankings = getAllDriverRankings();
let rating: number | null = driverStats?.rating ?? null;
let rank: number | null = null;
let totalDrivers: number | null = null;
if (driverStats) {
totalDrivers = allRankings.length || null;
if (typeof driverStats.overallRank === 'number' && driverStats.overallRank > 0) {
rank = driverStats.overallRank;
} else {
const indexInGlobal = allRankings.findIndex(
(stat) => stat.driverId === driverStats.driverId,
);
if (indexInGlobal !== -1) {
rank = indexInGlobal + 1;
}
}
if (rating === null) {
const globalEntry = allRankings.find(
(stat) => stat.driverId === driverStats.driverId,
);
if (globalEntry) {
rating = globalEntry.rating;
}
}
}
const avatarSrc = getImageService().getDriverAvatar(primaryDriverId);
return {
driver,
avatarSrc,
rating,
rank,
};
}, [session, driver, primaryDriverId]);
if (!session) {
const loginHref = '/auth/iracing/start?returnTo=/dashboard';
return (
<div className="flex items-center">
<Link
href={loginHref}
className="inline-flex items-center gap-2 rounded-full bg-primary-blue px-4 py-1.5 text-xs font-semibold text-white shadow-[0_0_12px_rgba(25,140,255,0.5)] hover:bg-primary-blue/90 hover:shadow-[0_0_18px_rgba(25,140,255,0.8)] transition-all"
>
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-white/10">
<Star className="h-3 w-3 text-amber-300" />
</span>
<span>Authenticate with iRacing</span>
</Link>
</div>
);
}
if (!data) {
return null;
}
return (
<div className="relative inline-flex items-center">
<DriverSummaryPill
driver={data.driver}
rating={data.rating}
rank={data.rank}
avatarSrc={data.avatarSrc}
onClick={() => setIsMenuOpen((open) => !open)}
/>
{isMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg bg-deep-graphite border border-charcoal-outline shadow-lg z-50">
<div className="py-1 text-sm text-gray-200">
<Link
href="/profile"
className="block px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Profile
</Link>
<Link
href="/profile/leagues"
className="block px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
Manage leagues
</Link>
</div>
<div className="border-t border-charcoal-outline">
<form action="/auth/logout" method="POST">
<button
type="submit"
className="flex w-full items-center justify-between px-3 py-2 text-sm text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<span>Logout</span>
<LogOut className="h-4 w-4" />
</button>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -4,7 +4,8 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { createTeam, getCurrentDriverId } from '@/lib/racingLegacyFacade';
import { getCreateTeamUseCase } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
interface CreateTeamFormProps {
onCancel?: () => void;
@@ -20,6 +21,7 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const currentDriverId = useEffectiveDriverId();
const validateForm = () => {
const newErrors: Record<string, string> = {};
@@ -56,18 +58,21 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
setSubmitting(true);
try {
getCurrentDriverId(); // ensure identity initialized
const team = createTeam({
const useCase = getCreateTeamUseCase();
const result = await useCase.execute({
name: formData.name,
tag: formData.tag.toUpperCase(),
description: formData.description,
ownerId: currentDriverId,
leagues: [],
});
const teamId = result.team.id;
if (onSuccess) {
onSuccess(team.id);
onSuccess(teamId);
} else {
router.push(`/teams/${team.id}`);
router.push(`/teams/${teamId}`);
}
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to create team');

View File

@@ -1,15 +1,15 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import Button from '@/components/ui/Button';
import {
getCurrentDriverId,
getTeamMembership,
getDriverTeam,
joinTeam,
requestToJoinTeam,
leaveTeam,
} from '@/lib/racingLegacyFacade';
getJoinTeamUseCase,
getLeaveTeamUseCase,
getGetDriverTeamQuery,
getTeamMembershipRepository,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { TeamMembership } from '@gridpilot/racing';
interface JoinTeamButtonProps {
teamId: string;
@@ -23,18 +23,50 @@ export default function JoinTeamButton({
onUpdate,
}: JoinTeamButtonProps) {
const [loading, setLoading] = useState(false);
const currentDriverId = getCurrentDriverId();
const membership = getTeamMembership(teamId, currentDriverId);
const currentTeam = getDriverTeam(currentDriverId);
const currentDriverId = useEffectiveDriverId();
const [membership, setMembership] = useState<TeamMembership | null>(null);
const [currentTeamName, setCurrentTeamName] = useState<string | null>(null);
const [currentTeamId, setCurrentTeamId] = useState<string | null>(null);
useEffect(() => {
const load = async () => {
const membershipRepo = getTeamMembershipRepository();
const m = await membershipRepo.getMembership(teamId, currentDriverId);
setMembership(m);
const driverTeamQuery = getGetDriverTeamQuery();
const driverTeam = await driverTeamQuery.execute({ driverId: currentDriverId });
if (driverTeam) {
setCurrentTeamId(driverTeam.team.id);
setCurrentTeamName(driverTeam.team.name);
} else {
setCurrentTeamId(null);
setCurrentTeamName(null);
}
};
void load();
}, [teamId, currentDriverId]);
const handleJoin = async () => {
setLoading(true);
try {
if (requiresApproval) {
requestToJoinTeam(teamId, currentDriverId);
const membershipRepo = getTeamMembershipRepository();
const existing = await membershipRepo.getMembership(teamId, currentDriverId);
if (existing) {
throw new Error('Already a member or have a pending request');
}
await membershipRepo.saveJoinRequest({
id: `team-request-${Date.now()}`,
teamId,
driverId: currentDriverId,
requestedAt: new Date(),
});
alert('Join request sent! Wait for team approval.');
} else {
joinTeam(teamId, currentDriverId);
const useCase = getJoinTeamUseCase();
await useCase.execute({ teamId, driverId: currentDriverId });
alert('Successfully joined team!');
}
onUpdate?.();
@@ -52,7 +84,8 @@ export default function JoinTeamButton({
setLoading(true);
try {
leaveTeam(teamId, currentDriverId);
const useCase = getLeaveTeamUseCase();
await useCase.execute({ teamId, driverId: currentDriverId });
alert('Successfully left team');
onUpdate?.();
} catch (error) {
@@ -84,10 +117,10 @@ export default function JoinTeamButton({
}
// Already on another team
if (currentTeam && currentTeam.team.id !== teamId) {
if (currentTeamId && currentTeamId !== teamId) {
return (
<Button variant="secondary" disabled>
Already on {currentTeam.team.name}
Already on {currentTeamName}
</Button>
);
}

View File

@@ -4,17 +4,16 @@ import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { getDriverRepository } from '@/lib/di-container';
import {
getDriverRepository,
getGetTeamJoinRequestsQuery,
getApproveTeamJoinRequestUseCase,
getRejectTeamJoinRequestUseCase,
getUpdateTeamUseCase,
} from '@/lib/di-container';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import {
Team,
TeamJoinRequest,
getTeamJoinRequests,
approveTeamJoinRequest,
rejectTeamJoinRequest,
updateTeam,
} from '@/lib/racingLegacyFacade';
import type { Team, TeamJoinRequest } from '@gridpilot/racing';
interface TeamAdminProps {
team: Team;
@@ -33,11 +32,12 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
});
useEffect(() => {
loadJoinRequests();
void loadJoinRequests();
}, [team.id]);
const loadJoinRequests = async () => {
const requests = getTeamJoinRequests(team.id);
const query = getGetTeamJoinRequestsQuery();
const requests = await query.execute({ teamId: team.id });
setJoinRequests(requests);
const driverRepo = getDriverRepository();
@@ -60,7 +60,8 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
const handleApprove = async (requestId: string) => {
try {
approveTeamJoinRequest(requestId);
const useCase = getApproveTeamJoinRequestUseCase();
await useCase.execute({ requestId });
await loadJoinRequests();
onUpdate();
} catch (error) {
@@ -70,16 +71,26 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
const handleReject = async (requestId: string) => {
try {
rejectTeamJoinRequest(requestId);
const useCase = getRejectTeamJoinRequestUseCase();
await useCase.execute({ requestId });
await loadJoinRequests();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to reject request');
}
};
const handleSaveChanges = () => {
const handleSaveChanges = async () => {
try {
updateTeam(team.id, editedTeam, team.ownerId);
const useCase = getUpdateTeamUseCase();
await useCase.execute({
teamId: team.id,
updates: {
name: editedTeam.name,
tag: editedTeam.tag,
description: editedTeam.description,
},
updatedBy: team.ownerId,
});
setEditMode(false);
onUpdate();
} catch (error) {

View File

@@ -2,7 +2,7 @@
import Image from 'next/image';
import Card from '../ui/Card';
import { getTeamLogoUrl } from '@/lib/racingLegacyFacade';
import { getImageService } from '@/lib/di-container';
interface TeamCardProps {
id: string;
@@ -10,6 +10,9 @@ interface TeamCardProps {
logo?: string;
memberCount: number;
leagues: string[];
rating?: number | null;
totalWins?: number;
totalRaces?: number;
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
onClick?: () => void;
}
@@ -20,6 +23,9 @@ export default function TeamCard({
logo,
memberCount,
leagues,
rating,
totalWins,
totalRaces,
performanceLevel,
onClick,
}: TeamCardProps) {
@@ -40,7 +46,7 @@ export default function TeamCard({
<div className="flex items-start gap-4">
<div className="w-16 h-16 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
<Image
src={logo || getTeamLogoUrl(id)}
src={logo || getImageService().getTeamLogo(id)}
alt={name}
width={64}
height={64}
@@ -54,6 +60,11 @@ export default function TeamCard({
<p className="text-sm text-gray-400">
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</p>
{typeof rating === 'number' && (
<p className="text-xs text-primary-blue mt-1">
Team rating: <span className="font-semibold">{Math.round(rating)}</span>
</p>
)}
</div>
</div>
@@ -69,6 +80,27 @@ export default function TeamCard({
</div>
)}
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-sm text-gray-400">Rating</div>
<div className="text-lg font-semibold text-primary-blue">
{typeof rating === 'number' ? Math.round(rating) : '—'}
</div>
</div>
<div>
<div className="text-sm text-gray-400">Wins</div>
<div className="text-lg font-semibold text-green-400">
{totalWins ?? 0}
</div>
</div>
<div>
<div className="text-sm text-gray-400">Races</div>
<div className="text-lg font-semibold text-white">
{totalRaces ?? 0}
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-400">Active in:</p>
<div className="flex flex-wrap gap-2">

View File

@@ -0,0 +1,78 @@
'use client';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { getImageService } from '@/lib/di-container';
export interface TeamLadderRowProps {
rank: number;
teamId: string;
teamName: string;
teamLogoUrl?: string;
memberCount: number;
teamRating: number | null;
totalWins: number;
totalRaces: number;
}
export default function TeamLadderRow({
rank,
teamId,
teamName,
teamLogoUrl,
memberCount,
teamRating,
totalWins,
totalRaces,
}: TeamLadderRowProps) {
const router = useRouter();
const imageService = getImageService();
const logo = teamLogoUrl ?? imageService.getTeamLogo(teamId);
const handleClick = () => {
router.push(`/teams/${teamId}`);
};
return (
<tr
onClick={handleClick}
className="cursor-pointer border-b border-charcoal-outline/60 hover:bg-iron-gray/30 transition-colors"
>
<td className="py-3 px-4 text-sm text-gray-300 font-semibold">#{rank}</td>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-md overflow-hidden bg-charcoal-outline flex-shrink-0">
<Image
src={logo}
alt={teamName}
width={32}
height={32}
className="w-full h-full object-cover"
/>
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold text-white truncate">
{teamName}
</span>
</div>
</div>
</td>
<td className="py-3 px-4 text-sm">
<span className="text-primary-blue font-semibold">
{teamRating !== null ? Math.round(teamRating) : '—'}
</span>
</td>
<td className="py-3 px-4 text-sm">
<span className="text-green-400 font-semibold">{totalWins}</span>
</td>
<td className="py-3 px-4 text-sm">
<span className="text-white">{totalRaces}</span>
</td>
<td className="py-3 px-4 text-sm">
<span className="text-gray-300">
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</span>
</td>
</tr>
);
}

View File

@@ -2,10 +2,11 @@
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import { TeamMembership, TeamRole } from '@/lib/racingLegacyFacade';
import type { TeamMembership, TeamRole } from '@gridpilot/racing';
interface TeamRosterProps {
teamId: string;
@@ -33,7 +34,7 @@ export default function TeamRoster({
const driverMap: Record<string, DriverDTO> = {};
for (const membership of memberships) {
const driver = allDrivers.find(d => d.id === membership.driverId);
const driver = allDrivers.find((d) => d.id === membership.driverId);
if (driver) {
const dto = EntityMappers.toDriverDTO(driver);
if (dto) {
@@ -41,12 +42,12 @@ export default function TeamRoster({
}
}
}
setDrivers(driverMap);
setLoading(false);
};
loadDrivers();
void loadDrivers();
}, [memberships]);
const getRoleBadgeColor = (role: TeamRole) => {
@@ -85,14 +86,15 @@ export default function TeamRoster({
}
});
const teamAverageRating = memberships.length > 0
? Math.round(
memberships.reduce((sum, m) => {
const stats = getDriverStats(m.driverId);
return sum + (stats?.rating || 0);
}, 0) / memberships.length
)
: 0;
const teamAverageRating =
memberships.length > 0
? Math.round(
memberships.reduce((sum, m) => {
const stats = getDriverStats(m.driverId);
return sum + (stats?.rating || 0);
}, 0) / memberships.length,
)
: 0;
if (loading) {
return (
@@ -108,10 +110,11 @@ export default function TeamRoster({
<div>
<h3 className="text-xl font-semibold text-white">Team Roster</h3>
<p className="text-sm text-gray-400 mt-1">
{memberships.length} {memberships.length === 1 ? 'member' : 'members'} Avg Rating: <span className="text-primary-blue font-medium">{teamAverageRating}</span>
{memberships.length} {memberships.length === 1 ? 'member' : 'members'} Avg Rating:{' '}
<span className="text-primary-blue font-medium">{teamAverageRating}</span>
</p>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-400">Sort by:</label>
<select
@@ -125,70 +128,67 @@ export default function TeamRoster({
</select>
</div>
</div>
<div className="space-y-3">
{sortedMemberships.map((membership) => {
const driver = drivers[membership.driverId];
const driverStats = getDriverStats(membership.driverId);
if (!driver) return null;
const canManageMembership = isAdmin && membership.role !== 'owner';
return (
<div
key={membership.driverId}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-charcoal-outline/60 transition-colors"
>
<div className="flex items-center gap-4 flex-1">
<div className="w-12 h-12 rounded-full bg-primary-blue/20 flex items-center justify-center text-lg font-bold text-white">
{driver.name.charAt(0)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-white font-medium">{driver.name}</h4>
<span className={`px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(membership.role)}`}>
{getRoleLabel(membership.role)}
</span>
</div>
<p className="text-sm text-gray-400">
{driver.country} Joined {new Date(membership.joinedAt).toLocaleDateString()}
</p>
</div>
<DriverIdentity
driver={driver}
href={`/drivers/${driver.id}?from=team&teamId=${teamId}`}
contextLabel={getRoleLabel(membership.role)}
meta={
<span>
{driver.country} Joined{' '}
{new Date(membership.joinedAt).toLocaleDateString()}
</span>
}
size="md"
/>
{driverStats && (
<div className="flex items-center gap-6 text-center">
<div>
<div className="text-lg font-bold text-primary-blue">{driverStats.rating}</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
<div>
<div className="text-sm text-gray-300">#{driverStats.overallRank}</div>
<div className="text-xs text-gray-500">Rank</div>
{driverStats && (
<div className="flex items-center gap-6 text-center">
<div>
<div className="text-lg font-bold text-primary-blue">
{driverStats.rating}
</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
)}
</div>
<div>
<div className="text-sm text-gray-300">#{driverStats.overallRank}</div>
<div className="text-xs text-gray-500">Rank</div>
</div>
</div>
)}
{isAdmin && membership.role !== 'owner' && (
{canManageMembership && (
<div className="flex items-center gap-2">
{onChangeRole && (
<select
className="px-3 py-2 bg-iron-gray border-0 rounded text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={membership.role}
onChange={(e) => onChangeRole(membership.driverId, e.target.value as TeamRole)}
>
<option value="driver">Driver</option>
<option value="manager">Manager</option>
</select>
)}
{onRemoveMember && (
<button
onClick={() => onRemoveMember(membership.driverId)}
className="px-3 py-2 bg-danger-red/20 hover:bg-danger-red/30 text-danger-red rounded text-sm font-medium transition-colors"
>
Remove
</button>
)}
<select
className="px-3 py-2 bg-iron-gray border-0 rounded text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={membership.role}
onChange={(e) =>
onChangeRole?.(membership.driverId, e.target.value as TeamRole)
}
>
<option value="driver">Driver</option>
<option value="manager">Manager</option>
</select>
<button
onClick={() => onRemoveMember?.(membership.driverId)}
className="px-3 py-2 bg-danger-red/20 hover:bg-danger-red/30 text-danger-red rounded text-sm font-medium transition-colors"
>
Remove
</button>
</div>
)}
</div>
@@ -197,9 +197,7 @@ export default function TeamRoster({
</div>
{memberships.length === 0 && (
<div className="text-center py-8 text-gray-400">
No team members yet.
</div>
<div className="text-center py-8 text-gray-400">No team members yet.</div>
)}
</Card>
);

View File

@@ -2,10 +2,9 @@
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import { getStandingRepository, getLeagueRepository } from '@/lib/di-container';
import { getStandingRepository, getLeagueRepository, getTeamMembershipRepository } from '@/lib/di-container';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
import { getTeamMembers } from '@/lib/racingLegacyFacade';
interface TeamStandingsProps {
teamId: string;
@@ -29,7 +28,8 @@ export default function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
const loadStandings = async () => {
const standingRepo = getStandingRepository();
const leagueRepo = getLeagueRepository();
const members = getTeamMembers(teamId);
const teamMembershipRepo = getTeamMembershipRepository();
const members = await teamMembershipRepo.getTeamMembers(teamId);
const memberIds = members.map(m => m.driverId);
const teamStandings: TeamLeagueStanding[] = [];

View File

@@ -0,0 +1,182 @@
'use client';
import {
useEffect,
useRef,
type ReactNode,
type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
interface ModalProps {
title: string;
description?: string;
children?: ReactNode;
primaryActionLabel?: string;
secondaryActionLabel?: string;
onPrimaryAction?: () => void | Promise<void>;
onSecondaryAction?: () => void;
onOpenChange?: (open: boolean) => void;
isOpen: boolean;
}
/**
* Generic, accessible modal component with backdrop, focus management, and semantic structure.
* Controlled via the `isOpen` prop; callers handle URL state and routing.
*/
export default function Modal({
title,
description,
children,
primaryActionLabel,
secondaryActionLabel,
onPrimaryAction,
onSecondaryAction,
onOpenChange,
isOpen,
}: ModalProps) {
const dialogRef = useRef<HTMLDivElement | null>(null);
const previouslyFocusedElementRef = useRef<Element | null>(null);
// When the modal opens, remember previous focus and move focus into the dialog
useEffect(() => {
if (isOpen) {
previouslyFocusedElementRef.current = document.activeElement;
const focusable = getFirstFocusable(dialogRef.current);
if (focusable) {
focusable.focus();
} else if (dialogRef.current) {
dialogRef.current.focus();
}
return;
}
// When closing, restore focus
if (!isOpen && previouslyFocusedElementRef.current instanceof HTMLElement) {
previouslyFocusedElementRef.current.focus();
}
}, [isOpen]);
// Basic focus trap with keyboard handling (Tab / Shift+Tab, Escape)
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
if (onOpenChange) {
onOpenChange(false);
}
return;
}
if (event.key === 'Tab') {
const focusable = getFocusableElements(dialogRef.current);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
}
}
};
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget && onOpenChange) {
onOpenChange(false);
}
};
if (!isOpen) {
return null;
}
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby={description ? 'modal-description' : undefined}
onKeyDown={handleKeyDown}
onClick={handleBackdropClick}
>
<div
ref={dialogRef}
className="w-full max-w-md rounded-2xl bg-deep-graphite border border-charcoal-outline shadow-2xl outline-none"
tabIndex={-1}
>
<div className="px-6 pt-5 pb-3 border-b border-charcoal-outline/80">
<h2
id="modal-title"
className="text-lg font-semibold text-white"
>
{title}
</h2>
{description && (
<p
id="modal-description"
className="mt-2 text-sm text-gray-400"
>
{description}
</p>
)}
</div>
<div className="px-6 py-4 text-sm text-gray-100">{children}</div>
{(primaryActionLabel || secondaryActionLabel) && (
<div className="flex justify-end gap-3 px-6 py-4 border-t border-charcoal-outline/80">
{secondaryActionLabel && (
<button
type="button"
onClick={() => {
onSecondaryAction?.();
onOpenChange?.(false);
}}
className="min-h-[40px] rounded-full px-4 py-2 text-sm font-medium text-gray-200 bg-iron-gray border border-charcoal-outline hover:bg-charcoal-outline transition-colors"
>
{secondaryActionLabel}
</button>
)}
{primaryActionLabel && (
<button
type="button"
onClick={async () => {
if (onPrimaryAction) {
await onPrimaryAction();
}
}}
className="min-h-[40px] rounded-full px-4 py-2 text-sm font-semibold text-white bg-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.4)] hover:shadow-[0_0_25px_rgba(25,140,255,0.6)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-blue transition-all"
>
{primaryActionLabel}
</button>
)}
</div>
)}
</div>
</div>
);
}
function getFocusableElements(root: HTMLElement | null): HTMLElement[] {
if (!root) return [];
const selectors = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
];
const nodes = Array.from(
root.querySelectorAll<HTMLElement>(selectors.join(',')),
);
return nodes.filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'));
}
function getFirstFocusable(root: HTMLElement | null): HTMLElement | null {
const elements = getFocusableElements(root);
return elements[0] ?? null;
}