website cleanup
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user