wip
This commit is contained in:
@@ -179,13 +179,18 @@ export default function DevToolbar() {
|
||||
leagueRepository.findAll(),
|
||||
]);
|
||||
|
||||
const completedRaces = allRaces.filter((race: any) => race.status === 'completed');
|
||||
const scheduledRaces = allRaces.filter((race: any) => race.status === 'scheduled');
|
||||
const completedRaces = allRaces.filter((race) => race.status === 'completed');
|
||||
const scheduledRaces = allRaces.filter((race) => race.status === 'scheduled');
|
||||
|
||||
const primaryRace = completedRaces[0] ?? allRaces[0];
|
||||
const secondaryRace = scheduledRaces[0] ?? allRaces[1] ?? primaryRace;
|
||||
const primaryLeague = allLeagues[0];
|
||||
|
||||
const notificationDeadline =
|
||||
selectedUrgency === 'modal'
|
||||
? new Date(Date.now() + 48 * 60 * 60 * 1000)
|
||||
: undefined;
|
||||
|
||||
let title: string;
|
||||
let body: string;
|
||||
let notificationType: 'protest_filed' | 'protest_defense_requested' | 'protest_vote_required';
|
||||
@@ -227,7 +232,7 @@ export default function DevToolbar() {
|
||||
{ label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' },
|
||||
{ label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' },
|
||||
]
|
||||
: undefined;
|
||||
: [];
|
||||
|
||||
await sendNotification.execute({
|
||||
recipientId: currentDriverId,
|
||||
@@ -240,12 +245,9 @@ export default function DevToolbar() {
|
||||
actions,
|
||||
data: {
|
||||
protestId: `demo-protest-${Date.now()}`,
|
||||
raceId: primaryRace?.id,
|
||||
leagueId: primaryLeague?.id,
|
||||
deadline:
|
||||
selectedUrgency === 'modal'
|
||||
? new Date(Date.now() + 48 * 60 * 60 * 1000)
|
||||
: undefined,
|
||||
raceId: primaryRace?.id ?? '',
|
||||
leagueId: primaryLeague?.id ?? '',
|
||||
...(notificationDeadline ? { deadline: notificationDeadline } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -70,13 +70,14 @@ export default function CreateDriverForm() {
|
||||
|
||||
try {
|
||||
const driverRepo = getDriverRepository();
|
||||
const bio = formData.bio.trim();
|
||||
|
||||
const driver = Driver.create({
|
||||
id: crypto.randomUUID(),
|
||||
iracingId: formData.iracingId.trim(),
|
||||
name: formData.name.trim(),
|
||||
country: formData.country.trim().toUpperCase(),
|
||||
bio: formData.bio.trim() || undefined,
|
||||
...(bio ? { bio } : {}),
|
||||
});
|
||||
|
||||
await driverRepo.create(driver);
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function DriverCard(props: DriverCardProps) {
|
||||
return (
|
||||
<Card
|
||||
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
|
||||
onClick={onClick}
|
||||
{...(onClick ? { onClick } : {})}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
|
||||
@@ -9,8 +9,10 @@ import DriverRankings from './DriverRankings';
|
||||
import PerformanceMetrics from './PerformanceMetrics';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getLeagueRankings, getGetDriverTeamUseCase, getGetProfileOverviewUseCase } from '@/lib/di-container';
|
||||
import { DriverTeamPresenter } from '@/lib/presenters/DriverTeamPresenter';
|
||||
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
|
||||
import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO';
|
||||
import type { ProfileOverviewViewModel } from '@gridpilot/racing/application/presenters/IProfileOverviewPresenter';
|
||||
import type { DriverTeamViewModel } from '@gridpilot/racing/application/presenters/IDriverTeamPresenter';
|
||||
|
||||
interface DriverProfileProps {
|
||||
driver: DriverDTO;
|
||||
@@ -18,23 +20,39 @@ interface DriverProfileProps {
|
||||
onEditClick?: () => void;
|
||||
}
|
||||
|
||||
interface DriverProfileStatsViewModel {
|
||||
rating: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
dnfs: number;
|
||||
totalRaces: number;
|
||||
avgFinish: number;
|
||||
bestFinish: number;
|
||||
worstFinish: number;
|
||||
consistency: number;
|
||||
percentile: number;
|
||||
overallRank?: number;
|
||||
}
|
||||
|
||||
type DriverProfileOverviewViewModel = ProfileOverviewViewModel | null;
|
||||
|
||||
export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
|
||||
const [profileData, setProfileData] = useState<any>(null);
|
||||
const [teamData, setTeamData] = useState<GetDriverTeamQueryResultDTO | null>(null);
|
||||
const [profileData, setProfileData] = useState<DriverProfileOverviewViewModel>(null);
|
||||
const [teamData, setTeamData] = useState<DriverTeamViewModel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
// Load profile data using GetProfileOverviewUseCase
|
||||
const profileUseCase = getGetProfileOverviewUseCase();
|
||||
await profileUseCase.execute({ driverId: driver.id });
|
||||
const profileViewModel = profileUseCase.presenter.getViewModel();
|
||||
const profileViewModel = await profileUseCase.execute({ driverId: driver.id });
|
||||
setProfileData(profileViewModel);
|
||||
|
||||
// Load team data
|
||||
// Load team data using caller-owned presenter
|
||||
const teamUseCase = getGetDriverTeamUseCase();
|
||||
await teamUseCase.execute({ driverId: driver.id });
|
||||
const teamViewModel = teamUseCase.presenter.getViewModel();
|
||||
setTeamData(teamViewModel.result);
|
||||
const driverTeamPresenter = new DriverTeamPresenter();
|
||||
await teamUseCase.execute({ driverId: driver.id }, driverTeamPresenter);
|
||||
const teamResult = driverTeamPresenter.getViewModel();
|
||||
setTeamData(teamResult ?? null);
|
||||
};
|
||||
void load();
|
||||
}, [driver.id]);
|
||||
@@ -44,27 +62,27 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
||||
const leagueRank = primaryLeagueId
|
||||
? getLeagueRankings(driver.id, primaryLeagueId)
|
||||
: { rank: 0, totalDrivers: 0, percentile: 0 };
|
||||
const globalRank = profileData?.currentDriver?.globalRank || null;
|
||||
const totalDrivers = profileData?.currentDriver?.totalDrivers || 0;
|
||||
const globalRank = profileData?.currentDriver?.globalRank ?? null;
|
||||
const totalDrivers = profileData?.currentDriver?.totalDrivers ?? 0;
|
||||
|
||||
const performanceStats = driverStats ? {
|
||||
winRate: (driverStats.wins / driverStats.totalRaces) * 100,
|
||||
podiumRate: (driverStats.podiums / driverStats.totalRaces) * 100,
|
||||
dnfRate: (driverStats.dnfs / driverStats.totalRaces) * 100,
|
||||
avgFinish: driverStats.avgFinish,
|
||||
consistency: driverStats.consistency,
|
||||
bestFinish: driverStats.bestFinish,
|
||||
worstFinish: driverStats.worstFinish,
|
||||
winRate: driverStats.totalRaces > 0 ? (driverStats.wins / driverStats.totalRaces) * 100 : 0,
|
||||
podiumRate: driverStats.totalRaces > 0 ? (driverStats.podiums / driverStats.totalRaces) * 100 : 0,
|
||||
dnfRate: driverStats.totalRaces > 0 ? (driverStats.dnfs / driverStats.totalRaces) * 100 : 0,
|
||||
avgFinish: driverStats.avgFinish ?? 0,
|
||||
consistency: driverStats.consistency ?? 0,
|
||||
bestFinish: driverStats.bestFinish ?? 0,
|
||||
worstFinish: driverStats.worstFinish ?? 0,
|
||||
} : null;
|
||||
|
||||
const rankings = driverStats ? [
|
||||
{
|
||||
type: 'overall' as const,
|
||||
name: 'Overall Ranking',
|
||||
rank: globalRank || driverStats.overallRank || 0,
|
||||
totalDrivers: totalDrivers,
|
||||
percentile: driverStats.percentile,
|
||||
rating: driverStats.rating,
|
||||
rank: globalRank ?? driverStats.overallRank ?? 0,
|
||||
totalDrivers,
|
||||
percentile: driverStats.percentile ?? 0,
|
||||
rating: driverStats.rating ?? 0,
|
||||
},
|
||||
{
|
||||
type: 'league' as const,
|
||||
@@ -72,7 +90,7 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
||||
rank: leagueRank.rank,
|
||||
totalDrivers: leagueRank.totalDrivers,
|
||||
percentile: leagueRank.percentile,
|
||||
rating: driverStats.rating,
|
||||
rating: driverStats.rating ?? 0,
|
||||
},
|
||||
] : [];
|
||||
|
||||
@@ -84,7 +102,7 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
||||
rating={driverStats?.rating ?? null}
|
||||
rank={driverStats?.overallRank ?? null}
|
||||
isOwnProfile={isOwnProfile}
|
||||
onEditClick={isOwnProfile ? onEditClick : undefined}
|
||||
onEditClick={onEditClick ?? (() => {})}
|
||||
teamName={teamData?.team.name ?? null}
|
||||
teamTag={teamData?.team.tag ?? null}
|
||||
/>
|
||||
@@ -103,7 +121,11 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
||||
<Card>
|
||||
<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={driverStats.rating.toString()} color="text-primary-blue" />
|
||||
<StatCard
|
||||
label="Rating"
|
||||
value={(driverStats.rating ?? 0).toString()}
|
||||
color="text-primary-blue"
|
||||
/>
|
||||
<StatCard label="Total Races" value={driverStats.totalRaces.toString()} color="text-white" />
|
||||
<StatCard label="Wins" value={driverStats.wins.toString()} color="text-green-400" />
|
||||
<StatCard label="Podiums" value={driverStats.podiums.toString()} color="text-warning-amber" />
|
||||
@@ -130,14 +152,21 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Performance by Class</h3>
|
||||
<ProfileStats 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
|
||||
} : undefined} />
|
||||
{driverStats && (
|
||||
<ProfileStats
|
||||
stats={{
|
||||
totalRaces: driverStats.totalRaces,
|
||||
wins: driverStats.wins,
|
||||
podiums: driverStats.podiums,
|
||||
dnfs: driverStats.dnfs,
|
||||
avgFinish: driverStats.avgFinish ?? 0,
|
||||
completionRate:
|
||||
driverStats.totalRaces > 0
|
||||
? ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
|
||||
: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<CareerHighlights />
|
||||
|
||||
@@ -135,8 +135,8 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
<Card>
|
||||
<div className="space-y-2">
|
||||
{paginatedResults.map(({ race, result, league }) => {
|
||||
if (!result) return null;
|
||||
|
||||
if (!result || !league) return null;
|
||||
|
||||
return (
|
||||
<RaceResultCard
|
||||
key={race.id}
|
||||
|
||||
@@ -5,6 +5,7 @@ import RankBadge from './RankBadge';
|
||||
import { getLeagueRankings, getGetProfileOverviewUseCase } from '@/lib/di-container';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
|
||||
import type { ProfileOverviewViewModel } from '@gridpilot/racing/application/presenters/IProfileOverviewPresenter';
|
||||
|
||||
interface ProfileStatsProps {
|
||||
driverId?: string;
|
||||
@@ -18,15 +19,16 @@ interface ProfileStatsProps {
|
||||
};
|
||||
}
|
||||
|
||||
type DriverProfileOverviewViewModel = ProfileOverviewViewModel | null;
|
||||
|
||||
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||
const [profileData, setProfileData] = useState<any>(null);
|
||||
const [profileData, setProfileData] = useState<DriverProfileOverviewViewModel>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (driverId) {
|
||||
const load = async () => {
|
||||
const profileUseCase = getGetProfileOverviewUseCase();
|
||||
await profileUseCase.execute({ driverId });
|
||||
const vm = profileUseCase.presenter.getViewModel();
|
||||
const vm = await profileUseCase.execute({ driverId });
|
||||
setProfileData(vm);
|
||||
};
|
||||
void load();
|
||||
@@ -34,23 +36,26 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||
}, [driverId]);
|
||||
|
||||
const driverStats = profileData?.stats || null;
|
||||
const totalDrivers = profileData?.currentDriver?.totalDrivers || 0;
|
||||
const totalDrivers = profileData?.currentDriver?.totalDrivers ?? 0;
|
||||
const primaryLeagueId = driverId ? getPrimaryLeagueIdForDriver(driverId) : null;
|
||||
const leagueRank =
|
||||
driverId && primaryLeagueId ? getLeagueRankings(driverId, primaryLeagueId) : 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,
|
||||
}
|
||||
: null);
|
||||
const defaultStats =
|
||||
stats ||
|
||||
(driverStats
|
||||
? {
|
||||
totalRaces: driverStats.totalRaces,
|
||||
wins: driverStats.wins,
|
||||
podiums: driverStats.podiums,
|
||||
dnfs: driverStats.dnfs,
|
||||
avgFinish: driverStats.avgFinish ?? 0,
|
||||
completionRate:
|
||||
driverStats.totalRaces > 0
|
||||
? ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
|
||||
: 0,
|
||||
}
|
||||
: null);
|
||||
|
||||
const winRate =
|
||||
defaultStats && defaultStats.totalRaces > 0
|
||||
@@ -91,17 +96,19 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<RankBadge rank={driverStats.overallRank} size="lg" />
|
||||
<RankBadge rank={driverStats.overallRank ?? 0} size="lg" />
|
||||
<div>
|
||||
<div className="text-white font-medium text-lg">Overall Ranking</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{driverStats.overallRank} of {totalDrivers} drivers
|
||||
{driverStats.overallRank ?? 0} of {totalDrivers} drivers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-sm font-medium ${getPercentileColor(driverStats.percentile)}`}>
|
||||
{getPercentileLabel(driverStats.percentile)}
|
||||
<div
|
||||
className={`text-sm font-medium ${getPercentileColor(driverStats.percentile ?? 0)}`}
|
||||
>
|
||||
{getPercentileLabel(driverStats.percentile ?? 0)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Global Percentile</div>
|
||||
</div>
|
||||
@@ -109,7 +116,9 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-charcoal-outline">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary-blue">{driverStats.rating}</div>
|
||||
<div className="text-2xl font-bold text-primary-blue">
|
||||
{driverStats.rating ?? 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Rating</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Image from 'next/image';
|
||||
import type { FeedItemDTO } from '@gridpilot/social/application/dto/FeedItemDTO';
|
||||
import { getDriverRepository, getImageService } from '@/lib/di-container';
|
||||
|
||||
function timeAgo(timestamp: Date): string {
|
||||
const diffMs = Date.now() - timestamp.getTime();
|
||||
function timeAgo(timestamp: Date | string): string {
|
||||
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60000);
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes} min ago`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useRef, ReactNode } from 'react';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { useScrollProgress, useParallax } from '@/hooks/useScrollProgress';
|
||||
import { useParallax } from '../../hooks/useScrollProgress';
|
||||
|
||||
interface AlternatingSectionProps {
|
||||
heading: string;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useRef } from 'react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { useScrollProgress } from '@/hooks/useScrollProgress';
|
||||
|
||||
export default function DiscordCTA() {
|
||||
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
|
||||
|
||||
@@ -11,7 +11,6 @@ import TeamCompetitionMockup from '@/components/mockups/TeamCompetitionMockup';
|
||||
import ProtestWorkflowMockup from '@/components/mockups/ProtestWorkflowMockup';
|
||||
import LeagueDiscoveryMockup from '@/components/mockups/LeagueDiscoveryMockup';
|
||||
import DriverProfileMockup from '@/components/mockups/DriverProfileMockup';
|
||||
import { useScrollProgress } from '@/hooks/useScrollProgress';
|
||||
|
||||
const features = [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useRef } from 'react';
|
||||
import { useScrollProgress } from '@/hooks/useScrollProgress';
|
||||
|
||||
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || 'https://discord.gg/gridpilot';
|
||||
const xUrl = process.env.NEXT_PUBLIC_X_URL || '#';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRef } from 'react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { useScrollProgress, useParallax } from '@/hooks/useScrollProgress';
|
||||
import { useParallax } from '../../hooks/useScrollProgress';
|
||||
|
||||
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
|
||||
|
||||
|
||||
@@ -156,7 +156,8 @@ function getDefaultSeasonStartDate(): string {
|
||||
const daysUntilSaturday = (6 - now.getDay() + 7) % 7 || 7;
|
||||
const nextSaturday = new Date(now);
|
||||
nextSaturday.setDate(now.getDate() + daysUntilSaturday);
|
||||
return nextSaturday.toISOString().split('T')[0];
|
||||
const [datePart] = nextSaturday.toISOString().split('T');
|
||||
return datePart ?? '';
|
||||
}
|
||||
|
||||
function createDefaultForm(): LeagueConfigFormModel {
|
||||
@@ -172,8 +173,6 @@ function createDefaultForm(): LeagueConfigFormModel {
|
||||
structure: {
|
||||
mode: 'solo',
|
||||
maxDrivers: 24,
|
||||
maxTeams: undefined,
|
||||
driversPerTeam: undefined,
|
||||
multiClassEnabled: false,
|
||||
},
|
||||
championships: {
|
||||
@@ -193,7 +192,7 @@ function createDefaultForm(): LeagueConfigFormModel {
|
||||
timings: {
|
||||
practiceMinutes: 20,
|
||||
qualifyingMinutes: 30,
|
||||
sprintRaceMinutes: defaultPatternId === 'sprint-main-driver' ? 20 : undefined,
|
||||
sprintRaceMinutes: 20,
|
||||
mainRaceMinutes: 40,
|
||||
sessionCount: 2,
|
||||
roundsPlanned: 8,
|
||||
@@ -265,12 +264,13 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
const query = getListLeagueScoringPresetsQuery();
|
||||
const result = await query.execute();
|
||||
setPresets(result);
|
||||
if (result.length > 0) {
|
||||
const firstPreset = result[0];
|
||||
if (firstPreset) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
scoring: {
|
||||
...prev.scoring,
|
||||
patternId: prev.scoring.patternId || result[0].id,
|
||||
patternId: prev.scoring.patternId || firstPreset.id,
|
||||
customScoringEnabled: prev.scoring.customScoringEnabled ?? false,
|
||||
},
|
||||
}));
|
||||
@@ -338,7 +338,10 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setErrors((prev) => ({ ...prev, submit: undefined }));
|
||||
setErrors((prev) => {
|
||||
const { submit, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await createLeagueFromConfig(form);
|
||||
@@ -577,7 +580,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
<LeagueBasicsSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
errors={errors.basics}
|
||||
errors={errors.basics ?? {}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -587,7 +590,11 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
<LeagueVisibilitySection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
errors={errors.basics}
|
||||
errors={
|
||||
errors.basics?.visibility
|
||||
? { visibility: errors.basics.visibility }
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -607,7 +614,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
<LeagueTimingsSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
errors={errors.timings}
|
||||
errors={errors.timings ?? {}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -619,7 +626,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
scoring={form.scoring}
|
||||
presets={presets}
|
||||
readOnly={presetsLoading}
|
||||
patternError={errors.scoring?.patternId}
|
||||
patternError={errors.scoring?.patternId ?? ''}
|
||||
onChangePatternId={handleScoringPresetChange}
|
||||
onToggleCustomScoring={() =>
|
||||
setForm((prev) => ({
|
||||
|
||||
@@ -32,24 +32,19 @@ export default function JoinLeagueButton({
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
|
||||
if (isInviteOnly) {
|
||||
// For alpha, treat "request to join" as creating a pending membership
|
||||
const pending = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
if (pending) {
|
||||
const existing = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
if (existing) {
|
||||
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 {
|
||||
const useCase = getJoinLeagueUseCase();
|
||||
await useCase.execute({ leagueId, driverId: currentDriverId });
|
||||
throw new Error(
|
||||
'Requesting to join invite-only leagues is not available in this alpha build.',
|
||||
);
|
||||
}
|
||||
|
||||
const useCase = getJoinLeagueUseCase();
|
||||
await useCase.execute({ leagueId, driverId: currentDriverId });
|
||||
|
||||
onMembershipChange?.();
|
||||
setShowConfirmDialog(false);
|
||||
} catch (err) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type LeagueAdminProtestsViewModel,
|
||||
} from '@/lib/presenters/LeagueAdminPresenter';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import type { LeagueSummaryViewModel } from '@/lib/presenters/LeagueAdminPresenter';
|
||||
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
||||
import { LeagueStructureSection } from './LeagueStructureSection';
|
||||
import { LeagueScoringSection } from './LeagueScoringSection';
|
||||
@@ -37,13 +38,7 @@ import { AlertTriangle, CheckCircle, Clock, XCircle, Flag, Calendar, User, Dolla
|
||||
type JoinRequest = LeagueJoinRequestViewModel;
|
||||
|
||||
interface LeagueAdminProps {
|
||||
league: {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
settings: {
|
||||
pointsSystem: string;
|
||||
};
|
||||
};
|
||||
league: LeagueSummaryViewModel;
|
||||
onLeagueUpdate?: () => void;
|
||||
}
|
||||
|
||||
@@ -83,7 +78,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
useEffect(() => {
|
||||
async function loadOwner() {
|
||||
try {
|
||||
const summary = await loadLeagueOwnerSummary(league);
|
||||
const summary = await loadLeagueOwnerSummary({ ownerId: league.ownerId });
|
||||
setOwnerSummary(summary);
|
||||
} catch (err) {
|
||||
console.error('Failed to load league owner:', err);
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
import React from 'react';
|
||||
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
|
||||
import Input from '@/components/ui/Input';
|
||||
import type {
|
||||
LeagueConfigFormModel,
|
||||
} from '@gridpilot/racing/application';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
|
||||
interface LeagueBasicsSectionProps {
|
||||
form: LeagueConfigFormModel;
|
||||
|
||||
@@ -259,13 +259,19 @@ export function LeagueDropSection({
|
||||
if (disabled || !onChange) return;
|
||||
|
||||
const option = DROP_OPTIONS.find((o) => o.value === strategy);
|
||||
onChange({
|
||||
const next: LeagueConfigFormModel = {
|
||||
...form,
|
||||
dropPolicy: {
|
||||
strategy,
|
||||
n: strategy === 'none' ? undefined : (dropPolicy.n ?? option?.defaultN),
|
||||
},
|
||||
});
|
||||
dropPolicy:
|
||||
strategy === 'none'
|
||||
? {
|
||||
strategy,
|
||||
}
|
||||
: {
|
||||
strategy,
|
||||
n: dropPolicy.n ?? option?.defaultN ?? 1,
|
||||
},
|
||||
};
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleNChange = (delta: number) => {
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function LeagueMembers({
|
||||
<label className="text-sm text-gray-400">Sort by:</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="rating">Rating</option>
|
||||
|
||||
@@ -357,20 +357,33 @@ export function LeagueScoringSection({
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const patternPanel = (
|
||||
<ScoringPatternSection
|
||||
scoring={form.scoring}
|
||||
presets={presets}
|
||||
readOnly={readOnly}
|
||||
onChangePatternId={!readOnly && onChange ? handleSelectPreset : undefined}
|
||||
onToggleCustomScoring={disabled ? undefined : handleToggleCustomScoring}
|
||||
/>
|
||||
);
|
||||
|
||||
const championshipsPanel = (
|
||||
<ChampionshipsSection form={form} onChange={onChange} readOnly={readOnly} />
|
||||
);
|
||||
const patternProps: ScoringPatternSectionProps = {
|
||||
scoring: form.scoring,
|
||||
presets,
|
||||
readOnly: !!readOnly,
|
||||
};
|
||||
|
||||
if (!readOnly && onChange) {
|
||||
patternProps.onChangePatternId = handleSelectPreset;
|
||||
}
|
||||
|
||||
if (!disabled) {
|
||||
patternProps.onToggleCustomScoring = handleToggleCustomScoring;
|
||||
}
|
||||
|
||||
const patternPanel = <ScoringPatternSection {...patternProps} />;
|
||||
|
||||
const championshipsProps: ChampionshipsSectionProps = {
|
||||
form,
|
||||
readOnly: !!readOnly,
|
||||
};
|
||||
|
||||
if (onChange) {
|
||||
championshipsProps.onChange = onChange;
|
||||
}
|
||||
|
||||
const championshipsPanel = <ChampionshipsSection {...championshipsProps} />;
|
||||
|
||||
if (patternOnly) {
|
||||
return <div>{patternPanel}</div>;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getRejectSponsorshipRequestUseCase,
|
||||
getSeasonRepository,
|
||||
} from '@/lib/di-container';
|
||||
import { PendingSponsorshipRequestsPresenter } from '@/lib/presenters/PendingSponsorshipRequestsPresenter';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
|
||||
interface SponsorshipSlot {
|
||||
@@ -72,11 +73,18 @@ export function LeagueSponsorshipsSection({
|
||||
setRequestsLoading(true);
|
||||
try {
|
||||
const useCase = getGetPendingSponsorshipRequestsUseCase();
|
||||
await useCase.execute({
|
||||
entityType: 'season',
|
||||
entityId: seasonId,
|
||||
});
|
||||
setPendingRequests(result.requests);
|
||||
const presenter = new PendingSponsorshipRequestsPresenter();
|
||||
|
||||
await useCase.execute(
|
||||
{
|
||||
entityType: 'season',
|
||||
entityId: seasonId,
|
||||
},
|
||||
presenter,
|
||||
);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
setPendingRequests(viewModel?.requests ?? []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load pending requests:', err);
|
||||
} finally {
|
||||
@@ -108,7 +116,7 @@ export function LeagueSponsorshipsSection({
|
||||
await useCase.execute({
|
||||
requestId,
|
||||
respondedBy: currentDriverId,
|
||||
reason,
|
||||
...(reason ? { reason } : {}),
|
||||
});
|
||||
await loadPendingRequests();
|
||||
} catch (err) {
|
||||
@@ -118,16 +126,21 @@ export function LeagueSponsorshipsSection({
|
||||
};
|
||||
|
||||
const handleEditPrice = (index: number) => {
|
||||
const slot = slots[index];
|
||||
if (!slot) return;
|
||||
setEditingIndex(index);
|
||||
setTempPrice(slots[index].price.toString());
|
||||
setTempPrice(slot.price.toString());
|
||||
};
|
||||
|
||||
const handleSavePrice = (index: number) => {
|
||||
const price = parseFloat(tempPrice);
|
||||
if (!isNaN(price) && price > 0) {
|
||||
const updated = [...slots];
|
||||
updated[index].price = price;
|
||||
setSlots(updated);
|
||||
const slot = updated[index];
|
||||
if (slot) {
|
||||
slot.price = price;
|
||||
setSlots(updated);
|
||||
}
|
||||
}
|
||||
setEditingIndex(null);
|
||||
setTempPrice('');
|
||||
|
||||
@@ -159,13 +159,10 @@ export function LeagueStructureSection({
|
||||
}
|
||||
|
||||
if (nextStructure.mode === 'solo') {
|
||||
const { maxTeams, driversPerTeam, ...restStructure } = nextStructure;
|
||||
nextForm = {
|
||||
...nextForm,
|
||||
structure: {
|
||||
...nextStructure,
|
||||
maxTeams: undefined,
|
||||
driversPerTeam: undefined,
|
||||
},
|
||||
structure: restStructure,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,8 +175,6 @@ export function LeagueStructureSection({
|
||||
updateStructure({
|
||||
mode: 'solo',
|
||||
maxDrivers: structure.maxDrivers || 24,
|
||||
maxTeams: undefined,
|
||||
driversPerTeam: undefined,
|
||||
});
|
||||
} else {
|
||||
const maxTeams = structure.maxTeams ?? 12;
|
||||
|
||||
@@ -38,7 +38,10 @@ const TIME_ZONES = [
|
||||
{ value: 'Australia/Sydney', label: 'Sydney (AU)', icon: Globe },
|
||||
];
|
||||
|
||||
type RecurrenceStrategy = NonNullable<LeagueConfigFormModel['timings']>['recurrenceStrategy'];
|
||||
type RecurrenceStrategy = Exclude<
|
||||
NonNullable<LeagueConfigFormModel['timings']>['recurrenceStrategy'],
|
||||
undefined
|
||||
>;
|
||||
|
||||
interface LeagueTimingsSectionProps {
|
||||
form: LeagueConfigFormModel;
|
||||
@@ -96,12 +99,16 @@ function RaceDayPreview({
|
||||
const effectiveRaceTime = raceTime || '20:00';
|
||||
|
||||
const getStartTime = (sessionIndex: number) => {
|
||||
const [hours, minutes] = effectiveRaceTime.split(':').map(Number);
|
||||
const [hoursStr, minutesStr] = effectiveRaceTime.split(':');
|
||||
const hours = Number(hoursStr ?? '0');
|
||||
const minutes = Number(minutesStr ?? '0');
|
||||
let totalMinutes = hours * 60 + minutes;
|
||||
|
||||
const active = allSessions.filter(s => s.active);
|
||||
|
||||
const active = allSessions.filter((s) => s.active);
|
||||
for (let i = 0; i < sessionIndex; i++) {
|
||||
totalMinutes += active[i].duration + 10; // 10 min break between sessions
|
||||
const session = active[i];
|
||||
if (!session) continue;
|
||||
totalMinutes += session.duration + 10; // 10 min break between sessions
|
||||
}
|
||||
|
||||
const h = Math.floor(totalMinutes / 60) % 24;
|
||||
@@ -231,10 +238,31 @@ function YearCalendarPreview({
|
||||
}) {
|
||||
// JavaScript getDay(): 0=Sunday, 1=Monday, 2=Tuesday, etc.
|
||||
const dayMap: Record<Weekday, number> = {
|
||||
'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
};
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
] as const;
|
||||
|
||||
const getMonthLabel = (index: number): string => months[index] ?? '—';
|
||||
|
||||
// Parse start and end dates
|
||||
const seasonStart = useMemo(() => {
|
||||
@@ -279,7 +307,8 @@ function YearCalendarPreview({
|
||||
const spacing = totalPossible / rounds;
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
const index = Math.min(Math.floor(i * spacing), totalPossible - 1);
|
||||
dates.push(allPossibleDays[index]);
|
||||
const chosen = allPossibleDays[index]!;
|
||||
dates.push(chosen);
|
||||
}
|
||||
} else {
|
||||
// Not enough days - use all available
|
||||
@@ -380,7 +409,7 @@ function YearCalendarPreview({
|
||||
}
|
||||
|
||||
view.push({
|
||||
month: months[targetMonth],
|
||||
month: months[targetMonth] ?? '—',
|
||||
monthIndex: targetMonth,
|
||||
year: targetYear,
|
||||
days
|
||||
@@ -391,8 +420,8 @@ function YearCalendarPreview({
|
||||
}
|
||||
|
||||
// Get the range of months that contain races
|
||||
const firstRaceDate = raceDates[0];
|
||||
const lastRaceDate = raceDates[raceDates.length - 1];
|
||||
const firstRaceDate = raceDates[0]!;
|
||||
const lastRaceDate = raceDates[raceDates.length - 1]!;
|
||||
|
||||
// Start from first race month, show 12 months total
|
||||
const startMonth = firstRaceDate.getMonth();
|
||||
@@ -414,18 +443,19 @@ function YearCalendarPreview({
|
||||
rd.getDate() === date.getDate()
|
||||
);
|
||||
const isRace = raceIndex >= 0;
|
||||
const raceNumber = isRace ? raceIndex + 1 : undefined;
|
||||
days.push({
|
||||
date,
|
||||
isRace,
|
||||
dayOfMonth: day,
|
||||
isStart: isSeasonStartDate(date),
|
||||
isEnd: isSeasonEndDate(date),
|
||||
raceNumber: isRace ? raceIndex + 1 : undefined,
|
||||
...(raceNumber !== undefined ? { raceNumber } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
view.push({
|
||||
month: months[targetMonth],
|
||||
month: months[targetMonth] ?? '—',
|
||||
monthIndex: targetMonth,
|
||||
year: targetYear,
|
||||
days
|
||||
@@ -438,9 +468,13 @@ function YearCalendarPreview({
|
||||
// Calculate season stats
|
||||
const firstRace = raceDates[0];
|
||||
const lastRace = raceDates[raceDates.length - 1];
|
||||
const seasonDurationWeeks = firstRace && lastRace
|
||||
? Math.ceil((lastRace.getTime() - firstRace.getTime()) / (7 * 24 * 60 * 60 * 1000))
|
||||
: 0;
|
||||
const seasonDurationWeeks =
|
||||
firstRace && lastRace
|
||||
? Math.ceil(
|
||||
(lastRace.getTime() - firstRace.getTime()) /
|
||||
(7 * 24 * 60 * 60 * 1000),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -525,12 +559,18 @@ function YearCalendarPreview({
|
||||
<div className="text-[9px] text-gray-500">Rounds</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-white">{seasonDurationWeeks || '—'}</div>
|
||||
<div className="text-lg font-bold text-white">
|
||||
{seasonDurationWeeks || '—'}
|
||||
</div>
|
||||
<div className="text-[9px] text-gray-500">Weeks</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-primary-blue">
|
||||
{firstRace ? `${months[firstRace.getMonth()]}–${months[lastRace?.getMonth() ?? 0]}` : '—'}
|
||||
{firstRace && lastRace
|
||||
? `${getMonthLabel(firstRace.getMonth())}–${getMonthLabel(
|
||||
lastRace.getMonth(),
|
||||
)}`
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="text-[9px] text-gray-500">Duration</div>
|
||||
</div>
|
||||
@@ -986,7 +1026,9 @@ export function LeagueTimingsSection({
|
||||
onClick={() =>
|
||||
updateTimings({
|
||||
recurrenceStrategy: opt.id as RecurrenceStrategy,
|
||||
intervalWeeks: opt.id === 'everyNWeeks' ? 2 : undefined,
|
||||
...(opt.id === 'everyNWeeks'
|
||||
? { intervalWeeks: 2 }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
className={`
|
||||
@@ -1054,7 +1096,7 @@ export function LeagueTimingsSection({
|
||||
<Input
|
||||
type="date"
|
||||
value={timings.seasonStartDate ?? ''}
|
||||
onChange={(e) => updateTimings({ seasonStartDate: e.target.value || undefined })}
|
||||
onChange={(e) => updateTimings({ seasonStartDate: e.target.value })}
|
||||
className="bg-iron-gray/30"
|
||||
/>
|
||||
</div>
|
||||
@@ -1066,7 +1108,7 @@ export function LeagueTimingsSection({
|
||||
<Input
|
||||
type="date"
|
||||
value={timings.seasonEndDate ?? ''}
|
||||
onChange={(e) => updateTimings({ seasonEndDate: e.target.value || undefined })}
|
||||
onChange={(e) => updateTimings({ seasonEndDate: e.target.value })}
|
||||
className="bg-iron-gray/30"
|
||||
/>
|
||||
</div>
|
||||
@@ -1086,7 +1128,7 @@ export function LeagueTimingsSection({
|
||||
<Input
|
||||
type="time"
|
||||
value={timings.raceStartTime ?? '20:00'}
|
||||
onChange={(e) => updateTimings({ raceStartTime: e.target.value || undefined })}
|
||||
onChange={(e) => updateTimings({ raceStartTime: e.target.value })}
|
||||
className="bg-iron-gray/30"
|
||||
/>
|
||||
</div>
|
||||
@@ -1214,39 +1256,59 @@ export function LeagueTimingsSection({
|
||||
|
||||
{/* Preview content */}
|
||||
<div className="p-4 min-h-[300px]">
|
||||
{previewTab === 'day' && (
|
||||
<RaceDayPreview
|
||||
template={showSprint ? 'sprintFeature' : 'feature'}
|
||||
practiceMin={timings.practiceMinutes ?? 20}
|
||||
qualifyingMin={timings.qualifyingMinutes ?? 15}
|
||||
sprintMin={showSprint ? (timings.sprintRaceMinutes ?? 20) : undefined}
|
||||
mainRaceMin={timings.mainRaceMinutes ?? 40}
|
||||
raceTime={timings.raceStartTime}
|
||||
/>
|
||||
)}
|
||||
{previewTab === 'day' && (() => {
|
||||
const sprintMinutes = showSprint
|
||||
? timings.sprintRaceMinutes ?? 20
|
||||
: undefined;
|
||||
return (
|
||||
<RaceDayPreview
|
||||
template={showSprint ? 'sprintFeature' : 'feature'}
|
||||
practiceMin={timings.practiceMinutes ?? 20}
|
||||
qualifyingMin={timings.qualifyingMinutes ?? 15}
|
||||
{...(sprintMinutes !== undefined
|
||||
? { sprintMin: sprintMinutes }
|
||||
: {})}
|
||||
mainRaceMin={timings.mainRaceMinutes ?? 40}
|
||||
{...(timings.raceStartTime
|
||||
? { raceTime: timings.raceStartTime }
|
||||
: {})}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{previewTab === 'year' && (
|
||||
<YearCalendarPreview
|
||||
weekdays={weekdays}
|
||||
frequency={recurrenceStrategy}
|
||||
rounds={timings.roundsPlanned ?? 8}
|
||||
startDate={timings.seasonStartDate}
|
||||
endDate={timings.seasonEndDate}
|
||||
{...(timings.seasonStartDate
|
||||
? { startDate: timings.seasonStartDate }
|
||||
: {})}
|
||||
{...(timings.seasonEndDate
|
||||
? { endDate: timings.seasonEndDate }
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewTab === 'stats' && (
|
||||
<SeasonStatsPreview
|
||||
rounds={timings.roundsPlanned ?? 8}
|
||||
weekdays={weekdays}
|
||||
frequency={recurrenceStrategy}
|
||||
weekendTemplate={showSprint ? 'sprintFeature' : 'feature'}
|
||||
practiceMin={timings.practiceMinutes ?? 20}
|
||||
qualifyingMin={timings.qualifyingMinutes ?? 15}
|
||||
sprintMin={showSprint ? (timings.sprintRaceMinutes ?? 20) : undefined}
|
||||
mainRaceMin={timings.mainRaceMinutes ?? 40}
|
||||
/>
|
||||
)}
|
||||
{previewTab === 'stats' && (() => {
|
||||
const sprintMinutes = showSprint
|
||||
? timings.sprintRaceMinutes ?? 20
|
||||
: undefined;
|
||||
return (
|
||||
<SeasonStatsPreview
|
||||
rounds={timings.roundsPlanned ?? 8}
|
||||
weekdays={weekdays}
|
||||
frequency={recurrenceStrategy}
|
||||
weekendTemplate={showSprint ? 'sprintFeature' : 'feature'}
|
||||
practiceMin={timings.practiceMinutes ?? 20}
|
||||
qualifyingMin={timings.qualifyingMinutes ?? 15}
|
||||
{...(sprintMinutes !== undefined
|
||||
? { sprintMin: sprintMinutes }
|
||||
: {})}
|
||||
mainRaceMin={timings.mainRaceMinutes ?? 40}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ export default function ScheduleRaceForm({
|
||||
</label>
|
||||
<select
|
||||
value={formData.sessionType}
|
||||
onChange={(e) => handleChange('sessionType', e.target.value as SessionType)}
|
||||
onChange={(e) => handleChange('sessionType', e.target.value)}
|
||||
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="practice">Practice</option>
|
||||
|
||||
@@ -157,11 +157,11 @@ export default function DriverProfileMockup() {
|
||||
variants={itemVariants}
|
||||
className="bg-iron-gray/50 border border-charcoal-outline rounded-lg p-1.5 sm:p-2 md:p-3 text-center"
|
||||
>
|
||||
<AnimatedCounter
|
||||
value={stat.value}
|
||||
<AnimatedCounter
|
||||
value={stat.value}
|
||||
shouldReduceMotion={shouldReduceMotion ?? false}
|
||||
delay={index * 0.1}
|
||||
suffix={stat.suffix}
|
||||
suffix={stat.suffix ?? ''}
|
||||
/>
|
||||
<div className="text-[8px] sm:text-[10px] md:text-xs text-gray-400 mt-0.5">{stat.label}</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -257,7 +257,10 @@ export default function OnboardingWizard() {
|
||||
generatedAvatars: [],
|
||||
selectedAvatarIndex: null,
|
||||
});
|
||||
setErrors({ ...errors, facePhoto: undefined });
|
||||
setErrors((prev) => {
|
||||
const { facePhoto, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
// Validate face
|
||||
await validateFacePhoto(base64);
|
||||
@@ -267,7 +270,10 @@ export default function OnboardingWizard() {
|
||||
|
||||
const validateFacePhoto = async (photoData: string) => {
|
||||
setAvatarInfo(prev => ({ ...prev, isValidating: true }));
|
||||
setErrors(prev => ({ ...prev, facePhoto: undefined }));
|
||||
setErrors(prev => {
|
||||
const { facePhoto, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/avatar/validate-face', {
|
||||
@@ -300,7 +306,10 @@ export default function OnboardingWizard() {
|
||||
}
|
||||
|
||||
setAvatarInfo(prev => ({ ...prev, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null }));
|
||||
setErrors(prev => ({ ...prev, avatar: undefined }));
|
||||
setErrors(prev => {
|
||||
const { avatar, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/avatar/generate', {
|
||||
@@ -490,7 +499,7 @@ export default function OnboardingWizard() {
|
||||
setPersonalInfo({ ...personalInfo, country: value })
|
||||
}
|
||||
error={!!errors.country}
|
||||
errorMessage={errors.country}
|
||||
errorMessage={errors.country ?? ''}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
@@ -600,17 +609,24 @@ export default function OnboardingWizard() {
|
||||
{/* Preview area */}
|
||||
<div className="w-32 flex flex-col items-center justify-center">
|
||||
<div className="w-24 h-24 rounded-xl bg-iron-gray border border-charcoal-outline flex items-center justify-center overflow-hidden">
|
||||
{avatarInfo.selectedAvatarIndex !== null && avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex] ? (
|
||||
<Image
|
||||
src={avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex]}
|
||||
alt="Selected avatar"
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User className="w-8 h-8 text-gray-600" />
|
||||
)}
|
||||
{(() => {
|
||||
const selectedAvatarUrl =
|
||||
avatarInfo.selectedAvatarIndex !== null
|
||||
? avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex]
|
||||
: undefined;
|
||||
if (!selectedAvatarUrl) {
|
||||
return <User className="w-8 h-8 text-gray-600" />;
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
src={selectedAvatarUrl}
|
||||
alt="Selected avatar"
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2 text-center">Your avatar</p>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { getFileProtestUseCase, getDriverRepository } from '@/lib/di-container';
|
||||
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||
import { getFileProtestUseCase } from '@/lib/di-container';
|
||||
import type { ProtestIncident } from '@gridpilot/racing/domain/entities/Protest';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -17,13 +16,18 @@ import {
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
|
||||
type ProtestParticipant = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
interface FileProtestModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
raceId: string;
|
||||
leagueId?: string;
|
||||
protestingDriverId: string;
|
||||
participants: Driver[];
|
||||
participants: ProtestParticipant[];
|
||||
}
|
||||
|
||||
export default function FileProtestModal({
|
||||
@@ -70,18 +74,26 @@ export default function FileProtestModal({
|
||||
|
||||
const incident: ProtestIncident = {
|
||||
lap: parseInt(lap, 10),
|
||||
timeInRace: timeInRace ? parseInt(timeInRace, 10) : undefined,
|
||||
description: description.trim(),
|
||||
...(timeInRace
|
||||
? { timeInRace: parseInt(timeInRace, 10) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await useCase.execute({
|
||||
const command = {
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
incident,
|
||||
comment: comment.trim() || undefined,
|
||||
proofVideoUrl: proofVideoUrl.trim() || undefined,
|
||||
});
|
||||
...(comment.trim()
|
||||
? { comment: comment.trim() }
|
||||
: {}),
|
||||
...(proofVideoUrl.trim()
|
||||
? { proofVideoUrl: proofVideoUrl.trim() }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await useCase.execute(command);
|
||||
|
||||
setStep('success');
|
||||
} catch (err) {
|
||||
|
||||
@@ -39,7 +39,8 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
||||
throw new Error('CSV file is empty or invalid');
|
||||
}
|
||||
|
||||
const header = lines[0].toLowerCase().split(',').map((h) => h.trim());
|
||||
const headerLine = lines[0]!;
|
||||
const header = headerLine.toLowerCase().split(',').map((h) => h.trim());
|
||||
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
@@ -50,7 +51,11 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
||||
|
||||
const rows: CSVRow[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = lines[i].split(',').map((v) => v.trim());
|
||||
const line = lines[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
const values = line.split(',').map((v) => v.trim());
|
||||
|
||||
if (values.length !== header.length) {
|
||||
throw new Error(
|
||||
@@ -63,11 +68,11 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
||||
row[field] = values[index] ?? '';
|
||||
});
|
||||
|
||||
const driverId = row.driverid;
|
||||
const position = parseInt(row.position, 10);
|
||||
const fastestLap = parseFloat(row.fastestlap);
|
||||
const incidents = parseInt(row.incidents, 10);
|
||||
const startPosition = parseInt(row.startposition, 10);
|
||||
const driverId = row['driverid'] ?? '';
|
||||
const position = parseInt(row['position'] ?? '', 10);
|
||||
const fastestLap = parseFloat(row['fastestlap'] ?? '');
|
||||
const incidents = parseInt(row['incidents'] ?? '', 10);
|
||||
const startPosition = parseInt(row['startposition'] ?? '', 10);
|
||||
|
||||
if (!driverId || driverId.length === 0) {
|
||||
throw new Error(`Row ${i}: driverId is required`);
|
||||
|
||||
@@ -38,9 +38,9 @@ interface ResultsTableProps {
|
||||
results: ResultDTO[];
|
||||
drivers: DriverDTO[];
|
||||
pointsSystem: Record<number, number>;
|
||||
fastestLapTime?: number;
|
||||
fastestLapTime?: number | undefined;
|
||||
penalties?: PenaltyData[];
|
||||
currentDriverId?: string;
|
||||
currentDriverId?: string | undefined;
|
||||
}
|
||||
|
||||
export default function ResultsTable({
|
||||
|
||||
@@ -5,11 +5,19 @@ import Button from '@/components/ui/Button';
|
||||
import {
|
||||
getJoinTeamUseCase,
|
||||
getLeaveTeamUseCase,
|
||||
getGetDriverTeamUseCase,
|
||||
getTeamMembershipRepository,
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import type { TeamMembership } from '@gridpilot/racing';
|
||||
|
||||
type TeamMembershipStatus = 'active' | 'pending' | 'inactive';
|
||||
|
||||
interface TeamMembership {
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
role: 'owner' | 'manager' | 'driver';
|
||||
status: TeamMembershipStatus;
|
||||
joinedAt: Date | string;
|
||||
}
|
||||
|
||||
interface JoinTeamButtonProps {
|
||||
teamId: string;
|
||||
@@ -25,25 +33,12 @@ export default function JoinTeamButton({
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 driverTeamUseCase = getGetDriverTeamUseCase();
|
||||
await driverTeamUseCase.execute({ driverId: currentDriverId });
|
||||
const viewModel = driverTeamUseCase.presenter.getViewModel();
|
||||
if (viewModel.result) {
|
||||
setCurrentTeamId(viewModel.result.team.id);
|
||||
setCurrentTeamName(viewModel.result.team.name);
|
||||
} else {
|
||||
setCurrentTeamId(null);
|
||||
setCurrentTeamName(null);
|
||||
}
|
||||
setMembership(m as TeamMembership | null);
|
||||
};
|
||||
void load();
|
||||
}, [teamId, currentDriverId]);
|
||||
@@ -117,15 +112,6 @@ export default function JoinTeamButton({
|
||||
);
|
||||
}
|
||||
|
||||
// Already on another team
|
||||
if (currentTeamId && currentTeamId !== teamId) {
|
||||
return (
|
||||
<Button variant="secondary" disabled>
|
||||
Already on {currentTeamName}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Can join
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -39,7 +39,13 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const viewModel = await loadTeamAdminViewModel(team as any);
|
||||
const viewModel = await loadTeamAdminViewModel({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
ownerId: team.ownerId,
|
||||
});
|
||||
setJoinRequests(viewModel.requests);
|
||||
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
|
||||
@@ -29,9 +29,9 @@ interface TeamCardProps {
|
||||
totalRaces?: number;
|
||||
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||
isRecruiting?: boolean;
|
||||
specialization?: 'endurance' | 'sprint' | 'mixed';
|
||||
specialization?: 'endurance' | 'sprint' | 'mixed' | undefined;
|
||||
region?: string;
|
||||
languages?: string[];
|
||||
languages?: string[] | undefined;
|
||||
leagues?: string[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
getTeamRosterViewModel,
|
||||
type TeamRosterViewModel,
|
||||
} from '@/lib/presenters/TeamRosterPresenter';
|
||||
|
||||
type TeamRole = 'owner' | 'manager' | 'driver';
|
||||
import type { TeamRole } from '@gridpilot/racing/domain/types/TeamMembership';
|
||||
|
||||
interface TeamMembershipSummary {
|
||||
driverId: string;
|
||||
@@ -39,7 +38,14 @@ export default function TeamRoster({
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const vm = await getTeamRosterViewModel(memberships);
|
||||
const fullMemberships = memberships.map((m) => ({
|
||||
teamId,
|
||||
driverId: m.driverId,
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt,
|
||||
status: 'active' as const,
|
||||
}));
|
||||
const vm = await getTeamRosterViewModel(fullMemberships);
|
||||
setViewModel(vm);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -64,6 +70,19 @@ export default function TeamRoster({
|
||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||
};
|
||||
|
||||
function getRoleOrder(role: TeamRole): number {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 0;
|
||||
case 'manager':
|
||||
return 1;
|
||||
case 'driver':
|
||||
return 2;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
const sortedMembers = viewModel
|
||||
? [...viewModel.members].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
@@ -73,8 +92,7 @@ export default function TeamRoster({
|
||||
return ratingB - ratingA;
|
||||
}
|
||||
case 'role': {
|
||||
const roleOrder: Record<TeamRole, number> = { owner: 0, manager: 1, driver: 2 };
|
||||
return roleOrder[a.role] - roleOrder[b.role];
|
||||
return getRoleOrder(a.role) - getRoleOrder(b.role);
|
||||
}
|
||||
case 'name': {
|
||||
return a.driver.name.localeCompare(b.driver.name);
|
||||
@@ -110,7 +128,7 @@ export default function TeamRoster({
|
||||
<label className="text-sm text-gray-400">Sort by:</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="rating">Rating</option>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { InputHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: boolean;
|
||||
errorMessage?: string;
|
||||
errorMessage?: string | undefined;
|
||||
}
|
||||
|
||||
export default function Input({
|
||||
|
||||
@@ -70,7 +70,11 @@ export default function Modal({
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const last = focusable[focusable.length - 1] ?? first;
|
||||
|
||||
if (!first || !last) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -10,7 +10,7 @@ interface RangeFieldProps {
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
helperText?: string;
|
||||
error?: string;
|
||||
error?: string | undefined;
|
||||
disabled?: boolean;
|
||||
unitLabel?: string;
|
||||
rangeHint?: string;
|
||||
|
||||
Reference in New Issue
Block a user