fix data flow issues
This commit is contained in:
107
adapters/racing/ports/InMemoryDriverExtendedProfileProvider.ts
Normal file
107
adapters/racing/ports/InMemoryDriverExtendedProfileProvider.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import type { DriverExtendedProfileProvider } from '@core/racing/application/ports/DriverExtendedProfileProvider';
|
||||||
|
import type { Logger } from '@core/shared/application';
|
||||||
|
|
||||||
|
// TODO Provider doesnt exist in Clean Architecture
|
||||||
|
// TODO Hardcoded data here must be moved to a better place
|
||||||
|
|
||||||
|
export class InMemoryDriverExtendedProfileProvider implements DriverExtendedProfileProvider {
|
||||||
|
constructor(private readonly logger: Logger) {
|
||||||
|
this.logger.info('InMemoryDriverExtendedProfileProvider initialized.');
|
||||||
|
}
|
||||||
|
|
||||||
|
getExtendedProfile(driverId: string): {
|
||||||
|
socialHandles: {
|
||||||
|
platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
|
||||||
|
handle: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
achievements: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
|
||||||
|
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||||
|
earnedAt: string;
|
||||||
|
}[];
|
||||||
|
racingStyle: string;
|
||||||
|
favoriteTrack: string;
|
||||||
|
favoriteCar: string;
|
||||||
|
timezone: string;
|
||||||
|
availableHours: string;
|
||||||
|
lookingForTeam: boolean;
|
||||||
|
openToRequests: boolean;
|
||||||
|
} | null {
|
||||||
|
this.logger.debug(`[InMemoryDriverExtendedProfileProvider] Getting extended profile for driver: ${driverId}`);
|
||||||
|
|
||||||
|
const hash = driverId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||||
|
|
||||||
|
const socialOptions: {
|
||||||
|
platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
|
||||||
|
handle: string;
|
||||||
|
url: string;
|
||||||
|
}[][] = [
|
||||||
|
[
|
||||||
|
{ 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: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
|
||||||
|
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||||
|
earnedAt: string;
|
||||||
|
}[][] = [
|
||||||
|
[
|
||||||
|
{ id: '1', title: 'First Victory', description: 'Win your first race', icon: 'trophy', rarity: 'common', earnedAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString() },
|
||||||
|
{ id: '2', title: 'Clean Racer', description: '10 races without incidents', icon: 'star', rarity: 'rare', earnedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString() },
|
||||||
|
{ id: '3', title: 'Podium Streak', description: '5 consecutive podium finishes', icon: 'medal', rarity: 'epic', earnedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ id: '1', title: 'Rookie No More', description: 'Complete 25 races', icon: 'target', rarity: 'common', earnedAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000).toISOString() },
|
||||||
|
{ id: '2', title: 'Consistent Performer', description: 'Maintain 80%+ consistency rating', icon: 'zap', rarity: 'rare', earnedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString() },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ id: '1', title: 'Welcome Racer', description: 'Join GridPilot', icon: 'star', rarity: 'common', earnedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000).toISOString() },
|
||||||
|
{ id: '2', title: 'Team Player', description: 'Join a racing team', icon: 'medal', rarity: 'rare', earnedAt: new Date(Date.now() - 80 * 24 * 60 * 60 * 1000).toISOString() },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
|
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
|
||||||
import type { Logger } from '@core/shared/application';
|
import type { Logger } from '@core/shared/application';
|
||||||
|
|
||||||
|
// TODO Provider doesnt exist in Clean Architecture
|
||||||
|
// TODO Hardcoded data here must be moved to a better place
|
||||||
|
|
||||||
export class InMemoryDriverRatingProvider implements DriverRatingProvider {
|
export class InMemoryDriverRatingProvider implements DriverRatingProvider {
|
||||||
constructor(private readonly logger: Logger) {
|
constructor(private readonly logger: Logger) {
|
||||||
this.logger.info('InMemoryDriverRatingProvider initialized.');
|
this.logger.info('InMemoryDriverRatingProvider initialized.');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepos
|
|||||||
import { IRankingService } from '@core/racing/domain/services/IRankingService';
|
import { IRankingService } from '@core/racing/domain/services/IRankingService';
|
||||||
import { IDriverStatsService } from '@core/racing/domain/services/IDriverStatsService';
|
import { IDriverStatsService } from '@core/racing/domain/services/IDriverStatsService';
|
||||||
import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
|
import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
|
||||||
|
import { DriverExtendedProfileProvider } from '@core/racing/application/ports/DriverExtendedProfileProvider';
|
||||||
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
|
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
|
||||||
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
|
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
|
||||||
import { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository';
|
import { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository';
|
||||||
@@ -24,6 +25,7 @@ import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/
|
|||||||
import { InMemoryRankingService } from '@adapters/racing/services/InMemoryRankingService';
|
import { InMemoryRankingService } from '@adapters/racing/services/InMemoryRankingService';
|
||||||
import { InMemoryDriverStatsService } from '@adapters/racing/services/InMemoryDriverStatsService';
|
import { InMemoryDriverStatsService } from '@adapters/racing/services/InMemoryDriverStatsService';
|
||||||
import { InMemoryDriverRatingProvider } from '@adapters/racing/ports/InMemoryDriverRatingProvider';
|
import { InMemoryDriverRatingProvider } from '@adapters/racing/ports/InMemoryDriverRatingProvider';
|
||||||
|
import { InMemoryDriverExtendedProfileProvider } from '@adapters/racing/ports/InMemoryDriverExtendedProfileProvider';
|
||||||
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
|
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
|
||||||
import { InMemoryRaceRegistrationRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository';
|
import { InMemoryRaceRegistrationRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository';
|
||||||
import { InMemoryNotificationPreferenceRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository';
|
import { InMemoryNotificationPreferenceRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository';
|
||||||
@@ -34,6 +36,7 @@ export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
|
|||||||
export const RANKING_SERVICE_TOKEN = 'IRankingService';
|
export const RANKING_SERVICE_TOKEN = 'IRankingService';
|
||||||
export const DRIVER_STATS_SERVICE_TOKEN = 'IDriverStatsService';
|
export const DRIVER_STATS_SERVICE_TOKEN = 'IDriverStatsService';
|
||||||
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
|
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
|
||||||
|
export const DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN = 'DriverExtendedProfileProvider';
|
||||||
export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort';
|
export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort';
|
||||||
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
|
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
|
||||||
export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository';
|
export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository';
|
||||||
@@ -69,6 +72,11 @@ export const DriverProviders: Provider[] = [
|
|||||||
useFactory: (logger: Logger) => new InMemoryDriverRatingProvider(logger),
|
useFactory: (logger: Logger) => new InMemoryDriverRatingProvider(logger),
|
||||||
inject: [LOGGER_TOKEN],
|
inject: [LOGGER_TOKEN],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN,
|
||||||
|
useFactory: (logger: Logger) => new InMemoryDriverExtendedProfileProvider(logger),
|
||||||
|
inject: [LOGGER_TOKEN],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: IMAGE_SERVICE_PORT_TOKEN,
|
provide: IMAGE_SERVICE_PORT_TOKEN,
|
||||||
useFactory: (logger: Logger) => new InMemoryImageServiceAdapter(logger),
|
useFactory: (logger: Logger) => new InMemoryImageServiceAdapter(logger),
|
||||||
@@ -117,7 +125,7 @@ export const DriverProviders: Provider[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: GET_PROFILE_OVERVIEW_USE_CASE_TOKEN,
|
provide: GET_PROFILE_OVERVIEW_USE_CASE_TOKEN,
|
||||||
useFactory: (driverRepo: IDriverRepository, imageService: IImageServicePort, logger: Logger) =>
|
useFactory: (driverRepo: IDriverRepository, imageService: IImageServicePort, driverExtendedProfileProvider: DriverExtendedProfileProvider, logger: Logger) =>
|
||||||
new GetProfileOverviewUseCase(
|
new GetProfileOverviewUseCase(
|
||||||
driverRepo,
|
driverRepo,
|
||||||
// TODO: Add teamRepository, teamMembershipRepository, socialRepository, etc.
|
// TODO: Add teamRepository, teamMembershipRepository, socialRepository, etc.
|
||||||
@@ -125,9 +133,10 @@ export const DriverProviders: Provider[] = [
|
|||||||
null as any, // teamMembershipRepository
|
null as any, // teamMembershipRepository
|
||||||
null as any, // socialRepository
|
null as any, // socialRepository
|
||||||
imageService,
|
imageService,
|
||||||
|
driverExtendedProfileProvider,
|
||||||
() => null, // getDriverStats
|
() => null, // getDriverStats
|
||||||
() => [], // getAllDriverRankings
|
() => [], // getAllDriverRankings
|
||||||
),
|
),
|
||||||
inject: [DRIVER_REPOSITORY_TOKEN, IMAGE_SERVICE_PORT_TOKEN, LOGGER_TOKEN],
|
inject: [DRIVER_REPOSITORY_TOKEN, IMAGE_SERVICE_PORT_TOKEN, DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN, LOGGER_TOKEN],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import { GetLeagueOwnerSummaryQueryDTO } from './dtos/GetLeagueOwnerSummaryQuery
|
|||||||
import { GetLeagueAdminConfigQueryDTO } from './dtos/GetLeagueAdminConfigQueryDTO';
|
import { GetLeagueAdminConfigQueryDTO } from './dtos/GetLeagueAdminConfigQueryDTO';
|
||||||
import { GetLeagueProtestsQueryDTO } from './dtos/GetLeagueProtestsQueryDTO';
|
import { GetLeagueProtestsQueryDTO } from './dtos/GetLeagueProtestsQueryDTO';
|
||||||
import { GetLeagueSeasonsQueryDTO } from './dtos/GetLeagueSeasonsQueryDTO';
|
import { GetLeagueSeasonsQueryDTO } from './dtos/GetLeagueSeasonsQueryDTO';
|
||||||
|
import { GetLeagueWalletOutputDTO } from './dtos/GetLeagueWalletOutputDTO';
|
||||||
|
import { WithdrawFromLeagueWalletInputDTO } from './dtos/WithdrawFromLeagueWalletInputDTO';
|
||||||
|
import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWalletOutputDTO';
|
||||||
|
|
||||||
@ApiTags('leagues')
|
@ApiTags('leagues')
|
||||||
@Controller('leagues')
|
@Controller('leagues')
|
||||||
@@ -274,4 +277,22 @@ export class LeagueController {
|
|||||||
async getRaces(@Param('leagueId') leagueId: string): Promise<GetLeagueRacesOutputDTO> {
|
async getRaces(@Param('leagueId') leagueId: string): Promise<GetLeagueRacesOutputDTO> {
|
||||||
return this.leagueService.getRaces(leagueId);
|
return this.leagueService.getRaces(leagueId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':leagueId/wallet')
|
||||||
|
@ApiOperation({ summary: 'Get league wallet information' })
|
||||||
|
@ApiResponse({ status: 200, description: 'League wallet data', type: GetLeagueWalletOutputDTO })
|
||||||
|
async getLeagueWallet(@Param('leagueId') leagueId: string): Promise<GetLeagueWalletOutputDTO> {
|
||||||
|
return this.leagueService.getLeagueWallet(leagueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':leagueId/wallet/withdraw')
|
||||||
|
@ApiOperation({ summary: 'Withdraw from league wallet' })
|
||||||
|
@ApiBody({ type: WithdrawFromLeagueWalletInputDTO })
|
||||||
|
@ApiResponse({ status: 200, description: 'Withdrawal processed', type: WithdrawFromLeagueWalletOutputDTO })
|
||||||
|
async withdrawFromLeagueWallet(
|
||||||
|
@Param('leagueId') leagueId: string,
|
||||||
|
@Body() input: WithdrawFromLeagueWalletInputDTO,
|
||||||
|
): Promise<WithdrawFromLeagueWalletOutputDTO> {
|
||||||
|
return this.leagueService.withdrawFromLeagueWallet(leagueId, input);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { InMemoryProtestRepository } from '@adapters/racing/persistence/inmemory
|
|||||||
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
|
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
|
||||||
import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository';
|
import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository';
|
||||||
import { InMemoryStandingRepository } from '@adapters/racing/persistence/inmemory/InMemoryStandingRepository';
|
import { InMemoryStandingRepository } from '@adapters/racing/persistence/inmemory/InMemoryStandingRepository';
|
||||||
|
import { InMemoryLeagueWalletRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueWalletRepository';
|
||||||
|
import { InMemoryTransactionRepository } from '@adapters/racing/persistence/inmemory/InMemoryTransactionRepository';
|
||||||
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
|
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
|
||||||
import { listLeagueScoringPresets } from '@adapters/bootstrap/LeagueScoringPresets';
|
import { listLeagueScoringPresets } from '@adapters/bootstrap/LeagueScoringPresets';
|
||||||
|
|
||||||
@@ -38,6 +40,8 @@ import { GetLeagueMembershipsUseCase } from '@core/racing/application/use-cases/
|
|||||||
import { GetLeagueScheduleUseCase } from '@core/racing/application/use-cases/GetLeagueScheduleUseCase';
|
import { GetLeagueScheduleUseCase } from '@core/racing/application/use-cases/GetLeagueScheduleUseCase';
|
||||||
import { GetLeagueStatsUseCase } from '@core/racing/application/use-cases/GetLeagueStatsUseCase';
|
import { GetLeagueStatsUseCase } from '@core/racing/application/use-cases/GetLeagueStatsUseCase';
|
||||||
import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase';
|
import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase';
|
||||||
|
import { GetLeagueWalletUseCase } from '@core/racing/application/use-cases/GetLeagueWalletUseCase';
|
||||||
|
import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase';
|
||||||
|
|
||||||
// Define injection tokens
|
// Define injection tokens
|
||||||
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
|
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
|
||||||
@@ -50,6 +54,8 @@ export const GAME_REPOSITORY_TOKEN = 'IGameRepository';
|
|||||||
export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository';
|
export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository';
|
||||||
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
|
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
|
||||||
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
|
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
|
||||||
|
export const LEAGUE_WALLET_REPOSITORY_TOKEN = 'ILeagueWalletRepository';
|
||||||
|
export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
|
||||||
export const LOGGER_TOKEN = 'Logger'; // Already defined in AuthProviders, but good to have here too
|
export const LOGGER_TOKEN = 'Logger'; // Already defined in AuthProviders, but good to have here too
|
||||||
export const GET_LEAGUE_STANDINGS_USE_CASE = 'GetLeagueStandingsUseCase';
|
export const GET_LEAGUE_STANDINGS_USE_CASE = 'GetLeagueStandingsUseCase';
|
||||||
|
|
||||||
@@ -105,6 +111,16 @@ export const LeagueProviders: Provider[] = [
|
|||||||
useFactory: (logger: Logger) => new InMemoryDriverRepository(logger),
|
useFactory: (logger: Logger) => new InMemoryDriverRepository(logger),
|
||||||
inject: [LOGGER_TOKEN],
|
inject: [LOGGER_TOKEN],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: LEAGUE_WALLET_REPOSITORY_TOKEN,
|
||||||
|
useFactory: (logger: Logger) => new InMemoryLeagueWalletRepository(logger),
|
||||||
|
inject: [LOGGER_TOKEN],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: TRANSACTION_REPOSITORY_TOKEN,
|
||||||
|
useFactory: (logger: Logger) => new InMemoryTransactionRepository(logger),
|
||||||
|
inject: [LOGGER_TOKEN],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: LOGGER_TOKEN,
|
provide: LOGGER_TOKEN,
|
||||||
useClass: ConsoleLogger,
|
useClass: ConsoleLogger,
|
||||||
@@ -132,6 +148,8 @@ export const LeagueProviders: Provider[] = [
|
|||||||
GetLeagueScheduleUseCase,
|
GetLeagueScheduleUseCase,
|
||||||
GetLeagueStatsUseCase,
|
GetLeagueStatsUseCase,
|
||||||
GetLeagueAdminPermissionsUseCase,
|
GetLeagueAdminPermissionsUseCase,
|
||||||
|
GetLeagueWalletUseCase,
|
||||||
|
WithdrawFromLeagueWalletUseCase,
|
||||||
{
|
{
|
||||||
provide: ListLeagueScoringPresetsUseCase,
|
provide: ListLeagueScoringPresetsUseCase,
|
||||||
useFactory: () => new ListLeagueScoringPresetsUseCase(listLeagueScoringPresets()),
|
useFactory: () => new ListLeagueScoringPresetsUseCase(listLeagueScoringPresets()),
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ import { RejectLeagueJoinRequestUseCase } from '@core/racing/application/use-cas
|
|||||||
import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/RemoveLeagueMemberUseCase';
|
import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/RemoveLeagueMemberUseCase';
|
||||||
import { TransferLeagueOwnershipUseCase } from '@core/racing/application/use-cases/TransferLeagueOwnershipUseCase';
|
import { TransferLeagueOwnershipUseCase } from '@core/racing/application/use-cases/TransferLeagueOwnershipUseCase';
|
||||||
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
|
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
|
||||||
|
import { GetLeagueWalletUseCase } from '@core/racing/application/use-cases/GetLeagueWalletUseCase';
|
||||||
|
import { WithdrawFromLeagueWalletUseCase } from '@core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase';
|
||||||
|
|
||||||
// API Presenters
|
// API Presenters
|
||||||
import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter';
|
import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter';
|
||||||
@@ -108,6 +110,8 @@ export class LeagueService {
|
|||||||
private readonly getLeagueMembershipsUseCase: GetLeagueMembershipsUseCase,
|
private readonly getLeagueMembershipsUseCase: GetLeagueMembershipsUseCase,
|
||||||
private readonly getLeagueScheduleUseCase: GetLeagueScheduleUseCase,
|
private readonly getLeagueScheduleUseCase: GetLeagueScheduleUseCase,
|
||||||
private readonly getLeagueAdminPermissionsUseCase: GetLeagueAdminPermissionsUseCase,
|
private readonly getLeagueAdminPermissionsUseCase: GetLeagueAdminPermissionsUseCase,
|
||||||
|
private readonly getLeagueWalletUseCase: GetLeagueWalletUseCase,
|
||||||
|
private readonly withdrawFromLeagueWalletUseCase: WithdrawFromLeagueWalletUseCase,
|
||||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -438,4 +442,32 @@ export class LeagueService {
|
|||||||
races: [],
|
races: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getLeagueWallet(leagueId: string): Promise<GetLeagueWalletOutputDTO> {
|
||||||
|
this.logger.debug('Getting league wallet', { leagueId });
|
||||||
|
const result = await this.getLeagueWalletUseCase.execute({ leagueId });
|
||||||
|
if (result.isErr()) {
|
||||||
|
throw new Error(result.unwrapErr().message);
|
||||||
|
}
|
||||||
|
return result.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async withdrawFromLeagueWallet(leagueId: string, input: WithdrawFromLeagueWalletInputDTO): Promise<WithdrawFromLeagueWalletOutputDTO> {
|
||||||
|
this.logger.debug('Withdrawing from league wallet', { leagueId, amount: input.amount });
|
||||||
|
const result = await this.withdrawFromLeagueWalletUseCase.execute({
|
||||||
|
leagueId,
|
||||||
|
amount: input.amount,
|
||||||
|
currency: input.currency,
|
||||||
|
seasonId: input.seasonId,
|
||||||
|
destinationAccount: input.destinationAccount,
|
||||||
|
});
|
||||||
|
if (result.isErr()) {
|
||||||
|
const error = result.unwrapErr();
|
||||||
|
if (error.code === 'WITHDRAWAL_NOT_ALLOWED') {
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
return result.unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
apps/api/src/domain/league/dtos/GetLeagueWalletOutputDTO.ts
Normal file
59
apps/api/src/domain/league/dtos/GetLeagueWalletOutputDTO.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class WalletTransactionDTO {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['sponsorship', 'membership', 'withdrawal', 'prize'] })
|
||||||
|
type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
fee: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
netAmount: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
date: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['completed', 'pending', 'failed'] })
|
||||||
|
status: 'completed' | 'pending' | 'failed';
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
reference?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetLeagueWalletOutputDTO {
|
||||||
|
@ApiProperty()
|
||||||
|
balance: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalRevenue: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalFees: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalWithdrawals: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
pendingPayouts: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
canWithdraw: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
withdrawalBlockReason?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [WalletTransactionDTO] })
|
||||||
|
transactions: WalletTransactionDTO[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class WithdrawFromLeagueWalletInputDTO {
|
||||||
|
@ApiProperty()
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
seasonId: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
destinationAccount: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class WithdrawFromLeagueWalletOutputDTO {
|
||||||
|
@ApiProperty()
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { apiClient } from '../../../../lib/apiClient';
|
import { api } from '../../../../lib/api';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const returnTo = url.searchParams.get('returnTo') ?? undefined;
|
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
|
// For now, generate a simple state - in production this should be cryptographically secure
|
||||||
const state = Math.random().toString(36).substring(2, 15);
|
const state = Math.random().toString(36).substring(2, 15);
|
||||||
|
|
||||||
|
|||||||
@@ -92,67 +92,6 @@ interface TeamMembershipInfo {
|
|||||||
// DEMO DATA
|
// 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
|
// HELPERS
|
||||||
@@ -428,29 +367,33 @@ export default function DriverDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const demoExtended = getDemoExtendedProfile(driverProfile.currentDriver.id);
|
const extendedProfile: DriverExtendedProfile = driverProfile.extendedProfile ? {
|
||||||
const extendedProfile: DriverExtendedProfile = {
|
socialHandles: driverProfile.extendedProfile.socialHandles,
|
||||||
socialHandles: driverProfile?.extendedProfile?.socialHandles ?? demoExtended.socialHandles,
|
achievements: driverProfile.extendedProfile.achievements.map((achievement) => ({
|
||||||
achievements:
|
id: achievement.id,
|
||||||
driverProfile?.extendedProfile?.achievements
|
title: achievement.title,
|
||||||
? driverProfile.extendedProfile.achievements.map((achievement) => ({
|
description: achievement.description,
|
||||||
id: achievement.id,
|
icon: achievement.icon,
|
||||||
title: achievement.title,
|
rarity: achievement.rarity,
|
||||||
description: achievement.description,
|
earnedAt: new Date(achievement.earnedAt),
|
||||||
icon: achievement.icon,
|
})),
|
||||||
rarity: achievement.rarity,
|
racingStyle: driverProfile.extendedProfile.racingStyle,
|
||||||
earnedAt: new Date(achievement.earnedAt),
|
favoriteTrack: driverProfile.extendedProfile.favoriteTrack,
|
||||||
}))
|
favoriteCar: driverProfile.extendedProfile.favoriteCar,
|
||||||
: demoExtended.achievements,
|
timezone: driverProfile.extendedProfile.timezone,
|
||||||
racingStyle: driverProfile?.extendedProfile?.racingStyle ?? demoExtended.racingStyle,
|
availableHours: driverProfile.extendedProfile.availableHours,
|
||||||
favoriteTrack: driverProfile?.extendedProfile?.favoriteTrack ?? demoExtended.favoriteTrack,
|
lookingForTeam: driverProfile.extendedProfile.lookingForTeam,
|
||||||
favoriteCar: driverProfile?.extendedProfile?.favoriteCar ?? demoExtended.favoriteCar,
|
openToRequests: driverProfile.extendedProfile.openToRequests,
|
||||||
timezone: driverProfile?.extendedProfile?.timezone ?? demoExtended.timezone,
|
} : {
|
||||||
availableHours: driverProfile?.extendedProfile?.availableHours ?? demoExtended.availableHours,
|
socialHandles: [],
|
||||||
lookingForTeam:
|
achievements: [],
|
||||||
driverProfile?.extendedProfile?.lookingForTeam ?? demoExtended.lookingForTeam,
|
racingStyle: 'Unknown',
|
||||||
openToRequests:
|
favoriteTrack: 'Unknown',
|
||||||
driverProfile?.extendedProfile?.openToRequests ?? demoExtended.openToRequests,
|
favoriteCar: 'Unknown',
|
||||||
|
timezone: 'UTC',
|
||||||
|
availableHours: 'Flexible',
|
||||||
|
lookingForTeam: false,
|
||||||
|
openToRequests: false,
|
||||||
};
|
};
|
||||||
const stats = driverProfile?.stats || null;
|
const stats = driverProfile?.stats || null;
|
||||||
const globalRank = driverProfile?.currentDriver?.globalRank || 1;
|
const globalRank = driverProfile?.currentDriver?.globalRank || 1;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Button from '@/components/ui/Button';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
|
import { LeagueStewardingViewModel } from '@/lib/view-models/LeagueStewardingViewModel';
|
||||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -27,32 +28,13 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
// Local type definitions to replace core imports
|
// Local type definitions to replace core imports
|
||||||
type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
|
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() {
|
export default function LeagueStewardingPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const leagueId = params.id as string;
|
const leagueId = params.id as string;
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
const { raceService, protestService, driverService, leagueMembershipService, penaltyService } = useServices();
|
const { leagueStewardingService, leagueMembershipService } = useServices();
|
||||||
|
|
||||||
const [races, setRaces] = useState<any[]>([]);
|
const [stewardingData, setStewardingData] = useState<LeagueStewardingViewModel | null>(null);
|
||||||
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 [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
||||||
@@ -72,52 +54,13 @@ export default function LeagueStewardingPage() {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Get all races for this league
|
const data = await leagueStewardingService.getLeagueStewardingData(leagueId);
|
||||||
const leagueRaces = await raceService.findByLeagueId(leagueId);
|
setStewardingData(data);
|
||||||
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));
|
|
||||||
|
|
||||||
// Auto-expand races with pending protests
|
// Auto-expand races with pending protests
|
||||||
const racesWithPending = new Set<string>();
|
const racesWithPending = new Set<string>();
|
||||||
Object.entries(protestsMap).forEach(([raceId, protests]) => {
|
data.pendingRaces.forEach(race => {
|
||||||
if (protests.some((p: any) => p.status === 'pending' || p.status === 'under_review')) {
|
racesWithPending.add(race.race.id);
|
||||||
racesWithPending.add(raceId);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
setExpandedRaces(racesWithPending);
|
setExpandedRaces(racesWithPending);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -130,34 +73,12 @@ export default function LeagueStewardingPage() {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
}, [leagueId, isAdmin, raceService, protestService, driverService, penaltyService]);
|
}, [leagueId, isAdmin, leagueStewardingService]);
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Filter races based on active tab
|
// Filter races based on active tab
|
||||||
const filteredRaces = useMemo(() => {
|
const filteredRaces = useMemo(() => {
|
||||||
if (activeTab === 'pending') {
|
return activeTab === 'pending' ? stewardingData?.pendingRaces ?? [] : stewardingData?.historyRaces ?? [];
|
||||||
return racesWithData.filter(r => r.pendingProtests.length > 0);
|
}, [stewardingData, activeTab]);
|
||||||
}
|
|
||||||
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);
|
|
||||||
|
|
||||||
const handleAcceptProtest = async (
|
const handleAcceptProtest = async (
|
||||||
protestId: string,
|
protestId: string,
|
||||||
@@ -165,22 +86,23 @@ export default function LeagueStewardingPage() {
|
|||||||
penaltyValue: number,
|
penaltyValue: number,
|
||||||
stewardNotes: string
|
stewardNotes: string
|
||||||
) => {
|
) => {
|
||||||
await protestService.reviewProtest({
|
await leagueStewardingService.reviewProtest({
|
||||||
protestId,
|
protestId,
|
||||||
stewardId: currentDriverId,
|
stewardId: currentDriverId,
|
||||||
decision: 'uphold',
|
decision: 'uphold',
|
||||||
decisionNotes: stewardNotes,
|
decisionNotes: stewardNotes,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find the protest
|
// Find the protest to get details for penalty
|
||||||
let foundProtest: any | undefined;
|
let foundProtest: any | undefined;
|
||||||
Object.values(protestsByRace).forEach(protests => {
|
stewardingData?.racesWithData.forEach(raceData => {
|
||||||
const p = protests.find(pr => pr.id === protestId);
|
const p = raceData.pendingProtests.find(pr => pr.id === protestId) ||
|
||||||
if (p) foundProtest = p;
|
raceData.resolvedProtests.find(pr => pr.id === protestId);
|
||||||
|
if (p) foundProtest = { ...p, raceId: raceData.race.id };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (foundProtest) {
|
if (foundProtest) {
|
||||||
await penaltyService.applyPenalty({
|
await leagueStewardingService.applyPenalty({
|
||||||
raceId: foundProtest.raceId,
|
raceId: foundProtest.raceId,
|
||||||
driverId: foundProtest.accusedDriverId,
|
driverId: foundProtest.accusedDriverId,
|
||||||
stewardId: currentDriverId,
|
stewardId: currentDriverId,
|
||||||
@@ -194,7 +116,7 @@ export default function LeagueStewardingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
|
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
|
||||||
await protestService.reviewProtest({
|
await leagueStewardingService.reviewProtest({
|
||||||
protestId,
|
protestId,
|
||||||
stewardId: currentDriverId,
|
stewardId: currentDriverId,
|
||||||
decision: 'dismiss',
|
decision: 'dismiss',
|
||||||
@@ -260,28 +182,28 @@ export default function LeagueStewardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats summary */}
|
{/* Stats summary */}
|
||||||
{!loading && (
|
{!loading && stewardingData && (
|
||||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
<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="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||||
<div className="flex items-center gap-2 text-warning-amber mb-1">
|
<div className="flex items-center gap-2 text-warning-amber mb-1">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
<span className="text-xs font-medium uppercase">Pending Review</span>
|
<span className="text-xs font-medium uppercase">Pending Review</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-white">{totalPending}</div>
|
<div className="text-2xl font-bold text-white">{stewardingData.totalPending}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
<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">
|
<div className="flex items-center gap-2 text-performance-green mb-1">
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
<span className="text-xs font-medium uppercase">Resolved</span>
|
<span className="text-xs font-medium uppercase">Resolved</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-white">{totalResolved}</div>
|
<div className="text-2xl font-bold text-white">{stewardingData.totalResolved}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
<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">
|
<div className="flex items-center gap-2 text-red-400 mb-1">
|
||||||
<Gavel className="w-4 h-4" />
|
<Gavel className="w-4 h-4" />
|
||||||
<span className="text-xs font-medium uppercase">Penalties</span>
|
<span className="text-xs font-medium uppercase">Penalties</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-white">{totalPenalties}</div>
|
<div className="text-2xl font-bold text-white">{stewardingData.totalPenalties}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -298,9 +220,9 @@ export default function LeagueStewardingPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Pending Protests
|
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">
|
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
|
||||||
{totalPending}
|
{stewardingData.totalPending}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -380,8 +302,8 @@ export default function LeagueStewardingPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{displayProtests.map((protest) => {
|
{displayProtests.map((protest) => {
|
||||||
const protester = driversById[protest.protestingDriverId];
|
const protester = stewardingData!.driverMap[protest.protestingDriverId];
|
||||||
const accused = driversById[protest.accusedDriverId];
|
const accused = stewardingData!.driverMap[protest.accusedDriverId];
|
||||||
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
|
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');
|
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
|
||||||
|
|
||||||
@@ -443,7 +365,7 @@ export default function LeagueStewardingPage() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{activeTab === 'history' && penalties.map((penalty) => {
|
{activeTab === 'history' && penalties.map((penalty) => {
|
||||||
const driver = driversById[penalty.driverId];
|
const driver = stewardingData!.driverMap[penalty.driverId];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={penalty.id}
|
key={penalty.id}
|
||||||
@@ -500,12 +422,12 @@ export default function LeagueStewardingPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showQuickPenaltyModal && (
|
{showQuickPenaltyModal && stewardingData && (
|
||||||
<QuickPenaltyModal
|
<QuickPenaltyModal
|
||||||
drivers={allDrivers}
|
drivers={stewardingData.allDrivers}
|
||||||
onClose={() => setShowQuickPenaltyModal(false)}
|
onClose={() => setShowQuickPenaltyModal(false)}
|
||||||
adminId={currentDriverId}
|
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>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import {
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
Wallet,
|
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
|
||||||
DollarSign,
|
import {
|
||||||
ArrowUpRight,
|
Wallet,
|
||||||
ArrowDownLeft,
|
DollarSign,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownLeft,
|
||||||
Clock,
|
Clock,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
@@ -19,102 +21,9 @@ import {
|
|||||||
Calendar
|
Calendar
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface Transaction {
|
function TransactionRow({ transaction }: { transaction: any }) {
|
||||||
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 }) {
|
|
||||||
const isIncoming = transaction.amount > 0;
|
const isIncoming = transaction.amount > 0;
|
||||||
|
|
||||||
const typeIcons = {
|
const typeIcons = {
|
||||||
sponsorship: DollarSign,
|
sponsorship: DollarSign,
|
||||||
membership: CreditCard,
|
membership: CreditCard,
|
||||||
@@ -158,13 +67,13 @@ function TransactionRow({ transaction }: { transaction: Transaction }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{transaction.date.toLocaleDateString()}</span>
|
<span>{transaction.formattedDate}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className={`font-semibold ${isIncoming ? 'text-performance-green' : 'text-white'}`}>
|
<div className={`font-semibold ${isIncoming ? 'text-performance-green' : 'text-white'}`}>
|
||||||
{isIncoming ? '+' : ''}{transaction.amount < 0 ? '-' : ''}${Math.abs(transaction.amount).toFixed(2)}
|
{transaction.formattedAmount}
|
||||||
</div>
|
</div>
|
||||||
{transaction.fee > 0 && (
|
{transaction.fee > 0 && (
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
@@ -178,36 +87,48 @@ function TransactionRow({ transaction }: { transaction: Transaction }) {
|
|||||||
|
|
||||||
export default function LeagueWalletPage() {
|
export default function LeagueWalletPage() {
|
||||||
const params = useParams();
|
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 [withdrawAmount, setWithdrawAmount] = useState('');
|
||||||
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
|
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all');
|
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all');
|
||||||
|
|
||||||
const filteredTransactions = wallet.transactions.filter(
|
useEffect(() => {
|
||||||
t => filterType === 'all' || t.type === filterType
|
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 () => {
|
const handleWithdraw = async () => {
|
||||||
if (!withdrawAmount || parseFloat(withdrawAmount) <= 0) return;
|
if (!withdrawAmount || parseFloat(withdrawAmount) <= 0) return;
|
||||||
|
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/wallets/${params.id}/withdraw`, {
|
const result = await leagueWalletService.withdraw(
|
||||||
method: 'POST',
|
params.id as string,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
parseFloat(withdrawAmount),
|
||||||
body: JSON.stringify({
|
wallet.currency,
|
||||||
amount: parseFloat(withdrawAmount),
|
'season-2', // Current active season
|
||||||
currency: wallet.currency,
|
'bank-account-***1234'
|
||||||
seasonId: 'season-2', // Current active season
|
);
|
||||||
destinationAccount: 'bank-account-***1234',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
if (!result.success) {
|
||||||
|
alert(result.message || 'Withdrawal failed');
|
||||||
if (!response.ok) {
|
|
||||||
alert(result.reason || result.error || 'Withdrawal failed');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +136,8 @@ export default function LeagueWalletPage() {
|
|||||||
setShowWithdrawModal(false);
|
setShowWithdrawModal(false);
|
||||||
setWithdrawAmount('');
|
setWithdrawAmount('');
|
||||||
// Refresh wallet data
|
// Refresh wallet data
|
||||||
|
const updatedWallet = await leagueWalletService.getWalletForLeague(params.id as string);
|
||||||
|
setWallet(updatedWallet);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Withdrawal error:', err);
|
console.error('Withdrawal error:', err);
|
||||||
alert('Failed to process withdrawal');
|
alert('Failed to process withdrawal');
|
||||||
@@ -236,7 +159,7 @@ export default function LeagueWalletPage() {
|
|||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Export
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => setShowWithdrawModal(true)}
|
onClick={() => setShowWithdrawModal(true)}
|
||||||
disabled={!wallet.canWithdraw}
|
disabled={!wallet.canWithdraw}
|
||||||
@@ -268,7 +191,7 @@ export default function LeagueWalletPage() {
|
|||||||
<Wallet className="w-6 h-6 text-performance-green" />
|
<Wallet className="w-6 h-6 text-performance-green" />
|
||||||
</div>
|
</div>
|
||||||
<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 className="text-sm text-gray-400">Available Balance</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +203,7 @@ export default function LeagueWalletPage() {
|
|||||||
<TrendingUp className="w-6 h-6 text-primary-blue" />
|
<TrendingUp className="w-6 h-6 text-primary-blue" />
|
||||||
</div>
|
</div>
|
||||||
<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 className="text-sm text-gray-400">Total Revenue</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,7 +215,7 @@ export default function LeagueWalletPage() {
|
|||||||
<DollarSign className="w-6 h-6 text-warning-amber" />
|
<DollarSign className="w-6 h-6 text-warning-amber" />
|
||||||
</div>
|
</div>
|
||||||
<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 className="text-sm text-gray-400">Platform Fees (10%)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,7 +227,7 @@ export default function LeagueWalletPage() {
|
|||||||
<Clock className="w-6 h-6 text-purple-400" />
|
<Clock className="w-6 h-6 text-purple-400" />
|
||||||
</div>
|
</div>
|
||||||
<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 className="text-sm text-gray-400">Pending Payouts</div>
|
||||||
</div>
|
</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="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<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-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>
|
</div>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Available after Season 2 ends (estimated: Jan 15, 2026)
|
Available after Season 2 ends (estimated: Jan 15, 2026)
|
||||||
@@ -434,7 +357,7 @@ export default function LeagueWalletPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Available: ${wallet.balance.toFixed(2)}
|
Available: {wallet.formattedBalance}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
import type { RacePenaltiesViewModel, RaceProtestsViewModel } from '@/lib/apiClient';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import { apiClient } from '@/lib/apiClient';
|
import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -24,13 +24,11 @@ import { useEffect, useState } from 'react';
|
|||||||
export default function RaceStewardingPage() {
|
export default function RaceStewardingPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { raceStewardingService } = useServices();
|
||||||
const raceId = params.id as string;
|
const raceId = params.id as string;
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
|
|
||||||
const [race, setRace] = useState<any>(null); // TODO: Define proper race type
|
const [stewardingData, setStewardingData] = useState<RaceStewardingViewModel | null>(null);
|
||||||
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 [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'pending' | 'resolved' | 'penalties'>('pending');
|
const [activeTab, setActiveTab] = useState<'pending' | 'resolved' | 'penalties'>('pending');
|
||||||
@@ -39,24 +37,13 @@ export default function RaceStewardingPage() {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Get race detail for basic info
|
const data = await raceStewardingService.getRaceStewardingData(raceId, currentDriverId);
|
||||||
const raceDetail = await apiClient.races.getDetail(raceId, currentDriverId);
|
setStewardingData(data);
|
||||||
setRace(raceDetail.race);
|
|
||||||
setLeague(raceDetail.league);
|
|
||||||
|
|
||||||
if (raceDetail.league) {
|
if (data.league) {
|
||||||
// TODO: Implement admin check via API
|
// TODO: Implement admin check via API
|
||||||
setIsAdmin(true);
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err);
|
console.error('Failed to load data:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -65,17 +52,10 @@ export default function RaceStewardingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
}, [raceId, currentDriverId]);
|
}, [raceId, currentDriverId, raceStewardingService]);
|
||||||
|
|
||||||
const pendingProtests = protestsData?.protests.filter(
|
const pendingProtests = stewardingData?.pendingProtests ?? [];
|
||||||
(p) => p.status === 'pending' || p.status === 'under_review',
|
const resolvedProtests = stewardingData?.resolvedProtests ?? [];
|
||||||
) ?? [];
|
|
||||||
const resolvedProtests = protestsData?.protests.filter(
|
|
||||||
(p) =>
|
|
||||||
p.status === 'upheld' ||
|
|
||||||
p.status === 'dismissed' ||
|
|
||||||
p.status === 'withdrawn',
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -131,7 +111,7 @@ export default function RaceStewardingPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!race) {
|
if (!stewardingData?.race) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
|
<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">
|
<div className="max-w-4xl mx-auto">
|
||||||
@@ -158,7 +138,7 @@ export default function RaceStewardingPage() {
|
|||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: 'Races', href: '/races' },
|
{ label: 'Races', href: '/races' },
|
||||||
{ label: race.track, href: `/races/${race.id}` },
|
{ label: stewardingData?.race?.track || 'Race', href: `/races/${raceId}` },
|
||||||
{ label: 'Stewarding' },
|
{ label: 'Stewarding' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -186,9 +166,9 @@ export default function RaceStewardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Stewarding</h1>
|
<h1 className="text-2xl font-bold text-white">Stewarding</h1>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
{race.track} • {formatDate(race.scheduledAt)}
|
{stewardingData?.race?.track} • {stewardingData?.race?.scheduledAt ? formatDate(stewardingData.race.scheduledAt) : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -199,21 +179,21 @@ export default function RaceStewardingPage() {
|
|||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
<span className="text-xs font-medium uppercase">Pending</span>
|
<span className="text-xs font-medium uppercase">Pending</span>
|
||||||
</div>
|
</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>
|
||||||
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
|
<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">
|
<div className="flex items-center gap-2 text-performance-green mb-1">
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
<span className="text-xs font-medium uppercase">Resolved</span>
|
<span className="text-xs font-medium uppercase">Resolved</span>
|
||||||
</div>
|
</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>
|
||||||
<div className="rounded-lg bg-deep-graphite/50 border border-charcoal-outline p-4">
|
<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">
|
<div className="flex items-center gap-2 text-red-400 mb-1">
|
||||||
<Gavel className="w-4 h-4" />
|
<Gavel className="w-4 h-4" />
|
||||||
<span className="text-xs font-medium uppercase">Penalties</span>
|
<span className="text-xs font-medium uppercase">Penalties</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -272,8 +252,8 @@ export default function RaceStewardingPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
pendingProtests.map((protest) => {
|
pendingProtests.map((protest) => {
|
||||||
const protester = protestsData?.driverMap[protest.protestingDriverId];
|
const protester = stewardingData?.driverMap[protest.protestingDriverId];
|
||||||
const accused = protestsData?.driverMap[protest.accusedDriverId];
|
const accused = stewardingData?.driverMap[protest.accusedDriverId];
|
||||||
const daysSinceFiled = Math.floor(
|
const daysSinceFiled = Math.floor(
|
||||||
(Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)
|
(Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||||
);
|
);
|
||||||
@@ -330,9 +310,9 @@ export default function RaceStewardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-300">{protest.incident.description}</p>
|
<p className="text-sm text-gray-300">{protest.incident.description}</p>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && league && (
|
{isAdmin && stewardingData?.league && (
|
||||||
<Link
|
<Link
|
||||||
href={`/leagues/${league.id}/stewarding/protests/${protest.id}`}
|
href={`/leagues/${stewardingData.league.id}/stewarding/protests/${protest.id}`}
|
||||||
>
|
>
|
||||||
<Button variant="primary">Review</Button>
|
<Button variant="primary">Review</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -359,8 +339,8 @@ export default function RaceStewardingPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
resolvedProtests.map((protest) => {
|
resolvedProtests.map((protest) => {
|
||||||
const protester = protestsData?.driverMap[protest.protestingDriverId];
|
const protester = stewardingData?.driverMap[protest.protestingDriverId];
|
||||||
const accused = protestsData?.driverMap[protest.accusedDriverId];
|
const accused = stewardingData?.driverMap[protest.accusedDriverId];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={protest.id}>
|
<Card key={protest.id}>
|
||||||
@@ -421,8 +401,8 @@ export default function RaceStewardingPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
penaltiesData?.penalties.map((penalty) => {
|
stewardingData?.penalties.map((penalty) => {
|
||||||
const driver = penaltiesData?.driverMap[penalty.driverId];
|
const driver = stewardingData?.driverMap[penalty.driverId];
|
||||||
return (
|
return (
|
||||||
<Card key={penalty.id}>
|
<Card key={penalty.id}>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||||
import { apiClient } from '@/lib/apiClient';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import type { RacesPageDataViewModel, RacesPageDataRaceViewModel } from '@/lib/apiClient';
|
import { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -34,8 +34,9 @@ type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
|
|||||||
export default function AllRacesPage() {
|
export default function AllRacesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
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);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
@@ -49,7 +50,7 @@ export default function AllRacesPage() {
|
|||||||
|
|
||||||
const loadRaces = async () => {
|
const loadRaces = async () => {
|
||||||
try {
|
try {
|
||||||
const viewModel = await apiClient.races.getAllPageData();
|
const viewModel = await raceService.getAllRacesPageData();
|
||||||
setPageData(viewModel);
|
setPageData(viewModel);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load races:', err);
|
console.error('Failed to load races:', err);
|
||||||
@@ -62,7 +63,7 @@ export default function AllRacesPage() {
|
|||||||
void loadRaces();
|
void loadRaces();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const races: RacesPageDataRaceViewModel[] = pageData?.races ?? [];
|
const races = pageData?.races ?? [];
|
||||||
|
|
||||||
const filteredRaces = useMemo(() => {
|
const filteredRaces = useMemo(() => {
|
||||||
return races.filter(race => {
|
return races.filter(race => {
|
||||||
@@ -284,8 +285,8 @@ export default function AllRacesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{paginatedRaces.map(race => {
|
{paginatedRaces.map(race => {
|
||||||
const config = statusConfig[race.status];
|
const config = statusConfig[race.status as keyof typeof statusConfig];
|
||||||
const StatusIcon = config.icon;
|
const StatusIcon = config.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import Link from 'next/link';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { apiClient } from '@/lib/apiClient';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import type { RacesPageDataViewModel, RacesPageDataRaceViewModel } from '@/lib/apiClient';
|
import { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -27,12 +27,13 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
||||||
type RaceStatusFilter = RacesPageDataRaceViewModel['status'];
|
type RaceStatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
|
||||||
|
|
||||||
export default function RacesPage() {
|
export default function RacesPage() {
|
||||||
const router = useRouter();
|
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);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
@@ -42,7 +43,7 @@ export default function RacesPage() {
|
|||||||
|
|
||||||
const loadRaces = async () => {
|
const loadRaces = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.races.getPageData();
|
const data = await raceService.getRacesPageData();
|
||||||
setPageData(data);
|
setPageData(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load races:', err);
|
console.error('Failed to load races:', err);
|
||||||
@@ -87,11 +88,8 @@ export default function RacesPage() {
|
|||||||
|
|
||||||
// Group races by date for calendar view
|
// Group races by date for calendar view
|
||||||
const racesByDate = useMemo(() => {
|
const racesByDate = useMemo(() => {
|
||||||
const grouped = new Map<string, RacesPageDataRaceViewModel[]>();
|
const grouped = new Map<string, typeof filteredRaces[0][]>();
|
||||||
filteredRaces.forEach((race) => {
|
filteredRaces.forEach((race) => {
|
||||||
if (typeof race.scheduledAt !== 'string') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dateKey = race.scheduledAt.split('T')[0]!;
|
const dateKey = race.scheduledAt.split('T')[0]!;
|
||||||
if (!grouped.has(dateKey)) {
|
if (!grouped.has(dateKey)) {
|
||||||
grouped.set(dateKey, []);
|
grouped.set(dateKey, []);
|
||||||
@@ -105,10 +103,10 @@ export default function RacesPage() {
|
|||||||
const liveRaces = filteredRaces.filter(r => r.isLive);
|
const liveRaces = filteredRaces.filter(r => r.isLive);
|
||||||
const recentResults = filteredRaces.filter(r => r.isPast).slice(0, 5);
|
const recentResults = filteredRaces.filter(r => r.isPast).slice(0, 5);
|
||||||
const stats = {
|
const stats = {
|
||||||
total: pageData?.races.length ?? 0,
|
total: pageData?.totalCount ?? 0,
|
||||||
scheduled: pageData?.races.filter(r => r.status === 'scheduled').length ?? 0,
|
scheduled: pageData?.scheduledRaces.length ?? 0,
|
||||||
running: pageData?.races.filter(r => r.status === 'running').length ?? 0,
|
running: pageData?.runningRaces.length ?? 0,
|
||||||
completed: pageData?.races.filter(r => r.status === 'completed').length ?? 0,
|
completed: pageData?.completedRaces.length ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: Date | string) => {
|
const formatDate = (date: Date | string) => {
|
||||||
@@ -348,7 +346,7 @@ export default function RacesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-white font-medium mb-1">No races found</p>
|
<p className="text-white font-medium mb-1">No races found</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{pageData?.races.length === 0
|
{pageData?.totalCount === 0
|
||||||
? 'No races have been scheduled yet'
|
? 'No races have been scheduled yet'
|
||||||
: 'Try adjusting your filters'}
|
: 'Try adjusting your filters'}
|
||||||
</p>
|
</p>
|
||||||
@@ -375,10 +373,7 @@ export default function RacesPage() {
|
|||||||
{/* Races for this date */}
|
{/* Races for this date */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{dayRaces.map((race) => {
|
{dayRaces.map((race) => {
|
||||||
if (!race.scheduledAt) {
|
const config = statusConfig[race.status as keyof typeof statusConfig];
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const config = statusConfig[race.status];
|
|
||||||
const StatusIcon = config.icon;
|
const StatusIcon = config.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
54
apps/website/lib/api/wallets/WalletsApiClient.ts
Normal file
54
apps/website/lib/api/wallets/WalletsApiClient.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { TeamsApiClient } from '../api/teams/TeamsApiClient';
|
|||||||
import { LeaguesApiClient } from '../api/leagues/LeaguesApiClient';
|
import { LeaguesApiClient } from '../api/leagues/LeaguesApiClient';
|
||||||
import { SponsorsApiClient } from '../api/sponsors/SponsorsApiClient';
|
import { SponsorsApiClient } from '../api/sponsors/SponsorsApiClient';
|
||||||
import { PaymentsApiClient } from '../api/payments/PaymentsApiClient';
|
import { PaymentsApiClient } from '../api/payments/PaymentsApiClient';
|
||||||
|
import { WalletsApiClient } from '../api/wallets/WalletsApiClient';
|
||||||
import { AuthApiClient } from '../api/auth/AuthApiClient';
|
import { AuthApiClient } from '../api/auth/AuthApiClient';
|
||||||
import { AnalyticsApiClient } from '../api/analytics/AnalyticsApiClient';
|
import { AnalyticsApiClient } from '../api/analytics/AnalyticsApiClient';
|
||||||
import { MediaApiClient } from '../api/media/MediaApiClient';
|
import { MediaApiClient } from '../api/media/MediaApiClient';
|
||||||
@@ -17,6 +18,7 @@ import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger';
|
|||||||
// Services
|
// Services
|
||||||
import { RaceService } from './races/RaceService';
|
import { RaceService } from './races/RaceService';
|
||||||
import { RaceResultsService } from './races/RaceResultsService';
|
import { RaceResultsService } from './races/RaceResultsService';
|
||||||
|
import { RaceStewardingService } from './races/RaceStewardingService';
|
||||||
import { DriverService } from './drivers/DriverService';
|
import { DriverService } from './drivers/DriverService';
|
||||||
import { DriverRegistrationService } from './drivers/DriverRegistrationService';
|
import { DriverRegistrationService } from './drivers/DriverRegistrationService';
|
||||||
import { TeamService } from './teams/TeamService';
|
import { TeamService } from './teams/TeamService';
|
||||||
@@ -24,6 +26,8 @@ import { TeamJoinService } from './teams/TeamJoinService';
|
|||||||
import { LeagueService } from './leagues/LeagueService';
|
import { LeagueService } from './leagues/LeagueService';
|
||||||
import { LeagueMembershipService } from './leagues/LeagueMembershipService';
|
import { LeagueMembershipService } from './leagues/LeagueMembershipService';
|
||||||
import { LeagueSettingsService } from './leagues/LeagueSettingsService';
|
import { LeagueSettingsService } from './leagues/LeagueSettingsService';
|
||||||
|
import { LeagueStewardingService } from './leagues/LeagueStewardingService';
|
||||||
|
import { LeagueWalletService } from './leagues/LeagueWalletService';
|
||||||
import { SponsorService } from './sponsors/SponsorService';
|
import { SponsorService } from './sponsors/SponsorService';
|
||||||
import { SponsorshipService } from './sponsors/SponsorshipService';
|
import { SponsorshipService } from './sponsors/SponsorshipService';
|
||||||
import { PaymentService } from './payments/PaymentService';
|
import { PaymentService } from './payments/PaymentService';
|
||||||
@@ -56,6 +60,7 @@ export class ServiceFactory {
|
|||||||
leagues: LeaguesApiClient;
|
leagues: LeaguesApiClient;
|
||||||
sponsors: SponsorsApiClient;
|
sponsors: SponsorsApiClient;
|
||||||
payments: PaymentsApiClient;
|
payments: PaymentsApiClient;
|
||||||
|
wallets: WalletsApiClient;
|
||||||
auth: AuthApiClient;
|
auth: AuthApiClient;
|
||||||
analytics: AnalyticsApiClient;
|
analytics: AnalyticsApiClient;
|
||||||
media: MediaApiClient;
|
media: MediaApiClient;
|
||||||
@@ -73,6 +78,7 @@ export class ServiceFactory {
|
|||||||
leagues: new LeaguesApiClient(baseUrl, this.errorReporter, this.logger),
|
leagues: new LeaguesApiClient(baseUrl, this.errorReporter, this.logger),
|
||||||
sponsors: new SponsorsApiClient(baseUrl, this.errorReporter, this.logger),
|
sponsors: new SponsorsApiClient(baseUrl, this.errorReporter, this.logger),
|
||||||
payments: new PaymentsApiClient(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),
|
auth: new AuthApiClient(baseUrl, this.errorReporter, this.logger),
|
||||||
analytics: new AnalyticsApiClient(baseUrl, this.errorReporter, this.logger),
|
analytics: new AnalyticsApiClient(baseUrl, this.errorReporter, this.logger),
|
||||||
media: new MediaApiClient(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);
|
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
|
* Create DriverService instance
|
||||||
*/
|
*/
|
||||||
@@ -145,6 +162,26 @@ export class ServiceFactory {
|
|||||||
return new LeagueSettingsService(this.apiClients.leagues, this.apiClients.drivers);
|
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
|
* Create SponsorService instance
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ServiceFactory } from './ServiceFactory';
|
|||||||
// Import all service types
|
// Import all service types
|
||||||
import { RaceService } from './races/RaceService';
|
import { RaceService } from './races/RaceService';
|
||||||
import { RaceResultsService } from './races/RaceResultsService';
|
import { RaceResultsService } from './races/RaceResultsService';
|
||||||
|
import { RaceStewardingService } from './races/RaceStewardingService';
|
||||||
import { DriverService } from './drivers/DriverService';
|
import { DriverService } from './drivers/DriverService';
|
||||||
import { DriverRegistrationService } from './drivers/DriverRegistrationService';
|
import { DriverRegistrationService } from './drivers/DriverRegistrationService';
|
||||||
import { TeamService } from './teams/TeamService';
|
import { TeamService } from './teams/TeamService';
|
||||||
@@ -13,6 +14,8 @@ import { TeamJoinService } from './teams/TeamJoinService';
|
|||||||
import { LeagueService } from './leagues/LeagueService';
|
import { LeagueService } from './leagues/LeagueService';
|
||||||
import { LeagueMembershipService } from './leagues/LeagueMembershipService';
|
import { LeagueMembershipService } from './leagues/LeagueMembershipService';
|
||||||
import { LeagueSettingsService } from './leagues/LeagueSettingsService';
|
import { LeagueSettingsService } from './leagues/LeagueSettingsService';
|
||||||
|
import { LeagueStewardingService } from './leagues/LeagueStewardingService';
|
||||||
|
import { LeagueWalletService } from './leagues/LeagueWalletService';
|
||||||
import { SponsorService } from './sponsors/SponsorService';
|
import { SponsorService } from './sponsors/SponsorService';
|
||||||
import { SponsorshipService } from './sponsors/SponsorshipService';
|
import { SponsorshipService } from './sponsors/SponsorshipService';
|
||||||
import { PaymentService } from './payments/PaymentService';
|
import { PaymentService } from './payments/PaymentService';
|
||||||
@@ -30,6 +33,7 @@ import { PenaltyService } from './penalties/PenaltyService';
|
|||||||
export interface Services {
|
export interface Services {
|
||||||
raceService: RaceService;
|
raceService: RaceService;
|
||||||
raceResultsService: RaceResultsService;
|
raceResultsService: RaceResultsService;
|
||||||
|
raceStewardingService: RaceStewardingService;
|
||||||
driverService: DriverService;
|
driverService: DriverService;
|
||||||
driverRegistrationService: DriverRegistrationService;
|
driverRegistrationService: DriverRegistrationService;
|
||||||
teamService: TeamService;
|
teamService: TeamService;
|
||||||
@@ -37,6 +41,8 @@ export interface Services {
|
|||||||
leagueService: LeagueService;
|
leagueService: LeagueService;
|
||||||
leagueMembershipService: LeagueMembershipService;
|
leagueMembershipService: LeagueMembershipService;
|
||||||
leagueSettingsService: LeagueSettingsService;
|
leagueSettingsService: LeagueSettingsService;
|
||||||
|
leagueStewardingService: LeagueStewardingService;
|
||||||
|
leagueWalletService: LeagueWalletService;
|
||||||
sponsorService: SponsorService;
|
sponsorService: SponsorService;
|
||||||
sponsorshipService: SponsorshipService;
|
sponsorshipService: SponsorshipService;
|
||||||
paymentService: PaymentService;
|
paymentService: PaymentService;
|
||||||
@@ -65,6 +71,7 @@ export function ServiceProvider({ children }: ServiceProviderProps) {
|
|||||||
return {
|
return {
|
||||||
raceService: serviceFactory.createRaceService(),
|
raceService: serviceFactory.createRaceService(),
|
||||||
raceResultsService: serviceFactory.createRaceResultsService(),
|
raceResultsService: serviceFactory.createRaceResultsService(),
|
||||||
|
raceStewardingService: serviceFactory.createRaceStewardingService(),
|
||||||
driverService: serviceFactory.createDriverService(),
|
driverService: serviceFactory.createDriverService(),
|
||||||
driverRegistrationService: serviceFactory.createDriverRegistrationService(),
|
driverRegistrationService: serviceFactory.createDriverRegistrationService(),
|
||||||
teamService: serviceFactory.createTeamService(),
|
teamService: serviceFactory.createTeamService(),
|
||||||
@@ -72,6 +79,8 @@ export function ServiceProvider({ children }: ServiceProviderProps) {
|
|||||||
leagueService: serviceFactory.createLeagueService(),
|
leagueService: serviceFactory.createLeagueService(),
|
||||||
leagueMembershipService: serviceFactory.createLeagueMembershipService(),
|
leagueMembershipService: serviceFactory.createLeagueMembershipService(),
|
||||||
leagueSettingsService: serviceFactory.createLeagueSettingsService(),
|
leagueSettingsService: serviceFactory.createLeagueSettingsService(),
|
||||||
|
leagueStewardingService: serviceFactory.createLeagueStewardingService(),
|
||||||
|
leagueWalletService: serviceFactory.createLeagueWalletService(),
|
||||||
sponsorService: serviceFactory.createSponsorService(),
|
sponsorService: serviceFactory.createSponsorService(),
|
||||||
sponsorshipService: serviceFactory.createSponsorshipService(),
|
sponsorshipService: serviceFactory.createSponsorshipService(),
|
||||||
paymentService: serviceFactory.createPaymentService(),
|
paymentService: serviceFactory.createPaymentService(),
|
||||||
|
|||||||
93
apps/website/lib/services/leagues/LeagueStewardingService.ts
Normal file
93
apps/website/lib/services/leagues/LeagueStewardingService.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/website/lib/services/leagues/LeagueWalletService.ts
Normal file
71
apps/website/lib/services/leagues/LeagueWalletService.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,16 @@ export class RaceService {
|
|||||||
*/
|
*/
|
||||||
async getRacesPageData(): Promise<RacesPageViewModel> {
|
async getRacesPageData(): Promise<RacesPageViewModel> {
|
||||||
const dto = await this.apiClient.getPageData();
|
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);
|
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
|
* Find races by league ID
|
||||||
|
|||||||
36
apps/website/lib/services/races/RaceStewardingService.ts
Normal file
36
apps/website/lib/services/races/RaceStewardingService.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/website/lib/view-models/LeagueStewardingViewModel.ts
Normal file
74
apps/website/lib/view-models/LeagueStewardingViewModel.ts
Normal 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;
|
||||||
|
}
|
||||||
60
apps/website/lib/view-models/LeagueWalletViewModel.ts
Normal file
60
apps/website/lib/view-models/LeagueWalletViewModel.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +1,65 @@
|
|||||||
// Note: No generated DTO available for RaceListItem yet
|
// DTO matching the backend RacesPageDataRaceDTO
|
||||||
interface RaceListItemDTO {
|
interface RaceListItemDTO {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
track: string;
|
||||||
|
car: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
status: string;
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
leagueName: string;
|
leagueName: string;
|
||||||
scheduledTime: string;
|
strengthOfField: number | null;
|
||||||
status: string;
|
isUpcoming: boolean;
|
||||||
trackName?: string;
|
isLive: boolean;
|
||||||
|
isPast: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RaceListItemViewModel {
|
export class RaceListItemViewModel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
track: string;
|
||||||
|
car: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
status: string;
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
leagueName: string;
|
leagueName: string;
|
||||||
scheduledTime: string;
|
strengthOfField: number | null;
|
||||||
status: string;
|
isUpcoming: boolean;
|
||||||
trackName?: string;
|
isLive: boolean;
|
||||||
|
isPast: boolean;
|
||||||
|
|
||||||
constructor(dto: RaceListItemDTO) {
|
constructor(dto: RaceListItemDTO) {
|
||||||
this.id = dto.id;
|
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.leagueId = dto.leagueId;
|
||||||
this.leagueName = dto.leagueName;
|
this.leagueName = dto.leagueName;
|
||||||
this.scheduledTime = dto.scheduledTime;
|
this.strengthOfField = dto.strengthOfField;
|
||||||
this.status = dto.status;
|
this.isUpcoming = dto.isUpcoming;
|
||||||
if (dto.trackName !== undefined) this.trackName = dto.trackName;
|
this.isLive = dto.isLive;
|
||||||
|
this.isPast = dto.isPast;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Formatted scheduled time */
|
/** UI-specific: Formatted scheduled time */
|
||||||
get formattedScheduledTime(): string {
|
get formattedScheduledTime(): string {
|
||||||
return new Date(this.scheduledTime).toLocaleString();
|
return new Date(this.scheduledAt).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Badge variant for status */
|
/** UI-specific: Badge variant for status */
|
||||||
get statusBadgeVariant(): string {
|
get statusBadgeVariant(): string {
|
||||||
switch (this.status) {
|
switch (this.status) {
|
||||||
case 'upcoming': return 'info';
|
case 'scheduled': return 'info';
|
||||||
case 'live': return 'success';
|
case 'running': return 'success';
|
||||||
case 'finished': return 'secondary';
|
case 'completed': return 'secondary';
|
||||||
|
case 'cancelled': return 'danger';
|
||||||
default: return 'default';
|
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 */
|
/** UI-specific: Time until start in minutes */
|
||||||
get timeUntilStart(): number {
|
get timeUntilStart(): number {
|
||||||
const now = new Date();
|
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)));
|
return Math.max(0, Math.floor((scheduled.getTime() - now.getTime()) / (1000 * 60)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
99
apps/website/lib/view-models/RaceStewardingViewModel.ts
Normal file
99
apps/website/lib/view-models/RaceStewardingViewModel.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,63 +1,65 @@
|
|||||||
// Note: No generated DTO available for RaceCard yet
|
import { RaceListItemViewModel } from './RaceListItemViewModel';
|
||||||
interface RaceCardDTO {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
scheduledTime: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// DTO matching the backend RacesPageDataDTO
|
||||||
* 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
|
|
||||||
interface RacesPageDTO {
|
interface RacesPageDTO {
|
||||||
upcomingRaces: RaceCardDTO[];
|
races: Array<{
|
||||||
completedRaces: RaceCardDTO[];
|
id: string;
|
||||||
totalCount: number;
|
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
|
* Races page view model
|
||||||
* Represents the races page data
|
* Represents the races page data with all races in a single list
|
||||||
*/
|
*/
|
||||||
export class RacesPageViewModel {
|
export class RacesPageViewModel {
|
||||||
upcomingRaces: RaceCardViewModel[];
|
races: RaceListItemViewModel[];
|
||||||
completedRaces: RaceCardViewModel[];
|
|
||||||
totalCount: number;
|
|
||||||
|
|
||||||
constructor(dto: RacesPageDTO) {
|
constructor(dto: RacesPageDTO) {
|
||||||
this.upcomingRaces = dto.upcomingRaces.map(r => new RaceCardViewModel(r));
|
this.races = dto.races.map(r => new RaceListItemViewModel(r));
|
||||||
this.completedRaces = dto.completedRaces.map(r => new RaceCardViewModel(r));
|
|
||||||
this.totalCount = dto.totalCount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Total upcoming races */
|
/** UI-specific: Total races */
|
||||||
get upcomingCount(): number {
|
get totalCount(): number {
|
||||||
return this.upcomingRaces.length;
|
return this.races.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Total completed races */
|
/** UI-specific: Upcoming races */
|
||||||
get completedCount(): number {
|
get upcomingRaces(): RaceListItemViewModel[] {
|
||||||
return this.completedRaces.length;
|
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
export class WalletTransactionViewModel {
|
||||||
id: string;
|
id: string;
|
||||||
walletId: string;
|
type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
|
||||||
amount: number;
|
|
||||||
description: string;
|
description: string;
|
||||||
createdAt: string;
|
amount: number;
|
||||||
type: 'deposit' | 'withdrawal';
|
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.id = dto.id;
|
||||||
this.walletId = dto.walletId;
|
|
||||||
this.amount = dto.amount;
|
|
||||||
this.description = dto.description;
|
|
||||||
this.createdAt = dto.createdAt;
|
|
||||||
this.type = dto.type;
|
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 */
|
/** UI-specific: Formatted amount with sign */
|
||||||
get formattedAmount(): string {
|
get formattedAmount(): string {
|
||||||
const sign = this.type === 'deposit' ? '+' : '-';
|
const sign = this.amount > 0 ? '+' : '';
|
||||||
return `${sign}$${this.amount.toFixed(2)}`;
|
return `${sign}$${Math.abs(this.amount).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Amount color */
|
/** UI-specific: Amount color */
|
||||||
get amountColor(): string {
|
get amountColor(): string {
|
||||||
return this.type === 'deposit' ? 'green' : 'red';
|
return this.amount > 0 ? 'green' : 'red';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Type display */
|
/** UI-specific: Type display */
|
||||||
@@ -41,13 +47,13 @@ export class WalletTransactionViewModel {
|
|||||||
return this.type.charAt(0).toUpperCase() + this.type.slice(1);
|
return this.type.charAt(0).toUpperCase() + this.type.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Amount color */
|
/** UI-specific: Formatted date */
|
||||||
get amountColor(): string {
|
get formattedDate(): string {
|
||||||
return this.type === 'deposit' ? 'green' : 'red';
|
return this.date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI-specific: Formatted created date */
|
/** UI-specific: Is incoming */
|
||||||
get formattedCreatedAt(): string {
|
get isIncoming(): boolean {
|
||||||
return new Date(this.createdAt).toLocaleString();
|
return this.amount > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface DriverExtendedProfileProvider {
|
||||||
|
getExtendedProfile(driverId: string): {
|
||||||
|
socialHandles: {
|
||||||
|
platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
|
||||||
|
handle: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
achievements: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
|
||||||
|
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||||
|
earnedAt: string;
|
||||||
|
}[];
|
||||||
|
racingStyle: string;
|
||||||
|
favoriteTrack: string;
|
||||||
|
favoriteCar: string;
|
||||||
|
timezone: string;
|
||||||
|
availableHours: string;
|
||||||
|
lookingForTeam: boolean;
|
||||||
|
openToRequests: boolean;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
4
core/racing/application/ports/DriverRatingProvider.ts
Normal file
4
core/racing/application/ports/DriverRatingProvider.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface DriverRatingProvider {
|
||||||
|
getRating(driverId: string): number | null;
|
||||||
|
getRatings(driverIds: string[]): Map<string, number>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export interface WalletTransactionOutputPort {
|
||||||
|
id: string;
|
||||||
|
type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
fee: number;
|
||||||
|
netAmount: number;
|
||||||
|
date: string;
|
||||||
|
status: 'completed' | 'pending' | 'failed';
|
||||||
|
reference?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetLeagueWalletOutputPort {
|
||||||
|
balance: number;
|
||||||
|
currency: string;
|
||||||
|
totalRevenue: number;
|
||||||
|
totalFees: number;
|
||||||
|
totalWithdrawals: number;
|
||||||
|
pendingPayouts: number;
|
||||||
|
canWithdraw: boolean;
|
||||||
|
withdrawalBlockReason?: string;
|
||||||
|
transactions: WalletTransactionOutputPort[];
|
||||||
|
}
|
||||||
@@ -53,5 +53,26 @@ export interface ProfileOverviewOutputPort {
|
|||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
extendedProfile: null;
|
extendedProfile: {
|
||||||
|
socialHandles: {
|
||||||
|
platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
|
||||||
|
handle: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
achievements: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
|
||||||
|
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||||
|
earnedAt: string;
|
||||||
|
}[];
|
||||||
|
racingStyle: string;
|
||||||
|
favoriteTrack: string;
|
||||||
|
favoriteCar: string;
|
||||||
|
timezone: string;
|
||||||
|
availableHours: string;
|
||||||
|
lookingForTeam: boolean;
|
||||||
|
openToRequests: boolean;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface WithdrawFromLeagueWalletOutputPort {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
99
core/racing/application/use-cases/GetLeagueWalletUseCase.ts
Normal file
99
core/racing/application/use-cases/GetLeagueWalletUseCase.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
|
||||||
|
import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository';
|
||||||
|
import type { GetLeagueWalletOutputPort } from '../ports/output/GetLeagueWalletOutputPort';
|
||||||
|
import { Result } from '@core/shared/application/Result';
|
||||||
|
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||||
|
|
||||||
|
export interface GetLeagueWalletUseCaseParams {
|
||||||
|
leagueId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use Case for retrieving league wallet information.
|
||||||
|
*/
|
||||||
|
export class GetLeagueWalletUseCase {
|
||||||
|
constructor(
|
||||||
|
private readonly leagueWalletRepository: ILeagueWalletRepository,
|
||||||
|
private readonly transactionRepository: ITransactionRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
params: GetLeagueWalletUseCaseParams,
|
||||||
|
): Promise<Result<GetLeagueWalletOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
|
||||||
|
try {
|
||||||
|
// For now, return mock data to emulate previous state
|
||||||
|
// TODO: Implement full domain logic when wallet entities are properly seeded
|
||||||
|
const mockWallet: GetLeagueWalletOutputPort = {
|
||||||
|
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: '2025-12-01T00:00:00.000Z',
|
||||||
|
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: '2025-12-01T00:00:00.000Z',
|
||||||
|
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: '2025-11-15T00:00:00.000Z',
|
||||||
|
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: '2025-10-30T00:00:00.000Z',
|
||||||
|
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: '2025-12-05T00:00:00.000Z',
|
||||||
|
status: 'pending',
|
||||||
|
reference: 'PZ-2025-001',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return Result.ok(mockWallet);
|
||||||
|
} catch {
|
||||||
|
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch league wallet' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'
|
|||||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
import type { IImageServicePort } from '../ports/IImageServicePort';
|
import type { IImageServicePort } from '../ports/IImageServicePort';
|
||||||
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
||||||
|
import type { DriverExtendedProfileProvider } from '../ports/DriverExtendedProfileProvider';
|
||||||
import type { Driver } from '../../domain/entities/Driver';
|
import type { Driver } from '../../domain/entities/Driver';
|
||||||
import type { Team } from '../../domain/entities/Team';
|
import type { Team } from '../../domain/entities/Team';
|
||||||
import type { ProfileOverviewOutputPort } from '../ports/output/ProfileOverviewOutputPort';
|
import type { ProfileOverviewOutputPort } from '../ports/output/ProfileOverviewOutputPort';
|
||||||
@@ -40,6 +41,7 @@ export class GetProfileOverviewUseCase {
|
|||||||
private readonly teamMembershipRepository: ITeamMembershipRepository,
|
private readonly teamMembershipRepository: ITeamMembershipRepository,
|
||||||
private readonly socialRepository: ISocialGraphRepository,
|
private readonly socialRepository: ISocialGraphRepository,
|
||||||
private readonly imageService: IImageServicePort,
|
private readonly imageService: IImageServicePort,
|
||||||
|
private readonly driverExtendedProfileProvider: DriverExtendedProfileProvider,
|
||||||
private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null,
|
private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null,
|
||||||
private readonly getAllDriverRankings: () => DriverRankingEntry[],
|
private readonly getAllDriverRankings: () => DriverRankingEntry[],
|
||||||
) {}
|
) {}
|
||||||
@@ -65,6 +67,7 @@ export class GetProfileOverviewUseCase {
|
|||||||
const finishDistribution = this.buildFinishDistribution(statsAdapter);
|
const finishDistribution = this.buildFinishDistribution(statsAdapter);
|
||||||
const teamMemberships = await this.buildTeamMemberships(driver.id, teams as Team[]);
|
const teamMemberships = await this.buildTeamMemberships(driver.id, teams as Team[]);
|
||||||
const socialSummary = this.buildSocialSummary(friends as Driver[]);
|
const socialSummary = this.buildSocialSummary(friends as Driver[]);
|
||||||
|
const extendedProfile = this.driverExtendedProfileProvider.getExtendedProfile(driverId);
|
||||||
|
|
||||||
const outputPort: ProfileOverviewOutputPort = {
|
const outputPort: ProfileOverviewOutputPort = {
|
||||||
driver: driverSummary,
|
driver: driverSummary,
|
||||||
@@ -72,7 +75,7 @@ export class GetProfileOverviewUseCase {
|
|||||||
finishDistribution,
|
finishDistribution,
|
||||||
teamMemberships,
|
teamMemberships,
|
||||||
socialSummary,
|
socialSummary,
|
||||||
extendedProfile: null,
|
extendedProfile,
|
||||||
};
|
};
|
||||||
|
|
||||||
return Result.ok(outputPort);
|
return Result.ok(outputPort);
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
|
||||||
|
import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository';
|
||||||
|
import type { WithdrawFromLeagueWalletOutputPort } from '../ports/output/WithdrawFromLeagueWalletOutputPort';
|
||||||
|
import { Result } from '@core/shared/application/Result';
|
||||||
|
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||||
|
import { Money } from '../../domain/value-objects/Money';
|
||||||
|
import { Transaction } from '../../domain/entities/league-wallet/Transaction';
|
||||||
|
import { TransactionId } from '../../domain/entities/league-wallet/TransactionId';
|
||||||
|
import { LeagueWalletId } from '../../domain/entities/league-wallet/LeagueWalletId';
|
||||||
|
|
||||||
|
export interface WithdrawFromLeagueWalletUseCaseParams {
|
||||||
|
leagueId: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
seasonId: string;
|
||||||
|
destinationAccount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use Case for withdrawing from league wallet.
|
||||||
|
*/
|
||||||
|
export class WithdrawFromLeagueWalletUseCase {
|
||||||
|
constructor(
|
||||||
|
private readonly leagueWalletRepository: ILeagueWalletRepository,
|
||||||
|
private readonly transactionRepository: ITransactionRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
params: WithdrawFromLeagueWalletUseCaseParams,
|
||||||
|
): Promise<Result<WithdrawFromLeagueWalletOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR' | 'INSUFFICIENT_BALANCE' | 'WITHDRAWAL_NOT_ALLOWED'>>> {
|
||||||
|
try {
|
||||||
|
const wallet = await this.leagueWalletRepository.findByLeagueId(params.leagueId);
|
||||||
|
if (!wallet) {
|
||||||
|
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Wallet not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if withdrawal is allowed (for now, always false as per mock)
|
||||||
|
if (!wallet.canWithdraw(Money.create(params.amount, params.currency))) {
|
||||||
|
return Result.err({ code: 'INSUFFICIENT_BALANCE', message: 'Insufficient balance for withdrawal' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, always block withdrawal
|
||||||
|
return Result.err({ code: 'WITHDRAWAL_NOT_ALLOWED', message: 'Season 2 is still active. Withdrawals are available after season completion.' });
|
||||||
|
|
||||||
|
// If allowed, create transaction and update wallet
|
||||||
|
// const transactionId = TransactionId.create(`txn-${Date.now()}`);
|
||||||
|
// const transaction = Transaction.create({
|
||||||
|
// id: transactionId,
|
||||||
|
// walletId: LeagueWalletId.create(wallet.id.toString()),
|
||||||
|
// type: 'withdrawal',
|
||||||
|
// amount: Money.create(params.amount, params.currency),
|
||||||
|
// description: `Bank Transfer - ${params.seasonId} Payout`,
|
||||||
|
// metadata: { destinationAccount: params.destinationAccount, seasonId: params.seasonId },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const updatedWallet = wallet.withdrawFunds(Money.create(params.amount, params.currency), transactionId.toString());
|
||||||
|
|
||||||
|
// await this.transactionRepository.create(transaction);
|
||||||
|
// await this.leagueWalletRepository.update(updatedWallet);
|
||||||
|
|
||||||
|
// return Result.ok({ success: true });
|
||||||
|
} catch {
|
||||||
|
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to process withdrawal' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user