wip
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
63
apps/website/components/drivers/DriverIdentity.tsx
Normal file
63
apps/website/components/drivers/DriverIdentity.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
“{request.message}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">No‑Shows</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>
|
||||
);
|
||||
|
||||
28
apps/website/components/profile/DriverRatingPill.tsx
Normal file
28
apps/website/components/profile/DriverRatingPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
apps/website/components/profile/DriverSummaryPill.tsx
Normal file
74
apps/website/components/profile/DriverSummaryPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
171
apps/website/components/profile/UserPill.tsx
Normal file
171
apps/website/components/profile/UserPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
78
apps/website/components/teams/TeamLadderRow.tsx
Normal file
78
apps/website/components/teams/TeamLadderRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
182
apps/website/components/ui/Modal.tsx
Normal file
182
apps/website/components/ui/Modal.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user