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

@@ -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[] = [];