fix data flow issues

This commit is contained in:
2025-12-19 21:58:03 +01:00
parent 94fc538f44
commit ec177a75ce
37 changed files with 1336 additions and 534 deletions

View File

@@ -1,13 +1,13 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { apiClient } from '../../../../lib/apiClient';
import { api } from '../../../../lib/api';
export async function GET(request: Request) {
const url = new URL(request.url);
const returnTo = url.searchParams.get('returnTo') ?? undefined;
const redirectUrl = apiClient.auth.getIracingAuthUrl(returnTo);
const redirectUrl = api.auth.getIracingAuthUrl(returnTo);
// For now, generate a simple state - in production this should be cryptographically secure
const state = Math.random().toString(36).substring(2, 15);

View File

@@ -92,67 +92,6 @@ interface TeamMembershipInfo {
// DEMO DATA
// ============================================================================
function getDemoExtendedProfile(driverId: string): DriverExtendedProfile {
const hash = driverId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
const socialOptions: SocialHandle[][] = [
[
{ platform: 'twitter', handle: '@speedracer', url: 'https://twitter.com/speedracer' },
{ platform: 'youtube', handle: 'SpeedRacer Racing', url: 'https://youtube.com/@speedracer' },
{ platform: 'twitch', handle: 'speedracer_live', url: 'https://twitch.tv/speedracer_live' },
],
[
{ platform: 'twitter', handle: '@racingpro', url: 'https://twitter.com/racingpro' },
{ platform: 'discord', handle: 'RacingPro#1234', url: '#' },
],
[
{ platform: 'twitch', handle: 'simracer_elite', url: 'https://twitch.tv/simracer_elite' },
{ platform: 'youtube', handle: 'SimRacer Elite', url: 'https://youtube.com/@simracerelite' },
],
];
const achievementSets: Achievement[][] = [
[
{ id: '1', title: 'First Victory', description: 'Win your first race', icon: 'trophy', rarity: 'common', earnedAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) },
{ id: '2', title: 'Clean Racer', description: '10 races without incidents', icon: 'star', rarity: 'rare', earnedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000) },
{ id: '3', title: 'Podium Streak', description: '5 consecutive podium finishes', icon: 'medal', rarity: 'epic', earnedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
],
[
{ id: '1', title: 'Rookie No More', description: 'Complete 25 races', icon: 'target', rarity: 'common', earnedAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000) },
{ id: '2', title: 'Consistent Performer', description: 'Maintain 80%+ consistency rating', icon: 'zap', rarity: 'rare', earnedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000) },
],
[
{ id: '1', title: 'Welcome Racer', description: 'Join GridPilot', icon: 'star', rarity: 'common', earnedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000) },
{ id: '2', title: 'Team Player', description: 'Join a racing team', icon: 'medal', rarity: 'rare', earnedAt: new Date(Date.now() - 80 * 24 * 60 * 60 * 1000) },
],
];
const tracks = ['Spa-Francorchamps', 'Nürburgring Nordschleife', 'Suzuka', 'Monza', 'Interlagos', 'Silverstone'];
const cars = ['Porsche 911 GT3 R', 'Ferrari 488 GT3', 'Mercedes-AMG GT3', 'BMW M4 GT3', 'Audi R8 LMS'];
const styles = ['Aggressive Overtaker', 'Consistent Pacer', 'Strategic Calculator', 'Late Braker', 'Smooth Operator'];
const timezones = ['EST (UTC-5)', 'CET (UTC+1)', 'PST (UTC-8)', 'GMT (UTC+0)', 'JST (UTC+9)'];
const hours = ['Evenings (18:00-23:00)', 'Weekends only', 'Late nights (22:00-02:00)', 'Flexible schedule'];
const socialHandles = socialOptions[hash % socialOptions.length] ?? [];
const achievements = achievementSets[hash % achievementSets.length] ?? [];
const racingStyle = styles[hash % styles.length] ?? 'Consistent Pacer';
const favoriteTrack = tracks[hash % tracks.length] ?? 'Unknown Track';
const favoriteCar = cars[hash % cars.length] ?? 'Unknown Car';
const timezone = timezones[hash % timezones.length] ?? 'UTC';
const availableHours = hours[hash % hours.length] ?? 'Flexible schedule';
return {
socialHandles,
achievements,
racingStyle,
favoriteTrack,
favoriteCar,
timezone,
availableHours,
lookingForTeam: hash % 3 === 0,
openToRequests: hash % 2 === 0,
};
}
// ============================================================================
// HELPERS
@@ -428,29 +367,33 @@ export default function DriverDetailPage() {
);
}
const demoExtended = getDemoExtendedProfile(driverProfile.currentDriver.id);
const extendedProfile: DriverExtendedProfile = {
socialHandles: driverProfile?.extendedProfile?.socialHandles ?? demoExtended.socialHandles,
achievements:
driverProfile?.extendedProfile?.achievements
? driverProfile.extendedProfile.achievements.map((achievement) => ({
id: achievement.id,
title: achievement.title,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
earnedAt: new Date(achievement.earnedAt),
}))
: demoExtended.achievements,
racingStyle: driverProfile?.extendedProfile?.racingStyle ?? demoExtended.racingStyle,
favoriteTrack: driverProfile?.extendedProfile?.favoriteTrack ?? demoExtended.favoriteTrack,
favoriteCar: driverProfile?.extendedProfile?.favoriteCar ?? demoExtended.favoriteCar,
timezone: driverProfile?.extendedProfile?.timezone ?? demoExtended.timezone,
availableHours: driverProfile?.extendedProfile?.availableHours ?? demoExtended.availableHours,
lookingForTeam:
driverProfile?.extendedProfile?.lookingForTeam ?? demoExtended.lookingForTeam,
openToRequests:
driverProfile?.extendedProfile?.openToRequests ?? demoExtended.openToRequests,
const extendedProfile: DriverExtendedProfile = driverProfile.extendedProfile ? {
socialHandles: driverProfile.extendedProfile.socialHandles,
achievements: driverProfile.extendedProfile.achievements.map((achievement) => ({
id: achievement.id,
title: achievement.title,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
earnedAt: new Date(achievement.earnedAt),
})),
racingStyle: driverProfile.extendedProfile.racingStyle,
favoriteTrack: driverProfile.extendedProfile.favoriteTrack,
favoriteCar: driverProfile.extendedProfile.favoriteCar,
timezone: driverProfile.extendedProfile.timezone,
availableHours: driverProfile.extendedProfile.availableHours,
lookingForTeam: driverProfile.extendedProfile.lookingForTeam,
openToRequests: driverProfile.extendedProfile.openToRequests,
} : {
socialHandles: [],
achievements: [],
racingStyle: 'Unknown',
favoriteTrack: 'Unknown',
favoriteCar: 'Unknown',
timezone: 'UTC',
availableHours: 'Flexible',
lookingForTeam: false,
openToRequests: false,
};
const stats = driverProfile?.stats || null;
const globalRank = driverProfile?.currentDriver?.globalRank || 1;

View File

@@ -7,6 +7,7 @@ import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useServices } from '@/lib/services/ServiceProvider';
import { LeagueStewardingViewModel } from '@/lib/view-models/LeagueStewardingViewModel';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import {
AlertCircle,
@@ -27,32 +28,13 @@ import { useEffect, useMemo, useState } from 'react';
// Local type definitions to replace core imports
type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
type DriverDTO = {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
};
interface RaceWithProtests {
race: any;
pendingProtests: any[];
resolvedProtests: any[];
penalties: any[];
}
export default function LeagueStewardingPage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const { raceService, protestService, driverService, leagueMembershipService, penaltyService } = useServices();
const { leagueStewardingService, leagueMembershipService } = useServices();
const [races, setRaces] = useState<any[]>([]);
const [protestsByRace, setProtestsByRace] = useState<Record<string, any[]>>({});
const [penaltiesByRace, setPenaltiesByRace] = useState<Record<string, any[]>>({});
const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({});
const [allDrivers, setAllDrivers] = useState<DriverDTO[]>([]);
const [stewardingData, setStewardingData] = useState<LeagueStewardingViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
@@ -72,52 +54,13 @@ export default function LeagueStewardingPage() {
async function loadData() {
setLoading(true);
try {
// Get all races for this league
const leagueRaces = await raceService.findByLeagueId(leagueId);
setRaces(leagueRaces);
// Get protests and penalties for each race
const protestsMap: Record<string, any[]> = {};
const penaltiesMap: Record<string, any[]> = {};
const driverIds = new Set<string>();
for (const race of leagueRaces) {
const raceProtests = await protestService.findByRaceId(race.id);
const racePenalties = await penaltyService.findByRaceId(race.id);
protestsMap[race.id] = raceProtests;
penaltiesMap[race.id] = racePenalties;
// Collect driver IDs
raceProtests.forEach((p: any) => {
driverIds.add(p.protestingDriverId);
driverIds.add(p.accusedDriverId);
});
racePenalties.forEach((p: any) => {
driverIds.add(p.driverId);
});
}
setProtestsByRace(protestsMap);
setPenaltiesByRace(penaltiesMap);
// Load driver info
const driverEntities = await driverService.findByIds(Array.from(driverIds));
const byId: Record<string, any> = {};
driverEntities.forEach((driver) => {
if (driver) {
byId[driver.id] = driver;
}
});
setDriversById(byId);
setAllDrivers(Object.values(byId));
const data = await leagueStewardingService.getLeagueStewardingData(leagueId);
setStewardingData(data);
// Auto-expand races with pending protests
const racesWithPending = new Set<string>();
Object.entries(protestsMap).forEach(([raceId, protests]) => {
if (protests.some((p: any) => p.status === 'pending' || p.status === 'under_review')) {
racesWithPending.add(raceId);
}
data.pendingRaces.forEach(race => {
racesWithPending.add(race.race.id);
});
setExpandedRaces(racesWithPending);
} catch (err) {
@@ -130,34 +73,12 @@ export default function LeagueStewardingPage() {
if (isAdmin) {
loadData();
}
}, [leagueId, isAdmin, raceService, protestService, driverService, penaltyService]);
// Compute race data with protest/penalty info
const racesWithData = useMemo((): RaceWithProtests[] => {
return races.map(race => {
const protests = protestsByRace[race.id] || [];
const penalties = penaltiesByRace[race.id] || [];
return {
race,
pendingProtests: protests.filter(p => p.status === 'pending' || p.status === 'under_review'),
resolvedProtests: protests.filter(p => p.status === 'upheld' || p.status === 'dismissed' || p.status === 'withdrawn'),
penalties
};
}).sort((a, b) => b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime());
}, [races, protestsByRace, penaltiesByRace]);
}, [leagueId, isAdmin, leagueStewardingService]);
// Filter races based on active tab
const filteredRaces = useMemo(() => {
if (activeTab === 'pending') {
return racesWithData.filter(r => r.pendingProtests.length > 0);
}
return racesWithData.filter(r => r.resolvedProtests.length > 0 || r.penalties.length > 0);
}, [racesWithData, activeTab]);
// Stats
const totalPending = racesWithData.reduce((sum, r) => sum + r.pendingProtests.length, 0);
const totalResolved = racesWithData.reduce((sum, r) => sum + r.resolvedProtests.length, 0);
const totalPenalties = racesWithData.reduce((sum, r) => sum + r.penalties.length, 0);
return activeTab === 'pending' ? stewardingData?.pendingRaces ?? [] : stewardingData?.historyRaces ?? [];
}, [stewardingData, activeTab]);
const handleAcceptProtest = async (
protestId: string,
@@ -165,22 +86,23 @@ export default function LeagueStewardingPage() {
penaltyValue: number,
stewardNotes: string
) => {
await protestService.reviewProtest({
await leagueStewardingService.reviewProtest({
protestId,
stewardId: currentDriverId,
decision: 'uphold',
decisionNotes: stewardNotes,
});
// Find the protest
// Find the protest to get details for penalty
let foundProtest: any | undefined;
Object.values(protestsByRace).forEach(protests => {
const p = protests.find(pr => pr.id === protestId);
if (p) foundProtest = p;
stewardingData?.racesWithData.forEach(raceData => {
const p = raceData.pendingProtests.find(pr => pr.id === protestId) ||
raceData.resolvedProtests.find(pr => pr.id === protestId);
if (p) foundProtest = { ...p, raceId: raceData.race.id };
});
if (foundProtest) {
await penaltyService.applyPenalty({
await leagueStewardingService.applyPenalty({
raceId: foundProtest.raceId,
driverId: foundProtest.accusedDriverId,
stewardId: currentDriverId,
@@ -194,7 +116,7 @@ export default function LeagueStewardingPage() {
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
await protestService.reviewProtest({
await leagueStewardingService.reviewProtest({
protestId,
stewardId: currentDriverId,
decision: 'dismiss',
@@ -260,28 +182,28 @@ export default function LeagueStewardingPage() {
</div>
{/* Stats summary */}
{!loading && (
{!loading && stewardingData && (
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-warning-amber mb-1">
<Clock className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Pending Review</span>
</div>
<div className="text-2xl font-bold text-white">{totalPending}</div>
<div className="text-2xl font-bold text-white">{stewardingData.totalPending}</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-performance-green mb-1">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Resolved</span>
</div>
<div className="text-2xl font-bold text-white">{totalResolved}</div>
<div className="text-2xl font-bold text-white">{stewardingData.totalResolved}</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-red-400 mb-1">
<Gavel className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Penalties</span>
</div>
<div className="text-2xl font-bold text-white">{totalPenalties}</div>
<div className="text-2xl font-bold text-white">{stewardingData.totalPenalties}</div>
</div>
</div>
)}
@@ -298,9 +220,9 @@ export default function LeagueStewardingPage() {
}`}
>
Pending Protests
{totalPending > 0 && (
{stewardingData && stewardingData.totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{totalPending}
{stewardingData.totalPending}
</span>
)}
</button>
@@ -380,8 +302,8 @@ export default function LeagueStewardingPage() {
) : (
<>
{displayProtests.map((protest) => {
const protester = driversById[protest.protestingDriverId];
const accused = driversById[protest.accusedDriverId];
const protester = stewardingData!.driverMap[protest.protestingDriverId];
const accused = stewardingData!.driverMap[protest.accusedDriverId];
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
@@ -443,7 +365,7 @@ export default function LeagueStewardingPage() {
})}
{activeTab === 'history' && penalties.map((penalty) => {
const driver = driversById[penalty.driverId];
const driver = stewardingData!.driverMap[penalty.driverId];
return (
<div
key={penalty.id}
@@ -500,12 +422,12 @@ export default function LeagueStewardingPage() {
/>
)}
{showQuickPenaltyModal && (
{showQuickPenaltyModal && stewardingData && (
<QuickPenaltyModal
drivers={allDrivers}
drivers={stewardingData.allDrivers}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId}
races={races.map(r => ({ id: r.id, track: r.track, scheduledAt: r.scheduledAt }))}
races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
/>
)}
</div>

View File

@@ -4,11 +4,13 @@ import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
Wallet,
DollarSign,
ArrowUpRight,
ArrowDownLeft,
import { useServices } from '@/lib/services/ServiceProvider';
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
import {
Wallet,
DollarSign,
ArrowUpRight,
ArrowDownLeft,
Clock,
AlertTriangle,
CheckCircle,
@@ -19,102 +21,9 @@ import {
Calendar
} from 'lucide-react';
interface Transaction {
id: string;
type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
description: string;
amount: number;
fee: number;
netAmount: number;
date: Date;
status: 'completed' | 'pending' | 'failed';
reference?: string;
}
interface WalletData {
balance: number;
currency: string;
totalRevenue: number;
totalFees: number;
totalWithdrawals: number;
pendingPayouts: number;
transactions: Transaction[];
canWithdraw: boolean;
withdrawalBlockReason?: string;
}
// Mock data for demonstration
const MOCK_WALLET: WalletData = {
balance: 2450.00,
currency: 'USD',
totalRevenue: 3200.00,
totalFees: 320.00,
totalWithdrawals: 430.00,
pendingPayouts: 150.00,
canWithdraw: false,
withdrawalBlockReason: 'Season 2 is still active. Withdrawals are available after season completion.',
transactions: [
{
id: 'txn-1',
type: 'sponsorship',
description: 'Main Sponsor - TechCorp',
amount: 1200.00,
fee: 120.00,
netAmount: 1080.00,
date: new Date('2025-12-01'),
status: 'completed',
reference: 'SP-2025-001',
},
{
id: 'txn-2',
type: 'sponsorship',
description: 'Secondary Sponsor - RaceFuel',
amount: 400.00,
fee: 40.00,
netAmount: 360.00,
date: new Date('2025-12-01'),
status: 'completed',
reference: 'SP-2025-002',
},
{
id: 'txn-3',
type: 'membership',
description: 'Season Fee - 32 drivers',
amount: 1600.00,
fee: 160.00,
netAmount: 1440.00,
date: new Date('2025-11-15'),
status: 'completed',
reference: 'MF-2025-032',
},
{
id: 'txn-4',
type: 'withdrawal',
description: 'Bank Transfer - Season 1 Payout',
amount: -430.00,
fee: 0,
netAmount: -430.00,
date: new Date('2025-10-30'),
status: 'completed',
reference: 'WD-2025-001',
},
{
id: 'txn-5',
type: 'prize',
description: 'Championship Prize Pool (reserved)',
amount: -150.00,
fee: 0,
netAmount: -150.00,
date: new Date('2025-12-05'),
status: 'pending',
reference: 'PZ-2025-001',
},
],
};
function TransactionRow({ transaction }: { transaction: Transaction }) {
function TransactionRow({ transaction }: { transaction: any }) {
const isIncoming = transaction.amount > 0;
const typeIcons = {
sponsorship: DollarSign,
membership: CreditCard,
@@ -158,13 +67,13 @@ function TransactionRow({ transaction }: { transaction: Transaction }) {
</>
)}
<span></span>
<span>{transaction.date.toLocaleDateString()}</span>
<span>{transaction.formattedDate}</span>
</div>
</div>
</div>
<div className="text-right">
<div className={`font-semibold ${isIncoming ? 'text-performance-green' : 'text-white'}`}>
{isIncoming ? '+' : ''}{transaction.amount < 0 ? '-' : ''}${Math.abs(transaction.amount).toFixed(2)}
{transaction.formattedAmount}
</div>
{transaction.fee > 0 && (
<div className="text-xs text-gray-500">
@@ -178,36 +87,48 @@ function TransactionRow({ transaction }: { transaction: Transaction }) {
export default function LeagueWalletPage() {
const params = useParams();
const [wallet, setWallet] = useState<WalletData>(MOCK_WALLET);
const { leagueWalletService } = useServices();
const [wallet, setWallet] = useState<LeagueWalletViewModel | null>(null);
const [withdrawAmount, setWithdrawAmount] = useState('');
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
const [processing, setProcessing] = useState(false);
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all');
const filteredTransactions = wallet.transactions.filter(
t => filterType === 'all' || t.type === filterType
);
useEffect(() => {
const loadWallet = async () => {
if (params.id) {
try {
const walletData = await leagueWalletService.getWalletForLeague(params.id as string);
setWallet(walletData);
} catch (error) {
console.error('Failed to load wallet:', error);
}
}
};
loadWallet();
}, [params.id, leagueWalletService]);
if (!wallet) {
return <div>Loading...</div>;
}
const filteredTransactions = wallet.getFilteredTransactions(filterType);
const handleWithdraw = async () => {
if (!withdrawAmount || parseFloat(withdrawAmount) <= 0) return;
setProcessing(true);
try {
const response = await fetch(`/api/wallets/${params.id}/withdraw`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: parseFloat(withdrawAmount),
currency: wallet.currency,
seasonId: 'season-2', // Current active season
destinationAccount: 'bank-account-***1234',
}),
});
const result = await leagueWalletService.withdraw(
params.id as string,
parseFloat(withdrawAmount),
wallet.currency,
'season-2', // Current active season
'bank-account-***1234'
);
const result = await response.json();
if (!response.ok) {
alert(result.reason || result.error || 'Withdrawal failed');
if (!result.success) {
alert(result.message || 'Withdrawal failed');
return;
}
@@ -215,6 +136,8 @@ export default function LeagueWalletPage() {
setShowWithdrawModal(false);
setWithdrawAmount('');
// Refresh wallet data
const updatedWallet = await leagueWalletService.getWalletForLeague(params.id as string);
setWallet(updatedWallet);
} catch (err) {
console.error('Withdrawal error:', err);
alert('Failed to process withdrawal');
@@ -236,7 +159,7 @@ export default function LeagueWalletPage() {
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button
<Button
variant="primary"
onClick={() => setShowWithdrawModal(true)}
disabled={!wallet.canWithdraw}
@@ -268,7 +191,7 @@ export default function LeagueWalletPage() {
<Wallet className="w-6 h-6 text-performance-green" />
</div>
<div>
<div className="text-2xl font-bold text-white">${wallet.balance.toFixed(2)}</div>
<div className="text-2xl font-bold text-white">{wallet.formattedBalance}</div>
<div className="text-sm text-gray-400">Available Balance</div>
</div>
</div>
@@ -280,7 +203,7 @@ export default function LeagueWalletPage() {
<TrendingUp className="w-6 h-6 text-primary-blue" />
</div>
<div>
<div className="text-2xl font-bold text-white">${wallet.totalRevenue.toFixed(2)}</div>
<div className="text-2xl font-bold text-white">{wallet.formattedTotalRevenue}</div>
<div className="text-sm text-gray-400">Total Revenue</div>
</div>
</div>
@@ -292,7 +215,7 @@ export default function LeagueWalletPage() {
<DollarSign className="w-6 h-6 text-warning-amber" />
</div>
<div>
<div className="text-2xl font-bold text-white">${wallet.totalFees.toFixed(2)}</div>
<div className="text-2xl font-bold text-white">{wallet.formattedTotalFees}</div>
<div className="text-sm text-gray-400">Platform Fees (10%)</div>
</div>
</div>
@@ -304,7 +227,7 @@ export default function LeagueWalletPage() {
<Clock className="w-6 h-6 text-purple-400" />
</div>
<div>
<div className="text-2xl font-bold text-white">${wallet.pendingPayouts.toFixed(2)}</div>
<div className="text-2xl font-bold text-white">{wallet.formattedPendingPayouts}</div>
<div className="text-sm text-gray-400">Pending Payouts</div>
</div>
</div>
@@ -396,7 +319,7 @@ export default function LeagueWalletPage() {
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Available for Withdrawal</span>
<span className="text-sm font-medium text-performance-green">${wallet.balance.toFixed(2)}</span>
<span className="text-sm font-medium text-performance-green">{wallet.formattedBalance}</span>
</div>
<p className="text-xs text-gray-500">
Available after Season 2 ends (estimated: Jan 15, 2026)
@@ -434,7 +357,7 @@ export default function LeagueWalletPage() {
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Available: ${wallet.balance.toFixed(2)}
Available: {wallet.formattedBalance}
</p>
</div>

View File

@@ -4,8 +4,8 @@ import Breadcrumbs from '@/components/layout/Breadcrumbs';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { RacePenaltiesViewModel, RaceProtestsViewModel } from '@/lib/apiClient';
import { apiClient } from '@/lib/apiClient';
import { useServices } from '@/lib/services/ServiceProvider';
import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
import {
AlertCircle,
AlertTriangle,
@@ -24,13 +24,11 @@ import { useEffect, useState } from 'react';
export default function RaceStewardingPage() {
const params = useParams();
const router = useRouter();
const { raceStewardingService } = useServices();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const [race, setRace] = useState<any>(null); // TODO: Define proper race type
const [league, setLeague] = useState<any>(null); // TODO: Define proper league type
const [protestsData, setProtestsData] = useState<RaceProtestsViewModel | null>(null);
const [penaltiesData, setPenaltiesData] = useState<RacePenaltiesViewModel | null>(null);
const [stewardingData, setStewardingData] = useState<RaceStewardingViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [activeTab, setActiveTab] = useState<'pending' | 'resolved' | 'penalties'>('pending');
@@ -39,24 +37,13 @@ export default function RaceStewardingPage() {
async function loadData() {
setLoading(true);
try {
// Get race detail for basic info
const raceDetail = await apiClient.races.getDetail(raceId, currentDriverId);
setRace(raceDetail.race);
setLeague(raceDetail.league);
const data = await raceStewardingService.getRaceStewardingData(raceId, currentDriverId);
setStewardingData(data);
if (raceDetail.league) {
if (data.league) {
// TODO: Implement admin check via API
setIsAdmin(true);
}
// Get protests and penalties
const [protestsData, penaltiesData] = await Promise.all([
apiClient.races.getProtests(raceId),
apiClient.races.getPenalties(raceId),
]);
setProtestsData(protestsData);
setPenaltiesData(penaltiesData);
} catch (err) {
console.error('Failed to load data:', err);
} finally {
@@ -65,17 +52,10 @@ export default function RaceStewardingPage() {
}
loadData();
}, [raceId, currentDriverId]);
}, [raceId, currentDriverId, raceStewardingService]);
const pendingProtests = protestsData?.protests.filter(
(p) => p.status === 'pending' || p.status === 'under_review',
) ?? [];
const resolvedProtests = protestsData?.protests.filter(
(p) =>
p.status === 'upheld' ||
p.status === 'dismissed' ||
p.status === 'withdrawn',
) ?? [];
const pendingProtests = stewardingData?.pendingProtests ?? [];
const resolvedProtests = stewardingData?.resolvedProtests ?? [];
const getStatusBadge = (status: string) => {
switch (status) {
@@ -131,7 +111,7 @@ export default function RaceStewardingPage() {
);
}
if (!race) {
if (!stewardingData?.race) {
return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
@@ -158,7 +138,7 @@ export default function RaceStewardingPage() {
const breadcrumbItems = [
{ label: 'Races', href: '/races' },
{ label: race.track, href: `/races/${race.id}` },
{ label: stewardingData?.race?.track || 'Race', href: `/races/${raceId}` },
{ label: 'Stewarding' },
];
@@ -186,9 +166,9 @@ export default function RaceStewardingPage() {
</div>
<div>
<h1 className="text-2xl font-bold text-white">Stewarding</h1>
<p className="text-sm text-gray-400">
{race.track} {formatDate(race.scheduledAt)}
</p>
<p className="text-sm text-gray-400">
{stewardingData?.race?.track} {stewardingData?.race?.scheduledAt ? formatDate(stewardingData.race.scheduledAt) : ''}
</p>
</div>
</div>
@@ -199,21 +179,21 @@ export default function RaceStewardingPage() {
<Clock className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Pending</span>
</div>
<div className="text-2xl font-bold text-white">{pendingProtests.length}</div>
<div className="text-2xl font-bold text-white">{stewardingData?.pendingCount ?? 0}</div>
</div>
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-performance-green mb-1">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Resolved</span>
</div>
<div className="text-2xl font-bold text-white">{resolvedProtests.length}</div>
<div className="text-2xl font-bold text-white">{stewardingData?.resolvedCount ?? 0}</div>
</div>
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-red-400 mb-1">
<Gavel className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Penalties</span>
</div>
<div className="text-2xl font-bold text-white">{penaltiesData?.penalties.length ?? 0}</div>
<div className="text-2xl font-bold text-white">{stewardingData?.penaltiesCount ?? 0}</div>
</div>
</div>
</Card>
@@ -272,8 +252,8 @@ export default function RaceStewardingPage() {
</Card>
) : (
pendingProtests.map((protest) => {
const protester = protestsData?.driverMap[protest.protestingDriverId];
const accused = protestsData?.driverMap[protest.accusedDriverId];
const protester = stewardingData?.driverMap[protest.protestingDriverId];
const accused = stewardingData?.driverMap[protest.accusedDriverId];
const daysSinceFiled = Math.floor(
(Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)
);
@@ -330,9 +310,9 @@ export default function RaceStewardingPage() {
</div>
<p className="text-sm text-gray-300">{protest.incident.description}</p>
</div>
{isAdmin && league && (
{isAdmin && stewardingData?.league && (
<Link
href={`/leagues/${league.id}/stewarding/protests/${protest.id}`}
href={`/leagues/${stewardingData.league.id}/stewarding/protests/${protest.id}`}
>
<Button variant="primary">Review</Button>
</Link>
@@ -359,8 +339,8 @@ export default function RaceStewardingPage() {
</Card>
) : (
resolvedProtests.map((protest) => {
const protester = protestsData?.driverMap[protest.protestingDriverId];
const accused = protestsData?.driverMap[protest.accusedDriverId];
const protester = stewardingData?.driverMap[protest.protestingDriverId];
const accused = stewardingData?.driverMap[protest.accusedDriverId];
return (
<Card key={protest.id}>
@@ -421,8 +401,8 @@ export default function RaceStewardingPage() {
</p>
</Card>
) : (
penaltiesData?.penalties.map((penalty) => {
const driver = penaltiesData?.driverMap[penalty.driverId];
stewardingData?.penalties.map((penalty) => {
const driver = stewardingData?.driverMap[penalty.driverId];
return (
<Card key={penalty.id}>
<div className="flex items-center gap-4">

View File

@@ -7,8 +7,8 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { apiClient } from '@/lib/apiClient';
import type { RacesPageDataViewModel, RacesPageDataRaceViewModel } from '@/lib/apiClient';
import { useServices } from '@/lib/services/ServiceProvider';
import { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel';
import {
Calendar,
Clock,
@@ -34,8 +34,9 @@ type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
export default function AllRacesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { raceService } = useServices();
const [pageData, setPageData] = useState<RacesPageDataViewModel | null>(null);
const [pageData, setPageData] = useState<RacesPageViewModel | null>(null);
const [loading, setLoading] = useState(true);
// Pagination
@@ -49,7 +50,7 @@ export default function AllRacesPage() {
const loadRaces = async () => {
try {
const viewModel = await apiClient.races.getAllPageData();
const viewModel = await raceService.getAllRacesPageData();
setPageData(viewModel);
} catch (err) {
console.error('Failed to load races:', err);
@@ -62,7 +63,7 @@ export default function AllRacesPage() {
void loadRaces();
}, []);
const races: RacesPageDataRaceViewModel[] = pageData?.races ?? [];
const races = pageData?.races ?? [];
const filteredRaces = useMemo(() => {
return races.filter(race => {
@@ -284,8 +285,8 @@ export default function AllRacesPage() {
) : (
<div className="space-y-3">
{paginatedRaces.map(race => {
const config = statusConfig[race.status];
const StatusIcon = config.icon;
const config = statusConfig[race.status as keyof typeof statusConfig];
const StatusIcon = config.icon;
return (
<div

View File

@@ -6,8 +6,8 @@ import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import { apiClient } from '@/lib/apiClient';
import type { RacesPageDataViewModel, RacesPageDataRaceViewModel } from '@/lib/apiClient';
import { useServices } from '@/lib/services/ServiceProvider';
import { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel';
import {
Calendar,
Clock,
@@ -27,12 +27,13 @@ import {
} from 'lucide-react';
type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
type RaceStatusFilter = RacesPageDataRaceViewModel['status'];
type RaceStatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
export default function RacesPage() {
const router = useRouter();
const { raceService } = useServices();
const [pageData, setPageData] = useState<RacesPageDataViewModel | null>(null);
const [pageData, setPageData] = useState<RacesPageViewModel | null>(null);
const [loading, setLoading] = useState(true);
// Filters
@@ -42,7 +43,7 @@ export default function RacesPage() {
const loadRaces = async () => {
try {
const data = await apiClient.races.getPageData();
const data = await raceService.getRacesPageData();
setPageData(data);
} catch (err) {
console.error('Failed to load races:', err);
@@ -87,11 +88,8 @@ export default function RacesPage() {
// Group races by date for calendar view
const racesByDate = useMemo(() => {
const grouped = new Map<string, RacesPageDataRaceViewModel[]>();
const grouped = new Map<string, typeof filteredRaces[0][]>();
filteredRaces.forEach((race) => {
if (typeof race.scheduledAt !== 'string') {
return;
}
const dateKey = race.scheduledAt.split('T')[0]!;
if (!grouped.has(dateKey)) {
grouped.set(dateKey, []);
@@ -105,10 +103,10 @@ export default function RacesPage() {
const liveRaces = filteredRaces.filter(r => r.isLive);
const recentResults = filteredRaces.filter(r => r.isPast).slice(0, 5);
const stats = {
total: pageData?.races.length ?? 0,
scheduled: pageData?.races.filter(r => r.status === 'scheduled').length ?? 0,
running: pageData?.races.filter(r => r.status === 'running').length ?? 0,
completed: pageData?.races.filter(r => r.status === 'completed').length ?? 0,
total: pageData?.totalCount ?? 0,
scheduled: pageData?.scheduledRaces.length ?? 0,
running: pageData?.runningRaces.length ?? 0,
completed: pageData?.completedRaces.length ?? 0,
};
const formatDate = (date: Date | string) => {
@@ -348,7 +346,7 @@ export default function RacesPage() {
<div>
<p className="text-white font-medium mb-1">No races found</p>
<p className="text-sm text-gray-500">
{pageData?.races.length === 0
{pageData?.totalCount === 0
? 'No races have been scheduled yet'
: 'Try adjusting your filters'}
</p>
@@ -375,10 +373,7 @@ export default function RacesPage() {
{/* Races for this date */}
<div className="space-y-2">
{dayRaces.map((race) => {
if (!race.scheduledAt) {
return null;
}
const config = statusConfig[race.status];
const config = statusConfig[race.status as keyof typeof statusConfig];
const StatusIcon = config.icon;
return (

View File

@@ -0,0 +1,54 @@
import { BaseApiClient } from '../base/BaseApiClient';
export interface LeagueWalletDTO {
balance: number;
currency: string;
totalRevenue: number;
totalFees: number;
totalWithdrawals: number;
pendingPayouts: number;
canWithdraw: boolean;
withdrawalBlockReason?: string;
transactions: WalletTransactionDTO[];
}
export interface WalletTransactionDTO {
id: string;
type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
description: string;
amount: number;
fee: number;
netAmount: number;
date: string; // ISO string
status: 'completed' | 'pending' | 'failed';
reference?: string;
}
export interface WithdrawRequestDTO {
amount: number;
currency: string;
seasonId: string;
destinationAccount: string;
}
export interface WithdrawResponseDTO {
success: boolean;
message?: string;
}
/**
* Wallets API Client
*
* Handles all wallet-related API operations.
*/
export class WalletsApiClient extends BaseApiClient {
/** Get league wallet */
getLeagueWallet(leagueId: string): Promise<LeagueWalletDTO> {
return this.get<LeagueWalletDTO>(`/leagues/${leagueId}/wallet`);
}
/** Withdraw from league wallet */
withdrawFromLeagueWallet(leagueId: string, request: WithdrawRequestDTO): Promise<WithdrawResponseDTO> {
return this.post<WithdrawResponseDTO>(`/leagues/${leagueId}/wallet/withdraw`, request);
}
}

View File

@@ -4,6 +4,7 @@ import { TeamsApiClient } from '../api/teams/TeamsApiClient';
import { LeaguesApiClient } from '../api/leagues/LeaguesApiClient';
import { SponsorsApiClient } from '../api/sponsors/SponsorsApiClient';
import { PaymentsApiClient } from '../api/payments/PaymentsApiClient';
import { WalletsApiClient } from '../api/wallets/WalletsApiClient';
import { AuthApiClient } from '../api/auth/AuthApiClient';
import { AnalyticsApiClient } from '../api/analytics/AnalyticsApiClient';
import { MediaApiClient } from '../api/media/MediaApiClient';
@@ -17,6 +18,7 @@ import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger';
// Services
import { RaceService } from './races/RaceService';
import { RaceResultsService } from './races/RaceResultsService';
import { RaceStewardingService } from './races/RaceStewardingService';
import { DriverService } from './drivers/DriverService';
import { DriverRegistrationService } from './drivers/DriverRegistrationService';
import { TeamService } from './teams/TeamService';
@@ -24,6 +26,8 @@ import { TeamJoinService } from './teams/TeamJoinService';
import { LeagueService } from './leagues/LeagueService';
import { LeagueMembershipService } from './leagues/LeagueMembershipService';
import { LeagueSettingsService } from './leagues/LeagueSettingsService';
import { LeagueStewardingService } from './leagues/LeagueStewardingService';
import { LeagueWalletService } from './leagues/LeagueWalletService';
import { SponsorService } from './sponsors/SponsorService';
import { SponsorshipService } from './sponsors/SponsorshipService';
import { PaymentService } from './payments/PaymentService';
@@ -56,6 +60,7 @@ export class ServiceFactory {
leagues: LeaguesApiClient;
sponsors: SponsorsApiClient;
payments: PaymentsApiClient;
wallets: WalletsApiClient;
auth: AuthApiClient;
analytics: AnalyticsApiClient;
media: MediaApiClient;
@@ -73,6 +78,7 @@ export class ServiceFactory {
leagues: new LeaguesApiClient(baseUrl, this.errorReporter, this.logger),
sponsors: new SponsorsApiClient(baseUrl, this.errorReporter, this.logger),
payments: new PaymentsApiClient(baseUrl, this.errorReporter, this.logger),
wallets: new WalletsApiClient(baseUrl, this.errorReporter, this.logger),
auth: new AuthApiClient(baseUrl, this.errorReporter, this.logger),
analytics: new AnalyticsApiClient(baseUrl, this.errorReporter, this.logger),
media: new MediaApiClient(baseUrl, this.errorReporter, this.logger),
@@ -96,6 +102,17 @@ export class ServiceFactory {
return new RaceResultsService(this.apiClients.races);
}
/**
* Create RaceStewardingService instance
*/
createRaceStewardingService(): RaceStewardingService {
return new RaceStewardingService(
this.apiClients.races,
this.apiClients.protests,
this.apiClients.penalties
);
}
/**
* Create DriverService instance
*/
@@ -145,6 +162,26 @@ export class ServiceFactory {
return new LeagueSettingsService(this.apiClients.leagues, this.apiClients.drivers);
}
/**
* Create LeagueStewardingService instance
*/
createLeagueStewardingService(): LeagueStewardingService {
return new LeagueStewardingService(
this.createRaceService(),
this.createProtestService(),
this.createPenaltyService(),
this.createDriverService(),
this.createLeagueMembershipService()
);
}
/**
* Create LeagueWalletService instance
*/
createLeagueWalletService(): LeagueWalletService {
return new LeagueWalletService(this.apiClients.wallets);
}
/**
* Create SponsorService instance
*/

View File

@@ -6,6 +6,7 @@ import { ServiceFactory } from './ServiceFactory';
// Import all service types
import { RaceService } from './races/RaceService';
import { RaceResultsService } from './races/RaceResultsService';
import { RaceStewardingService } from './races/RaceStewardingService';
import { DriverService } from './drivers/DriverService';
import { DriverRegistrationService } from './drivers/DriverRegistrationService';
import { TeamService } from './teams/TeamService';
@@ -13,6 +14,8 @@ import { TeamJoinService } from './teams/TeamJoinService';
import { LeagueService } from './leagues/LeagueService';
import { LeagueMembershipService } from './leagues/LeagueMembershipService';
import { LeagueSettingsService } from './leagues/LeagueSettingsService';
import { LeagueStewardingService } from './leagues/LeagueStewardingService';
import { LeagueWalletService } from './leagues/LeagueWalletService';
import { SponsorService } from './sponsors/SponsorService';
import { SponsorshipService } from './sponsors/SponsorshipService';
import { PaymentService } from './payments/PaymentService';
@@ -30,6 +33,7 @@ import { PenaltyService } from './penalties/PenaltyService';
export interface Services {
raceService: RaceService;
raceResultsService: RaceResultsService;
raceStewardingService: RaceStewardingService;
driverService: DriverService;
driverRegistrationService: DriverRegistrationService;
teamService: TeamService;
@@ -37,6 +41,8 @@ export interface Services {
leagueService: LeagueService;
leagueMembershipService: LeagueMembershipService;
leagueSettingsService: LeagueSettingsService;
leagueStewardingService: LeagueStewardingService;
leagueWalletService: LeagueWalletService;
sponsorService: SponsorService;
sponsorshipService: SponsorshipService;
paymentService: PaymentService;
@@ -65,6 +71,7 @@ export function ServiceProvider({ children }: ServiceProviderProps) {
return {
raceService: serviceFactory.createRaceService(),
raceResultsService: serviceFactory.createRaceResultsService(),
raceStewardingService: serviceFactory.createRaceStewardingService(),
driverService: serviceFactory.createDriverService(),
driverRegistrationService: serviceFactory.createDriverRegistrationService(),
teamService: serviceFactory.createTeamService(),
@@ -72,6 +79,8 @@ export function ServiceProvider({ children }: ServiceProviderProps) {
leagueService: serviceFactory.createLeagueService(),
leagueMembershipService: serviceFactory.createLeagueMembershipService(),
leagueSettingsService: serviceFactory.createLeagueSettingsService(),
leagueStewardingService: serviceFactory.createLeagueStewardingService(),
leagueWalletService: serviceFactory.createLeagueWalletService(),
sponsorService: serviceFactory.createSponsorService(),
sponsorshipService: serviceFactory.createSponsorshipService(),
paymentService: serviceFactory.createPaymentService(),

View File

@@ -0,0 +1,93 @@
import { RaceService } from '../races/RaceService';
import { ProtestService } from '../protests/ProtestService';
import { PenaltyService } from '../penalties/PenaltyService';
import { DriverService } from '../drivers/DriverService';
import { LeagueMembershipService } from './LeagueMembershipService';
import { LeagueStewardingViewModel, RaceWithProtests } from '../../view-models/LeagueStewardingViewModel';
/**
* League Stewarding Service
*
* Orchestrates league stewarding operations by coordinating calls to race, protest, penalty, driver, and membership services.
* All dependencies are injected via constructor.
*/
export class LeagueStewardingService {
constructor(
private readonly raceService: RaceService,
private readonly protestService: ProtestService,
private readonly penaltyService: PenaltyService,
private readonly driverService: DriverService,
private readonly leagueMembershipService: LeagueMembershipService
) {}
/**
* Get league stewarding data for all races in a league
*/
async getLeagueStewardingData(leagueId: string): Promise<LeagueStewardingViewModel> {
// Get all races for this league
const leagueRaces = await this.raceService.findByLeagueId(leagueId);
// Get protests and penalties for each race
const protestsMap: Record<string, any[]> = {};
const penaltiesMap: Record<string, any[]> = {};
const driverIds = new Set<string>();
for (const race of leagueRaces) {
const raceProtests = await this.protestService.findByRaceId(race.id);
const racePenalties = await this.penaltyService.findByRaceId(race.id);
protestsMap[race.id] = raceProtests;
penaltiesMap[race.id] = racePenalties;
// Collect driver IDs
raceProtests.forEach((p: any) => {
driverIds.add(p.protestingDriverId);
driverIds.add(p.accusedDriverId);
});
racePenalties.forEach((p: any) => {
driverIds.add(p.driverId);
});
}
// Load driver info
const driverEntities = await this.driverService.findByIds(Array.from(driverIds));
const driverMap: Record<string, any> = {};
driverEntities.forEach((driver) => {
if (driver) {
driverMap[driver.id] = driver;
}
});
// Compute race data with protest/penalty info
const racesWithData: RaceWithProtests[] = leagueRaces.map(race => {
const protests = protestsMap[race.id] || [];
const penalties = penaltiesMap[race.id] || [];
return {
race: {
id: race.id,
track: race.track,
scheduledAt: new Date(race.scheduledAt),
},
pendingProtests: protests.filter(p => p.status === 'pending' || p.status === 'under_review'),
resolvedProtests: protests.filter(p => p.status === 'upheld' || p.status === 'dismissed' || p.status === 'withdrawn'),
penalties
};
}).sort((a, b) => b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime());
return new LeagueStewardingViewModel(racesWithData, driverMap);
}
/**
* Review a protest
*/
async reviewProtest(input: { protestId: string; stewardId: string; decision: string; decisionNotes: string }): Promise<void> {
await this.protestService.reviewProtest(input);
}
/**
* Apply a penalty
*/
async applyPenalty(input: any): Promise<void> {
await this.penaltyService.applyPenalty(input);
}
}

View File

@@ -0,0 +1,71 @@
import { WalletsApiClient, LeagueWalletDTO, WithdrawRequestDTO, WithdrawResponseDTO } from '@/lib/api/wallets/WalletsApiClient';
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
import { WalletTransactionViewModel } from '@/lib/view-models/WalletTransactionViewModel';
import { SubmitBlocker, ThrottleBlocker } from '@/lib/blockers';
/**
* League Wallet Service
*
* Orchestrates league wallet operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class LeagueWalletService {
private readonly submitBlocker = new SubmitBlocker();
private readonly throttle = new ThrottleBlocker(500);
constructor(
private readonly apiClient: WalletsApiClient
) {}
/**
* Get wallet for a league
*/
async getWalletForLeague(leagueId: string): Promise<LeagueWalletViewModel> {
const dto = await this.apiClient.getLeagueWallet(leagueId);
const transactions = dto.transactions.map(t => new WalletTransactionViewModel({
id: t.id,
type: t.type,
description: t.description,
amount: t.amount,
fee: t.fee,
netAmount: t.netAmount,
date: new Date(t.date),
status: t.status,
reference: t.reference,
}));
return new LeagueWalletViewModel({
balance: dto.balance,
currency: dto.currency,
totalRevenue: dto.totalRevenue,
totalFees: dto.totalFees,
totalWithdrawals: dto.totalWithdrawals,
pendingPayouts: dto.pendingPayouts,
transactions,
canWithdraw: dto.canWithdraw,
withdrawalBlockReason: dto.withdrawalBlockReason,
});
}
/**
* Withdraw from league wallet
*/
async withdraw(leagueId: string, amount: number, currency: string, seasonId: string, destinationAccount: string): Promise<WithdrawResponseDTO> {
if (!this.submitBlocker.canExecute() || !this.throttle.canExecute()) {
throw new Error('Request blocked due to rate limiting');
}
this.submitBlocker.block();
this.throttle.block();
try {
const request: WithdrawRequestDTO = {
amount,
currency,
seasonId,
destinationAccount,
};
return await this.apiClient.withdrawFromLeagueWallet(leagueId, request);
} finally {
this.submitBlocker.release();
}
}
}

View File

@@ -43,7 +43,16 @@ export class RaceService {
*/
async getRacesPageData(): Promise<RacesPageViewModel> {
const dto = await this.apiClient.getPageData();
return new RacesPageViewModel(this.transformRacesPageData(dto));
return new RacesPageViewModel(dto);
}
/**
* Get all races page data with view model transformation
* Currently same as getRacesPageData, but can be extended for different filtering
*/
async getAllRacesPageData(): Promise<RacesPageViewModel> {
const dto = await this.apiClient.getPageData();
return new RacesPageViewModel(dto);
}
/**
@@ -82,38 +91,6 @@ export class RaceService {
await this.apiClient.complete(raceId);
}
/**
* Transform API races page data to view model format
*/
private transformRacesPageData(dto: RacesPageDataDto): {
upcomingRaces: Array<{ id: string; title: string; scheduledTime: string; status: string }>;
completedRaces: Array<{ id: string; title: string; scheduledTime: string; status: string }>;
totalCount: number;
} {
const upcomingRaces = dto.races
.filter(race => race.status !== 'completed')
.map(race => ({
id: race.id,
title: `${race.track} - ${race.car}`,
scheduledTime: race.scheduledAt,
status: race.status,
}));
const completedRaces = dto.races
.filter(race => race.status === 'completed')
.map(race => ({
id: race.id,
title: `${race.track} - ${race.car}`,
scheduledTime: race.scheduledAt,
status: race.status,
}));
return {
upcomingRaces,
completedRaces,
totalCount: dto.races.length,
};
}
/**
* Find races by league ID

View File

@@ -0,0 +1,36 @@
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient';
import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient';
import { RaceStewardingViewModel } from '../../view-models/RaceStewardingViewModel';
/**
* Race Stewarding Service
*
* Orchestrates race stewarding operations by coordinating API calls for race details,
* protests, and penalties, and returning a unified view model.
*/
export class RaceStewardingService {
constructor(
private readonly racesApiClient: RacesApiClient,
private readonly protestsApiClient: ProtestsApiClient,
private readonly penaltiesApiClient: PenaltiesApiClient
) {}
/**
* Get race stewarding data with view model transformation
*/
async getRaceStewardingData(raceId: string, driverId: string): Promise<RaceStewardingViewModel> {
// Fetch all data in parallel
const [raceDetail, protests, penalties] = await Promise.all([
this.racesApiClient.getDetail(raceId, driverId),
this.protestsApiClient.getRaceProtests(raceId),
this.penaltiesApiClient.getRacePenalties(raceId),
]);
return new RaceStewardingViewModel({
raceDetail,
protests,
penalties,
});
}
}

View File

@@ -0,0 +1,74 @@
/**
* League Stewarding View Model
* Represents all data needed for league stewarding across all races
*/
export class LeagueStewardingViewModel {
constructor(
public readonly racesWithData: RaceWithProtests[],
public readonly driverMap: Record<string, { id: string; name: string; avatarUrl?: string; iracingId?: string; rating?: number }>
) {}
/** UI-specific: Total pending protests count */
get totalPending(): number {
return this.racesWithData.reduce((sum, r) => sum + r.pendingProtests.length, 0);
}
/** UI-specific: Total resolved protests count */
get totalResolved(): number {
return this.racesWithData.reduce((sum, r) => sum + r.resolvedProtests.length, 0);
}
/** UI-specific: Total penalties count */
get totalPenalties(): number {
return this.racesWithData.reduce((sum, r) => sum + r.penalties.length, 0);
}
/** UI-specific: Filtered races for pending tab */
get pendingRaces(): RaceWithProtests[] {
return this.racesWithData.filter(r => r.pendingProtests.length > 0);
}
/** UI-specific: Filtered races for history tab */
get historyRaces(): RaceWithProtests[] {
return this.racesWithData.filter(r => r.resolvedProtests.length > 0 || r.penalties.length > 0);
}
/** UI-specific: All drivers for quick penalty modal */
get allDrivers(): Array<{ id: string; name: string; avatarUrl?: string; iracingId?: string; rating?: number }> {
return Object.values(this.driverMap);
}
}
export interface RaceWithProtests {
race: {
id: string;
track: string;
scheduledAt: Date;
};
pendingProtests: Protest[];
resolvedProtests: Protest[];
penalties: Penalty[];
}
export interface Protest {
id: string;
protestingDriverId: string;
accusedDriverId: string;
incident: {
lap: number;
description: string;
};
filedAt: string;
status: string;
decisionNotes?: string;
proofVideoUrl?: string;
}
export interface Penalty {
id: string;
driverId: string;
type: string;
value: number;
reason: string;
notes?: string;
}

View File

@@ -0,0 +1,60 @@
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
export class LeagueWalletViewModel {
balance: number;
currency: string;
totalRevenue: number;
totalFees: number;
totalWithdrawals: number;
pendingPayouts: number;
transactions: WalletTransactionViewModel[];
canWithdraw: boolean;
withdrawalBlockReason?: string;
constructor(dto: {
balance: number;
currency: string;
totalRevenue: number;
totalFees: number;
totalWithdrawals: number;
pendingPayouts: number;
transactions: WalletTransactionViewModel[];
canWithdraw: boolean;
withdrawalBlockReason?: string;
}) {
this.balance = dto.balance;
this.currency = dto.currency;
this.totalRevenue = dto.totalRevenue;
this.totalFees = dto.totalFees;
this.totalWithdrawals = dto.totalWithdrawals;
this.pendingPayouts = dto.pendingPayouts;
this.transactions = dto.transactions;
this.canWithdraw = dto.canWithdraw;
this.withdrawalBlockReason = dto.withdrawalBlockReason;
}
/** UI-specific: Formatted balance */
get formattedBalance(): string {
return `$${this.balance.toFixed(2)}`;
}
/** UI-specific: Formatted total revenue */
get formattedTotalRevenue(): string {
return `$${this.totalRevenue.toFixed(2)}`;
}
/** UI-specific: Formatted total fees */
get formattedTotalFees(): string {
return `$${this.totalFees.toFixed(2)}`;
}
/** UI-specific: Formatted pending payouts */
get formattedPendingPayouts(): string {
return `$${this.pendingPayouts.toFixed(2)}`;
}
/** UI-specific: Filtered transactions by type */
getFilteredTransactions(type: 'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'): WalletTransactionViewModel[] {
return type === 'all' ? this.transactions : this.transactions.filter(t => t.type === type);
}
}

View File

@@ -1,62 +1,65 @@
// Note: No generated DTO available for RaceListItem yet
// DTO matching the backend RacesPageDataRaceDTO
interface RaceListItemDTO {
id: string;
name: string;
track: string;
car: string;
scheduledAt: string;
status: string;
leagueId: string;
leagueName: string;
scheduledTime: string;
status: string;
trackName?: string;
strengthOfField: number | null;
isUpcoming: boolean;
isLive: boolean;
isPast: boolean;
}
export class RaceListItemViewModel {
id: string;
name: string;
track: string;
car: string;
scheduledAt: string;
status: string;
leagueId: string;
leagueName: string;
scheduledTime: string;
status: string;
trackName?: string;
strengthOfField: number | null;
isUpcoming: boolean;
isLive: boolean;
isPast: boolean;
constructor(dto: RaceListItemDTO) {
this.id = dto.id;
this.name = dto.name;
this.track = dto.track;
this.car = dto.car;
this.scheduledAt = dto.scheduledAt;
this.status = dto.status;
this.leagueId = dto.leagueId;
this.leagueName = dto.leagueName;
this.scheduledTime = dto.scheduledTime;
this.status = dto.status;
if (dto.trackName !== undefined) this.trackName = dto.trackName;
this.strengthOfField = dto.strengthOfField;
this.isUpcoming = dto.isUpcoming;
this.isLive = dto.isLive;
this.isPast = dto.isPast;
}
/** UI-specific: Formatted scheduled time */
get formattedScheduledTime(): string {
return new Date(this.scheduledTime).toLocaleString();
return new Date(this.scheduledAt).toLocaleString();
}
/** UI-specific: Badge variant for status */
get statusBadgeVariant(): string {
switch (this.status) {
case 'upcoming': return 'info';
case 'live': return 'success';
case 'finished': return 'secondary';
case 'scheduled': return 'info';
case 'running': return 'success';
case 'completed': return 'secondary';
case 'cancelled': return 'danger';
default: return 'default';
}
}
/** UI-specific: Whether race is upcoming */
get isUpcoming(): boolean {
return this.status === 'upcoming';
}
/** UI-specific: Whether race is live */
get isLive(): boolean {
return this.status === 'live';
}
/** UI-specific: Time until start in minutes */
get timeUntilStart(): number {
const now = new Date();
const scheduled = new Date(this.scheduledTime);
const scheduled = new Date(this.scheduledAt);
return Math.max(0, Math.floor((scheduled.getTime() - now.getTime()) / (1000 * 60)));
}

View File

@@ -0,0 +1,99 @@
// DTO interfaces matching the API responses
interface RaceDetailDTO {
race: {
id: string;
track: string;
scheduledAt: string;
status: string;
} | null;
league: {
id: string;
name: string;
} | null;
}
interface RaceProtestsDTO {
protests: Array<{
id: string;
protestingDriverId: string;
accusedDriverId: string;
incident: {
lap: number;
description: string;
};
filedAt: string;
status: string;
decisionNotes?: string;
proofVideoUrl?: string;
}>;
driverMap: Record<string, { id: string; name: string }>;
}
interface RacePenaltiesDTO {
penalties: Array<{
id: string;
driverId: string;
type: string;
value: number;
reason: string;
notes?: string;
}>;
driverMap: Record<string, { id: string; name: string }>;
}
interface RaceStewardingDTO {
raceDetail: RaceDetailDTO;
protests: RaceProtestsDTO;
penalties: RacePenaltiesDTO;
}
/**
* Race Stewarding View Model
* Represents all data needed for race stewarding (protests, penalties, race info)
*/
export class RaceStewardingViewModel {
race: RaceDetailDTO['race'];
league: RaceDetailDTO['league'];
protests: RaceProtestsDTO['protests'];
penalties: RacePenaltiesDTO['penalties'];
driverMap: Record<string, { id: string; name: string }>;
constructor(dto: RaceStewardingDTO) {
this.race = dto.raceDetail.race;
this.league = dto.raceDetail.league;
this.protests = dto.protests.protests;
this.penalties = dto.penalties.penalties;
// Merge driver maps from protests and penalties
this.driverMap = { ...dto.protests.driverMap, ...dto.penalties.driverMap };
}
/** UI-specific: Pending protests */
get pendingProtests() {
return this.protests.filter(p => p.status === 'pending' || p.status === 'under_review');
}
/** UI-specific: Resolved protests */
get resolvedProtests() {
return this.protests.filter(p =>
p.status === 'upheld' ||
p.status === 'dismissed' ||
p.status === 'withdrawn'
);
}
/** UI-specific: Total pending protests count */
get pendingCount(): number {
return this.pendingProtests.length;
}
/** UI-specific: Total resolved protests count */
get resolvedCount(): number {
return this.resolvedProtests.length;
}
/** UI-specific: Total penalties count */
get penaltiesCount(): number {
return this.penalties.length;
}
}

View File

@@ -1,63 +1,65 @@
// Note: No generated DTO available for RaceCard yet
interface RaceCardDTO {
id: string;
title: string;
scheduledTime: string;
status: string;
}
import { RaceListItemViewModel } from './RaceListItemViewModel';
/**
* Race card view model
* Represents a race card in list views
*/
export class RaceCardViewModel {
id: string;
title: string;
scheduledTime: string;
status: string;
constructor(dto: RaceCardDTO) {
this.id = dto.id;
this.title = dto.title;
this.scheduledTime = dto.scheduledTime;
this.status = dto.status;
}
/** UI-specific: Formatted scheduled time */
get formattedScheduledTime(): string {
return new Date(this.scheduledTime).toLocaleString();
}
}
// Note: No generated DTO available for RacesPage yet
// DTO matching the backend RacesPageDataDTO
interface RacesPageDTO {
upcomingRaces: RaceCardDTO[];
completedRaces: RaceCardDTO[];
totalCount: number;
races: Array<{
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
leagueId: string;
leagueName: string;
strengthOfField: number | null;
isUpcoming: boolean;
isLive: boolean;
isPast: boolean;
}>;
}
/**
* Races page view model
* Represents the races page data
* Represents the races page data with all races in a single list
*/
export class RacesPageViewModel {
upcomingRaces: RaceCardViewModel[];
completedRaces: RaceCardViewModel[];
totalCount: number;
races: RaceListItemViewModel[];
constructor(dto: RacesPageDTO) {
this.upcomingRaces = dto.upcomingRaces.map(r => new RaceCardViewModel(r));
this.completedRaces = dto.completedRaces.map(r => new RaceCardViewModel(r));
this.totalCount = dto.totalCount;
this.races = dto.races.map(r => new RaceListItemViewModel(r));
}
/** UI-specific: Total upcoming races */
get upcomingCount(): number {
return this.upcomingRaces.length;
/** UI-specific: Total races */
get totalCount(): number {
return this.races.length;
}
/** UI-specific: Total completed races */
get completedCount(): number {
return this.completedRaces.length;
/** UI-specific: Upcoming races */
get upcomingRaces(): RaceListItemViewModel[] {
return this.races.filter(r => r.isUpcoming);
}
/** UI-specific: Live races */
get liveRaces(): RaceListItemViewModel[] {
return this.races.filter(r => r.isLive);
}
/** UI-specific: Past races */
get pastRaces(): RaceListItemViewModel[] {
return this.races.filter(r => r.isPast);
}
/** UI-specific: Scheduled races */
get scheduledRaces(): RaceListItemViewModel[] {
return this.races.filter(r => r.status === 'scheduled');
}
/** UI-specific: Running races */
get runningRaces(): RaceListItemViewModel[] {
return this.races.filter(r => r.status === 'running');
}
/** UI-specific: Completed races */
get completedRaces(): RaceListItemViewModel[] {
return this.races.filter(r => r.status === 'completed');
}
}

View File

@@ -1,39 +1,45 @@
import { TransactionDto } from '../types/generated/TransactionDto';
// TODO: Use generated TransactionDto when it includes all required fields
export type FullTransactionDto = TransactionDto & {
amount: number;
description: string;
createdAt: string;
type: 'deposit' | 'withdrawal';
};
export class WalletTransactionViewModel {
id: string;
walletId: string;
amount: number;
type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
description: string;
createdAt: string;
type: 'deposit' | 'withdrawal';
amount: number;
fee: number;
netAmount: number;
date: Date;
status: 'completed' | 'pending' | 'failed';
reference?: string;
constructor(dto: FullTransactionDto) {
constructor(dto: {
id: string;
type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
description: string;
amount: number;
fee: number;
netAmount: number;
date: Date;
status: 'completed' | 'pending' | 'failed';
reference?: string;
}) {
this.id = dto.id;
this.walletId = dto.walletId;
this.amount = dto.amount;
this.description = dto.description;
this.createdAt = dto.createdAt;
this.type = dto.type;
this.description = dto.description;
this.amount = dto.amount;
this.fee = dto.fee;
this.netAmount = dto.netAmount;
this.date = dto.date;
this.status = dto.status;
this.reference = dto.reference;
}
/** UI-specific: Formatted amount with sign */
get formattedAmount(): string {
const sign = this.type === 'deposit' ? '+' : '-';
return `${sign}$${this.amount.toFixed(2)}`;
const sign = this.amount > 0 ? '+' : '';
return `${sign}$${Math.abs(this.amount).toFixed(2)}`;
}
/** UI-specific: Amount color */
get amountColor(): string {
return this.type === 'deposit' ? 'green' : 'red';
return this.amount > 0 ? 'green' : 'red';
}
/** UI-specific: Type display */
@@ -41,13 +47,13 @@ export class WalletTransactionViewModel {
return this.type.charAt(0).toUpperCase() + this.type.slice(1);
}
/** UI-specific: Amount color */
get amountColor(): string {
return this.type === 'deposit' ? 'green' : 'red';
/** UI-specific: Formatted date */
get formattedDate(): string {
return this.date.toLocaleDateString();
}
/** UI-specific: Formatted created date */
get formattedCreatedAt(): string {
return new Date(this.createdAt).toLocaleString();
/** UI-specific: Is incoming */
get isIncoming(): boolean {
return this.amount > 0;
}
}