wip
This commit is contained in:
@@ -27,7 +27,10 @@ export async function POST(request: Request) {
|
||||
return jsonError(400, 'Invalid request body');
|
||||
}
|
||||
|
||||
const email = (body as any)?.email;
|
||||
const email =
|
||||
typeof body === 'object' && body !== null && 'email' in body
|
||||
? (body as { email: unknown }).email
|
||||
: undefined;
|
||||
|
||||
if (typeof email !== 'string' || !email.trim()) {
|
||||
return jsonError(400, 'Invalid email address');
|
||||
|
||||
@@ -9,7 +9,8 @@ export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get('code') ?? undefined;
|
||||
const state = url.searchParams.get('state') ?? undefined;
|
||||
const returnTo = url.searchParams.get('returnTo') ?? undefined;
|
||||
const rawReturnTo = url.searchParams.get('returnTo');
|
||||
const returnTo = rawReturnTo ?? undefined;
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect('/auth/iracing');
|
||||
@@ -23,7 +24,8 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const authService = getAuthService();
|
||||
await authService.loginWithIracingCallback({ code, state, returnTo });
|
||||
const loginInput = returnTo ? { code, state, returnTo } : { code, state };
|
||||
await authService.loginWithIracingCallback(loginInput);
|
||||
|
||||
cookieStore.delete(STATE_COOKIE);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, use } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
@@ -43,12 +43,16 @@ import {
|
||||
getGetAllTeamsUseCase,
|
||||
getGetTeamMembersUseCase,
|
||||
} from '@/lib/di-container';
|
||||
import { AllTeamsPresenter } from '@/lib/presenters/AllTeamsPresenter';
|
||||
import { TeamMembersPresenter } from '@/lib/presenters/TeamMembersPresenter';
|
||||
import { Driver, EntityMappers, type Team } from '@gridpilot/racing';
|
||||
import type { DriverDTO } from '@gridpilot/racing';
|
||||
import type { ProfileOverviewViewModel } from '@gridpilot/racing/application/presenters/IProfileOverviewPresenter';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO';
|
||||
import type { TeamMemberViewModel } from '@gridpilot/racing/application/presenters/ITeamMembersPresenter';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
@@ -134,14 +138,22 @@ function getDemoExtendedProfile(driverId: string): DriverExtendedProfile {
|
||||
const timezones = ['EST (UTC-5)', 'CET (UTC+1)', 'PST (UTC-8)', 'GMT (UTC+0)', 'JST (UTC+9)'];
|
||||
const hours = ['Evenings (18:00-23:00)', 'Weekends only', 'Late nights (22:00-02:00)', 'Flexible schedule'];
|
||||
|
||||
const socialHandles = socialOptions[hash % socialOptions.length] ?? [];
|
||||
const achievements = achievementSets[hash % achievementSets.length] ?? [];
|
||||
const racingStyle = styles[hash % styles.length] ?? 'Consistent Pacer';
|
||||
const favoriteTrack = tracks[hash % tracks.length] ?? 'Unknown Track';
|
||||
const favoriteCar = cars[hash % cars.length] ?? 'Unknown Car';
|
||||
const timezone = timezones[hash % timezones.length] ?? 'UTC';
|
||||
const availableHours = hours[hash % hours.length] ?? 'Flexible schedule';
|
||||
|
||||
return {
|
||||
socialHandles: socialOptions[hash % socialOptions.length],
|
||||
achievements: achievementSets[hash % achievementSets.length],
|
||||
racingStyle: styles[hash % styles.length],
|
||||
favoriteTrack: tracks[hash % tracks.length],
|
||||
favoriteCar: cars[hash % cars.length],
|
||||
timezone: timezones[hash % timezones.length],
|
||||
availableHours: hours[hash % hours.length],
|
||||
socialHandles,
|
||||
achievements,
|
||||
racingStyle,
|
||||
favoriteTrack,
|
||||
favoriteCar,
|
||||
timezone,
|
||||
availableHours,
|
||||
lookingForTeam: hash % 3 === 0,
|
||||
openToRequests: hash % 2 === 0,
|
||||
};
|
||||
@@ -301,11 +313,46 @@ function HorizontalBarChart({ data, maxValue }: BarChartProps) {
|
||||
// MAIN PAGE
|
||||
// ============================================================================
|
||||
|
||||
export default function DriverDetailPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: any;
|
||||
}) {
|
||||
interface DriverProfileStatsViewModel {
|
||||
rating: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
dnfs: number;
|
||||
totalRaces: number;
|
||||
avgFinish: number;
|
||||
bestFinish: number;
|
||||
worstFinish: number;
|
||||
consistency: number;
|
||||
percentile: number;
|
||||
}
|
||||
|
||||
interface DriverProfileFriendViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
interface DriverProfileExtendedViewModel extends DriverExtendedProfile {}
|
||||
|
||||
interface DriverProfileViewModel {
|
||||
currentDriver?: {
|
||||
id: string;
|
||||
name: string;
|
||||
iracingId?: string | null;
|
||||
country: string;
|
||||
bio?: string | null;
|
||||
joinedAt: string | Date;
|
||||
globalRank?: number;
|
||||
totalDrivers?: number;
|
||||
};
|
||||
stats?: DriverProfileStatsViewModel;
|
||||
extendedProfile?: DriverProfileExtendedViewModel;
|
||||
socialSummary?: {
|
||||
friends: DriverProfileFriendViewModel[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function DriverDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const driverId = params.id as string;
|
||||
@@ -318,24 +365,16 @@ export default function DriverDetailPage({
|
||||
const [allTeamMemberships, setAllTeamMemberships] = useState<TeamMembershipInfo[]>([]);
|
||||
const [friends, setFriends] = useState<Driver[]>([]);
|
||||
const [friendRequestSent, setFriendRequestSent] = useState(false);
|
||||
const [profileData, setProfileData] = useState<any>(null);
|
||||
const [profileData, setProfileData] = useState<ProfileOverviewViewModel | null>(null);
|
||||
|
||||
const unwrappedSearchParams = use(searchParams) as URLSearchParams | undefined;
|
||||
|
||||
const from =
|
||||
typeof unwrappedSearchParams?.get === 'function'
|
||||
? unwrappedSearchParams.get('from') ?? undefined
|
||||
const search =
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search)
|
||||
: undefined;
|
||||
|
||||
const leagueId =
|
||||
typeof unwrappedSearchParams?.get === 'function'
|
||||
? unwrappedSearchParams.get('leagueId') ?? undefined
|
||||
: undefined;
|
||||
|
||||
const raceId =
|
||||
typeof unwrappedSearchParams?.get === 'function'
|
||||
? unwrappedSearchParams.get('raceId') ?? undefined
|
||||
: undefined;
|
||||
const from = search?.get('from') ?? undefined;
|
||||
const leagueId = search?.get('leagueId') ?? undefined;
|
||||
const raceId = search?.get('raceId') ?? undefined;
|
||||
|
||||
let backLink: string | null = null;
|
||||
|
||||
@@ -362,8 +401,7 @@ export default function DriverDetailPage({
|
||||
try {
|
||||
// Use GetProfileOverviewUseCase to load all profile data
|
||||
const profileUseCase = getGetProfileOverviewUseCase();
|
||||
await profileUseCase.execute({ driverId });
|
||||
const profileViewModel = profileUseCase.presenter.getViewModel();
|
||||
const profileViewModel = await profileUseCase.execute({ driverId });
|
||||
|
||||
if (!profileViewModel || !profileViewModel.currentDriver) {
|
||||
setError('Driver not found');
|
||||
@@ -375,7 +413,7 @@ export default function DriverDetailPage({
|
||||
const driverData: DriverDTO = {
|
||||
id: profileViewModel.currentDriver.id,
|
||||
name: profileViewModel.currentDriver.name,
|
||||
iracingId: profileViewModel.currentDriver.iracingId,
|
||||
iracingId: profileViewModel.currentDriver.iracingId ?? '',
|
||||
country: profileViewModel.currentDriver.country,
|
||||
bio: profileViewModel.currentDriver.bio || '',
|
||||
joinedAt: profileViewModel.currentDriver.joinedAt,
|
||||
@@ -383,30 +421,37 @@ export default function DriverDetailPage({
|
||||
setDriver(driverData);
|
||||
setProfileData(profileViewModel);
|
||||
|
||||
// Load team data
|
||||
const teamUseCase = getGetDriverTeamUseCase();
|
||||
await teamUseCase.execute({ driverId });
|
||||
const teamViewModel = teamUseCase.presenter.getViewModel();
|
||||
setTeamData(teamViewModel.result);
|
||||
|
||||
// Load ALL team memberships
|
||||
// Load ALL team memberships using caller-owned presenters
|
||||
const allTeamsUseCase = getGetAllTeamsUseCase();
|
||||
await allTeamsUseCase.execute();
|
||||
const allTeamsViewModel = allTeamsUseCase.presenter.getViewModel();
|
||||
const allTeams = allTeamsViewModel.teams;
|
||||
const membershipsUseCase = getGetTeamMembersUseCase();
|
||||
const allTeamsPresenter = new AllTeamsPresenter();
|
||||
await allTeamsUseCase.execute(undefined as void, allTeamsPresenter);
|
||||
const allTeamsViewModel = allTeamsPresenter.getViewModel();
|
||||
const allTeams = allTeamsViewModel?.teams ?? [];
|
||||
|
||||
const membershipsUseCase = getGetTeamMembersUseCase();
|
||||
const memberships: TeamMembershipInfo[] = [];
|
||||
|
||||
for (const team of allTeams) {
|
||||
await membershipsUseCase.execute({ teamId: team.id });
|
||||
const membersViewModel = membershipsUseCase.presenter.getViewModel();
|
||||
const members = membersViewModel.members;
|
||||
const membership = members.find((m) => m.driverId === driverId);
|
||||
const teamMembersPresenter = new TeamMembersPresenter();
|
||||
await membershipsUseCase.execute({ teamId: team.id }, teamMembersPresenter);
|
||||
const membersResult = teamMembersPresenter.getViewModel();
|
||||
const members = membersResult?.members ?? [];
|
||||
const membership = members.find(
|
||||
(member: TeamMemberViewModel) => member.driverId === driverId,
|
||||
);
|
||||
if (membership) {
|
||||
memberships.push({
|
||||
team,
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
ownerId: '',
|
||||
leagues: team.leagues,
|
||||
createdAt: new Date(),
|
||||
} as Team,
|
||||
role: membership.role,
|
||||
joinedAt: membership.joinedAt,
|
||||
joinedAt: new Date(membership.joinedAt),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -459,7 +504,30 @@ export default function DriverDetailPage({
|
||||
);
|
||||
}
|
||||
|
||||
const extendedProfile = profileData?.extendedProfile || getDemoExtendedProfile(driver.id);
|
||||
const demoExtended = getDemoExtendedProfile(driver.id);
|
||||
const extendedProfile: DriverExtendedProfile = {
|
||||
socialHandles: profileData?.extendedProfile?.socialHandles ?? demoExtended.socialHandles,
|
||||
achievements:
|
||||
profileData?.extendedProfile?.achievements
|
||||
? profileData.extendedProfile.achievements.map((achievement) => ({
|
||||
id: achievement.id,
|
||||
title: achievement.title,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
earnedAt: new Date(achievement.earnedAt),
|
||||
}))
|
||||
: demoExtended.achievements,
|
||||
racingStyle: profileData?.extendedProfile?.racingStyle ?? demoExtended.racingStyle,
|
||||
favoriteTrack: profileData?.extendedProfile?.favoriteTrack ?? demoExtended.favoriteTrack,
|
||||
favoriteCar: profileData?.extendedProfile?.favoriteCar ?? demoExtended.favoriteCar,
|
||||
timezone: profileData?.extendedProfile?.timezone ?? demoExtended.timezone,
|
||||
availableHours: profileData?.extendedProfile?.availableHours ?? demoExtended.availableHours,
|
||||
lookingForTeam:
|
||||
profileData?.extendedProfile?.lookingForTeam ?? demoExtended.lookingForTeam,
|
||||
openToRequests:
|
||||
profileData?.extendedProfile?.openToRequests ?? demoExtended.openToRequests,
|
||||
};
|
||||
const stats = profileData?.stats || null;
|
||||
const globalRank = profileData?.currentDriver?.globalRank || 1;
|
||||
|
||||
@@ -627,7 +695,7 @@ export default function DriverDetailPage({
|
||||
<div className="mt-6 pt-6 border-t border-charcoal-outline/50">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-gray-500 mr-2">Connect:</span>
|
||||
{extendedProfile.socialHandles.map((social) => {
|
||||
{extendedProfile.socialHandles.map((social: SocialHandle) => {
|
||||
const Icon = getSocialIcon(social.platform);
|
||||
return (
|
||||
<a
|
||||
@@ -724,7 +792,7 @@ export default function DriverDetailPage({
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<CircularProgress
|
||||
value={stats.consistency}
|
||||
value={stats.consistency ?? 0}
|
||||
max={100}
|
||||
label="Consistency"
|
||||
color="text-primary-blue"
|
||||
@@ -766,7 +834,9 @@ export default function DriverDetailPage({
|
||||
<Target className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-xs text-gray-500 uppercase">Avg Finish</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-primary-blue">P{stats.avgFinish.toFixed(1)}</p>
|
||||
<p className="text-2xl font-bold text-primary-blue">
|
||||
P{(stats.avgFinish ?? 0).toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -888,7 +958,7 @@ export default function DriverDetailPage({
|
||||
<span className="ml-auto text-sm text-gray-500">{extendedProfile.achievements.length} earned</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{extendedProfile.achievements.map((achievement) => {
|
||||
{extendedProfile.achievements.map((achievement: Achievement) => {
|
||||
const Icon = getAchievementIcon(achievement.icon);
|
||||
const rarityClasses = getRarityColor(achievement.rarity);
|
||||
return (
|
||||
@@ -1033,7 +1103,9 @@ export default function DriverDetailPage({
|
||||
<div className="text-xs text-gray-400 uppercase">Best Finish</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
||||
<div className="text-4xl font-bold text-primary-blue mb-1">P{stats.avgFinish.toFixed(1)}</div>
|
||||
<div className="text-4xl font-bold text-primary-blue mb-1">
|
||||
P{(stats.avgFinish ?? 0).toFixed(1)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase">Avg Finish</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">
|
||||
|
||||
@@ -71,11 +71,15 @@ interface TopThreePodiumProps {
|
||||
}
|
||||
|
||||
function TopThreePodium({ drivers, onDriverClick }: TopThreePodiumProps) {
|
||||
const top3 = drivers.slice(0, 3);
|
||||
if (drivers.length < 3) return null;
|
||||
|
||||
if (top3.length < 3) return null;
|
||||
const top3 = drivers.slice(0, 3) as [DriverListItem, DriverListItem, DriverListItem];
|
||||
|
||||
const podiumOrder = [top3[1], top3[0], top3[2]]; // 2nd, 1st, 3rd
|
||||
const podiumOrder: [DriverListItem, DriverListItem, DriverListItem] = [
|
||||
top3[1],
|
||||
top3[0],
|
||||
top3[2],
|
||||
]; // 2nd, 1st, 3rd
|
||||
const podiumHeights = ['h-32', 'h-40', 'h-24'];
|
||||
const podiumColors = [
|
||||
'from-gray-400/20 to-gray-500/10 border-gray-400/40',
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { getGetDriversLeaderboardUseCase, getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
|
||||
import { TeamsLeaderboardPresenter } from '@/lib/presenters/TeamsLeaderboardPresenter';
|
||||
import type { DriverLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter';
|
||||
import type { TeamLeaderboardItemViewModel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
|
||||
import Image from 'next/image';
|
||||
@@ -286,14 +287,16 @@ export default function LeaderboardsPage() {
|
||||
try {
|
||||
const driversUseCase = getGetDriversLeaderboardUseCase();
|
||||
const teamsUseCase = getGetTeamsLeaderboardUseCase();
|
||||
const teamsPresenter = new TeamsLeaderboardPresenter();
|
||||
|
||||
await driversUseCase.execute();
|
||||
await teamsUseCase.execute();
|
||||
await teamsUseCase.execute(undefined as void, teamsPresenter);
|
||||
|
||||
const driversViewModel = driversUseCase.presenter.getViewModel();
|
||||
const teamsViewModel = teamsUseCase.presenter.getViewModel();
|
||||
const teamsViewModel = teamsPresenter.getViewModel();
|
||||
|
||||
setDrivers(driversViewModel.drivers);
|
||||
setTeams(teamsViewModel.teams);
|
||||
setTeams(teamsViewModel ? teamsViewModel.teams : []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load leaderboard data:', error);
|
||||
setDrivers([]);
|
||||
|
||||
@@ -19,8 +19,8 @@ import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
// Main sponsor info for "by XYZ" display
|
||||
interface MainSponsorInfo {
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
logoUrl: string;
|
||||
websiteUrl: string;
|
||||
}
|
||||
|
||||
export default function LeagueLayout({
|
||||
@@ -80,8 +80,8 @@ export default function LeagueLayout({
|
||||
if (sponsor) {
|
||||
setMainSponsor({
|
||||
name: sponsor.name,
|
||||
logoUrl: sponsor.logoUrl,
|
||||
websiteUrl: sponsor.websiteUrl,
|
||||
logoUrl: sponsor.logoUrl ?? '',
|
||||
websiteUrl: sponsor.websiteUrl ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function LeagueDetailPage() {
|
||||
const getLeagueScoringConfigUseCase = getGetLeagueScoringConfigUseCase();
|
||||
await getLeagueScoringConfigUseCase.execute({ leagueId });
|
||||
const scoringViewModel = getLeagueScoringConfigUseCase.presenter.getViewModel();
|
||||
setScoringConfig(scoringViewModel);
|
||||
setScoringConfig(scoringViewModel as unknown as LeagueScoringConfigDTO);
|
||||
|
||||
// Load all drivers for standings and map to DTOs for UI components
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
@@ -157,23 +157,23 @@ export default function LeagueDetailPage() {
|
||||
|
||||
if (activeSeason) {
|
||||
const sponsorships = await sponsorshipRepo.findBySeasonId(activeSeason.id);
|
||||
const activeSponsorships = sponsorships.filter(s => s.status === 'active');
|
||||
const activeSponsorships = sponsorships.filter((s) => s.status === 'active');
|
||||
|
||||
const sponsorInfos: SponsorInfo[] = [];
|
||||
for (const sponsorship of activeSponsorships) {
|
||||
const sponsor = await sponsorRepo.findById(sponsorship.sponsorId);
|
||||
if (sponsor) {
|
||||
// Get tagline from demo data if available
|
||||
const demoSponsors = (await import('@gridpilot/testing-support')).sponsors;
|
||||
const demoSponsor = demoSponsors.find((s: any) => s.id === sponsor.id);
|
||||
const testingSupportModule = await import('@gridpilot/testing-support');
|
||||
const demoSponsors = testingSupportModule.sponsors as Array<{ id: string; tagline?: string }>;
|
||||
const demoSponsor = demoSponsors.find((demo) => demo.id === sponsor.id);
|
||||
|
||||
sponsorInfos.push({
|
||||
id: sponsor.id,
|
||||
name: sponsor.name,
|
||||
logoUrl: sponsor.logoUrl,
|
||||
websiteUrl: sponsor.websiteUrl,
|
||||
logoUrl: sponsor.logoUrl ?? '',
|
||||
websiteUrl: sponsor.websiteUrl ?? '',
|
||||
tier: sponsorship.tier,
|
||||
tagline: demoSponsor?.tagline,
|
||||
tagline: demoSponsor?.tagline ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function LeagueRulebookPage() {
|
||||
|
||||
await scoringUseCase.execute({ leagueId });
|
||||
const scoringViewModel = scoringUseCase.presenter.getViewModel();
|
||||
setScoringConfig(scoringViewModel);
|
||||
setScoringConfig(scoringViewModel as unknown as LeagueScoringConfigDTO);
|
||||
} catch (err) {
|
||||
console.error('Failed to load scoring config:', err);
|
||||
} finally {
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
getListLeagueScoringPresetsUseCase,
|
||||
getTransferLeagueOwnershipUseCase
|
||||
} from '@/lib/di-container';
|
||||
import { LeagueFullConfigPresenter } from '@/lib/presenters/LeagueFullConfigPresenter';
|
||||
import { LeagueScoringPresetsPresenter } from '@/lib/presenters/LeagueScoringPresetsPresenter';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import { ScoringPatternSection, ChampionshipsSection } from '@/components/leagues/LeagueScoringSection';
|
||||
@@ -70,13 +72,17 @@ export default function LeagueSettingsPage() {
|
||||
|
||||
setLeague(leagueData);
|
||||
|
||||
await useCase.execute({ leagueId });
|
||||
const configViewModel = useCase.presenter.getViewModel();
|
||||
setConfigForm(configViewModel);
|
||||
const configPresenter = new LeagueFullConfigPresenter();
|
||||
await useCase.execute({ leagueId }, configPresenter);
|
||||
const configViewModel = configPresenter.getViewModel();
|
||||
if (configViewModel) {
|
||||
setConfigForm(configViewModel as LeagueConfigFormModel);
|
||||
}
|
||||
|
||||
await presetsUseCase.execute();
|
||||
const presetsViewModel = presetsUseCase.presenter.getViewModel();
|
||||
setPresets(presetsViewModel);
|
||||
const presetsPresenter = new LeagueScoringPresetsPresenter();
|
||||
await presetsUseCase.execute(undefined as void, presetsPresenter);
|
||||
const presetsViewModel = presetsPresenter.getViewModel();
|
||||
setPresets(presetsViewModel.presets);
|
||||
|
||||
const entity = await driverRepo.findById(leagueData.ownerId);
|
||||
if (entity) {
|
||||
|
||||
@@ -37,8 +37,15 @@ export default function LeagueStandingsPage() {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
|
||||
await getLeagueDriverSeasonStatsUseCase.execute({ leagueId });
|
||||
const standingsViewModel = getLeagueDriverSeasonStatsUseCase.presenter.getViewModel();
|
||||
setStandings(standingsViewModel);
|
||||
type GetLeagueDriverSeasonStatsUseCaseType = {
|
||||
presenter: {
|
||||
getViewModel(): { stats: LeagueDriverSeasonStatsDTO[] };
|
||||
};
|
||||
};
|
||||
const typedUseCase =
|
||||
getLeagueDriverSeasonStatsUseCase as GetLeagueDriverSeasonStatsUseCaseType;
|
||||
const standingsViewModel = typedUseCase.presenter.getViewModel();
|
||||
setStandings(standingsViewModel.stats);
|
||||
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const driverDtos: DriverDTO[] = allDrivers
|
||||
@@ -48,8 +55,19 @@ export default function LeagueStandingsPage() {
|
||||
|
||||
// Load league memberships from repository (consistent with other data)
|
||||
const allMemberships = await membershipRepo.getLeagueMembers(leagueId);
|
||||
// Convert to the format expected by StandingsTable
|
||||
const membershipData: LeagueMembership[] = allMemberships.map(m => ({
|
||||
|
||||
type RawMembership = {
|
||||
id: string | number;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
role: MembershipRole;
|
||||
status: LeagueMembership['status'];
|
||||
joinedAt: string | Date;
|
||||
};
|
||||
|
||||
// Convert to the format expected by StandingsTable (website-level LeagueMembership)
|
||||
const membershipData: LeagueMembership[] = (allMemberships as RawMembership[]).map((m) => ({
|
||||
id: String(m.id),
|
||||
leagueId: m.leagueId,
|
||||
driverId: m.driverId,
|
||||
role: m.role,
|
||||
|
||||
@@ -246,12 +246,15 @@ export default function ProtestReviewPage() {
|
||||
});
|
||||
|
||||
const selectedPenalty = PENALTY_TYPES.find(p => p.type === penaltyType);
|
||||
const penaltyValueToUse =
|
||||
selectedPenalty && selectedPenalty.requiresValue ? penaltyValue : 0;
|
||||
|
||||
await penaltyUseCase.execute({
|
||||
raceId: protest.raceId,
|
||||
driverId: protest.accusedDriverId,
|
||||
stewardId: currentDriverId,
|
||||
type: penaltyType,
|
||||
value: selectedPenalty?.requiresValue ? penaltyValue : undefined,
|
||||
value: penaltyValueToUse,
|
||||
reason: protest.incident.description,
|
||||
protestId: protest.id,
|
||||
notes: stewardNotes,
|
||||
|
||||
@@ -35,8 +35,8 @@ export default function CreateLeaguePage() {
|
||||
|
||||
const handleStepChange = (stepName: StepName) => {
|
||||
const params = new URLSearchParams(
|
||||
searchParams && typeof (searchParams as any).toString === 'function'
|
||||
? (searchParams as any).toString()
|
||||
searchParams && typeof searchParams.toString === 'function'
|
||||
? searchParams.toString()
|
||||
: '',
|
||||
);
|
||||
params.set('step', stepName);
|
||||
|
||||
@@ -391,8 +391,11 @@ export default function LeaguesPage() {
|
||||
try {
|
||||
const useCase = getGetAllLeaguesWithCapacityAndScoringUseCase();
|
||||
await useCase.execute();
|
||||
const viewModel = useCase.presenter.getViewModel();
|
||||
setRealLeagues(viewModel);
|
||||
const presenter = useCase.presenter as unknown as {
|
||||
getViewModel(): { leagues: LeagueSummaryDTO[] };
|
||||
};
|
||||
const viewModel = presenter.getViewModel();
|
||||
setRealLeagues(viewModel.leagues);
|
||||
} catch (error) {
|
||||
console.error('Failed to load leagues:', error);
|
||||
} finally {
|
||||
|
||||
@@ -284,15 +284,14 @@ export default function ProfilePage() {
|
||||
|
||||
// Use GetProfileOverviewUseCase to load all profile data
|
||||
const profileUseCase = getGetProfileOverviewUseCase();
|
||||
await profileUseCase.execute({ driverId: currentDriverId });
|
||||
const profileViewModel = profileUseCase.presenter.getViewModel();
|
||||
const profileViewModel = await profileUseCase.execute({ driverId: currentDriverId });
|
||||
|
||||
if (profileViewModel && profileViewModel.currentDriver) {
|
||||
// Set driver from ViewModel instead of direct repository access
|
||||
const driverData: DriverDTO = {
|
||||
id: profileViewModel.currentDriver.id,
|
||||
name: profileViewModel.currentDriver.name,
|
||||
iracingId: profileViewModel.currentDriver.iracingId,
|
||||
iracingId: profileViewModel.currentDriver.iracingId ?? '',
|
||||
country: profileViewModel.currentDriver.country,
|
||||
bio: profileViewModel.currentDriver.bio || '',
|
||||
joinedAt: profileViewModel.currentDriver.joinedAt,
|
||||
@@ -335,11 +334,14 @@ export default function ProfilePage() {
|
||||
|
||||
try {
|
||||
const updateProfileUseCase = getUpdateDriverProfileUseCase();
|
||||
const updatedDto = await updateProfileUseCase.execute({
|
||||
driverId: driver.id,
|
||||
bio: updates.bio,
|
||||
country: updates.country,
|
||||
});
|
||||
const input: { driverId: string; bio?: string; country?: string } = { driverId: driver.id };
|
||||
if (typeof updates.bio === 'string') {
|
||||
input.bio = updates.bio;
|
||||
}
|
||||
if (typeof updates.country === 'string') {
|
||||
input.country = updates.country;
|
||||
}
|
||||
const updatedDto = await updateProfileUseCase.execute(input);
|
||||
|
||||
if (updatedDto) {
|
||||
setDriver(updatedDto);
|
||||
@@ -468,7 +470,9 @@ export default function ProfilePage() {
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
|
||||
<Star className="w-4 h-4 text-primary-blue" />
|
||||
<span className="font-mono font-bold text-primary-blue">{stats.rating}</span>
|
||||
<span className="font-mono font-bold text-primary-blue">
|
||||
{stats.rating ?? 0}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">Rating</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30">
|
||||
@@ -644,7 +648,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<CircularProgress
|
||||
value={stats.consistency}
|
||||
value={stats.consistency ?? 0}
|
||||
max={100}
|
||||
label="Consistency"
|
||||
color="text-primary-blue"
|
||||
@@ -684,7 +688,9 @@ export default function ProfilePage() {
|
||||
<Target className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-xs text-gray-500 uppercase">Avg Finish</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-primary-blue">P{stats.avgFinish.toFixed(1)}</p>
|
||||
<p className="text-2xl font-bold text-primary-blue">
|
||||
P{(stats.avgFinish ?? 0).toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -758,7 +764,9 @@ export default function ProfilePage() {
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Podiums</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.consistency}%</div>
|
||||
<div className="text-3xl font-bold text-primary-blue mb-1">
|
||||
{stats.consistency ?? 0}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Consistency</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -863,30 +871,35 @@ export default function ProfilePage() {
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{extendedProfile.achievements.map((achievement) => {
|
||||
const Icon = getAchievementIcon(achievement.icon);
|
||||
const rarityClasses = getRarityColor(achievement.rarity);
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className={`p-4 rounded-xl border ${rarityClasses} transition-all hover:scale-105`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${rarityClasses.split(' ')[1]}`}>
|
||||
<Icon className={`w-5 h-5 ${rarityClasses.split(' ')[0]}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-semibold text-sm">{achievement.title}</p>
|
||||
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
{new Date(achievement.earnedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
const Icon = getAchievementIcon(achievement.icon);
|
||||
const rarityClasses = getRarityColor(achievement.rarity);
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className={`p-4 rounded-xl border ${rarityClasses} transition-all hover:scale-105`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${rarityClasses.split(' ')[1]}`}>
|
||||
<Icon className={`w-5 h-5 ${rarityClasses.split(' ')[0]}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-semibold text-sm">{achievement.title}</p>
|
||||
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
{new Date(achievement.earnedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Friends Preview */}
|
||||
{socialSummary && socialSummary.friends.length > 0 && (
|
||||
@@ -987,7 +1000,9 @@ export default function ProfilePage() {
|
||||
<Activity className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-xs text-gray-500 uppercase">Consistency</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-primary-blue">{stats.consistency}%</p>
|
||||
<p className="text-2xl font-bold text-primary-blue">
|
||||
{stats.consistency ?? 0}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -1015,7 +1030,9 @@ export default function ProfilePage() {
|
||||
<div className="text-xs text-gray-400 uppercase">Best Finish</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
||||
<div className="text-4xl font-bold text-primary-blue mb-1">P{stats.avgFinish.toFixed(1)}</div>
|
||||
<div className="text-4xl font-bold text-primary-blue mb-1">
|
||||
P{(stats.avgFinish ?? 0).toFixed(1)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase">Avg Finish</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">
|
||||
@@ -1044,7 +1061,9 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
<div className="p-6 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
||||
<Star className="w-8 h-8 text-primary-blue mx-auto mb-3" />
|
||||
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.rating}</div>
|
||||
<div className="text-3xl font-bold text-primary-blue mb-1">
|
||||
{stats.rating ?? 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Rating</div>
|
||||
</div>
|
||||
<div className="p-6 rounded-xl bg-gradient-to-br from-purple-400/20 to-purple-600/5 border border-purple-400/30 text-center">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getLeagueMembershipRepository,
|
||||
getTeamMembershipRepository,
|
||||
} from '@/lib/di-container';
|
||||
import { PendingSponsorshipRequestsPresenter } from '@/lib/presenters/PendingSponsorshipRequestsPresenter';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import { Handshake, User, Users, Trophy, ChevronRight, Building, AlertTriangle } from 'lucide-react';
|
||||
@@ -51,12 +52,17 @@ export default function SponsorshipRequestsPage() {
|
||||
const allSections: EntitySection[] = [];
|
||||
|
||||
// 1. Driver's own sponsorship requests
|
||||
const driverResult = await query.execute({
|
||||
entityType: 'driver',
|
||||
entityId: currentDriverId,
|
||||
});
|
||||
const driverPresenter = new PendingSponsorshipRequestsPresenter();
|
||||
await useCase.execute(
|
||||
{
|
||||
entityType: 'driver',
|
||||
entityId: currentDriverId,
|
||||
},
|
||||
driverPresenter,
|
||||
);
|
||||
const driverResult = driverPresenter.getViewModel();
|
||||
|
||||
if (driverResult.requests.length > 0) {
|
||||
if (driverResult && driverResult.requests.length > 0) {
|
||||
const driver = await driverRepo.findById(currentDriverId);
|
||||
allSections.push({
|
||||
entityType: 'driver',
|
||||
@@ -74,12 +80,17 @@ export default function SponsorshipRequestsPage() {
|
||||
// Load sponsorship requests for this league's active season
|
||||
try {
|
||||
// For simplicity, we'll query by season entityType - in production you'd get the active season ID
|
||||
const leagueResult = await query.execute({
|
||||
entityType: 'season',
|
||||
entityId: league.id, // Using league ID as a proxy for now
|
||||
});
|
||||
const leaguePresenter = new PendingSponsorshipRequestsPresenter();
|
||||
await useCase.execute(
|
||||
{
|
||||
entityType: 'season',
|
||||
entityId: league.id, // Using league ID as a proxy for now
|
||||
},
|
||||
leaguePresenter,
|
||||
);
|
||||
const leagueResult = leaguePresenter.getViewModel();
|
||||
|
||||
if (leagueResult.requests.length > 0) {
|
||||
if (leagueResult && leagueResult.requests.length > 0) {
|
||||
allSections.push({
|
||||
entityType: 'season',
|
||||
entityId: league.id,
|
||||
@@ -98,12 +109,17 @@ export default function SponsorshipRequestsPage() {
|
||||
for (const team of allTeams) {
|
||||
const membership = await teamMembershipRepo.getMembership(team.id, currentDriverId);
|
||||
if (membership && (membership.role === 'owner' || membership.role === 'manager')) {
|
||||
const teamResult = await query.execute({
|
||||
entityType: 'team',
|
||||
entityId: team.id,
|
||||
});
|
||||
const teamPresenter = new PendingSponsorshipRequestsPresenter();
|
||||
await useCase.execute(
|
||||
{
|
||||
entityType: 'team',
|
||||
entityId: team.id,
|
||||
},
|
||||
teamPresenter,
|
||||
);
|
||||
const teamResult = teamPresenter.getViewModel();
|
||||
|
||||
if (teamResult.requests.length > 0) {
|
||||
if (teamResult && teamResult.requests.length > 0) {
|
||||
allSections.push({
|
||||
entityType: 'team',
|
||||
entityId: team.id,
|
||||
@@ -138,11 +154,14 @@ export default function SponsorshipRequestsPage() {
|
||||
|
||||
const handleReject = async (requestId: string, reason?: string) => {
|
||||
const useCase = getRejectSponsorshipRequestUseCase();
|
||||
await useCase.execute({
|
||||
const input: { requestId: string; respondedBy: string; reason?: string } = {
|
||||
requestId,
|
||||
respondedBy: currentDriverId,
|
||||
reason,
|
||||
});
|
||||
};
|
||||
if (typeof reason === 'string') {
|
||||
input.reason = reason;
|
||||
}
|
||||
await useCase.execute(input);
|
||||
await loadAllRequests();
|
||||
};
|
||||
|
||||
|
||||
@@ -928,7 +928,7 @@ export default function RaceDetailPage() {
|
||||
isOpen={showProtestModal}
|
||||
onClose={() => setShowProtestModal(false)}
|
||||
raceId={race.id}
|
||||
leagueId={league?.id}
|
||||
leagueId={league ? league.id : ''}
|
||||
protestingDriverId={currentDriverId}
|
||||
participants={entryList.map(d => ({ id: d.id, name: d.name }))}
|
||||
/>
|
||||
|
||||
@@ -115,13 +115,17 @@ export default function RaceResultsPage() {
|
||||
setPointsSystem(viewModel.pointsSystem);
|
||||
setFastestLapTime(viewModel.fastestLapTime);
|
||||
setCurrentDriverId(viewModel.currentDriverId);
|
||||
setPenalties(
|
||||
viewModel.penalties.map((p) => ({
|
||||
const mappedPenalties: PenaltyData[] = viewModel.penalties.map((p) => {
|
||||
const base: PenaltyData = {
|
||||
driverId: p.driverId,
|
||||
type: p.type as PenaltyTypeDTO,
|
||||
value: p.value,
|
||||
})),
|
||||
);
|
||||
};
|
||||
if (typeof p.value === 'number') {
|
||||
return { ...base, value: p.value };
|
||||
}
|
||||
return base;
|
||||
});
|
||||
setPenalties(mappedPenalties);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -287,9 +291,9 @@ export default function RaceResultsPage() {
|
||||
results={results}
|
||||
drivers={drivers}
|
||||
pointsSystem={pointsSystem}
|
||||
fastestLapTime={fastestLapTime}
|
||||
fastestLapTime={fastestLapTime ?? 0}
|
||||
penalties={penalties}
|
||||
currentDriverId={currentDriverId}
|
||||
currentDriverId={currentDriverId ?? ''}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import { RaceProtestsPresenter } from '@/lib/presenters/RaceProtestsPresenter';
|
||||
import { RacePenaltiesPresenter } from '@/lib/presenters/RacePenaltiesPresenter';
|
||||
import type { RaceProtestViewModel } from '@gridpilot/racing/application/presenters/IRaceProtestsPresenter';
|
||||
import type { RacePenaltyViewModel } from '@gridpilot/racing/application/presenters/IRacePenaltiesPresenter';
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
@@ -41,7 +43,9 @@ export default function RaceStewardingPage() {
|
||||
const router = useRouter();
|
||||
const raceId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
|
||||
const driversById: Record<string, { name?: string }> = {};
|
||||
|
||||
const [race, setRace] = useState<Race | null>(null);
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [protests, setProtests] = useState<RaceProtestViewModel[]>([]);
|
||||
@@ -78,13 +82,15 @@ export default function RaceStewardingPage() {
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
}
|
||||
|
||||
await protestsUseCase.execute(raceId);
|
||||
const protestsViewModel = protestsUseCase.presenter.getViewModel();
|
||||
setProtests(protestsViewModel.protests);
|
||||
const protestsPresenter = new RaceProtestsPresenter();
|
||||
await protestsUseCase.execute({ raceId }, protestsPresenter);
|
||||
const protestsViewModel = protestsPresenter.getViewModel();
|
||||
setProtests(protestsViewModel?.protests ?? []);
|
||||
|
||||
await penaltiesUseCase.execute(raceId);
|
||||
const penaltiesViewModel = penaltiesUseCase.presenter.getViewModel();
|
||||
setPenalties(penaltiesViewModel.penalties);
|
||||
const penaltiesPresenter = new RacePenaltiesPresenter();
|
||||
await penaltiesUseCase.execute({ raceId }, penaltiesPresenter);
|
||||
const penaltiesViewModel = penaltiesPresenter.getViewModel();
|
||||
setPenalties(penaltiesViewModel?.penalties ?? []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err);
|
||||
} finally {
|
||||
|
||||
@@ -105,8 +105,9 @@ export default function AllRacesPage() {
|
||||
setCurrentPage(1);
|
||||
}, [statusFilter, leagueFilter, searchQuery]);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
const formatDate = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -114,8 +115,9 @@ export default function AllRacesPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString('en-US', {
|
||||
const formatTime = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
@@ -93,8 +93,11 @@ export default function RacesPage() {
|
||||
// Group races by date for calendar view
|
||||
const racesByDate = useMemo(() => {
|
||||
const grouped = new Map<string, RaceListItemViewModel[]>();
|
||||
filteredRaces.forEach(race => {
|
||||
const dateKey = new Date(race.scheduledAt).toISOString().split('T')[0];
|
||||
filteredRaces.forEach((race) => {
|
||||
if (typeof race.scheduledAt !== 'string') {
|
||||
return;
|
||||
}
|
||||
const dateKey = race.scheduledAt.split('T')[0]!;
|
||||
if (!grouped.has(dateKey)) {
|
||||
grouped.set(dateKey, []);
|
||||
}
|
||||
@@ -108,23 +111,26 @@ export default function RacesPage() {
|
||||
const recentResults = pageData?.recentResults ?? [];
|
||||
const stats = pageData?.stats ?? { total: 0, scheduled: 0, running: 0, completed: 0 };
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
const formatDate = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString('en-US', {
|
||||
const formatTime = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatFullDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
const formatFullDate = (date: Date | string) => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
@@ -132,9 +138,10 @@ export default function RacesPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const getRelativeTime = (date: Date) => {
|
||||
const getRelativeTime = (date?: Date | string) => {
|
||||
if (!date) return '';
|
||||
const now = new Date();
|
||||
const targetDate = new Date(date);
|
||||
const targetDate = typeof date === 'string' ? new Date(date) : date;
|
||||
const diffMs = targetDate.getTime() - now.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
@@ -144,7 +151,7 @@ export default function RacesPage() {
|
||||
if (diffHours < 24) return `In ${diffHours}h`;
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
if (diffDays < 7) return `In ${diffDays} days`;
|
||||
return formatDate(date);
|
||||
return formatDate(targetDate);
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
@@ -368,6 +375,9 @@ export default function RacesPage() {
|
||||
{/* Races for this date */}
|
||||
<div className="space-y-2">
|
||||
{dayRaces.map((race) => {
|
||||
if (!race.scheduledAt) {
|
||||
return null;
|
||||
}
|
||||
const config = statusConfig[race.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
@@ -385,9 +395,13 @@ export default function RacesPage() {
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Time Column */}
|
||||
<div className="flex-shrink-0 text-center min-w-[60px]">
|
||||
<p className="text-lg font-bold text-white">{formatTime(new Date(race.scheduledAt))}</p>
|
||||
<p className="text-lg font-bold text-white">
|
||||
{formatTime(race.scheduledAt)}
|
||||
</p>
|
||||
<p className={`text-xs ${config.color}`}>
|
||||
{race.status === 'running' ? 'LIVE' : getRelativeTime(new Date(race.scheduledAt))}
|
||||
{race.status === 'running'
|
||||
? 'LIVE'
|
||||
: getRelativeTime(race.scheduledAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -427,7 +441,7 @@ export default function RacesPage() {
|
||||
{/* League Link */}
|
||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<Link
|
||||
href={`/leagues/${race.leagueId}`}
|
||||
href={`/leagues/${race.leagueId ?? ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
|
||||
>
|
||||
@@ -482,24 +496,30 @@ export default function RacesPage() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingRaces.map((race) => (
|
||||
<div
|
||||
key={race.id}
|
||||
onClick={() => router.push(`/races/${race.id}`)}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-primary-blue">
|
||||
{new Date(race.scheduledAt).getDate()}
|
||||
</span>
|
||||
{upcomingRaces.map((race) => {
|
||||
if (!race.scheduledAt) {
|
||||
return null;
|
||||
}
|
||||
const scheduledAtDate = new Date(race.scheduledAt);
|
||||
return (
|
||||
<div
|
||||
key={race.id}
|
||||
onClick={() => router.push(`/races/${race.id}`)}
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-primary-blue">
|
||||
{scheduledAtDate.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">{race.track}</p>
|
||||
<p className="text-xs text-gray-500">{formatTime(scheduledAtDate)}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">{race.track}</p>
|
||||
<p className="text-xs text-gray-500">{formatTime(new Date(race.scheduledAt))}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -9,6 +9,7 @@ import Button from '@/components/ui/Button';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
|
||||
import { getImageService } from '@/lib/di-container';
|
||||
import { TeamMembersPresenter } from '@/lib/presenters/TeamMembersPresenter';
|
||||
import TeamRoster from '@/components/teams/TeamRoster';
|
||||
import TeamStandings from '@/components/teams/TeamStandings';
|
||||
import TeamAdmin from '@/components/teams/TeamAdmin';
|
||||
@@ -19,9 +20,17 @@ import {
|
||||
getTeamMembershipRepository,
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import type { Team, TeamMembership, TeamRole } from '@gridpilot/racing';
|
||||
import type { Team } from '@gridpilot/racing';
|
||||
import { Users, Trophy, TrendingUp, Star, Zap } from 'lucide-react';
|
||||
|
||||
type TeamRole = 'owner' | 'manager' | 'driver';
|
||||
|
||||
interface TeamMembership {
|
||||
driverId: string;
|
||||
role: TeamRole;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||
|
||||
export default function TeamDetailPage() {
|
||||
@@ -42,16 +51,32 @@ export default function TeamDetailPage() {
|
||||
const detailsUseCase = getGetTeamDetailsUseCase();
|
||||
const membersUseCase = getGetTeamMembersUseCase();
|
||||
|
||||
await detailsUseCase.execute({ teamId, driverId: currentDriverId });
|
||||
const detailsViewModel = detailsUseCase.presenter.getViewModel();
|
||||
|
||||
await membersUseCase.execute({ teamId });
|
||||
const membersViewModel = membersUseCase.presenter.getViewModel();
|
||||
const teamMemberships = membersViewModel.members;
|
||||
await detailsUseCase.execute(teamId, currentDriverId);
|
||||
const detailsPresenter = detailsUseCase.presenter;
|
||||
const detailsViewModel = detailsPresenter
|
||||
? (detailsPresenter as any).getViewModel?.() as { team: Team } | null
|
||||
: null;
|
||||
|
||||
if (!detailsViewModel) {
|
||||
setTeam(null);
|
||||
setMemberships([]);
|
||||
setIsAdmin(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const teamMembersPresenter = new TeamMembersPresenter();
|
||||
await membersUseCase.execute({ teamId }, teamMembersPresenter);
|
||||
const membersViewModel = teamMembersPresenter.getViewModel();
|
||||
|
||||
const teamMemberships: TeamMembership[] = (membersViewModel?.members ?? []).map((m) => ({
|
||||
driverId: m.driverId,
|
||||
role: m.role as TeamRole,
|
||||
joinedAt: new Date(m.joinedAt),
|
||||
}));
|
||||
|
||||
const adminStatus =
|
||||
teamMemberships.some(
|
||||
(m) =>
|
||||
(m: TeamMembership) =>
|
||||
m.driverId === currentDriverId &&
|
||||
(m.role === 'owner' || m.role === 'manager'),
|
||||
) ?? false;
|
||||
|
||||
@@ -23,6 +23,7 @@ import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
|
||||
import { TeamsLeaderboardPresenter } from '@/lib/presenters/TeamsLeaderboardPresenter';
|
||||
import type {
|
||||
TeamLeaderboardItemViewModel,
|
||||
SkillLevel,
|
||||
@@ -36,6 +37,23 @@ type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
||||
|
||||
type TeamDisplayData = TeamLeaderboardItemViewModel;
|
||||
|
||||
const getSafeRating = (team: TeamDisplayData): number => {
|
||||
const value = typeof team.rating === 'number' ? team.rating : 0;
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
|
||||
const getSafeTotalWins = (team: TeamDisplayData): number => {
|
||||
const raw = team.totalWins;
|
||||
const value = typeof raw === 'number' ? raw : 0;
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
|
||||
const getSafeTotalRaces = (team: TeamDisplayData): number => {
|
||||
const raw = team.totalRaces;
|
||||
const value = typeof raw === 'number' ? raw : 0;
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SKILL LEVEL CONFIG
|
||||
// ============================================================================
|
||||
@@ -103,11 +121,15 @@ interface TopThreePodiumProps {
|
||||
}
|
||||
|
||||
function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
|
||||
const top3 = teams.slice(0, 3);
|
||||
if (top3.length < 3) return null;
|
||||
const top3 = teams.slice(0, 3) as [TeamDisplayData, TeamDisplayData, TeamDisplayData];
|
||||
if (teams.length < 3) return null;
|
||||
|
||||
// Display order: 2nd, 1st, 3rd
|
||||
const podiumOrder = [top3[1], top3[0], top3[2]];
|
||||
const podiumOrder: [TeamDisplayData, TeamDisplayData, TeamDisplayData] = [
|
||||
top3[1],
|
||||
top3[0],
|
||||
top3[2],
|
||||
];
|
||||
const podiumHeights = ['h-28', 'h-36', 'h-20'];
|
||||
const podiumPositions = [2, 1, 3];
|
||||
|
||||
@@ -159,7 +181,7 @@ function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
|
||||
|
||||
<div className="flex items-end justify-center gap-4 md:gap-8">
|
||||
{podiumOrder.map((team, index) => {
|
||||
const position = podiumPositions[index];
|
||||
const position = podiumPositions[index] ?? 0;
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
|
||||
const LevelIcon = levelConfig?.icon || Shield;
|
||||
|
||||
@@ -172,7 +194,7 @@ function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
|
||||
>
|
||||
{/* Team card */}
|
||||
<div
|
||||
className={`relative mb-4 p-4 rounded-xl bg-gradient-to-br ${getGradient(position)} border ${getBorderColor(position)} transition-all group-hover:scale-105 group-hover:shadow-lg`}
|
||||
className={`relative mb-4 p-4 rounded-xl bg-gradient-to-br ${getGradient(position ?? 0)} border ${getBorderColor(position ?? 0)} transition-all group-hover:scale-105 group-hover:shadow-lg`}
|
||||
>
|
||||
{/* Crown for 1st place */}
|
||||
{position === 1 && (
|
||||
@@ -198,14 +220,14 @@ function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
|
||||
|
||||
{/* Rating */}
|
||||
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
|
||||
{team.rating?.toLocaleString() ?? '—'}
|
||||
{getSafeRating(team).toLocaleString()}
|
||||
</p>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center justify-center gap-3 mt-2 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Trophy className="w-3 h-3 text-performance-green" />
|
||||
{team.totalWins}
|
||||
{getSafeTotalWins(team)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3 text-purple-400" />
|
||||
@@ -246,9 +268,14 @@ export default function TeamLeaderboardPage() {
|
||||
const loadTeams = async () => {
|
||||
try {
|
||||
const useCase = getGetTeamsLeaderboardUseCase();
|
||||
await useCase.execute();
|
||||
const viewModel = useCase.presenter.getViewModel();
|
||||
setTeams(viewModel.teams);
|
||||
const presenter = new TeamsLeaderboardPresenter();
|
||||
|
||||
await useCase.execute(undefined as void, presenter);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
if (viewModel) {
|
||||
setTeams(viewModel.teams);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load teams:', error);
|
||||
} finally {
|
||||
@@ -286,17 +313,30 @@ export default function TeamLeaderboardPage() {
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'rating':
|
||||
return (b.rating ?? 0) - (a.rating ?? 0);
|
||||
case 'wins':
|
||||
return b.totalWins - a.totalWins;
|
||||
case 'rating': {
|
||||
const aRating = getSafeRating(a);
|
||||
const bRating = getSafeRating(b);
|
||||
return bRating - aRating;
|
||||
}
|
||||
case 'wins': {
|
||||
const aWinsSort = getSafeTotalWins(a);
|
||||
const bWinsSort = getSafeTotalWins(b);
|
||||
return bWinsSort - aWinsSort;
|
||||
}
|
||||
case 'winRate': {
|
||||
const aRate = a.totalRaces > 0 ? a.totalWins / a.totalRaces : 0;
|
||||
const bRate = b.totalRaces > 0 ? b.totalWins / b.totalRaces : 0;
|
||||
const aRaces = getSafeTotalRaces(a);
|
||||
const bRaces = getSafeTotalRaces(b);
|
||||
const aWins = getSafeTotalWins(a);
|
||||
const bWins = getSafeTotalWins(b);
|
||||
const aRate = aRaces > 0 ? aWins / aRaces : 0;
|
||||
const bRate = bRaces > 0 ? bWins / bRaces : 0;
|
||||
return bRate - aRate;
|
||||
}
|
||||
case 'races':
|
||||
return b.totalRaces - a.totalRaces;
|
||||
case 'races': {
|
||||
const aRacesSort = getSafeTotalRaces(a);
|
||||
const bRacesSort = getSafeTotalRaces(b);
|
||||
return bRacesSort - aRacesSort;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -468,7 +508,10 @@ export default function TeamLeaderboardPage() {
|
||||
<span className="text-xs text-gray-500">Total Wins</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{filteredAndSortedTeams.reduce((sum, t) => sum + t.totalWins, 0)}
|
||||
{filteredAndSortedTeams.reduce<number>(
|
||||
(sum, t) => sum + getSafeTotalWins(t),
|
||||
0,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
|
||||
@@ -477,7 +520,10 @@ export default function TeamLeaderboardPage() {
|
||||
<span className="text-xs text-gray-500">Total Races</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{filteredAndSortedTeams.reduce((sum, t) => sum + t.totalRaces, 0)}
|
||||
{filteredAndSortedTeams.reduce<number>(
|
||||
(sum, t) => sum + getSafeTotalRaces(t),
|
||||
0,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -499,7 +545,10 @@ export default function TeamLeaderboardPage() {
|
||||
{filteredAndSortedTeams.map((team, index) => {
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
|
||||
const LevelIcon = levelConfig?.icon || Shield;
|
||||
const winRate = team.totalRaces > 0 ? ((team.totalWins / team.totalRaces) * 100).toFixed(1) : '0.0';
|
||||
const totalRaces = getSafeTotalRaces(team);
|
||||
const totalWins = getSafeTotalWins(team);
|
||||
const winRate =
|
||||
totalRaces > 0 ? ((totalWins / totalRaces) * 100).toFixed(1) : '0.0';
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -565,15 +614,19 @@ export default function TeamLeaderboardPage() {
|
||||
|
||||
{/* Rating */}
|
||||
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
|
||||
<span className={`font-mono font-semibold ${sortBy === 'rating' ? 'text-purple-400' : 'text-white'}`}>
|
||||
{team.rating?.toLocaleString() ?? '—'}
|
||||
<span
|
||||
className={`font-mono font-semibold ${
|
||||
sortBy === 'rating' ? 'text-purple-400' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
{getSafeRating(team).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Wins */}
|
||||
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
|
||||
<span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-purple-400' : 'text-white'}`}>
|
||||
{team.totalWins}
|
||||
{getSafeTotalWins(team)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import CreateTeamForm from '@/components/teams/CreateTeamForm';
|
||||
import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
|
||||
import { TeamsLeaderboardPresenter } from '@/lib/presenters/TeamsLeaderboardPresenter';
|
||||
import type { TeamLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
|
||||
|
||||
// ============================================================================
|
||||
@@ -204,7 +205,7 @@ function SkillLevelSection({ level, teams, onTeamClick, defaultExpanded = false
|
||||
key={team.id}
|
||||
id={team.id}
|
||||
name={team.name}
|
||||
description={team.description}
|
||||
description={team.description ?? ''}
|
||||
memberCount={team.memberCount}
|
||||
rating={team.rating}
|
||||
totalWins={team.totalWins}
|
||||
@@ -212,7 +213,7 @@ function SkillLevelSection({ level, teams, onTeamClick, defaultExpanded = false
|
||||
performanceLevel={team.performanceLevel}
|
||||
isRecruiting={team.isRecruiting}
|
||||
specialization={team.specialization}
|
||||
region={team.region}
|
||||
region={team.region ?? ''}
|
||||
languages={team.languages}
|
||||
onClick={() => onTeamClick(team.id)}
|
||||
/>
|
||||
@@ -449,11 +450,16 @@ export default function TeamsPage() {
|
||||
const loadTeams = async () => {
|
||||
try {
|
||||
const useCase = getGetTeamsLeaderboardUseCase();
|
||||
await useCase.execute();
|
||||
const viewModel = useCase.presenter.getViewModel();
|
||||
setRealTeams(viewModel.teams);
|
||||
setGroupsBySkillLevel(viewModel.groupsBySkillLevel);
|
||||
setTopTeams(viewModel.topTeams);
|
||||
const presenter = new TeamsLeaderboardPresenter();
|
||||
|
||||
await useCase.execute(undefined as void, presenter);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
if (viewModel) {
|
||||
setRealTeams(viewModel.teams);
|
||||
setGroupsBySkillLevel(viewModel.groupsBySkillLevel);
|
||||
setTopTeams(viewModel.topTeams);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load teams:', error);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user