website cleanup

This commit is contained in:
2025-12-24 21:44:58 +01:00
parent 9b683a59d3
commit d78854a4c6
277 changed files with 6141 additions and 2693 deletions

View File

@@ -1,11 +1,19 @@
import Card from '@/components/ui/Card';
import type { LeagueScoringChampionshipDTO } from '@core/racing/application/dto/LeagueScoringConfigDTO';
import type { LeagueScoringChampionshipDTO } from '@/lib/types/generated/LeagueScoringChampionshipDTO';
type PointsPreviewRow = {
sessionType: string;
position: number;
points: number;
};
interface ChampionshipCardProps {
championship: LeagueScoringChampionshipDTO;
}
export function ChampionshipCard({ championship }: ChampionshipCardProps) {
const pointsPreview = (championship.pointsPreview as unknown as PointsPreviewRow[]) ?? [];
const dropPolicyDescription = (championship as unknown as { dropPolicyDescription?: string }).dropPolicyDescription ?? '';
const getTypeLabel = (type: string): string => {
switch (type) {
case 'driver':
@@ -66,12 +74,12 @@ export function ChampionshipCard({ championship }: ChampionshipCardProps) {
)}
{/* Points Preview */}
{championship.pointsPreview.length > 0 && (
{pointsPreview.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Points Distribution</h4>
<div className="bg-deep-graphite rounded-lg border border-charcoal-outline p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
{championship.pointsPreview.slice(0, 6).map((preview, idx) => (
{pointsPreview.slice(0, 6).map((preview, idx) => (
<div key={idx} className="text-center">
<div className="text-xs text-gray-500 mb-1">P{preview.position}</div>
<div className="text-lg font-bold text-white tabular-nums">{preview.points}</div>
@@ -87,9 +95,9 @@ export function ChampionshipCard({ championship }: ChampionshipCardProps) {
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Drop Policy</span>
</div>
<p className="text-sm text-gray-300">{championship.dropPolicyDescription}</p>
<p className="text-sm text-gray-300">{dropPolicyDescription}</p>
</div>
</div>
</Card>
);
}
}

View File

@@ -3,6 +3,7 @@
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { getMembership } from '@/lib/leagueMembership';
import { useState } from 'react';
import { useServices } from '@/lib/services/ServiceProvider';
import Button from '../ui/Button';
interface JoinLeagueButtonProps {
@@ -18,6 +19,7 @@ export default function JoinLeagueButton({
}: JoinLeagueButtonProps) {
const currentDriverId = useEffectiveDriverId();
const membership = getMembership(leagueId, currentDriverId);
const { leagueMembershipService } = useServices();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -28,21 +30,13 @@ export default function JoinLeagueButton({
setLoading(true);
setError(null);
try {
const membershipRepo = getLeagueMembershipRepository();
if (isInviteOnly) {
const existing = await membershipRepo.getMembership(leagueId, currentDriverId);
if (existing) {
throw new Error('Already a member or have a pending request');
}
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 });
await leagueMembershipService.joinLeague(leagueId, currentDriverId);
onMembershipChange?.();
setShowConfirmDialog(false);
@@ -57,15 +51,11 @@ export default function JoinLeagueButton({
setLoading(true);
setError(null);
try {
const membershipRepo = getLeagueMembershipRepository();
const existing = await membershipRepo.getMembership(leagueId, currentDriverId);
if (!existing) {
throw new Error('Not a member of this league');
}
if (existing.role === 'owner') {
if (membership?.role === 'owner') {
throw new Error('League owner cannot leave the league');
}
await membershipRepo.removeMembership(leagueId, currentDriverId);
await leagueMembershipService.leaveLeague(leagueId, currentDriverId);
onMembershipChange?.();
setShowConfirmDialog(false);
@@ -171,4 +161,4 @@ export default function JoinLeagueButton({
)}
</>
);
}
}

View File

@@ -1,10 +1,9 @@
'use client';
import { Calendar, Award, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
import { Race, Penalty } from '@core/racing';
import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import { useEffect, useState } from 'react';
import type { Driver } from '@core/racing';
import { useServices } from '@/lib/services/ServiceProvider';
import type { RaceListItemViewModel } from '@/lib/view-models/RaceListItemViewModel';
export type LeagueActivity =
| { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date }
@@ -33,65 +32,42 @@ function timeAgo(timestamp: Date): string {
}
export default function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) {
const { raceService, driverService } = useServices();
const [activities, setActivities] = useState<LeagueActivity[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadActivities() {
try {
const raceRepo = getRaceRepository();
const penaltyRepo = getPenaltyRepository();
const driverRepo = getDriverRepository();
const raceList = await raceService.findByLeagueId(leagueId);
const races = await raceRepo.findByLeagueId(leagueId);
const drivers = await driverRepo.findAll();
const driversMap = new Map(drivers.map(d => [d.id, d]));
const completedRaces = raceList
.filter((r) => r.status === 'completed')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 5);
const upcomingRaces = raceList
.filter((r) => r.status === 'scheduled')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 3);
const activityList: LeagueActivity[] = [];
// Add completed races
const completedRaces = races.filter(r => r.status === 'completed')
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
.slice(0, 5);
for (const race of completedRaces) {
activityList.push({
type: 'race_completed',
raceId: race.id,
raceName: `${race.track} - ${race.car}`,
timestamp: race.scheduledAt,
timestamp: new Date(race.scheduledAt),
});
// Add penalties from this race
const racePenalties = await penaltyRepo.findByRaceId(race.id);
const appliedPenalties = racePenalties.filter(p => p.status === 'applied' && p.type === 'points_deduction');
for (const penalty of appliedPenalties) {
const driver = driversMap.get(penalty.driverId);
if (driver && penalty.value) {
activityList.push({
type: 'penalty_applied',
penaltyId: penalty.id,
driverName: driver.name,
reason: penalty.reason,
points: penalty.value,
timestamp: penalty.appliedAt || penalty.issuedAt,
});
}
}
}
// Add scheduled races
const upcomingRaces = races.filter(r => r.status === 'scheduled')
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
.slice(0, 3);
for (const race of upcomingRaces) {
activityList.push({
type: 'race_scheduled',
raceId: race.id,
raceName: `${race.track} - ${race.car}`,
timestamp: new Date(race.scheduledAt.getTime() - 7 * 24 * 60 * 60 * 1000), // Simulate schedule announcement
timestamp: new Date(new Date(race.scheduledAt).getTime() - 7 * 24 * 60 * 60 * 1000), // Simulate schedule announcement
});
}
@@ -107,7 +83,7 @@ export default function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActiv
}
loadActivities();
}, [leagueId, limit]);
}, [leagueId, limit, raceService, driverService]);
if (loading) {
return (
@@ -217,4 +193,4 @@ function ActivityItem({ activity }: { activity: LeagueActivity }) {
</div>
</div>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import React from 'react';
import Card from '@/components/ui/Card';
import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
import { DriverViewModel } from '@/lib/view-models';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
interface LeagueChampionshipStatsProps {
standings: StandingEntryViewModel[];
@@ -56,4 +56,4 @@ export default function LeagueChampionshipStats({ standings, drivers }: LeagueCh
</Card>
</div>
);
}
}

View File

@@ -1,6 +1,7 @@
'use client';
import MembershipStatus from '@/components/leagues/MembershipStatus';
import { useServices } from '@/lib/services/ServiceProvider';
import Image from 'next/image';
@@ -27,8 +28,8 @@ export default function LeagueHeader({
ownerId,
mainSponsor,
}: LeagueHeaderProps) {
const imageService = getImageService();
const logoUrl = imageService.getLeagueLogo(leagueId);
const { mediaService } = useServices();
const logoUrl = mediaService.getLeagueLogo(leagueId);
return (
<div className="mb-8">
@@ -76,4 +77,4 @@ export default function LeagueHeader({
</div>
</div>
);
}
}

View File

@@ -1,9 +1,10 @@
'use client';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { LeagueScheduleRaceItemViewModel } from '@/lib/presenters/LeagueSchedulePresenter';
import { useLeagueSchedule } from '@/hooks/useLeagueService';
import { useRegisterForRace, useWithdrawFromRace } from '@/hooks/useRaceService';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useMemo, useState } from 'react';
interface LeagueScheduleProps {
leagueId: string;
@@ -11,69 +12,44 @@ interface LeagueScheduleProps {
export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const router = useRouter();
const [races, setRaces] = useState<LeagueScheduleRaceItemViewModel[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
const [registrationStates, setRegistrationStates] = useState<Record<string, boolean>>({});
const [processingRace, setProcessingRace] = useState<string | null>(null);
const currentDriverId = useEffectiveDriverId();
const loadRacesCallback = useCallback(async () => {
setLoading(true);
try {
const viewModel = await loadLeagueSchedule(leagueId, currentDriverId);
setRaces(viewModel.races);
const { data: schedule, isLoading } = useLeagueSchedule(leagueId);
const registerMutation = useRegisterForRace();
const withdrawMutation = useWithdrawFromRace();
const states: Record<string, boolean> = {};
for (const race of viewModel.races) {
states[race.id] = race.isRegistered;
}
setRegistrationStates(states);
} catch (error) {
console.error('Failed to load races:', error);
} finally {
setLoading(false);
}
}, [leagueId, currentDriverId]);
const races = useMemo(() => {
// Current contract uses `unknown[]` for races; treat as any until a proper schedule DTO/view-model is introduced.
return (schedule?.races ?? []) as Array<any>;
}, [schedule]);
useEffect(() => {
void loadRacesCallback();
}, [loadRacesCallback]);
const handleRegister = async (race: LeagueScheduleRaceItemViewModel, e: React.MouseEvent) => {
const handleRegister = async (race: any, e: React.MouseEvent) => {
e.stopPropagation();
const confirmed = window.confirm(`Register for ${race.track}?`);
if (!confirmed) return;
setProcessingRace(race.id);
try {
await registerForRace(race.id, leagueId, currentDriverId);
setRegistrationStates((prev) => ({ ...prev, [race.id]: true }));
await registerMutation.mutateAsync({ raceId: race.id, leagueId, driverId: currentDriverId });
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register');
} finally {
setProcessingRace(null);
}
};
const handleWithdraw = async (race: LeagueScheduleRaceItemViewModel, e: React.MouseEvent) => {
const handleWithdraw = async (race: any, e: React.MouseEvent) => {
e.stopPropagation();
const confirmed = window.confirm('Withdraw from this race?');
if (!confirmed) return;
setProcessingRace(race.id);
try {
await withdrawFromRace(race.id, currentDriverId);
setRegistrationStates((prev) => ({ ...prev, [race.id]: false }));
await withdrawMutation.mutateAsync({ raceId: race.id, driverId: currentDriverId });
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw');
} finally {
setProcessingRace(null);
}
};
@@ -95,7 +71,7 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const displayRaces = getDisplayRaces();
if (loading) {
if (isLoading) {
return (
<div className="text-center py-8 text-gray-400">
Loading schedule...
@@ -157,6 +133,9 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
{displayRaces.map((race) => {
const isPast = race.isPast;
const isUpcoming = race.isUpcoming;
const isRegistered = Boolean(race.isRegistered);
const isProcessing =
registerMutation.isPending || withdrawMutation.isPending;
return (
<div
@@ -172,12 +151,12 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
<div className="flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3 className="text-white font-medium">{race.track}</h3>
{isUpcoming && !registrationStates[race.id] && (
{isUpcoming && !isRegistered && (
<span className="px-2 py-0.5 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
Upcoming
</span>
)}
{isUpcoming && registrationStates[race.id] && (
{isUpcoming && isRegistered && (
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-400 rounded border border-green-500/30">
Registered
</span>
@@ -217,21 +196,21 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
{/* Registration Actions */}
{isUpcoming && (
<div onClick={(e) => e.stopPropagation()}>
{!registrationStates[race.id] ? (
{!isRegistered ? (
<button
onClick={(e) => handleRegister(race, e)}
disabled={processingRace === race.id}
disabled={isProcessing}
className="px-3 py-1.5 text-sm font-medium bg-primary-blue hover:bg-primary-blue/80 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{processingRace === race.id ? 'Registering...' : 'Register'}
{registerMutation.isPending ? 'Registering...' : 'Register'}
</button>
) : (
<button
onClick={(e) => handleWithdraw(race, e)}
disabled={processingRace === race.id}
disabled={isProcessing}
className="px-3 py-1.5 text-sm font-medium bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{processingRace === race.id ? 'Withdrawing...' : 'Withdraw'}
{withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'}
</button>
)}
</div>
@@ -245,4 +224,4 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
)}
</div>
);
}
}

View File

@@ -3,7 +3,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { LeagueScoringPresetDTO } from '@/hooks/useLeagueScoringPresets';
import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
// ============================================================================
@@ -1157,4 +1157,4 @@ export function ChampionshipsSection({
</div>
</div>
);
}
}

View File

@@ -1,8 +1,22 @@
'use client';
import type { LeagueScoringConfigDTO } from '@core/racing/application/dto/LeagueScoringConfigDTO';
import type { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO';
import { Trophy, Clock, Target, Zap, Info } from 'lucide-react';
type LeagueScoringConfigUi = LeagueScoringConfigDTO & {
scoringPresetName?: string;
dropPolicySummary?: string;
championships?: Array<{
id: string;
name: string;
type: 'driver' | 'team' | 'nations' | 'trophy' | string;
sessionTypes: string[];
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
bonusSummary: string[];
dropPolicyDescription?: string;
}>;
};
interface LeagueScoringTabProps {
scoringConfig: LeagueScoringConfigDTO | null;
practiceMinutes?: number;
@@ -32,9 +46,12 @@ export default function LeagueScoringTab({
);
}
const ui = scoringConfig as unknown as LeagueScoringConfigUi;
const championships = ui.championships ?? [];
const primaryChampionship =
scoringConfig.championships.find((c) => c.type === 'driver') ??
scoringConfig.championships[0];
championships.find((c) => c.type === 'driver') ??
championships[0];
const resolvedPractice = practiceMinutes ?? 20;
const resolvedQualifying = qualifyingMinutes ?? 30;
@@ -54,10 +71,10 @@ export default function LeagueScoringTab({
</h2>
<p className="text-sm text-gray-400">
{scoringConfig.gameName}{' '}
{scoringConfig.scoringPresetName
? `${scoringConfig.scoringPresetName}`
{ui.scoringPresetName
? `${ui.scoringPresetName}`
: '• Custom scoring'}{' '}
{scoringConfig.dropPolicySummary}
{ui.dropPolicySummary ? `${ui.dropPolicySummary}` : ''}
</p>
</div>
</div>
@@ -71,7 +88,7 @@ export default function LeagueScoringTab({
</h3>
</div>
<div className="flex flex-wrap gap-2 text-xs">
{primaryChampionship.sessionTypes.map((session) => (
{primaryChampionship.sessionTypes.map((session: string) => (
<span
key={session}
className="px-3 py-1 rounded-full bg-primary-blue/10 text-primary-blue border border-primary-blue/20 font-medium"
@@ -106,7 +123,7 @@ export default function LeagueScoringTab({
)}
</div>
{scoringConfig.championships.map((championship) => (
{championships.map((championship) => (
<div
key={championship.id}
className="border border-charcoal-outline rounded-lg bg-iron-gray/40 p-4 space-y-4"
@@ -128,7 +145,7 @@ export default function LeagueScoringTab({
</div>
{championship.sessionTypes.length > 0 && (
<div className="flex flex-wrap gap-1 justify-end">
{championship.sessionTypes.map((session) => (
{championship.sessionTypes.map((session: string) => (
<span
key={session}
className="px-2 py-0.5 rounded-full bg-charcoal-outline/60 text-xs text-gray-200"
@@ -161,7 +178,7 @@ export default function LeagueScoringTab({
</tr>
</thead>
<tbody>
{championship.pointsPreview.map((row, index) => (
{championship.pointsPreview.map((row, index: number) => (
<tr
key={`${row.sessionType}-${row.position}-${index}`}
className="border-b border-charcoal-outline/30"
@@ -192,7 +209,7 @@ export default function LeagueScoringTab({
</h4>
</div>
<ul className="list-disc list-inside text-xs text-gray-300 space-y-1">
{championship.bonusSummary.map((item, index) => (
{championship.bonusSummary.map((item: string, index: number) => (
<li key={index}>{item}</li>
))}
</ul>
@@ -207,11 +224,11 @@ export default function LeagueScoringTab({
</h4>
</div>
<p className="text-xs text-gray-300">
{championship.dropPolicyDescription}
{championship.dropPolicyDescription ?? ''}
</p>
</div>
</div>
))}
</div>
);
}
}

View File

@@ -29,7 +29,7 @@ export function LeagueSponsorshipsSection({
readOnly = false
}: LeagueSponsorshipsSectionProps) {
const currentDriverId = useEffectiveDriverId();
const { sponsorshipService } = useServices();
const { sponsorshipService, leagueService } = useServices();
const [slots, setSlots] = useState<SponsorshipSlot[]>([
{ tier: 'main', price: 500, isOccupied: false },
{ tier: 'secondary', price: 200, isOccupied: false },
@@ -49,18 +49,15 @@ export function LeagueSponsorshipsSection({
return;
}
try {
const seasonRepo = getSeasonRepository();
const seasons = await seasonRepo.findByLeagueId(leagueId);
const activeSeason = seasons.find(s => s.status === 'active') ?? seasons[0];
if (activeSeason) {
setSeasonId(activeSeason.id);
}
const seasons = await leagueService.getLeagueSeasons(leagueId);
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
if (activeSeason) setSeasonId(activeSeason.seasonId);
} catch (err) {
console.error('Failed to load season:', err);
}
}
loadSeasonId();
}, [leagueId, propSeasonId]);
}, [leagueId, propSeasonId, leagueService]);
// Load pending sponsorship requests
const loadPendingRequests = useCallback(async () => {
@@ -68,25 +65,36 @@ export function LeagueSponsorshipsSection({
setRequestsLoading(true);
try {
const useCase = getGetPendingSponsorshipRequestsUseCase();
const presenter = new PendingSponsorshipRequestsPresenter();
const requests = await sponsorshipService.getPendingSponsorshipRequests({
entityType: 'season',
entityId: seasonId,
});
await useCase.execute(
{
entityType: 'season',
entityId: seasonId,
},
presenter,
// Convert service view-models to component DTO type (UI-only)
setPendingRequests(
requests.map(
(r): PendingRequestDTO => ({
id: r.id,
sponsorId: r.sponsorId,
sponsorName: r.sponsorName,
sponsorLogo: r.sponsorLogo,
tier: r.tier,
offeredAmount: r.offeredAmount,
currency: r.currency,
formattedAmount: r.formattedAmount,
message: r.message,
createdAt: r.createdAt,
platformFee: r.platformFee,
netAmount: r.netAmount,
}),
),
);
const viewModel = presenter.getViewModel();
setPendingRequests(viewModel?.requests ?? []);
} catch (err) {
console.error('Failed to load pending requests:', err);
} finally {
setRequestsLoading(false);
}
}, [seasonId]);
}, [seasonId, sponsorshipService]);
useEffect(() => {
loadPendingRequests();
@@ -94,11 +102,7 @@ export function LeagueSponsorshipsSection({
const handleAcceptRequest = async (requestId: string) => {
try {
const useCase = getAcceptSponsorshipRequestUseCase();
await useCase.execute({
requestId,
respondedBy: currentDriverId,
});
await sponsorshipService.acceptSponsorshipRequest(requestId, currentDriverId);
await loadPendingRequests();
} catch (err) {
console.error('Failed to accept request:', err);
@@ -108,12 +112,7 @@ export function LeagueSponsorshipsSection({
const handleRejectRequest = async (requestId: string, reason?: string) => {
try {
const useCase = getRejectSponsorshipRequestUseCase();
await useCase.execute({
requestId,
respondedBy: currentDriverId,
...(reason ? { reason } : {}),
});
await sponsorshipService.rejectSponsorshipRequest(requestId, currentDriverId, reason);
await loadPendingRequests();
} catch (err) {
console.error('Failed to reject request:', err);
@@ -324,4 +323,4 @@ export function LeagueSponsorshipsSection({
</div>
</div>
);
}
}

View File

@@ -2,8 +2,7 @@
import React from 'react';
import { Scale, Users, Clock, Bell, Shield, Vote, UserCheck, AlertTriangle } from 'lucide-react';
import type { LeagueConfigFormModel, LeagueStewardingFormDTO } from '@core/racing/application';
import type { StewardingDecisionMode } from '@core/racing/domain/entities/League';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
interface LeagueStewardingSectionProps {
form: LeagueConfigFormModel;
@@ -12,7 +11,7 @@ interface LeagueStewardingSectionProps {
}
type DecisionModeOption = {
value: StewardingDecisionMode;
value: NonNullable<LeagueConfigFormModel['stewarding']>['decisionMode'];
label: string;
description: string;
icon: React.ReactNode;
@@ -21,40 +20,19 @@ type DecisionModeOption = {
const decisionModeOptions: DecisionModeOption[] = [
{
value: 'admin_only',
label: 'Admin Decision',
description: 'League admins make all penalty decisions',
value: 'single_steward',
label: 'Single Steward',
description: 'A single steward/admin makes all penalty decisions',
icon: <Shield className="w-5 h-5" />,
requiresVotes: false,
},
{
value: 'steward_vote',
label: 'Steward Vote',
description: 'Designated stewards vote to uphold protests',
value: 'committee_vote',
label: 'Committee Vote',
description: 'A group votes to uphold/dismiss protests',
icon: <Scale className="w-5 h-5" />,
requiresVotes: true,
},
{
value: 'member_vote',
label: 'Member Vote',
description: 'All league members vote on protests',
icon: <Users className="w-5 h-5" />,
requiresVotes: true,
},
{
value: 'steward_veto',
label: 'Steward Veto',
description: 'Protests upheld unless stewards vote against',
icon: <Vote className="w-5 h-5" />,
requiresVotes: true,
},
{
value: 'member_veto',
label: 'Member Veto',
description: 'Protests upheld unless members vote against',
icon: <UserCheck className="w-5 h-5" />,
requiresVotes: true,
},
];
export function LeagueStewardingSection({
@@ -64,7 +42,7 @@ export function LeagueStewardingSection({
}: LeagueStewardingSectionProps) {
// Provide default stewarding settings if not present
const stewarding = form.stewarding ?? {
decisionMode: 'admin_only' as const,
decisionMode: 'single_steward' as const,
requiredVotes: 2,
requireDefense: false,
defenseTimeLimit: 48,
@@ -75,7 +53,7 @@ export function LeagueStewardingSection({
notifyOnVoteRequired: true,
};
const updateStewarding = (updates: Partial<LeagueStewardingFormDTO>) => {
const updateStewarding = (updates: Partial<NonNullable<LeagueConfigFormModel['stewarding']>>) => {
onChange({
...form,
stewarding: {
@@ -147,7 +125,7 @@ export function LeagueStewardingSection({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5">
Required votes to {stewarding.decisionMode.includes('veto') ? 'block' : 'uphold'}
Required votes to uphold
</label>
<select
value={stewarding.requiredVotes ?? 2}
@@ -375,7 +353,7 @@ export function LeagueStewardingSection({
</div>
{/* Warning about strict settings */}
{stewarding.requireDefense && stewarding.decisionMode !== 'admin_only' && (
{stewarding.requireDefense && stewarding.decisionMode !== 'single_steward' && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-warning-amber/10 border border-warning-amber/20">
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<div>
@@ -389,4 +367,4 @@ export function LeagueStewardingSection({
)}
</div>
);
}
}

View File

@@ -4,11 +4,12 @@ import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Star } from 'lucide-react';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
import type { LeagueDriverSeasonStatsDTO } from '@core/racing/application/dto/LeagueDriverSeasonStatsDTO';
import type { LeagueMembership, MembershipRole } from '@/lib/leagueMembership';
import { getLeagueRoleDisplay } from '@/lib/leagueRoles';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { MembershipRoleDTO } from '@/lib/types/generated/MembershipRoleDTO';
import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay';
import CountryFlag from '@/components/ui/CountryFlag';
import { useServices } from '@/lib/services/ServiceProvider';
// Position background colors
const getPositionBgColor = (position: number): string => {
@@ -21,14 +22,25 @@ const getPositionBgColor = (position: number): string => {
};
interface StandingsTableProps {
standings: LeagueDriverSeasonStatsDTO[];
standings: Array<{
leagueId: string;
driverId: string;
position: number;
totalPoints: number;
racesFinished: number;
racesStarted: number;
avgFinish: number | null;
penaltyPoints: number;
bonusPoints: number;
teamName?: string;
}>;
drivers: DriverDTO[];
leagueId: string;
memberships?: LeagueMembership[];
currentDriverId?: string;
isAdmin?: boolean;
onRemoveMember?: (driverId: string) => void;
onUpdateRole?: (driverId: string, role: MembershipRole) => void;
onUpdateRole?: (driverId: string, role: MembershipRoleDTO['value']) => void;
}
export default function StandingsTable({
@@ -41,6 +53,7 @@ export default function StandingsTable({
onRemoveMember,
onUpdateRole
}: StandingsTableProps) {
const { mediaService } = useServices();
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
const [activeMenu, setActiveMenu] = useState<{ driverId: string; type: 'member' | 'points' } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
@@ -78,12 +91,14 @@ export default function StandingsTable({
return driverId === currentDriverId;
};
type MembershipRole = MembershipRoleDTO['value'];
const handleRoleChange = (driverId: string, newRole: MembershipRole) => {
if (!onUpdateRole) return;
const membership = getMembership(driverId);
if (!membership) return;
const confirmationMessages: Record<MembershipRole, string> = {
const confirmationMessages: Record<string, string> = {
owner: 'Cannot promote to owner',
admin: 'Promote this member to Admin? They will have full management permissions.',
steward: 'Assign Steward role? They will be able to manage protests and penalties.',
@@ -96,7 +111,7 @@ export default function StandingsTable({
}
if (newRole !== membership.role && confirm(confirmationMessages[newRole])) {
onUpdateRole(driverId, newRole);
onUpdateRole(driverId, newRole as MembershipRoleDTO['value']);
setActiveMenu(null);
}
};
@@ -266,9 +281,10 @@ export default function StandingsTable({
{standings.map((row) => {
const driver = getDriver(row.driverId);
const membership = getMembership(row.driverId);
const roleDisplay = membership ? getLeagueRoleDisplay(membership.role) : null;
const roleDisplay = membership ? LeagueRoleDisplay.getLeagueRoleDisplay(membership.role) : null;
const canModify = canModifyMember(row.driverId);
const driverStatsData = getDriverStats(row.driverId);
// TODO: Hook up real driver stats once API provides it
const driverStatsData: null = null;
const isRowHovered = hoveredRow === row.driverId;
const isMemberMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'member';
const isPointsMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'points';
@@ -307,7 +323,7 @@ export default function StandingsTable({
<div className="w-10 h-10 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0">
{driver && (
<Image
src={getImageService().getDriverAvatar(driver.id)}
src={mediaService.getDriverAvatar(driver.id)}
alt={driver.name}
width={40}
height={40}
@@ -344,12 +360,7 @@ export default function StandingsTable({
)}
</div>
<div className="text-xs flex items-center gap-1">
{driverStatsData && (
<span className="inline-flex items-center gap-1 text-amber-300">
<Star className="h-3 w-3" />
<span className="tabular-nums font-medium">{driverStatsData.rating}</span>
</span>
)}
{/* Rating intentionally omitted until API provides driver stats */}
</div>
</div>
@@ -435,4 +446,4 @@ export default function StandingsTable({
`}</style>
</div>
);
}
}