fix data flow issues

This commit is contained in:
2025-12-19 23:18:53 +01:00
parent ec177a75ce
commit 5c74837d73
45 changed files with 2726 additions and 746 deletions

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, HttpCode, HttpStatus, Param, Query } from '@nestjs/common';
import { Controller, Get, Post, Put, Body, HttpCode, HttpStatus, Param, Query } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { SponsorService } from './SponsorService';
import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshipPricingResultDTO';
@@ -13,6 +13,16 @@ import { GetSponsorOutputDTO } from './dtos/GetSponsorOutputDTO';
import { GetPendingSponsorshipRequestsOutputDTO } from './dtos/GetPendingSponsorshipRequestsOutputDTO';
import { AcceptSponsorshipRequestInputDTO } from './dtos/AcceptSponsorshipRequestInputDTO';
import { RejectSponsorshipRequestInputDTO } from './dtos/RejectSponsorshipRequestInputDTO';
import { PaymentMethodDTO } from './dtos/PaymentMethodDTO';
import { InvoiceDTO } from './dtos/InvoiceDTO';
import { BillingStatsDTO } from './dtos/BillingStatsDTO';
import { AvailableLeagueDTO } from './dtos/AvailableLeagueDTO';
import { LeagueDetailDTO } from './dtos/LeagueDetailDTO';
import { DriverDTO } from './dtos/DriverDTO';
import { RaceDTO } from './dtos/RaceDTO';
import { SponsorProfileDTO } from './dtos/SponsorProfileDTO';
import { NotificationSettingsDTO } from './dtos/NotificationSettingsDTO';
import { PrivacySettingsDTO } from './dtos/PrivacySettingsDTO';
import type { AcceptSponsorshipRequestResultPort } from '@core/racing/application/ports/output/AcceptSponsorshipRequestResultPort';
import type { RejectSponsorshipRequestResultDTO } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
@@ -93,4 +103,59 @@ export class SponsorController {
async rejectSponsorshipRequest(@Param('requestId') requestId: string, @Body() input: RejectSponsorshipRequestInputDTO): Promise<RejectSponsorshipRequestResultDTO | null> {
return this.sponsorService.rejectSponsorshipRequest(requestId, input.respondedBy, input.reason);
}
@Get('billing/:sponsorId')
@ApiOperation({ summary: 'Get sponsor billing information' })
@ApiResponse({ status: 200, description: 'Sponsor billing data', type: Object })
async getSponsorBilling(@Param('sponsorId') sponsorId: string): Promise<{
paymentMethods: PaymentMethodDTO[];
invoices: InvoiceDTO[];
stats: BillingStatsDTO;
}> {
return this.sponsorService.getSponsorBilling(sponsorId);
}
@Get('leagues/available')
@ApiOperation({ summary: 'Get available leagues for sponsorship' })
@ApiResponse({ status: 200, description: 'Available leagues', type: [AvailableLeagueDTO] })
async getAvailableLeagues(): Promise<AvailableLeagueDTO[]> {
return this.sponsorService.getAvailableLeagues();
}
@Get('leagues/:leagueId/detail')
@ApiOperation({ summary: 'Get detailed league information for sponsors' })
@ApiResponse({ status: 200, description: 'League detail data', type: Object })
async getLeagueDetail(@Param('leagueId') leagueId: string): Promise<{
league: LeagueDetailDTO;
drivers: DriverDTO[];
races: RaceDTO[];
}> {
return this.sponsorService.getLeagueDetail(leagueId);
}
@Get('settings/:sponsorId')
@ApiOperation({ summary: 'Get sponsor settings' })
@ApiResponse({ status: 200, description: 'Sponsor settings', type: Object })
async getSponsorSettings(@Param('sponsorId') sponsorId: string): Promise<{
profile: SponsorProfileDTO;
notifications: NotificationSettingsDTO;
privacy: PrivacySettingsDTO;
}> {
return this.sponsorService.getSponsorSettings(sponsorId);
}
@Put('settings/:sponsorId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update sponsor settings' })
@ApiResponse({ status: 200, description: 'Settings updated successfully' })
async updateSponsorSettings(
@Param('sponsorId') sponsorId: string,
@Body() input: {
profile?: Partial<SponsorProfileDTO>;
notifications?: Partial<NotificationSettingsDTO>;
privacy?: Partial<PrivacySettingsDTO>;
}
): Promise<void> {
return this.sponsorService.updateSponsorSettings(sponsorId, input);
}
}

View File

@@ -13,10 +13,11 @@ import { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/
import { INotificationService } from '@core/notifications/application/ports/INotificationService';
import { IPaymentGateway } from '@core/racing/application/ports/IPaymentGateway';
import { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository';
import { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository';
import type { Logger } from '@core/shared/application';
// Import use cases
// Import use cases / application services
import { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
import { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase';
import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
@@ -27,6 +28,7 @@ import { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSponsor
import { GetPendingSponsorshipRequestsUseCase } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
import { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
import { SponsorBillingService } from '@core/payments/application/services/SponsorBillingService';
// Import concrete in-memory implementations
import { InMemorySponsorRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorRepository';
@@ -37,6 +39,7 @@ import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
import { InMemorySponsorshipPricingRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipPricingRepository';
import { InMemorySponsorshipRequestRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipRequestRepository';
import { InMemoryPaymentRepository } from '@adapters/payments/persistence/inmemory/InMemoryPaymentRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Define injection tokens
@@ -50,7 +53,7 @@ export const SPONSORSHIP_PRICING_REPOSITORY_TOKEN = 'ISponsorshipPricingReposito
export const SPONSORSHIP_REQUEST_REPOSITORY_TOKEN = 'ISponsorshipRequestRepository';
export const LOGGER_TOKEN = 'Logger';
// Use case tokens
// Use case / application service tokens
export const GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN = 'GetSponsorshipPricingUseCase';
export const GET_SPONSORS_USE_CASE_TOKEN = 'GetSponsorsUseCase';
export const CREATE_SPONSOR_USE_CASE_TOKEN = 'CreateSponsorUseCase';
@@ -61,6 +64,7 @@ export const GET_SPONSOR_USE_CASE_TOKEN = 'GetSponsorUseCase';
export const GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN = 'GetPendingSponsorshipRequestsUseCase';
export const ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'AcceptSponsorshipRequestUseCase';
export const REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'RejectSponsorshipRequestUseCase';
export const SPONSOR_BILLING_SERVICE_TOKEN = 'SponsorBillingService';
export const SponsorProviders: Provider[] = [
SponsorService,
@@ -133,9 +137,28 @@ export const SponsorProviders: Provider[] = [
},
{
provide: GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN,
useFactory: (sponsorRepo: ISponsorRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, seasonRepo: ISeasonRepository, raceRepo: IRaceRepository) =>
new GetSponsorSponsorshipsUseCase(sponsorRepo, seasonSponsorshipRepo, seasonRepo, raceRepo),
inject: [SPONSOR_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SEASON_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN],
useFactory: (
sponsorRepo: ISponsorRepository,
seasonSponsorshipRepo: ISeasonSponsorshipRepository,
seasonRepo: ISeasonRepository,
leagueRepo: ILeagueRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
raceRepo: IRaceRepository,
) => new GetSponsorSponsorshipsUseCase(sponsorRepo, seasonSponsorshipRepo, seasonRepo, leagueRepo, leagueMembershipRepo, raceRepo),
inject: [
SPONSOR_REPOSITORY_TOKEN,
SEASON_SPONSORSHIP_REPOSITORY_TOKEN,
SEASON_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
],
},
{
provide: SPONSOR_BILLING_SERVICE_TOKEN,
useFactory: (paymentRepo: IPaymentRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository) =>
new SponsorBillingService(paymentRepo, seasonSponsorshipRepo),
inject: ['IPaymentRepository', SEASON_SPONSORSHIP_REPOSITORY_TOKEN],
},
{
provide: GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN,

View File

@@ -11,6 +11,16 @@ import { GetSponsorOutputDTO } from './dtos/GetSponsorOutputDTO';
import { GetPendingSponsorshipRequestsOutputDTO } from './dtos/GetPendingSponsorshipRequestsOutputDTO';
import { AcceptSponsorshipRequestInputDTO } from './dtos/AcceptSponsorshipRequestInputDTO';
import { RejectSponsorshipRequestInputDTO } from './dtos/RejectSponsorshipRequestInputDTO';
import { PaymentMethodDTO } from './dtos/PaymentMethodDTO';
import { InvoiceDTO } from './dtos/InvoiceDTO';
import { BillingStatsDTO } from './dtos/BillingStatsDTO';
import { AvailableLeagueDTO } from './dtos/AvailableLeagueDTO';
import { LeagueDetailDTO } from './dtos/LeagueDetailDTO';
import { DriverDTO } from './dtos/DriverDTO';
import { RaceDTO } from './dtos/RaceDTO';
import { SponsorProfileDTO } from './dtos/SponsorProfileDTO';
import { NotificationSettingsDTO } from './dtos/NotificationSettingsDTO';
import { PrivacySettingsDTO } from './dtos/PrivacySettingsDTO';
// Use cases
import { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
@@ -144,4 +154,259 @@ export class SponsorService {
}
return result.value;
}
async getSponsorBilling(sponsorId: string): Promise<{
paymentMethods: PaymentMethodDTO[];
invoices: InvoiceDTO[];
stats: BillingStatsDTO;
}> {
this.logger.debug('[SponsorService] Fetching sponsor billing.', { sponsorId });
// Mock data - in real implementation, this would come from repositories
const paymentMethods: PaymentMethodDTO[] = [
{
id: 'pm-1',
type: 'card',
last4: '4242',
brand: 'Visa',
isDefault: true,
expiryMonth: 12,
expiryYear: 2027,
},
{
id: 'pm-2',
type: 'card',
last4: '5555',
brand: 'Mastercard',
isDefault: false,
expiryMonth: 6,
expiryYear: 2026,
},
{
id: 'pm-3',
type: 'sepa',
last4: '8901',
bankName: 'Deutsche Bank',
isDefault: false,
},
];
const invoices: InvoiceDTO[] = [
{
id: 'inv-1',
invoiceNumber: 'GP-2025-001234',
date: '2025-11-01',
dueDate: '2025-11-15',
amount: 1090.91,
vatAmount: 207.27,
totalAmount: 1298.18,
status: 'paid',
description: 'GT3 Pro Championship - Primary Sponsor (Q4 2025)',
sponsorshipType: 'league',
pdfUrl: '#',
},
{
id: 'inv-2',
invoiceNumber: 'GP-2025-001235',
date: '2025-10-01',
dueDate: '2025-10-15',
amount: 363.64,
vatAmount: 69.09,
totalAmount: 432.73,
status: 'paid',
description: 'Team Velocity - Gear Sponsor (Q4 2025)',
sponsorshipType: 'team',
pdfUrl: '#',
},
{
id: 'inv-3',
invoiceNumber: 'GP-2025-001236',
date: '2025-12-01',
dueDate: '2025-12-15',
amount: 318.18,
vatAmount: 60.45,
totalAmount: 378.63,
status: 'pending',
description: 'Alex Thompson - Driver Sponsorship (Dec 2025)',
sponsorshipType: 'driver',
pdfUrl: '#',
},
];
const stats: BillingStatsDTO = {
totalSpent: 12450,
pendingAmount: 919.54,
nextPaymentDate: '2025-12-15',
nextPaymentAmount: 378.63,
activeSponsorships: 6,
averageMonthlySpend: 2075,
};
return { paymentMethods, invoices, stats };
}
async getAvailableLeagues(): Promise<AvailableLeagueDTO[]> {
this.logger.debug('[SponsorService] Fetching available leagues.');
// Mock data
return [
{
id: 'league-1',
name: 'GT3 Masters Championship',
game: 'iRacing',
drivers: 48,
avgViewsPerRace: 8200,
mainSponsorSlot: { available: true, price: 1200 },
secondarySlots: { available: 1, total: 2, price: 400 },
rating: 4.8,
tier: 'premium',
nextRace: '2025-12-20',
seasonStatus: 'active',
description: 'Premier GT3 racing with top-tier drivers. Weekly broadcasts and active community.',
},
{
id: 'league-2',
name: 'Endurance Pro Series',
game: 'ACC',
drivers: 72,
avgViewsPerRace: 12500,
mainSponsorSlot: { available: false, price: 1500 },
secondarySlots: { available: 2, total: 2, price: 500 },
rating: 4.9,
tier: 'premium',
nextRace: '2026-01-05',
seasonStatus: 'active',
description: 'Multi-class endurance racing. High engagement from dedicated endurance fans.',
},
];
}
async getLeagueDetail(leagueId: string): Promise<{
league: LeagueDetailDTO;
drivers: DriverDTO[];
races: RaceDTO[];
}> {
this.logger.debug('[SponsorService] Fetching league detail.', { leagueId });
// Mock data
const league: LeagueDetailDTO = {
id: leagueId,
name: 'GT3 Masters Championship',
game: 'iRacing',
tier: 'premium',
season: 'Season 3',
description: 'Premier GT3 racing with top-tier drivers competing across the world\'s most iconic circuits.',
drivers: 48,
races: 12,
completedRaces: 8,
totalImpressions: 45200,
avgViewsPerRace: 5650,
engagement: 4.2,
rating: 4.8,
seasonStatus: 'active',
seasonDates: { start: '2025-10-01', end: '2026-02-28' },
nextRace: { name: 'Spa-Francorchamps', date: '2025-12-20' },
sponsorSlots: {
main: {
available: true,
price: 1200,
benefits: [
'Primary logo placement on all liveries',
'League page header banner',
'Race results page branding',
'Social media feature posts',
'Newsletter sponsor spot',
]
},
secondary: {
available: 1,
total: 2,
price: 400,
benefits: [
'Secondary logo on liveries',
'League page sidebar placement',
'Race results mention',
'Social media mentions',
]
},
},
};
const drivers: DriverDTO[] = [
{ id: 'd1', name: 'Max Verstappen', country: 'NL', position: 1, races: 8, impressions: 4200, team: 'Red Bull Racing' },
{ id: 'd2', name: 'Lewis Hamilton', country: 'GB', position: 2, races: 8, impressions: 3980, team: 'Mercedes AMG' },
];
const races: RaceDTO[] = [
{ id: 'r1', name: 'Spa-Francorchamps', date: '2025-12-20', views: 0, status: 'upcoming' },
{ id: 'r2', name: 'Monza', date: '2025-12-08', views: 5800, status: 'completed' },
];
return { league, drivers, races };
}
async getSponsorSettings(sponsorId: string): Promise<{
profile: SponsorProfileDTO;
notifications: NotificationSettingsDTO;
privacy: PrivacySettingsDTO;
}> {
this.logger.debug('[SponsorService] Fetching sponsor settings.', { sponsorId });
// Mock data
const profile: SponsorProfileDTO = {
companyName: 'Acme Racing Co.',
contactName: 'John Smith',
contactEmail: 'sponsor@acme-racing.com',
contactPhone: '+1 (555) 123-4567',
website: 'https://acme-racing.com',
description: 'Premium sim racing equipment and accessories for competitive drivers.',
logoUrl: null,
industry: 'Racing Equipment',
address: {
street: '123 Racing Boulevard',
city: 'Indianapolis',
country: 'United States',
postalCode: '46222',
},
taxId: 'US12-3456789',
socialLinks: {
twitter: '@acmeracing',
linkedin: 'acme-racing-co',
instagram: '@acmeracing',
},
};
const notifications: NotificationSettingsDTO = {
emailNewSponsorships: true,
emailWeeklyReport: true,
emailRaceAlerts: false,
emailPaymentAlerts: true,
emailNewOpportunities: true,
emailContractExpiry: true,
};
const privacy: PrivacySettingsDTO = {
publicProfile: true,
showStats: false,
showActiveSponsorships: true,
allowDirectContact: true,
};
return { profile, notifications, privacy };
}
async updateSponsorSettings(
sponsorId: string,
input: {
profile?: Partial<SponsorProfileDTO>;
notifications?: Partial<NotificationSettingsDTO>;
privacy?: Partial<PrivacySettingsDTO>;
}
): Promise<void> {
this.logger.debug('[SponsorService] Updating sponsor settings.', { sponsorId, input });
// Mock implementation - in real app, this would persist to database
// For now, just log the update
this.logger.info('[SponsorService] Settings updated successfully.', { sponsorId });
}
}

View File

@@ -0,0 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum, IsOptional, IsNumber } from 'class-validator';
export class ActivityItemDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty({ enum: ['race', 'league', 'team', 'driver', 'platform'] })
@IsEnum(['race', 'league', 'team', 'driver', 'platform'])
type: 'race' | 'league' | 'team' | 'driver' | 'platform';
@ApiProperty()
@IsString()
message: string;
@ApiProperty()
@IsString()
time: string;
@ApiProperty({ required: false })
@IsOptional()
@IsNumber()
impressions?: number;
}

View File

@@ -0,0 +1,58 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum, IsNumber, IsBoolean, IsOptional, IsDateString } from 'class-validator';
export class AvailableLeagueDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsString()
game: string;
@ApiProperty()
@IsNumber()
drivers: number;
@ApiProperty()
@IsNumber()
avgViewsPerRace: number;
@ApiProperty({ type: Object })
mainSponsorSlot: {
available: boolean;
price: number;
};
@ApiProperty({ type: Object })
secondarySlots: {
available: number;
total: number;
price: number;
};
@ApiProperty()
@IsNumber()
rating: number;
@ApiProperty({ enum: ['premium', 'standard', 'starter'] })
@IsEnum(['premium', 'standard', 'starter'])
tier: 'premium' | 'standard' | 'starter';
@ApiProperty({ required: false })
@IsOptional()
@IsDateString()
nextRace?: string;
@ApiProperty({ enum: ['active', 'upcoming', 'completed'] })
@IsEnum(['active', 'upcoming', 'completed'])
seasonStatus: 'active' | 'upcoming' | 'completed';
@ApiProperty()
@IsString()
description: string;
}

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsDateString } from 'class-validator';
export class BillingStatsDTO {
@ApiProperty()
@IsNumber()
totalSpent: number;
@ApiProperty()
@IsNumber()
pendingAmount: number;
@ApiProperty()
@IsDateString()
nextPaymentDate: string;
@ApiProperty()
@IsNumber()
nextPaymentAmount: number;
@ApiProperty()
@IsNumber()
activeSponsorships: number;
@ApiProperty()
@IsNumber()
averageMonthlySpend: number;
}

View File

@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber } from 'class-validator';
export class DriverDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsString()
country: string;
@ApiProperty()
@IsNumber()
position: number;
@ApiProperty()
@IsNumber()
races: number;
@ApiProperty()
@IsNumber()
impressions: number;
@ApiProperty()
@IsString()
team: string;
}

View File

@@ -0,0 +1,48 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum, IsNumber, IsDateString } from 'class-validator';
export class InvoiceDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
invoiceNumber: string;
@ApiProperty()
@IsDateString()
date: string;
@ApiProperty()
@IsDateString()
dueDate: string;
@ApiProperty()
@IsNumber()
amount: number;
@ApiProperty()
@IsNumber()
vatAmount: number;
@ApiProperty()
@IsNumber()
totalAmount: number;
@ApiProperty({ enum: ['paid', 'pending', 'overdue', 'failed'] })
@IsEnum(['paid', 'pending', 'overdue', 'failed'])
status: 'paid' | 'pending' | 'overdue' | 'failed';
@ApiProperty()
@IsString()
description: string;
@ApiProperty({ enum: ['league', 'team', 'driver', 'race', 'platform'] })
@IsEnum(['league', 'team', 'driver', 'race', 'platform'])
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
@ApiProperty()
@IsString()
pdfUrl: string;
}

View File

@@ -0,0 +1,88 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum, IsNumber, IsDateString, IsOptional } from 'class-validator';
export class LeagueDetailDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsString()
game: string;
@ApiProperty({ enum: ['premium', 'standard', 'starter'] })
@IsEnum(['premium', 'standard', 'starter'])
tier: 'premium' | 'standard' | 'starter';
@ApiProperty()
@IsString()
season: string;
@ApiProperty()
@IsString()
description: string;
@ApiProperty()
@IsNumber()
drivers: number;
@ApiProperty()
@IsNumber()
races: number;
@ApiProperty()
@IsNumber()
completedRaces: number;
@ApiProperty()
@IsNumber()
totalImpressions: number;
@ApiProperty()
@IsNumber()
avgViewsPerRace: number;
@ApiProperty()
@IsNumber()
engagement: number;
@ApiProperty()
@IsNumber()
rating: number;
@ApiProperty({ enum: ['active', 'upcoming', 'completed'] })
@IsEnum(['active', 'upcoming', 'completed'])
seasonStatus: 'active' | 'upcoming' | 'completed';
@ApiProperty({ type: Object })
seasonDates: {
start: string;
end: string;
};
@ApiProperty({ type: Object, required: false })
@IsOptional()
nextRace?: {
name: string;
date: string;
};
@ApiProperty({ type: Object })
sponsorSlots: {
main: {
available: boolean;
price: number;
benefits: string[];
};
secondary: {
available: number;
total: number;
price: number;
benefits: string[];
};
};
}

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean } from 'class-validator';
export class NotificationSettingsDTO {
@ApiProperty()
@IsBoolean()
emailNewSponsorships: boolean;
@ApiProperty()
@IsBoolean()
emailWeeklyReport: boolean;
@ApiProperty()
@IsBoolean()
emailRaceAlerts: boolean;
@ApiProperty()
@IsBoolean()
emailPaymentAlerts: boolean;
@ApiProperty()
@IsBoolean()
emailNewOpportunities: boolean;
@ApiProperty()
@IsBoolean()
emailContractExpiry: boolean;
}

View File

@@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum, IsBoolean, IsOptional, IsNumber } from 'class-validator';
export class PaymentMethodDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty({ enum: ['card', 'bank', 'sepa'] })
@IsEnum(['card', 'bank', 'sepa'])
type: 'card' | 'bank' | 'sepa';
@ApiProperty()
@IsString()
last4: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
brand?: string;
@ApiProperty()
@IsBoolean()
isDefault: boolean;
@ApiProperty({ required: false })
@IsOptional()
@IsNumber()
expiryMonth?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsNumber()
expiryYear?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
bankName?: string;
}

View File

@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean } from 'class-validator';
export class PrivacySettingsDTO {
@ApiProperty()
@IsBoolean()
publicProfile: boolean;
@ApiProperty()
@IsBoolean()
showStats: boolean;
@ApiProperty()
@IsBoolean()
showActiveSponsorships: boolean;
@ApiProperty()
@IsBoolean()
allowDirectContact: boolean;
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum, IsNumber, IsDateString } from 'class-validator';
export class RaceDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
name: string;
@ApiProperty()
@IsDateString()
date: string;
@ApiProperty()
@IsNumber()
views: number;
@ApiProperty({ enum: ['upcoming', 'completed'] })
@IsEnum(['upcoming', 'completed'])
status: 'upcoming' | 'completed';
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum, IsNumber, IsDateString } from 'class-validator';
export class RenewalAlertDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
name: string;
@ApiProperty({ enum: ['league', 'team', 'driver', 'race', 'platform'] })
@IsEnum(['league', 'team', 'driver', 'race', 'platform'])
type: 'league' | 'team' | 'driver' | 'race' | 'platform';
@ApiProperty()
@IsDateString()
renewDate: string;
@ApiProperty()
@IsNumber()
price: number;
}

View File

@@ -1,8 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { IsString, IsArray, IsObject } from 'class-validator';
import { SponsorDashboardMetricsDTO } from './SponsorDashboardMetricsDTO';
import { SponsoredLeagueDTO } from './SponsoredLeagueDTO';
import { SponsorDashboardInvestmentDTO } from './SponsorDashboardInvestmentDTO';
import { SponsorshipDTO } from './SponsorshipDTO';
import { ActivityItemDTO } from './ActivityItemDTO';
import { RenewalAlertDTO } from './RenewalAlertDTO';
export class SponsorDashboardDTO {
@ApiProperty()
@@ -21,4 +24,19 @@ export class SponsorDashboardDTO {
@ApiProperty({ type: SponsorDashboardInvestmentDTO })
investment: SponsorDashboardInvestmentDTO;
@ApiProperty({ type: Object })
sponsorships: {
leagues: SponsorshipDTO[];
teams: SponsorshipDTO[];
drivers: SponsorshipDTO[];
races: SponsorshipDTO[];
platform: SponsorshipDTO[];
};
@ApiProperty({ type: [ActivityItemDTO] })
recentActivity: ActivityItemDTO[];
@ApiProperty({ type: [RenewalAlertDTO] })
upcomingRenewals: RenewalAlertDTO[];
}

View File

@@ -0,0 +1,56 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsOptional, IsObject } from 'class-validator';
export class SponsorProfileDTO {
@ApiProperty()
@IsString()
companyName: string;
@ApiProperty()
@IsString()
contactName: string;
@ApiProperty()
@IsString()
contactEmail: string;
@ApiProperty()
@IsString()
contactPhone: string;
@ApiProperty()
@IsString()
website: string;
@ApiProperty()
@IsString()
description: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
logoUrl?: string;
@ApiProperty()
@IsString()
industry: string;
@ApiProperty({ type: Object })
address: {
street: string;
city: string;
country: string;
postalCode: string;
};
@ApiProperty()
@IsString()
taxId: string;
@ApiProperty({ type: Object })
socialLinks: {
twitter: string;
linkedin: string;
instagram: string;
};
}

View File

@@ -0,0 +1,85 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum, IsNumber, IsOptional, IsDateString } from 'class-validator';
export class SponsorshipDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty({ enum: ['leagues', 'teams', 'drivers', 'races', 'platform'] })
@IsEnum(['leagues', 'teams', 'drivers', 'races', 'platform'])
type: 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
@ApiProperty()
@IsString()
entityId: string;
@ApiProperty()
@IsString()
entityName: string;
@ApiProperty({ enum: ['main', 'secondary'], required: false })
@IsOptional()
@IsEnum(['main', 'secondary'])
tier?: 'main' | 'secondary';
@ApiProperty({ enum: ['active', 'pending_approval', 'approved', 'rejected', 'expired'] })
@IsEnum(['active', 'pending_approval', 'approved', 'rejected', 'expired'])
status: 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired';
@ApiProperty({ required: false })
@IsOptional()
@IsDateString()
applicationDate?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsDateString()
approvalDate?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
rejectionReason?: string;
@ApiProperty()
@IsDateString()
startDate: string;
@ApiProperty()
@IsDateString()
endDate: string;
@ApiProperty()
@IsNumber()
price: number;
@ApiProperty()
@IsNumber()
impressions: number;
@ApiProperty({ required: false })
@IsOptional()
@IsNumber()
impressionsChange?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsNumber()
engagement?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
details?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
entityOwner?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
applicationMessage?: string;
}

View File

@@ -2,44 +2,57 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import OnboardingWizard from '@/components/onboarding/OnboardingWizard';
import { Loader2 } from 'lucide-react';
// TODO: Re-enable API integration once backend is ready
// import { redirect } from 'next/navigation';
import OnboardingWizard from '@/components/onboarding/OnboardingWizard';
import { useAuth } from '@/lib/auth/AuthContext';
import { useServices } from '@/lib/services/ServiceProvider';
export default function OnboardingPage() {
const router = useRouter();
const { session } = useAuth();
const { driverService } = useServices();
const [checking, setChecking] = useState(true);
useEffect(() => {
// TODO: Re-enable auth check once backend is ready
// For now, just show onboarding after a brief check
const checkDemoMode = () => {
// Check if user has demo mode cookie
const cookies = document.cookie.split(';');
const demoModeCookie = cookies.find(c => c.trim().startsWith('gridpilot_demo_mode='));
if (!demoModeCookie) {
// Not logged in, redirect to auth
router.push('/auth/login?returnTo=/onboarding');
return;
// If user is not authenticated, redirect to login
if (!session) {
router.replace('/auth/login?returnTo=/onboarding');
return;
}
let cancelled = false;
const checkOnboarding = async () => {
try {
const driver = await driverService.getCurrentDriver();
if (cancelled) return;
// If driver profile exists, onboarding is complete go to dashboard
if (driver) {
router.replace('/dashboard');
return;
}
// Otherwise allow onboarding wizard to render
setChecking(false);
} catch {
// On error, allow onboarding to proceed so user isn't blocked
if (!cancelled) {
setChecking(false);
}
}
// For demo, skip onboarding and go to dashboard
// In production, this would check if onboarding is complete
router.push('/dashboard');
};
// Brief delay to prevent flash
const timer = setTimeout(() => {
checkDemoMode();
}, 500);
checkOnboarding();
return () => clearTimeout(timer);
}, [router]);
return () => {
cancelled = true;
};
}, [session, driverService, router]);
// Show loading while checking
// Show loading while checking auth/onboarding status
if (checking) {
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">

View File

@@ -14,19 +14,39 @@ import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
import MockupStack from '@/components/ui/MockupStack';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { LandingService } from '@/lib/services/landing/LandingService';
import { SessionService } from '@/lib/services/auth/SessionService';
import { AuthApiClient } from '@/lib/api/auth/AuthApiClient';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
export default async function HomePage() {
const session = null; // TODO
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const errorReporter = new ConsoleErrorReporter();
const logger = new ConsoleLogger();
const authApiClient = new AuthApiClient(baseUrl, errorReporter, logger);
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
const sessionService = new SessionService(authApiClient);
const landingService = new LandingService(racesApiClient, leaguesApiClient, teamsApiClient);
const session = await sessionService.getSession();
if (session) {
redirect('/dashboard');
}
const mode = getAppMode();
const isAlpha = mode === 'alpha';
// const upcomingRaces = getUpcomingRaces(3);
const upcomingRaces = []; // TODO
const topLeagues = []; // TODO
const teams = []; // TODO
const discovery = await landingService.getHomeDiscovery();
const upcomingRaces = discovery.upcomingRaces;
const topLeagues = discovery.topLeagues;
const teams = discovery.teams;
return (
<main className="min-h-screen">
@@ -332,10 +352,7 @@ export default async function HomePage() {
<p className="text-xs text-gray-400 truncate">{race.car}</p>
</div>
<div className="text-right text-xs text-gray-500 whitespace-nowrap">
{race.scheduledAt.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
})}
{race.formattedDate}
</div>
</li>
))}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
@@ -10,6 +10,9 @@ import StatusBadge from '@/components/ui/StatusBadge';
import InfoBanner from '@/components/ui/InfoBanner';
import PageHeader from '@/components/ui/PageHeader';
import { siteConfig } from '@/lib/siteConfig';
import { BillingViewModel } from '@/lib/view-models/BillingViewModel';
import { SponsorService } from '@/lib/services/sponsors/SponsorService';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import {
CreditCard,
DollarSign,
@@ -73,108 +76,17 @@ interface BillingStats {
// Mock Data
// ============================================================================
const MOCK_PAYMENT_METHODS: PaymentMethod[] = [
{
id: 'pm-1',
type: 'card',
last4: '4242',
brand: 'Visa',
isDefault: true,
expiryMonth: 12,
expiryYear: 2027,
},
{
id: 'pm-2',
type: 'card',
last4: '5555',
brand: 'Mastercard',
isDefault: false,
expiryMonth: 6,
expiryYear: 2026,
},
{
id: 'pm-3',
type: 'sepa',
last4: '8901',
bankName: 'Deutsche Bank',
isDefault: false,
},
];
const MOCK_INVOICES: Invoice[] = [
{
id: 'inv-1',
invoiceNumber: 'GP-2025-001234',
date: new Date('2025-11-01'),
dueDate: new Date('2025-11-15'),
amount: 1090.91,
vatAmount: 207.27,
totalAmount: 1298.18,
status: 'paid',
description: 'GT3 Pro Championship - Primary Sponsor (Q4 2025)',
sponsorshipType: 'league',
pdfUrl: '#',
},
{
id: 'inv-2',
invoiceNumber: 'GP-2025-001235',
date: new Date('2025-10-01'),
dueDate: new Date('2025-10-15'),
amount: 363.64,
vatAmount: 69.09,
totalAmount: 432.73,
status: 'paid',
description: 'Team Velocity - Gear Sponsor (Q4 2025)',
sponsorshipType: 'team',
pdfUrl: '#',
},
{
id: 'inv-3',
invoiceNumber: 'GP-2025-001236',
date: new Date('2025-12-01'),
dueDate: new Date('2025-12-15'),
amount: 318.18,
vatAmount: 60.45,
totalAmount: 378.63,
status: 'pending',
description: 'Alex Thompson - Driver Sponsorship (Dec 2025)',
sponsorshipType: 'driver',
pdfUrl: '#',
},
{
id: 'inv-4',
invoiceNumber: 'GP-2025-001237',
date: new Date('2025-11-15'),
dueDate: new Date('2025-11-29'),
amount: 454.55,
vatAmount: 86.36,
totalAmount: 540.91,
status: 'overdue',
description: 'Touring Car Cup - Secondary Sponsor (Q1 2026)',
sponsorshipType: 'league',
pdfUrl: '#',
},
];
const MOCK_STATS: BillingStats = {
totalSpent: 12450,
pendingAmount: 919.54,
nextPaymentDate: new Date('2025-12-15'),
nextPaymentAmount: 378.63,
activeSponsorships: 6,
averageMonthlySpend: 2075,
};
// ============================================================================
// Components
// ============================================================================
function PaymentMethodCard({
method,
method,
onSetDefault,
onRemove
}: {
method: PaymentMethod;
onRemove
}: {
method: any;
onSetDefault: () => void;
onRemove: () => void;
}) {
@@ -214,16 +126,16 @@ function PaymentMethodCard({
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{getLabel()}</span>
<span className="font-medium text-white">{method.displayLabel}</span>
{method.isDefault && (
<span className="px-2 py-0.5 rounded-full text-xs bg-primary-blue/20 text-primary-blue font-medium">
Default
</span>
)}
</div>
{method.expiryMonth && method.expiryYear && (
{method.expiryDisplay && (
<span className="text-sm text-gray-500">
Expires {String(method.expiryMonth).padStart(2, '0')}/{method.expiryYear}
Expires {method.expiryDisplay}
</span>
)}
{method.type === 'sepa' && (
@@ -246,7 +158,7 @@ function PaymentMethodCard({
);
}
function InvoiceRow({ invoice, index }: { invoice: Invoice; index: number }) {
function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
const shouldReduceMotion = useReducedMotion();
const statusConfig = {
@@ -313,7 +225,7 @@ function InvoiceRow({ invoice, index }: { invoice: Invoice; index: number }) {
<span>{invoice.invoiceNumber}</span>
<span></span>
<span>
{invoice.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
{invoice.formattedDate}
</span>
</div>
</div>
@@ -322,13 +234,13 @@ function InvoiceRow({ invoice, index }: { invoice: Invoice; index: number }) {
<div className="flex items-center gap-6">
<div className="text-right">
<div className="font-semibold text-white">
{invoice.totalAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}
{invoice.formattedTotalAmount}
</div>
<div className="text-xs text-gray-500">
incl. {invoice.vatAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })} VAT
incl. {invoice.formattedVatAmount} VAT
</div>
</div>
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${status.bg} ${status.color} border ${status.border}`}>
<StatusIcon className="w-3 h-3" />
{status.label}
@@ -349,9 +261,49 @@ function InvoiceRow({ invoice, index }: { invoice: Invoice; index: number }) {
export default function SponsorBillingPage() {
const shouldReduceMotion = useReducedMotion();
const [paymentMethods, setPaymentMethods] = useState(MOCK_PAYMENT_METHODS);
const [data, setData] = useState<BillingViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAllInvoices, setShowAllInvoices] = useState(false);
useEffect(() => {
const loadBilling = async () => {
try {
const sponsorService = ServiceFactory.getSponsorService();
const billingData = await sponsorService.getBilling('demo-sponsor-1');
setData(new BillingViewModel(billingData));
} catch (err) {
console.error('Error loading billing data:', err);
setError('Failed to load billing data');
} finally {
setLoading(false);
}
};
loadBilling();
}, []);
if (loading) {
return (
<div className="max-w-5xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400">Loading billing data...</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="max-w-5xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="text-gray-400">{error || 'No billing data available'}</p>
</div>
</div>
);
}
const handleSetDefault = (methodId: string) => {
setPaymentMethods(methods =>
methods.map(m => ({ ...m, isDefault: m.id === methodId }))
@@ -364,7 +316,17 @@ export default function SponsorBillingPage() {
}
};
const displayedInvoices = showAllInvoices ? MOCK_INVOICES : MOCK_INVOICES.slice(0, 4);
const handleSetDefault = (methodId: string) => {
// In a real app, this would call an API
console.log('Setting default payment method:', methodId);
};
const handleRemoveMethod = (methodId: string) => {
if (confirm('Remove this payment method?')) {
// In a real app, this would call an API
console.log('Removing payment method:', methodId);
}
};
const containerVariants = {
hidden: { opacity: 0 },
@@ -404,7 +366,7 @@ export default function SponsorBillingPage() {
<StatCard
icon={DollarSign}
label="Total Spent"
value={`${MOCK_STATS.totalSpent.toLocaleString('de-DE')}`}
value={data.stats.formattedTotalSpent}
subValue="All time"
color="text-performance-green"
bgColor="bg-performance-green/10"
@@ -412,23 +374,23 @@ export default function SponsorBillingPage() {
<StatCard
icon={AlertTriangle}
label="Pending Payments"
value={`${MOCK_STATS.pendingAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`}
subValue={`${MOCK_INVOICES.filter(i => i.status === 'pending' || i.status === 'overdue').length} invoices`}
value={data.stats.formattedPendingAmount}
subValue={`${data.invoices.filter(i => i.status === 'pending' || i.status === 'overdue').length} invoices`}
color="text-warning-amber"
bgColor="bg-warning-amber/10"
/>
<StatCard
icon={Calendar}
label="Next Payment"
value={MOCK_STATS.nextPaymentDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
subValue={`${MOCK_STATS.nextPaymentAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`}
value={data.stats.formattedNextPaymentDate}
subValue={data.stats.formattedNextPaymentAmount}
color="text-primary-blue"
bgColor="bg-primary-blue/10"
/>
<StatCard
icon={TrendingUp}
label="Monthly Average"
value={`${MOCK_STATS.averageMonthlySpend.toLocaleString('de-DE')}`}
value={data.stats.formattedAverageMonthlySpend}
subValue="Last 6 months"
color="text-gray-400"
bgColor="bg-iron-gray"
@@ -449,10 +411,10 @@ export default function SponsorBillingPage() {
}
/>
<div className="p-5 space-y-3">
{paymentMethods.map((method) => (
<PaymentMethodCard
key={method.id}
method={method}
{data.paymentMethods.map((method) => (
<PaymentMethodCard
key={method.id}
method={method}
onSetDefault={() => handleSetDefault(method.id)}
onRemove={() => handleRemoveMethod(method.id)}
/>
@@ -482,18 +444,18 @@ export default function SponsorBillingPage() {
}
/>
<div>
{displayedInvoices.map((invoice, index) => (
{data.invoices.slice(0, showAllInvoices ? data.invoices.length : 4).map((invoice, index) => (
<InvoiceRow key={invoice.id} invoice={invoice} index={index} />
))}
</div>
{MOCK_INVOICES.length > 4 && (
{data.invoices.length > 4 && (
<div className="p-4 border-t border-charcoal-outline">
<Button
variant="secondary"
<Button
variant="secondary"
className="w-full"
onClick={() => setShowAllInvoices(!showAllInvoices)}
>
{showAllInvoices ? 'Show Less' : `View All ${MOCK_INVOICES.length} Invoices`}
{showAllInvoices ? 'Show Less' : `View All ${data.invoices.length} Invoices`}
<ChevronRight className={`w-4 h-4 ml-2 transition-transform ${showAllInvoices ? 'rotate-90' : ''}`} />
</Button>
</div>

View File

@@ -8,6 +8,9 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import StatusBadge from '@/components/ui/StatusBadge';
import InfoBanner from '@/components/ui/InfoBanner';
import { SponsorService } from '@/lib/services/sponsors/SponsorService';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { SponsorSponsorshipsViewModel } from '@/lib/view-models/SponsorSponsorshipsViewModel';
import {
Megaphone,
Trophy,
@@ -69,241 +72,6 @@ interface Sponsorship {
// Mock Data - Updated to show application workflow
// ============================================================================
const MOCK_SPONSORSHIPS: Sponsorship[] = [
// Active sponsorships (approved and started)
{
id: 's1',
type: 'leagues',
entityId: 'l1',
entityName: 'GT3 Masters Championship',
tier: 'main',
status: 'active',
applicationDate: new Date('2025-09-15'),
approvalDate: new Date('2025-09-18'),
startDate: new Date('2025-10-01'),
endDate: new Date('2026-02-28'),
price: 1200,
impressions: 45200,
impressionsChange: 12.5,
engagement: 4.2,
details: '48 drivers • 12 races',
entityOwner: 'Championship Admin',
},
{
id: 's2',
type: 'leagues',
entityId: 'l2',
entityName: 'Endurance Pro Series',
tier: 'secondary',
status: 'active',
applicationDate: new Date('2025-10-20'),
approvalDate: new Date('2025-10-25'),
startDate: new Date('2025-11-01'),
endDate: new Date('2026-03-31'),
price: 500,
impressions: 38400,
impressionsChange: 8.3,
engagement: 3.8,
details: '72 drivers • 6 races',
entityOwner: 'Endurance Racing LLC',
},
{
id: 's3',
type: 'teams',
entityId: 't1',
entityName: 'Velocity Racing',
status: 'active',
applicationDate: new Date('2025-08-25'),
approvalDate: new Date('2025-08-26'),
startDate: new Date('2025-09-01'),
endDate: new Date('2026-08-31'),
price: 400,
impressions: 12300,
impressionsChange: 5.2,
engagement: 5.1,
details: '4 drivers • GT3 & LMP',
entityOwner: 'Team Principal',
},
// Pending approval (waiting for entity owner)
{
id: 's9',
type: 'leagues',
entityId: 'l3',
entityName: 'Formula Sim Series',
tier: 'main',
status: 'pending_approval',
applicationDate: new Date('2025-12-14'),
startDate: new Date('2026-01-01'),
endDate: new Date('2026-06-30'),
price: 1500,
impressions: 0,
details: '36 drivers • F3 class',
entityOwner: 'Formula Sim Organization',
applicationMessage: 'We would love to be your main sponsor for the upcoming season.',
},
{
id: 's10',
type: 'teams',
entityId: 't3',
entityName: 'Phoenix Racing Team',
status: 'pending_approval',
applicationDate: new Date('2025-12-12'),
startDate: new Date('2026-01-01'),
endDate: new Date('2026-12-31'),
price: 600,
impressions: 0,
details: '5 drivers • Multi-class',
entityOwner: 'Phoenix Team Manager',
applicationMessage: 'Interested in sponsoring your team for the full 2026 season.',
},
{
id: 's11',
type: 'drivers',
entityId: 'd3',
entityName: 'James Rodriguez',
status: 'pending_approval',
applicationDate: new Date('2025-12-10'),
startDate: new Date('2026-01-01'),
endDate: new Date('2026-12-31'),
price: 250,
impressions: 0,
details: 'Rising rookie • GT3 Masters',
entityOwner: 'James Rodriguez',
applicationMessage: 'Would like to support your racing career.',
},
// Recently approved (not yet started)
{
id: 's12',
type: 'races',
entityId: 'r1',
entityName: 'Spa 24 Hours',
status: 'approved',
applicationDate: new Date('2025-12-01'),
approvalDate: new Date('2025-12-05'),
startDate: new Date('2025-12-20'),
endDate: new Date('2025-12-21'),
price: 300,
impressions: 0,
details: 'Endurance Pro Series • Dec 20-21',
entityOwner: 'Race Director',
},
{
id: 's13',
type: 'drivers',
entityId: 'd4',
entityName: 'Emma Wilson',
status: 'approved',
applicationDate: new Date('2025-12-08'),
approvalDate: new Date('2025-12-10'),
startDate: new Date('2026-01-01'),
endDate: new Date('2026-12-31'),
price: 180,
impressions: 0,
details: 'Touring Car specialist',
entityOwner: 'Emma Wilson',
},
// Rejected applications
{
id: 's14',
type: 'leagues',
entityId: 'l4',
entityName: 'Elite GT Championship',
tier: 'main',
status: 'rejected',
applicationDate: new Date('2025-11-20'),
startDate: new Date('2026-01-01'),
endDate: new Date('2026-06-30'),
price: 2000,
impressions: 0,
details: '24 drivers • Invite-only',
entityOwner: 'Elite Racing Committee',
rejectionReason: 'Main sponsor position already filled for the upcoming season.',
},
{
id: 's15',
type: 'teams',
entityId: 't4',
entityName: 'Apex Motorsport',
status: 'rejected',
applicationDate: new Date('2025-11-15'),
startDate: new Date('2026-01-01'),
endDate: new Date('2026-12-31'),
price: 450,
impressions: 0,
details: '3 drivers • LMP2',
entityOwner: 'Apex Team Owner',
rejectionReason: 'Already have exclusive sponsor agreement in this category.',
},
// Existing active ones
{
id: 's4',
type: 'teams',
entityId: 't2',
entityName: 'Storm Motorsport',
status: 'active',
applicationDate: new Date('2025-10-01'),
approvalDate: new Date('2025-10-05'),
startDate: new Date('2025-10-15'),
endDate: new Date('2026-10-14'),
price: 350,
impressions: 8900,
impressionsChange: -2.1,
engagement: 4.5,
details: '3 drivers • Formula',
entityOwner: 'Storm Racing LLC',
},
{
id: 's5',
type: 'drivers',
entityId: 'd1',
entityName: 'Max Velocity',
status: 'active',
applicationDate: new Date('2025-10-20'),
approvalDate: new Date('2025-10-21'),
startDate: new Date('2025-11-01'),
endDate: new Date('2026-10-31'),
price: 200,
impressions: 8200,
impressionsChange: 15.8,
engagement: 6.2,
details: 'Velocity Racing • P1 in GT3 Masters',
entityOwner: 'Max Velocity',
},
{
id: 's6',
type: 'drivers',
entityId: 'd2',
entityName: 'Sarah Storm',
status: 'active',
applicationDate: new Date('2025-09-25'),
approvalDate: new Date('2025-09-26'),
startDate: new Date('2025-10-01'),
endDate: new Date('2026-09-30'),
price: 150,
impressions: 6100,
impressionsChange: 22.4,
engagement: 5.8,
details: 'Storm Motorsport • Rising star',
entityOwner: 'Sarah Storm',
},
{
id: 's8',
type: 'platform',
entityId: 'p1',
entityName: 'Homepage Banner',
status: 'active',
applicationDate: new Date('2025-11-25'),
approvalDate: new Date('2025-11-25'),
startDate: new Date('2025-12-01'),
endDate: new Date('2025-12-31'),
price: 500,
impressions: 52000,
impressionsChange: 3.4,
engagement: 2.1,
details: 'Header position • All pages',
entityOwner: 'GridPilot',
},
];
// ============================================================================
// Configuration
@@ -360,7 +128,7 @@ const STATUS_CONFIG = {
// Components
// ============================================================================
function SponsorshipCard({ sponsorship }: { sponsorship: Sponsorship }) {
function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
const router = useRouter();
const shouldReduceMotion = useReducedMotion();
@@ -492,7 +260,7 @@ function SponsorshipCard({ sponsorship }: { sponsorship: Sponsorship }) {
Impressions
</div>
<div className="flex items-center gap-2">
<span className="text-white font-semibold">{sponsorship.impressions.toLocaleString()}</span>
<span className="text-white font-semibold">{sponsorship.formattedImpressions}</span>
{sponsorship.impressionsChange !== undefined && sponsorship.impressionsChange !== 0 && (
<span className={`text-xs flex items-center ${
sponsorship.impressionsChange > 0 ? 'text-performance-green' : 'text-racing-red'
@@ -529,7 +297,7 @@ function SponsorshipCard({ sponsorship }: { sponsorship: Sponsorship }) {
<Trophy className="w-3 h-3" />
Investment
</div>
<div className="text-white font-semibold">${sponsorship.price}</div>
<div className="text-white font-semibold">{sponsorship.formattedPrice}</div>
</div>
</div>
)}
@@ -539,11 +307,11 @@ function SponsorshipCard({ sponsorship }: { sponsorship: Sponsorship }) {
<div className="flex items-center gap-4 mb-4 text-sm">
<div className="flex items-center gap-1 text-gray-400">
<Calendar className="w-3.5 h-3.5" />
{sponsorship.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - {sponsorship.endDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
{sponsorship.periodDisplay}
</div>
<div className="flex items-center gap-1 text-gray-400">
<Trophy className="w-3.5 h-3.5" />
${sponsorship.price}
{sponsorship.formattedPrice}
</div>
</div>
)}
@@ -597,20 +365,59 @@ export default function SponsorCampaignsPage() {
const router = useRouter();
const searchParams = useSearchParams();
const shouldReduceMotion = useReducedMotion();
const initialType = (searchParams.get('type') as SponsorshipType) || 'all';
const [typeFilter, setTypeFilter] = useState<SponsorshipType>(initialType);
const [statusFilter, setStatusFilter] = useState<SponsorshipStatus>('all');
const [searchQuery, setSearchQuery] = useState('');
const [data, setData] = useState<SponsorSponsorshipsViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 300);
return () => clearTimeout(timer);
const loadSponsorships = async () => {
try {
const sponsorService = ServiceFactory.getSponsorService();
const sponsorshipsData = await sponsorService.getSponsorSponsorships('demo-sponsor-1');
if (sponsorshipsData) {
setData(sponsorshipsData);
} else {
setError('Failed to load sponsorships data');
}
} catch (err) {
console.error('Error loading sponsorships:', err);
setError('Failed to load sponsorships data');
} finally {
setLoading(false);
}
};
loadSponsorships();
}, []);
if (loading) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400">Loading sponsorships...</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="text-gray-400">{error || 'No sponsorships data available'}</p>
</div>
</div>
);
}
// Filter sponsorships
const filteredSponsorships = MOCK_SPONSORSHIPS.filter(s => {
const filteredSponsorships = data.sponsorships.filter(s => {
if (typeFilter !== 'all' && s.type !== typeFilter) return false;
if (statusFilter !== 'all' && s.status !== statusFilter) return false;
if (searchQuery && !s.entityName.toLowerCase().includes(searchQuery.toLowerCase())) return false;
@@ -619,22 +426,22 @@ export default function SponsorCampaignsPage() {
// Calculate stats
const stats = {
total: MOCK_SPONSORSHIPS.length,
active: MOCK_SPONSORSHIPS.filter(s => s.status === 'active').length,
pending: MOCK_SPONSORSHIPS.filter(s => s.status === 'pending_approval').length,
approved: MOCK_SPONSORSHIPS.filter(s => s.status === 'approved').length,
rejected: MOCK_SPONSORSHIPS.filter(s => s.status === 'rejected').length,
totalInvestment: MOCK_SPONSORSHIPS.filter(s => s.status === 'active').reduce((sum, s) => sum + s.price, 0),
totalImpressions: MOCK_SPONSORSHIPS.reduce((sum, s) => sum + s.impressions, 0),
total: data.sponsorships.length,
active: data.sponsorships.filter(s => s.status === 'active').length,
pending: data.sponsorships.filter(s => s.status === 'pending_approval').length,
approved: data.sponsorships.filter(s => s.status === 'approved').length,
rejected: data.sponsorships.filter(s => s.status === 'rejected').length,
totalInvestment: data.sponsorships.filter(s => s.status === 'active').reduce((sum, s) => sum + s.price, 0),
totalImpressions: data.sponsorships.reduce((sum, s) => sum + s.impressions, 0),
};
// Stats by type
const statsByType = {
leagues: MOCK_SPONSORSHIPS.filter(s => s.type === 'leagues').length,
teams: MOCK_SPONSORSHIPS.filter(s => s.type === 'teams').length,
drivers: MOCK_SPONSORSHIPS.filter(s => s.type === 'drivers').length,
races: MOCK_SPONSORSHIPS.filter(s => s.type === 'races').length,
platform: MOCK_SPONSORSHIPS.filter(s => s.type === 'platform').length,
leagues: data.sponsorships.filter(s => s.type === 'leagues').length,
teams: data.sponsorships.filter(s => s.type === 'teams').length,
drivers: data.sponsorships.filter(s => s.type === 'drivers').length,
races: data.sponsorships.filter(s => s.type === 'races').length,
platform: data.sponsorships.filter(s => s.type === 'platform').length,
};
if (loading) {
@@ -796,9 +603,9 @@ export default function SponsorCampaignsPage() {
const config = status === 'all'
? { label: 'All', color: 'text-gray-400' }
: STATUS_CONFIG[status];
const count = status === 'all'
? stats.total
: MOCK_SPONSORSHIPS.filter(s => s.status === status).length;
const count = status === 'all'
? stats.total
: data.sponsorships.filter(s => s.status === status).length;
return (
<button
key={status}

View File

@@ -31,52 +31,9 @@ import {
RefreshCw
} from 'lucide-react';
import Link from 'next/link';
// Static mock data for prototype
const MOCK_SPONSOR_DATA = {
sponsorId: 'demo-sponsor-1',
sponsorName: 'Acme Racing Co.',
metrics: {
totalImpressions: 127450,
impressionsChange: 12.5,
uniqueViewers: 34200,
viewersChange: 8.3,
activeSponsors: 7,
totalInvestment: 4850,
avgEngagement: 4.2,
engagementChange: 0.8,
},
sponsorships: {
leagues: [
{ id: 'l1', name: 'GT3 Masters Championship', tier: 'main', drivers: 48, impressions: 45200, status: 'active' },
{ id: 'l2', name: 'Endurance Pro Series', tier: 'secondary', drivers: 72, impressions: 38400, status: 'active' },
],
teams: [
{ id: 't1', name: 'Velocity Racing', drivers: 4, impressions: 12300, status: 'active' },
{ id: 't2', name: 'Storm Motorsport', drivers: 3, impressions: 8900, status: 'active' },
],
drivers: [
{ id: 'd1', name: 'Max Velocity', team: 'Velocity Racing', impressions: 8200, status: 'active' },
{ id: 'd2', name: 'Sarah Storm', team: 'Storm Motorsport', impressions: 6100, status: 'active' },
],
races: [
{ id: 'r1', name: 'Spa 24 Hours', league: 'Endurance Pro', impressions: 15600, date: '2025-12-20', status: 'upcoming' },
],
platform: [
{ id: 'p1', name: 'Homepage Banner', placement: 'Header', impressions: 52000, status: 'active' },
],
},
recentActivity: [
{ id: 'a1', type: 'race', message: 'GT3 Masters Championship race completed', time: '2 hours ago', impressions: 1240 },
{ id: 'a2', type: 'driver', message: 'Max Velocity finished P1 at Monza', time: '5 hours ago', impressions: 890 },
{ id: 'a3', type: 'league', message: 'New driver joined Endurance Pro Series', time: '1 day ago', impressions: null },
{ id: 'a4', type: 'team', message: 'Velocity Racing won team championship', time: '2 days ago', impressions: 2100 },
],
upcomingRenewals: [
{ id: 'ren1', name: 'GT3 Masters Championship', type: 'league', renewDate: '2026-01-15', price: 1200 },
{ id: 'ren2', name: 'Homepage Banner', type: 'platform', renewDate: '2025-12-31', price: 500 },
],
};
import { SponsorService } from '@/lib/services/sponsors/SponsorService';
import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardViewModel';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
// Metric Card Component
function MetricCard({
@@ -169,26 +126,18 @@ function SponsorshipCategoryCard({
}
// Activity Item
function ActivityItem({ activity }: { activity: typeof MOCK_SPONSOR_DATA.recentActivity[0] }) {
const typeColors = {
race: 'bg-warning-amber',
league: 'bg-primary-blue',
team: 'bg-purple-400',
driver: 'bg-performance-green',
platform: 'bg-racing-red',
};
function ActivityItem({ activity }: { activity: any }) {
return (
<div className="flex items-start gap-3 py-3 border-b border-charcoal-outline/50 last:border-b-0">
<div className={`w-2 h-2 rounded-full mt-2 ${typeColors[activity.type as keyof typeof typeColors] || 'bg-gray-500'}`} />
<div className={`w-2 h-2 rounded-full mt-2 ${activity.typeColor}`} />
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{activity.message}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500">{activity.time}</span>
{activity.impressions && (
{activity.formattedImpressions && (
<>
<span className="text-xs text-gray-600"></span>
<span className="text-xs text-gray-400">{activity.impressions.toLocaleString()} views</span>
<span className="text-xs text-gray-400">{activity.formattedImpressions} views</span>
</>
)}
</div>
@@ -198,7 +147,7 @@ function ActivityItem({ activity }: { activity: typeof MOCK_SPONSOR_DATA.recentA
}
// Renewal Alert
function RenewalAlert({ renewal }: { renewal: typeof MOCK_SPONSOR_DATA.upcomingRenewals[0] }) {
function RenewalAlert({ renewal }: { renewal: any }) {
const typeIcons = {
league: Trophy,
team: Users,
@@ -206,7 +155,7 @@ function RenewalAlert({ renewal }: { renewal: typeof MOCK_SPONSOR_DATA.upcomingR
race: Flag,
platform: Megaphone,
};
const Icon = typeIcons[renewal.type as keyof typeof typeIcons] || Trophy;
const Icon = typeIcons[renewal.type] || Trophy;
return (
<div className="flex items-center justify-between p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
@@ -214,11 +163,11 @@ function RenewalAlert({ renewal }: { renewal: typeof MOCK_SPONSOR_DATA.upcomingR
<Icon className="w-4 h-4 text-warning-amber" />
<div>
<p className="text-sm text-white">{renewal.name}</p>
<p className="text-xs text-gray-400">Renews {renewal.renewDate}</p>
<p className="text-xs text-gray-400">Renews {renewal.formattedRenewDate}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-white">${renewal.price}</p>
<p className="text-sm font-semibold text-white">{renewal.formattedPrice}</p>
<Button variant="secondary" className="text-xs mt-1 py-1 px-2 min-h-0">
Renew
</Button>
@@ -231,38 +180,52 @@ export default function SponsorDashboardPage() {
const shouldReduceMotion = useReducedMotion();
const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | 'all'>('30d');
const [loading, setLoading] = useState(true);
const [data, setData] = useState<SponsorDashboardViewModel | null>(null);
const [error, setError] = useState<string | null>(null);
// Simulate loading
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 500);
return () => clearTimeout(timer);
const loadDashboard = async () => {
try {
const sponsorService = ServiceFactory.getSponsorService();
const dashboardData = await sponsorService.getSponsorDashboard('demo-sponsor-1');
if (dashboardData) {
setData(dashboardData);
} else {
setError('Failed to load dashboard data');
}
} catch (err) {
console.error('Error loading dashboard:', err);
setError('Failed to load dashboard data');
} finally {
setLoading(false);
}
};
loadDashboard();
}, []);
const data = MOCK_SPONSOR_DATA;
if (loading) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[600px]">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-primary-blue mx-auto mb-4" />
<p className="text-gray-400">Loading dashboard...</p>
</div>
</div>
);
}
// Calculate category totals
const categoryData = {
leagues: {
count: data.sponsorships.leagues.length,
impressions: data.sponsorships.leagues.reduce((sum, l) => sum + l.impressions, 0),
},
teams: {
count: data.sponsorships.teams.length,
impressions: data.sponsorships.teams.reduce((sum, t) => sum + t.impressions, 0),
},
drivers: {
count: data.sponsorships.drivers.length,
impressions: data.sponsorships.drivers.reduce((sum, d) => sum + d.impressions, 0),
},
races: {
count: data.sponsorships.races.length,
impressions: data.sponsorships.races.reduce((sum, r) => sum + r.impressions, 0),
},
platform: {
count: data.sponsorships.platform.length,
impressions: data.sponsorships.platform.reduce((sum, p) => sum + p.impressions, 0),
},
};
if (error || !data) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[600px]">
<div className="text-center">
<p className="text-gray-400">{error || 'No dashboard data available'}</p>
</div>
</div>
);
}
const categoryData = data.categoryData;
if (loading) {
return (
@@ -317,7 +280,7 @@ export default function SponsorDashboardPage() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<MetricCard
title="Total Impressions"
value={data.metrics.totalImpressions}
value={data.totalImpressions}
change={data.metrics.impressionsChange}
icon={Eye}
delay={0}
@@ -331,15 +294,15 @@ export default function SponsorDashboardPage() {
/>
<MetricCard
title="Engagement Rate"
value={data.metrics.avgEngagement}
change={data.metrics.engagementChange}
value={data.metrics.exposure}
change={data.metrics.exposureChange}
icon={TrendingUp}
suffix="%"
delay={0.2}
/>
<MetricCard
title="Total Investment"
value={data.metrics.totalInvestment}
value={data.totalInvestment}
icon={DollarSign}
prefix="$"
delay={0.3}
@@ -423,7 +386,7 @@ export default function SponsorDashboardPage() {
<div key={league.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`px-2 py-1 rounded text-xs font-medium ${
league.tier === 'main'
league.tier === 'main'
? 'bg-primary-blue/20 text-primary-blue border border-primary-blue/30'
: 'bg-purple-500/20 text-purple-400 border border-purple-500/30'
}`}>
@@ -432,17 +395,17 @@ export default function SponsorDashboardPage() {
<div>
<div className="flex items-center gap-2">
<Trophy className="w-4 h-4 text-gray-500" />
<span className="font-medium text-white">{league.name}</span>
<span className="font-medium text-white">{league.entityName}</span>
</div>
<div className="text-sm text-gray-500">{league.drivers} drivers</div>
<div className="text-sm text-gray-500">{league.details}</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="font-semibold text-white">{league.impressions.toLocaleString()}</div>
<div className="font-semibold text-white">{league.formattedImpressions}</div>
<div className="text-xs text-gray-500">impressions</div>
</div>
<Link href={`/sponsor/leagues/${league.id}`}>
<Link href={`/sponsor/leagues/${league.entityId}`}>
<Button variant="secondary" className="text-xs">
<ExternalLink className="w-3 h-3" />
</Button>
@@ -450,7 +413,7 @@ export default function SponsorDashboardPage() {
</div>
</div>
))}
{/* Teams */}
{data.sponsorships.teams.map((team) => (
<div key={team.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
@@ -461,14 +424,14 @@ export default function SponsorDashboardPage() {
<div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-gray-500" />
<span className="font-medium text-white">{team.name}</span>
<span className="font-medium text-white">{team.entityName}</span>
</div>
<div className="text-sm text-gray-500">{team.drivers} drivers</div>
<div className="text-sm text-gray-500">{team.details}</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="font-semibold text-white">{team.impressions.toLocaleString()}</div>
<div className="font-semibold text-white">{team.formattedImpressions}</div>
<div className="text-xs text-gray-500">impressions</div>
</div>
<Button variant="secondary" className="text-xs">
@@ -488,14 +451,14 @@ export default function SponsorDashboardPage() {
<div>
<div className="flex items-center gap-2">
<Car className="w-4 h-4 text-gray-500" />
<span className="font-medium text-white">{driver.name}</span>
<span className="font-medium text-white">{driver.entityName}</span>
</div>
<div className="text-sm text-gray-500">{driver.team}</div>
<div className="text-sm text-gray-500">{driver.details}</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="font-semibold text-white">{driver.impressions.toLocaleString()}</div>
<div className="font-semibold text-white">{driver.formattedImpressions}</div>
<div className="text-xs text-gray-500">impressions</div>
</div>
<Button variant="secondary" className="text-xs">
@@ -525,8 +488,8 @@ export default function SponsorDashboardPage() {
<Flag className="w-5 h-5 text-warning-amber" />
</div>
<div>
<p className="font-medium text-white">{race.name}</p>
<p className="text-sm text-gray-500">{race.league} {race.date}</p>
<p className="font-medium text-white">{race.entityName}</p>
<p className="text-sm text-gray-500">{race.details}</p>
</div>
</div>
<div className="text-right">
@@ -620,16 +583,16 @@ export default function SponsorDashboardPage() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-gray-400">Active Sponsorships</span>
<span className="font-medium text-white">{data.metrics.activeSponsors}</span>
<span className="font-medium text-white">{data.activeSponsorships}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Total Investment</span>
<span className="font-medium text-white">${data.metrics.totalInvestment.toLocaleString()}</span>
<span className="font-medium text-white">{data.formattedTotalInvestment}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Cost per 1K Views</span>
<span className="font-medium text-performance-green">
${(data.metrics.totalInvestment / data.metrics.totalImpressions * 1000).toFixed(2)}
{data.costPerThousandViews}
</span>
</div>
<div className="flex items-center justify-between">

View File

@@ -1,12 +1,15 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { motion, useReducedMotion } from 'framer-motion';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { siteConfig } from '@/lib/siteConfig';
import { LeagueDetailViewModel } from '@/lib/view-models/LeagueDetailViewModel';
import { SponsorService } from '@/lib/services/sponsors/SponsorService';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import {
Trophy,
Users,
@@ -29,64 +32,6 @@ import {
FileText
} from 'lucide-react';
// Mock data for league detail
const MOCK_LEAGUE = {
id: 'league-1',
name: 'GT3 Masters Championship',
game: 'iRacing',
tier: 'premium' as const,
season: 'Season 3',
description: 'Premier GT3 racing with top-tier drivers competing across the world\'s most iconic circuits. Weekly broadcasts and an active community make this league a premium sponsorship opportunity.',
drivers: 48,
races: 12,
completedRaces: 8,
totalImpressions: 45200,
avgViewsPerRace: 5650,
engagement: 4.2,
rating: 4.8,
seasonStatus: 'active' as const,
seasonDates: { start: '2025-10-01', end: '2026-02-28' },
nextRace: { name: 'Spa-Francorchamps', date: '2025-12-20' },
sponsorSlots: {
main: {
available: true,
price: 1200,
benefits: [
'Primary logo placement on all liveries',
'League page header banner',
'Race results page branding',
'Social media feature posts',
'Newsletter sponsor spot',
]
},
secondary: {
available: 1,
total: 2,
price: 400,
benefits: [
'Secondary logo on liveries',
'League page sidebar placement',
'Race results mention',
'Social media mentions',
]
},
},
};
const MOCK_DRIVERS = [
{ id: 'd1', name: 'Max Verstappen', country: 'NL', position: 1, races: 8, impressions: 4200, team: 'Red Bull Racing' },
{ id: 'd2', name: 'Lewis Hamilton', country: 'GB', position: 2, races: 8, impressions: 3980, team: 'Mercedes AMG' },
{ id: 'd3', name: 'Charles Leclerc', country: 'MC', position: 3, races: 8, impressions: 3750, team: 'Ferrari' },
{ id: 'd4', name: 'Lando Norris', country: 'GB', position: 4, races: 7, impressions: 3420, team: 'McLaren' },
{ id: 'd5', name: 'Carlos Sainz', country: 'ES', position: 5, races: 8, impressions: 3100, team: 'Ferrari' },
];
const MOCK_RACES = [
{ id: 'r1', name: 'Spa-Francorchamps', date: '2025-12-20', views: 0, status: 'upcoming' },
{ id: 'r2', name: 'Monza', date: '2025-12-08', views: 5800, status: 'completed' },
{ id: 'r3', name: 'Silverstone', date: '2025-11-24', views: 6200, status: 'completed' },
{ id: 'r4', name: 'Nürburgring', date: '2025-11-10', views: 5400, status: 'completed' },
];
type TabType = 'overview' | 'drivers' | 'races' | 'sponsor';
@@ -94,19 +39,57 @@ export default function SponsorLeagueDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const shouldReduceMotion = useReducedMotion();
const showSponsorAction = searchParams.get('action') === 'sponsor';
const [activeTab, setActiveTab] = useState<TabType>(showSponsorAction ? 'sponsor' : 'overview');
const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
const [data, setData] = useState<LeagueDetailViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const league = MOCK_LEAGUE;
const tierConfig = {
premium: { color: 'text-yellow-400', bgColor: 'bg-yellow-500/10', border: 'border-yellow-500/30' },
standard: { color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', border: 'border-primary-blue/30' },
starter: { color: 'text-gray-400', bgColor: 'bg-gray-500/10', border: 'border-gray-500/30' },
};
const leagueId = params.id as string;
const config = tierConfig[league.tier];
useEffect(() => {
const loadLeagueDetail = async () => {
try {
const sponsorService = ServiceFactory.getSponsorService();
const leagueData = await sponsorService.getLeagueDetail(leagueId);
setData(new LeagueDetailViewModel(leagueData));
} catch (err) {
console.error('Error loading league detail:', err);
setError('Failed to load league detail');
} finally {
setLoading(false);
}
};
if (leagueId) {
loadLeagueDetail();
}
}, [leagueId]);
if (loading) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400">Loading league details...</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="text-gray-400">{error || 'No league data available'}</p>
</div>
</div>
);
}
const config = data.league.tierConfig;
return (
<div className="max-w-7xl mx-auto py-8 px-4">
@@ -134,9 +117,9 @@ export default function SponsorLeagueDetailPage() {
<span className="text-sm font-medium text-white">{league.rating}</span>
</div>
</div>
<h1 className="text-3xl font-bold text-white mb-2">{league.name}</h1>
<p className="text-gray-400 mb-4">{league.game} {league.season} {league.completedRaces}/{league.races} races completed</p>
<p className="text-gray-400 max-w-2xl">{league.description}</p>
<h1 className="text-3xl font-bold text-white mb-2">{data.league.name}</h1>
<p className="text-gray-400 mb-4">{data.league.game} {data.league.season} {data.league.completedRaces}/{data.league.races} races completed</p>
<p className="text-gray-400 max-w-2xl">{data.league.description}</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
@@ -167,7 +150,7 @@ export default function SponsorLeagueDetailPage() {
<Eye className="w-5 h-5 text-primary-blue" />
</div>
<div>
<div className="text-xl font-bold text-white">{league.totalImpressions.toLocaleString()}</div>
<div className="text-xl font-bold text-white">{data.league.formattedTotalImpressions}</div>
<div className="text-xs text-gray-400">Total Views</div>
</div>
</div>
@@ -184,7 +167,7 @@ export default function SponsorLeagueDetailPage() {
<TrendingUp className="w-5 h-5 text-performance-green" />
</div>
<div>
<div className="text-xl font-bold text-white">{league.avgViewsPerRace.toLocaleString()}</div>
<div className="text-xl font-bold text-white">{data.league.formattedAvgViewsPerRace}</div>
<div className="text-xs text-gray-400">Avg/Race</div>
</div>
</div>
@@ -201,7 +184,7 @@ export default function SponsorLeagueDetailPage() {
<Users className="w-5 h-5 text-purple-400" />
</div>
<div>
<div className="text-xl font-bold text-white">{league.drivers}</div>
<div className="text-xl font-bold text-white">{data.league.drivers}</div>
<div className="text-xs text-gray-400">Drivers</div>
</div>
</div>
@@ -218,7 +201,7 @@ export default function SponsorLeagueDetailPage() {
<BarChart3 className="w-5 h-5 text-warning-amber" />
</div>
<div>
<div className="text-xl font-bold text-white">{league.engagement}%</div>
<div className="text-xl font-bold text-white">{data.league.engagement}%</div>
<div className="text-xs text-gray-400">Engagement</div>
</div>
</div>
@@ -235,7 +218,7 @@ export default function SponsorLeagueDetailPage() {
<Calendar className="w-5 h-5 text-racing-red" />
</div>
<div>
<div className="text-xl font-bold text-white">{league.races - league.completedRaces}</div>
<div className="text-xl font-bold text-white">{data.league.racesLeft}</div>
<div className="text-xs text-gray-400">Races Left</div>
</div>
</div>
@@ -271,7 +254,7 @@ export default function SponsorLeagueDetailPage() {
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Platform</span>
<span className="text-white font-medium">{league.game}</span>
<span className="text-white font-medium">{data.league.game}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Season</span>
@@ -300,16 +283,16 @@ export default function SponsorLeagueDetailPage() {
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Total Season Views</span>
<span className="text-white font-medium">{league.totalImpressions.toLocaleString()}</span>
<span className="text-white font-medium">{data.league.formattedTotalImpressions}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Projected Total</span>
<span className="text-white font-medium">{Math.round(league.avgViewsPerRace * league.races).toLocaleString()}</span>
<span className="text-white font-medium">{data.league.formattedProjectedTotal}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Main Sponsor CPM</span>
<span className="text-performance-green font-medium">
${((league.sponsorSlots.main.price / (league.avgViewsPerRace * league.races)) * 1000).toFixed(2)}
{data.league.formattedMainSponsorCpm}
</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
@@ -359,7 +342,7 @@ export default function SponsorLeagueDetailPage() {
<p className="text-sm text-gray-400">Top drivers carrying sponsor branding</p>
</div>
<div className="divide-y divide-charcoal-outline/50">
{MOCK_DRIVERS.map((driver) => (
{data.drivers.map((driver) => (
<div key={driver.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center text-lg font-bold text-white">
@@ -376,7 +359,7 @@ export default function SponsorLeagueDetailPage() {
<div className="text-xs text-gray-500">races</div>
</div>
<div className="text-right">
<div className="font-semibold text-white">{driver.impressions.toLocaleString()}</div>
<div className="font-semibold text-white">{driver.formattedImpressions}</div>
<div className="text-xs text-gray-500">views</div>
</div>
</div>
@@ -393,7 +376,7 @@ export default function SponsorLeagueDetailPage() {
<p className="text-sm text-gray-400">Season schedule with view statistics</p>
</div>
<div className="divide-y divide-charcoal-outline/50">
{MOCK_RACES.map((race) => (
{data.races.map((race) => (
<div key={race.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`w-3 h-3 rounded-full ${

View File

@@ -1,11 +1,14 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { siteConfig } from '@/lib/siteConfig';
import { AvailableLeaguesViewModel } from '@/lib/view-models/AvailableLeaguesViewModel';
import { SponsorService } from '@/lib/services/sponsors/SponsorService';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import {
Trophy,
Users,
@@ -38,98 +41,12 @@ interface AvailableLeague {
description: string;
}
const MOCK_AVAILABLE_LEAGUES: AvailableLeague[] = [
{
id: 'league-1',
name: 'GT3 Masters Championship',
game: 'iRacing',
drivers: 48,
avgViewsPerRace: 8200,
mainSponsorSlot: { available: true, price: 1200 },
secondarySlots: { available: 1, total: 2, price: 400 },
rating: 4.8,
tier: 'premium',
nextRace: 'Dec 20 - Spa',
seasonStatus: 'active',
description: 'Premier GT3 racing with top-tier drivers. Weekly broadcasts and active community.',
},
{
id: 'league-2',
name: 'Endurance Pro Series',
game: 'ACC',
drivers: 72,
avgViewsPerRace: 12500,
mainSponsorSlot: { available: false, price: 1500 },
secondarySlots: { available: 2, total: 2, price: 500 },
rating: 4.9,
tier: 'premium',
nextRace: 'Jan 5 - Nürburgring 24h',
seasonStatus: 'active',
description: 'Multi-class endurance racing. High engagement from dedicated endurance fans.',
},
{
id: 'league-3',
name: 'Formula Sim League',
game: 'iRacing',
drivers: 24,
avgViewsPerRace: 5400,
mainSponsorSlot: { available: true, price: 800 },
secondarySlots: { available: 2, total: 2, price: 300 },
rating: 4.5,
tier: 'standard',
nextRace: 'Dec 22 - Monza',
seasonStatus: 'active',
description: 'Open-wheel racing excellence. Competitive field with consistent racing.',
},
{
id: 'league-4',
name: 'Touring Car Masters',
game: 'rFactor 2',
drivers: 32,
avgViewsPerRace: 3200,
mainSponsorSlot: { available: true, price: 500 },
secondarySlots: { available: 2, total: 2, price: 200 },
rating: 4.2,
tier: 'starter',
nextRace: 'Jan 10 - Brands Hatch',
seasonStatus: 'upcoming',
description: 'Touring car action with close racing. Great for building brand awareness.',
},
{
id: 'league-5',
name: 'LMP Challenge',
game: 'Le Mans Ultimate',
drivers: 36,
avgViewsPerRace: 6800,
mainSponsorSlot: { available: true, price: 900 },
secondarySlots: { available: 1, total: 2, price: 350 },
rating: 4.6,
tier: 'standard',
nextRace: 'Dec 28 - Sebring',
seasonStatus: 'active',
description: 'Prototype racing at its finest. Growing community with passionate fans.',
},
{
id: 'league-6',
name: 'Rally Championship',
game: 'EA WRC',
drivers: 28,
avgViewsPerRace: 4500,
mainSponsorSlot: { available: true, price: 650 },
secondarySlots: { available: 2, total: 2, price: 250 },
rating: 4.4,
tier: 'standard',
nextRace: 'Jan 15 - Monte Carlo',
seasonStatus: 'upcoming',
description: 'Thrilling rally stages. Unique sponsorship exposure in rallying content.',
},
];
type SortOption = 'rating' | 'drivers' | 'price' | 'views';
type TierFilter = 'all' | 'premium' | 'standard' | 'starter';
type AvailabilityFilter = 'all' | 'main' | 'secondary';
function LeagueCard({ league, index }: { league: AvailableLeague; index: number }) {
function LeagueCard({ league, index }: { league: any; index: number }) {
const shouldReduceMotion = useReducedMotion();
const tierConfig = {
@@ -159,9 +76,8 @@ function LeagueCard({ league, index }: { league: AvailableLeague; index: number
completed: { color: 'text-gray-400', bg: 'bg-gray-400/10', label: 'Season Ended' },
};
const config = tierConfig[league.tier];
const status = statusConfig[league.seasonStatus];
const cpm = (league.mainSponsorSlot.price / league.avgViewsPerRace * 1000).toFixed(0);
const config = league.tierConfig;
const status = league.statusConfig;
return (
<motion.div
@@ -201,11 +117,11 @@ function LeagueCard({ league, index }: { league: AvailableLeague; index: number
<div className="text-xs text-gray-500">Drivers</div>
</div>
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
<div className="text-lg font-bold text-white">{(league.avgViewsPerRace / 1000).toFixed(1)}k</div>
<div className="text-lg font-bold text-white">{league.formattedAvgViews}</div>
<div className="text-xs text-gray-500">Avg Views</div>
</div>
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
<div className="text-lg font-bold text-performance-green">${cpm}</div>
<div className="text-lg font-bold text-performance-green">{league.formattedCpm}</div>
<div className="text-xs text-gray-500">CPM</div>
</div>
</div>
@@ -282,9 +198,50 @@ export default function SponsorLeaguesPage() {
const [tierFilter, setTierFilter] = useState<TierFilter>('all');
const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all');
const [sortBy, setSortBy] = useState<SortOption>('rating');
const [data, setData] = useState<AvailableLeaguesViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadLeagues = async () => {
try {
const sponsorService = ServiceFactory.getSponsorService();
const leaguesData = await sponsorService.getAvailableLeagues();
setData(new AvailableLeaguesViewModel(leaguesData));
} catch (err) {
console.error('Error loading leagues:', err);
setError('Failed to load leagues data');
} finally {
setLoading(false);
}
};
loadLeagues();
}, []);
if (loading) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400">Loading leagues...</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="text-gray-400">{error || 'No leagues data available'}</p>
</div>
</div>
);
}
// Filter and sort leagues
const filteredLeagues = MOCK_AVAILABLE_LEAGUES
const filteredLeagues = data.leagues
.filter(league => {
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
@@ -312,13 +269,12 @@ export default function SponsorLeaguesPage() {
// Calculate summary stats
const stats = {
total: MOCK_AVAILABLE_LEAGUES.length,
mainAvailable: MOCK_AVAILABLE_LEAGUES.filter(l => l.mainSponsorSlot.available).length,
secondaryAvailable: MOCK_AVAILABLE_LEAGUES.reduce((sum, l) => sum + l.secondarySlots.available, 0),
totalDrivers: MOCK_AVAILABLE_LEAGUES.reduce((sum, l) => sum + l.drivers, 0),
total: data.leagues.length,
mainAvailable: data.leagues.filter(l => l.mainSponsorSlot.available).length,
secondaryAvailable: data.leagues.reduce((sum, l) => sum + l.secondarySlots.available, 0),
totalDrivers: data.leagues.reduce((sum, l) => sum + l.drivers, 0),
avgCpm: Math.round(
MOCK_AVAILABLE_LEAGUES.reduce((sum, l) => sum + (l.mainSponsorSlot.price / l.avgViewsPerRace * 1000), 0) /
MOCK_AVAILABLE_LEAGUES.length
data.leagues.reduce((sum, l) => sum + l.cpm, 0) / data.leagues.length
),
};
@@ -448,7 +404,7 @@ export default function SponsorLeaguesPage() {
{/* Results Count */}
<div className="flex items-center justify-between mb-6">
<p className="text-sm text-gray-400">
Showing {filteredLeagues.length} of {MOCK_AVAILABLE_LEAGUES.length} leagues
Showing {filteredLeagues.length} of {data.leagues.length} leagues
</p>
<div className="flex items-center gap-2">
<Link href="/teams">

View File

@@ -63,4 +63,41 @@ export class SponsorsApiClient extends BaseApiClient {
rejectSponsorshipRequest(requestId: string, input: RejectSponsorshipRequestInputDTO): Promise<void> {
return this.post(`/sponsors/requests/${requestId}/reject`, input);
}
/** Get sponsor billing information */
getBilling(sponsorId: string): Promise<{
paymentMethods: any[];
invoices: any[];
stats: any;
}> {
return this.get(`/sponsors/billing/${sponsorId}`);
}
/** Get available leagues for sponsorship */
getAvailableLeagues(): Promise<any[]> {
return this.get('/sponsors/leagues/available');
}
/** Get detailed league information */
getLeagueDetail(leagueId: string): Promise<{
league: any;
drivers: any[];
races: any[];
}> {
return this.get(`/sponsors/leagues/${leagueId}/detail`);
}
/** Get sponsor settings */
getSettings(sponsorId: string): Promise<{
profile: any;
notifications: any;
privacy: any;
}> {
return this.get(`/sponsors/settings/${sponsorId}`);
}
/** Update sponsor settings */
updateSettings(sponsorId: string, input: any): Promise<void> {
return this.put(`/sponsors/settings/${sponsorId}`, input);
}
}

View File

@@ -0,0 +1,66 @@
export interface LoginFormData {
email: string;
password: string;
}
export interface LoginValidationErrors {
email?: string;
password?: string;
}
/**
* LoginCommandModel
*
* Encapsulates login form state, client-side validation, and
* prepares data for submission to the AuthService.
*/
export class LoginCommandModel {
private _email: string;
private _password: string;
constructor(initial: LoginFormData = { email: '', password: '' }) {
this._email = initial.email;
this._password = initial.password;
}
get email(): string {
return this._email;
}
set email(value: string) {
this._email = value;
}
get password(): string {
return this._password;
}
set password(value: string) {
this._password = value;
}
/** Basic client-side validation for login form */
validate(): LoginValidationErrors {
const errors: LoginValidationErrors = {};
if (!this._email.trim()) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this._email)) {
errors.email = 'Invalid email format';
}
if (!this._password) {
errors.password = 'Password is required';
}
return errors;
}
/** Convert to API LoginParams DTO */
toRequestDto(): { email: string; password: string } {
return {
email: this._email,
password: this._password,
};
}
}

View File

@@ -0,0 +1,89 @@
export interface SignupFormData {
displayName: string;
email: string;
password: string;
confirmPassword: string;
}
export interface SignupValidationErrors {
displayName?: string;
email?: string;
password?: string;
confirmPassword?: string;
}
/**
* SignupCommandModel
*
* Encapsulates signup form state, client-side validation, and
* prepares data for submission to the AuthService.
*/
export class SignupCommandModel {
private _displayName: string;
private _email: string;
private _password: string;
private _confirmPassword: string;
constructor(initial: SignupFormData) {
this._displayName = initial.displayName;
this._email = initial.email;
this._password = initial.password;
this._confirmPassword = initial.confirmPassword;
}
get displayName(): string {
return this._displayName;
}
get email(): string {
return this._email;
}
get password(): string {
return this._password;
}
get confirmPassword(): string {
return this._confirmPassword;
}
/** Basic client-side validation for signup form */
validate(): SignupValidationErrors {
const errors: SignupValidationErrors = {};
if (!this._displayName.trim()) {
errors.displayName = 'Display name is required';
} else if (this._displayName.trim().length < 3) {
errors.displayName = 'Display name must be at least 3 characters';
}
if (!this._email.trim()) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this._email)) {
errors.email = 'Invalid email format';
}
if (!this._password) {
errors.password = 'Password is required';
} else if (this._password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (!this._confirmPassword) {
errors.confirmPassword = 'Please confirm your password';
} else if (this._password !== this._confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
}
return errors;
}
/** Convert to API SignupParams DTO */
toRequestDto(): { email: string; password: string; displayName: string } {
return {
email: this._email,
password: this._password,
displayName: this._displayName,
};
}
}

View File

@@ -0,0 +1,79 @@
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import type { LeagueSummaryDTO } from '@/lib/types/generated/LeagueSummaryDTO';
import type { GetAllTeamsOutputDTO, TeamListItemDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO';
import { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel';
import { HomeDiscoveryViewModel } from '@/lib/view-models/HomeDiscoveryViewModel';
import { LeagueCardViewModel } from '@/lib/view-models/LeagueCardViewModel';
import { TeamCardViewModel } from '@/lib/view-models/TeamCardViewModel';
import { UpcomingRaceCardViewModel } from '@/lib/view-models/UpcomingRaceCardViewModel';
// DTO matching backend RacesPageDataDTO for discovery usage
interface RacesPageDataDTO {
races: {
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
leagueId: string;
leagueName: string;
strengthOfField: number | null;
isUpcoming: boolean;
isLive: boolean;
isPast: boolean;
}[];
}
export class LandingService {
constructor(
private readonly racesApi: RacesApiClient,
private readonly leaguesApi: LeaguesApiClient,
private readonly teamsApi: TeamsApiClient,
) {}
async getHomeDiscovery(): Promise<HomeDiscoveryViewModel> {
const [racesDto, leaguesDto, teamsDto] = await Promise.all([
this.racesApi.getPageData() as Promise<RacesPageDataDTO>,
this.leaguesApi.getAllWithCapacity() as Promise<{ leagues: LeagueSummaryDTO[] }>,
this.teamsApi.getAll(),
]);
const racesVm = new RacesPageViewModel(racesDto);
const topLeagues = leaguesDto.leagues.slice(0, 4).map(
league => new LeagueCardViewModel({
id: league.id,
name: league.name,
description: 'Competitive iRacing league',
}),
);
const teams = (teamsDto as GetAllTeamsOutputDTO).teams.slice(0, 4).map(
(team: TeamListItemDTO) =>
new TeamCardViewModel({
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
}),
);
const upcomingRaces = racesVm.upcomingRaces.slice(0, 4).map(
race =>
new UpcomingRaceCardViewModel({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
}),
);
return new HomeDiscoveryViewModel({
topLeagues,
teams,
upcomingRaces,
});
}
}

View File

@@ -58,4 +58,51 @@ export class SponsorService {
async getSponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDto> {
return await this.apiClient.getPricing();
}
/**
* Get sponsor billing information
*/
async getBilling(sponsorId: string): Promise<{
paymentMethods: any[];
invoices: any[];
stats: any;
}> {
return await this.apiClient.getBilling(sponsorId);
}
/**
* Get available leagues for sponsorship
*/
async getAvailableLeagues(): Promise<any[]> {
return await this.apiClient.getAvailableLeagues();
}
/**
* Get detailed league information
*/
async getLeagueDetail(leagueId: string): Promise<{
league: any;
drivers: any[];
races: any[];
}> {
return await this.apiClient.getLeagueDetail(leagueId);
}
/**
* Get sponsor settings
*/
async getSettings(sponsorId: string): Promise<{
profile: any;
notifications: any;
privacy: any;
}> {
return await this.apiClient.getSettings(sponsorId);
}
/**
* Update sponsor settings
*/
async updateSettings(sponsorId: string, input: any): Promise<void> {
return await this.apiClient.updateSettings(sponsorId, input);
}
}

View File

@@ -0,0 +1,35 @@
/**
* Activity Item View Model
*
* View model for recent activity items.
*/
export class ActivityItemViewModel {
id: string;
type: 'race' | 'league' | 'team' | 'driver' | 'platform';
message: string;
time: string;
impressions?: number;
constructor(data: any) {
this.id = data.id;
this.type = data.type;
this.message = data.message;
this.time = data.time;
this.impressions = data.impressions;
}
get typeColor(): string {
const colors = {
race: 'bg-warning-amber',
league: 'bg-primary-blue',
team: 'bg-purple-400',
driver: 'bg-performance-green',
platform: 'bg-racing-red',
};
return colors[this.type] || 'bg-gray-500';
}
get formattedImpressions(): string | null {
return this.impressions ? this.impressions.toLocaleString() : null;
}
}

View File

@@ -0,0 +1,76 @@
/**
* Available Leagues View Model
*
* View model for leagues available for sponsorship.
*/
export class AvailableLeaguesViewModel {
leagues: AvailableLeagueViewModel[];
constructor(leagues: any[]) {
this.leagues = leagues.map(league => new AvailableLeagueViewModel(league));
}
}
export class AvailableLeagueViewModel {
id: string;
name: string;
game: string;
drivers: number;
avgViewsPerRace: number;
mainSponsorSlot: { available: boolean; price: number };
secondarySlots: { available: number; total: number; price: number };
rating: number;
tier: 'premium' | 'standard' | 'starter';
nextRace?: string;
seasonStatus: 'active' | 'upcoming' | 'completed';
description: string;
constructor(data: any) {
this.id = data.id;
this.name = data.name;
this.game = data.game;
this.drivers = data.drivers;
this.avgViewsPerRace = data.avgViewsPerRace;
this.mainSponsorSlot = data.mainSponsorSlot;
this.secondarySlots = data.secondarySlots;
this.rating = data.rating;
this.tier = data.tier;
this.nextRace = data.nextRace;
this.seasonStatus = data.seasonStatus;
this.description = data.description;
}
get formattedAvgViews(): string {
return `${(this.avgViewsPerRace / 1000).toFixed(1)}k`;
}
get cpm(): number {
return Math.round((this.mainSponsorSlot.price / this.avgViewsPerRace) * 1000);
}
get formattedCpm(): string {
return `$${this.cpm}`;
}
get hasAvailableSlots(): boolean {
return this.mainSponsorSlot.available || this.secondarySlots.available > 0;
}
get tierConfig() {
const configs = {
premium: { color: 'text-yellow-400', bgColor: 'bg-yellow-500/10', border: 'border-yellow-500/30', icon: '⭐' },
standard: { color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', border: 'border-primary-blue/30', icon: '🏆' },
starter: { color: 'text-gray-400', bgColor: 'bg-gray-500/10', border: 'border-gray-500/30', icon: '🚀' },
};
return configs[this.tier];
}
get statusConfig() {
const configs = {
active: { color: 'text-performance-green', bg: 'bg-performance-green/10', label: 'Active Season' },
upcoming: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', label: 'Starting Soon' },
completed: { color: 'text-gray-400', bg: 'bg-gray-400/10', label: 'Season Ended' },
};
return configs[this.seasonStatus];
}
}

View File

@@ -0,0 +1,138 @@
/**
* Billing View Model
*
* View model for sponsor billing data with UI-specific transformations.
*/
export class BillingViewModel {
paymentMethods: PaymentMethodViewModel[];
invoices: InvoiceViewModel[];
stats: BillingStatsViewModel;
constructor(data: {
paymentMethods: any[];
invoices: any[];
stats: any;
}) {
this.paymentMethods = data.paymentMethods.map(pm => new PaymentMethodViewModel(pm));
this.invoices = data.invoices.map(inv => new InvoiceViewModel(inv));
this.stats = new BillingStatsViewModel(data.stats);
}
}
export class PaymentMethodViewModel {
id: string;
type: 'card' | 'bank' | 'sepa';
last4: string;
brand?: string;
isDefault: boolean;
expiryMonth?: number;
expiryYear?: number;
bankName?: string;
constructor(data: any) {
this.id = data.id;
this.type = data.type;
this.last4 = data.last4;
this.brand = data.brand;
this.isDefault = data.isDefault;
this.expiryMonth = data.expiryMonth;
this.expiryYear = data.expiryYear;
this.bankName = data.bankName;
}
get displayLabel(): string {
if (this.type === 'sepa' && this.bankName) {
return `${this.bankName} •••• ${this.last4}`;
}
return `${this.brand} •••• ${this.last4}`;
}
get expiryDisplay(): string | null {
if (this.expiryMonth && this.expiryYear) {
return `${String(this.expiryMonth).padStart(2, '0')}/${this.expiryYear}`;
}
return null;
}
}
export class InvoiceViewModel {
id: string;
invoiceNumber: string;
date: Date;
dueDate: Date;
amount: number;
vatAmount: number;
totalAmount: number;
status: 'paid' | 'pending' | 'overdue' | 'failed';
description: string;
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
pdfUrl: string;
constructor(data: any) {
this.id = data.id;
this.invoiceNumber = data.invoiceNumber;
this.date = new Date(data.date);
this.dueDate = new Date(data.dueDate);
this.amount = data.amount;
this.vatAmount = data.vatAmount;
this.totalAmount = data.totalAmount;
this.status = data.status;
this.description = data.description;
this.sponsorshipType = data.sponsorshipType;
this.pdfUrl = data.pdfUrl;
}
get formattedTotalAmount(): string {
return `${this.totalAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`;
}
get formattedVatAmount(): string {
return `${this.vatAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`;
}
get formattedDate(): string {
return this.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
get isOverdue(): boolean {
return this.status === 'overdue' || (this.status === 'pending' && new Date() > this.dueDate);
}
}
export class BillingStatsViewModel {
totalSpent: number;
pendingAmount: number;
nextPaymentDate: Date;
nextPaymentAmount: number;
activeSponsorships: number;
averageMonthlySpend: number;
constructor(data: any) {
this.totalSpent = data.totalSpent;
this.pendingAmount = data.pendingAmount;
this.nextPaymentDate = new Date(data.nextPaymentDate);
this.nextPaymentAmount = data.nextPaymentAmount;
this.activeSponsorships = data.activeSponsorships;
this.averageMonthlySpend = data.averageMonthlySpend;
}
get formattedTotalSpent(): string {
return `${this.totalSpent.toLocaleString('de-DE')}`;
}
get formattedPendingAmount(): string {
return `${this.pendingAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`;
}
get formattedNextPaymentAmount(): string {
return `${this.nextPaymentAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`;
}
get formattedAverageMonthlySpend(): string {
return `${this.averageMonthlySpend.toLocaleString('de-DE')}`;
}
get formattedNextPaymentDate(): string {
return this.nextPaymentDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
}

View File

@@ -0,0 +1,25 @@
import { LeagueCardViewModel } from './LeagueCardViewModel';
import { TeamCardViewModel } from './TeamCardViewModel';
import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel';
interface HomeDiscoveryDTO {
topLeagues: LeagueCardViewModel[];
teams: TeamCardViewModel[];
upcomingRaces: UpcomingRaceCardViewModel[];
}
/**
* Home discovery view model
* Aggregates discovery data for the landing page.
*/
export class HomeDiscoveryViewModel {
readonly topLeagues: LeagueCardViewModel[];
readonly teams: TeamCardViewModel[];
readonly upcomingRaces: UpcomingRaceCardViewModel[];
constructor(dto: HomeDiscoveryDTO) {
this.topLeagues = dto.topLeagues;
this.teams = dto.teams;
this.upcomingRaces = dto.upcomingRaces;
}
}

View File

@@ -0,0 +1,23 @@
import type { LeagueSummaryDTO } from '@/lib/types/generated/LeagueSummaryDTO';
interface LeagueCardDTO {
id: string;
name: string;
description?: string;
}
/**
* League card view model
* UI representation of a league on the landing page.
*/
export class LeagueCardViewModel {
readonly id: string;
readonly name: string;
readonly description: string;
constructor(dto: LeagueCardDTO | LeagueSummaryDTO & { description?: string }) {
this.id = dto.id;
this.name = dto.name;
this.description = dto.description ?? 'Competitive iRacing league';
}
}

View File

@@ -1,35 +1,144 @@
export interface MainSponsorInfo {
name: string;
logoUrl: string;
websiteUrl: string;
/**
* League Detail View Model
*
* View model for detailed league information for sponsors.
*/
export class LeagueDetailViewModel {
league: LeagueViewModel;
drivers: DriverViewModel[];
races: RaceViewModel[];
constructor(data: { league: any; drivers: any[]; races: any[] }) {
this.league = new LeagueViewModel(data.league);
this.drivers = data.drivers.map(driver => new DriverViewModel(driver));
this.races = data.races.map(race => new RaceViewModel(race));
}
}
export class LeagueDetailViewModel {
export class LeagueViewModel {
id: string;
name: string;
game: string;
tier: 'premium' | 'standard' | 'starter';
season: string;
description: string;
ownerId: string;
ownerName: string;
mainSponsor: MainSponsorInfo | null;
isAdmin: boolean;
drivers: number;
races: number;
completedRaces: number;
totalImpressions: number;
avgViewsPerRace: number;
engagement: number;
rating: number;
seasonStatus: 'active' | 'upcoming' | 'completed';
seasonDates: { start: string; end: string };
nextRace?: { name: string; date: string };
sponsorSlots: {
main: { available: boolean; price: number; benefits: string[] };
secondary: { available: number; total: number; price: number; benefits: string[] };
};
constructor(
id: string,
name: string,
description: string,
ownerId: string,
ownerName: string,
mainSponsor: MainSponsorInfo | null,
isAdmin: boolean
) {
this.id = id;
this.name = name;
this.description = description;
this.ownerId = ownerId;
this.ownerName = ownerName;
this.mainSponsor = mainSponsor;
this.isAdmin = isAdmin;
constructor(data: any) {
this.id = data.id;
this.name = data.name;
this.game = data.game;
this.tier = data.tier;
this.season = data.season;
this.description = data.description;
this.drivers = data.drivers;
this.races = data.races;
this.completedRaces = data.completedRaces;
this.totalImpressions = data.totalImpressions;
this.avgViewsPerRace = data.avgViewsPerRace;
this.engagement = data.engagement;
this.rating = data.rating;
this.seasonStatus = data.seasonStatus;
this.seasonDates = data.seasonDates;
this.nextRace = data.nextRace;
this.sponsorSlots = data.sponsorSlots;
}
// UI-specific getters can be added here if needed
get formattedTotalImpressions(): string {
return this.totalImpressions.toLocaleString();
}
get formattedAvgViewsPerRace(): string {
return this.avgViewsPerRace.toLocaleString();
}
get projectedTotalViews(): number {
return Math.round(this.avgViewsPerRace * this.races);
}
get formattedProjectedTotal(): string {
return this.projectedTotalViews.toLocaleString();
}
get mainSponsorCpm(): number {
return Math.round((this.sponsorSlots.main.price / this.projectedTotalViews) * 1000);
}
get formattedMainSponsorCpm(): string {
return `$${this.mainSponsorCpm.toFixed(2)}`;
}
get racesLeft(): number {
return this.races - this.completedRaces;
}
get tierConfig() {
const configs = {
premium: { color: 'text-yellow-400', bgColor: 'bg-yellow-500/10', border: 'border-yellow-500/30' },
standard: { color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', border: 'border-primary-blue/30' },
starter: { color: 'text-gray-400', bgColor: 'bg-gray-500/10', border: 'border-gray-500/30' },
};
return configs[this.tier];
}
}
export class DriverViewModel {
id: string;
name: string;
country: string;
position: number;
races: number;
impressions: number;
team: string;
constructor(data: any) {
this.id = data.id;
this.name = data.name;
this.country = data.country;
this.position = data.position;
this.races = data.races;
this.impressions = data.impressions;
this.team = data.team;
}
get formattedImpressions(): string {
return this.impressions.toLocaleString();
}
}
export class RaceViewModel {
id: string;
name: string;
date: Date;
views: number;
status: 'upcoming' | 'completed';
constructor(data: any) {
this.id = data.id;
this.name = data.name;
this.date = new Date(data.date);
this.views = data.views;
this.status = data.status;
}
get formattedDate(): string {
return this.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
get formattedViews(): string {
return this.views.toLocaleString();
}
}

View File

@@ -0,0 +1,49 @@
/**
* Renewal Alert View Model
*
* View model for upcoming renewal alerts.
*/
export class RenewalAlertViewModel {
id: string;
name: string;
type: 'league' | 'team' | 'driver' | 'race' | 'platform';
renewDate: Date;
price: number;
constructor(data: any) {
this.id = data.id;
this.name = data.name;
this.type = data.type;
this.renewDate = new Date(data.renewDate);
this.price = data.price;
}
get formattedPrice(): string {
return `$${this.price}`;
}
get formattedRenewDate(): string {
return this.renewDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
get typeIcon() {
const icons = {
league: 'Trophy',
team: 'Users',
driver: 'Car',
race: 'Flag',
platform: 'Megaphone',
};
return icons[this.type] || 'Trophy';
}
get daysUntilRenewal(): number {
const now = new Date();
const diffTime = this.renewDate.getTime() - now.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
get isUrgent(): boolean {
return this.daysUntilRenewal <= 30;
}
}

View File

@@ -1,4 +1,7 @@
import type { SponsorDashboardDTO } from '../types/generated/SponsorDashboardDTO';
import { SponsorshipViewModel } from './SponsorshipViewModel';
import { ActivityItemViewModel } from './ActivityItemViewModel';
import { RenewalAlertViewModel } from './RenewalAlertViewModel';
/**
* Sponsor Dashboard View Model
@@ -8,17 +11,72 @@ import type { SponsorDashboardDTO } from '../types/generated/SponsorDashboardDTO
export class SponsorDashboardViewModel {
sponsorId: string;
sponsorName: string;
metrics: any;
sponsorships: {
leagues: SponsorshipViewModel[];
teams: SponsorshipViewModel[];
drivers: SponsorshipViewModel[];
races: SponsorshipViewModel[];
platform: SponsorshipViewModel[];
};
recentActivity: ActivityItemViewModel[];
upcomingRenewals: RenewalAlertViewModel[];
constructor(dto: SponsorDashboardDTO) {
this.sponsorId = dto.sponsorId;
this.sponsorName = dto.sponsorName;
this.metrics = dto.metrics;
this.sponsorships = {
leagues: (dto.sponsorships?.leagues || []).map(s => new SponsorshipViewModel(s)),
teams: (dto.sponsorships?.teams || []).map(s => new SponsorshipViewModel(s)),
drivers: (dto.sponsorships?.drivers || []).map(s => new SponsorshipViewModel(s)),
races: (dto.sponsorships?.races || []).map(s => new SponsorshipViewModel(s)),
platform: (dto.sponsorships?.platform || []).map(s => new SponsorshipViewModel(s)),
};
this.recentActivity = (dto.recentActivity || []).map(a => new ActivityItemViewModel(a));
this.upcomingRenewals = (dto.upcomingRenewals || []).map(r => new RenewalAlertViewModel(r));
}
// Note: The generated DTO doesn't include these fields yet
// These will need to be added when the OpenAPI spec is updated
totalSponsorships: number = 0;
activeSponsorships: number = 0;
totalInvestment: number = 0;
get totalSponsorships(): number {
return this.sponsorships.leagues.length +
this.sponsorships.teams.length +
this.sponsorships.drivers.length +
this.sponsorships.races.length +
this.sponsorships.platform.length;
}
get activeSponsorships(): number {
const all = [
...this.sponsorships.leagues,
...this.sponsorships.teams,
...this.sponsorships.drivers,
...this.sponsorships.races,
...this.sponsorships.platform,
];
return all.filter(s => s.status === 'active').length;
}
get totalInvestment(): number {
const all = [
...this.sponsorships.leagues,
...this.sponsorships.teams,
...this.sponsorships.drivers,
...this.sponsorships.races,
...this.sponsorships.platform,
];
return all.filter(s => s.status === 'active').reduce((sum, s) => sum + s.price, 0);
}
get totalImpressions(): number {
const all = [
...this.sponsorships.leagues,
...this.sponsorships.teams,
...this.sponsorships.drivers,
...this.sponsorships.races,
...this.sponsorships.platform,
];
return all.reduce((sum, s) => sum + s.impressions, 0);
}
/** UI-specific: Formatted total investment */
get formattedTotalInvestment(): string {
@@ -42,4 +100,36 @@ export class SponsorDashboardViewModel {
if (this.activeSponsorships === this.totalSponsorships) return 'All sponsorships active';
return `${this.activeSponsorships} of ${this.totalSponsorships} active`;
}
/** UI-specific: Cost per 1K views */
get costPerThousandViews(): string {
if (this.totalImpressions === 0) return '$0.00';
return `$${(this.totalInvestment / this.totalImpressions * 1000).toFixed(2)}`;
}
/** UI-specific: Category data for charts */
get categoryData() {
return {
leagues: {
count: this.sponsorships.leagues.length,
impressions: this.sponsorships.leagues.reduce((sum, l) => sum + l.impressions, 0),
},
teams: {
count: this.sponsorships.teams.length,
impressions: this.sponsorships.teams.reduce((sum, t) => sum + t.impressions, 0),
},
drivers: {
count: this.sponsorships.drivers.length,
impressions: this.sponsorships.drivers.reduce((sum, d) => sum + d.impressions, 0),
},
races: {
count: this.sponsorships.races.length,
impressions: this.sponsorships.races.reduce((sum, r) => sum + r.impressions, 0),
},
platform: {
count: this.sponsorships.platform.length,
impressions: this.sponsorships.platform.reduce((sum, p) => sum + p.impressions, 0),
},
};
}
}

View File

@@ -0,0 +1,89 @@
/**
* Sponsor Settings View Model
*
* View model for sponsor settings data.
*/
export class SponsorSettingsViewModel {
profile: SponsorProfileViewModel;
notifications: NotificationSettingsViewModel;
privacy: PrivacySettingsViewModel;
constructor(data: { profile: any; notifications: any; privacy: any }) {
this.profile = new SponsorProfileViewModel(data.profile);
this.notifications = new NotificationSettingsViewModel(data.notifications);
this.privacy = new PrivacySettingsViewModel(data.privacy);
}
}
export class SponsorProfileViewModel {
companyName: string;
contactName: string;
contactEmail: string;
contactPhone: string;
website: string;
description: string;
logoUrl: string | null;
industry: string;
address: {
street: string;
city: string;
country: string;
postalCode: string;
};
taxId: string;
socialLinks: {
twitter: string;
linkedin: string;
instagram: string;
};
constructor(data: any) {
this.companyName = data.companyName;
this.contactName = data.contactName;
this.contactEmail = data.contactEmail;
this.contactPhone = data.contactPhone;
this.website = data.website;
this.description = data.description;
this.logoUrl = data.logoUrl;
this.industry = data.industry;
this.address = data.address;
this.taxId = data.taxId;
this.socialLinks = data.socialLinks;
}
get fullAddress(): string {
return `${this.address.street}, ${this.address.city}, ${this.address.postalCode}, ${this.address.country}`;
}
}
export class NotificationSettingsViewModel {
emailNewSponsorships: boolean;
emailWeeklyReport: boolean;
emailRaceAlerts: boolean;
emailPaymentAlerts: boolean;
emailNewOpportunities: boolean;
emailContractExpiry: boolean;
constructor(data: any) {
this.emailNewSponsorships = data.emailNewSponsorships;
this.emailWeeklyReport = data.emailWeeklyReport;
this.emailRaceAlerts = data.emailRaceAlerts;
this.emailPaymentAlerts = data.emailPaymentAlerts;
this.emailNewOpportunities = data.emailNewOpportunities;
this.emailContractExpiry = data.emailContractExpiry;
}
}
export class PrivacySettingsViewModel {
publicProfile: boolean;
showStats: boolean;
showActiveSponsorships: boolean;
allowDirectContact: boolean;
constructor(data: any) {
this.publicProfile = data.publicProfile;
this.showStats = data.showStats;
this.showActiveSponsorships = data.showActiveSponsorships;
this.allowDirectContact = data.allowDirectContact;
}
}

View File

@@ -0,0 +1,92 @@
/**
* Sponsorship View Model
*
* View model for individual sponsorship data.
*/
export class SponsorshipViewModel {
id: string;
type: 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
entityId: string;
entityName: string;
tier?: 'main' | 'secondary';
status: 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired';
applicationDate?: Date;
approvalDate?: Date;
rejectionReason?: string;
startDate: Date;
endDate: Date;
price: number;
impressions: number;
impressionsChange?: number;
engagement?: number;
details?: string;
entityOwner?: string;
applicationMessage?: string;
constructor(data: any) {
this.id = data.id;
this.type = data.type;
this.entityId = data.entityId;
this.entityName = data.entityName;
this.tier = data.tier;
this.status = data.status;
this.applicationDate = data.applicationDate ? new Date(data.applicationDate) : undefined;
this.approvalDate = data.approvalDate ? new Date(data.approvalDate) : undefined;
this.rejectionReason = data.rejectionReason;
this.startDate = new Date(data.startDate);
this.endDate = new Date(data.endDate);
this.price = data.price;
this.impressions = data.impressions;
this.impressionsChange = data.impressionsChange;
this.engagement = data.engagement;
this.details = data.details;
this.entityOwner = data.entityOwner;
this.applicationMessage = data.applicationMessage;
}
get formattedImpressions(): string {
return this.impressions.toLocaleString();
}
get formattedPrice(): string {
return `$${this.price}`;
}
get daysRemaining(): number {
const now = new Date();
const diffTime = this.endDate.getTime() - now.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
get isExpiringSoon(): boolean {
return this.daysRemaining > 0 && this.daysRemaining <= 30;
}
get statusLabel(): string {
const labels = {
active: 'Active',
pending_approval: 'Awaiting Approval',
approved: 'Approved',
rejected: 'Declined',
expired: 'Expired',
};
return labels[this.status] || this.status;
}
get typeLabel(): string {
const labels = {
leagues: 'League',
teams: 'Team',
drivers: 'Driver',
races: 'Race',
platform: 'Platform',
};
return labels[this.type] || this.type;
}
get periodDisplay(): string {
const start = this.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
const end = this.endDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
return `${start} - ${end}`;
}
}

View File

@@ -0,0 +1,26 @@
import type { TeamListItemDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO';
interface TeamCardDTO {
id: string;
name: string;
tag: string;
description: string;
}
/**
* Team card view model
* UI representation of a team on the landing page.
*/
export class TeamCardViewModel {
readonly id: string;
readonly name: string;
readonly tag: string;
readonly description: string;
constructor(dto: TeamCardDTO | TeamListItemDTO) {
this.id = dto.id;
this.name = dto.name;
this.tag = dto.tag;
this.description = dto.description;
}
}

View File

@@ -0,0 +1,32 @@
interface UpcomingRaceCardDTO {
id: string;
track: string;
car: string;
scheduledAt: string;
}
/**
* Upcoming race card view model
* UI representation of an upcoming race on the landing page.
*/
export class UpcomingRaceCardViewModel {
readonly id: string;
readonly track: string;
readonly car: string;
readonly scheduledAt: string;
constructor(dto: UpcomingRaceCardDTO) {
this.id = dto.id;
this.track = dto.track;
this.car = dto.car;
this.scheduledAt = dto.scheduledAt;
}
/** UI-specific: formatted date label */
get formattedDate(): string {
return new Date(this.scheduledAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
}
}

View File

@@ -0,0 +1,150 @@
import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository';
import { PaymentStatus, PaymentType, PayerType } from '@core/payments/domain/entities/Payment';
import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
export interface SponsorBillingStats {
totalSpent: number;
pendingAmount: number;
nextPaymentDate: string | null;
nextPaymentAmount: number | null;
activeSponsorships: number;
averageMonthlySpend: number;
}
export interface SponsorInvoiceSummary {
id: string;
invoiceNumber: string;
date: string;
dueDate: string;
amount: number;
vatAmount: number;
totalAmount: number;
status: 'paid' | 'pending' | 'overdue' | 'failed';
description: string;
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
pdfUrl: string;
}
export interface SponsorPaymentMethodSummary {
id: string;
type: 'card' | 'bank' | 'sepa';
last4: string;
brand?: string;
isDefault: boolean;
expiryMonth?: number;
expiryYear?: number;
bankName?: string;
}
export interface SponsorBillingSummary {
paymentMethods: SponsorPaymentMethodSummary[];
invoices: SponsorInvoiceSummary[];
stats: SponsorBillingStats;
}
/**
* Application Service: SponsorBillingService
*
* Aggregates sponsor-facing billing information from payments and season sponsorships.
*/
export class SponsorBillingService {
constructor(
private readonly paymentRepository: IPaymentRepository,
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
) {}
async getSponsorBilling(sponsorId: string): Promise<SponsorBillingSummary> {
// In this in-memory implementation we derive billing data from payments
// where the sponsor is the payer.
const payments = await this.paymentRepository.findByFilters({
payerId: sponsorId,
type: PaymentType.SPONSORSHIP,
});
const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId);
const invoices: SponsorInvoiceSummary[] = payments.map((payment, index) => {
const createdAt = payment.createdAt ?? new Date();
const dueDate = new Date(createdAt.getTime());
dueDate.setDate(dueDate.getDate() + 14);
const vatAmount = +(payment.amount * 0.19).toFixed(2);
const totalAmount = +(payment.amount + vatAmount).toFixed(2);
let status: 'paid' | 'pending' | 'overdue' | 'failed' = 'pending';
if (payment.status === PaymentStatus.COMPLETED) status = 'paid';
else if (payment.status === PaymentStatus.FAILED || payment.status === PaymentStatus.REFUNDED) status = 'failed';
const now = new Date();
if (status === 'pending' && dueDate < now) {
status = 'overdue';
}
return {
id: payment.id,
invoiceNumber: `GP-${createdAt.getFullYear()}-${String(index + 1).padStart(6, '0')}`,
date: createdAt.toISOString(),
dueDate: dueDate.toISOString(),
amount: payment.amount,
vatAmount,
totalAmount,
status,
description: 'Sponsorship payment',
sponsorshipType: 'league',
pdfUrl: '#',
};
});
const totalSpent = invoices
.filter(i => i.status === 'paid')
.reduce((sum, i) => sum + i.totalAmount, 0);
const pendingAmount = invoices
.filter(i => i.status === 'pending' || i.status === 'overdue')
.reduce((sum, i) => sum + i.totalAmount, 0);
const pendingInvoices = invoices.filter(i => i.status === 'pending' || i.status === 'overdue');
const nextPending = pendingInvoices.length > 0
? pendingInvoices.reduce((earliest, current) => (current.dueDate < earliest.dueDate ? current : earliest))
: null;
const stats: SponsorBillingStats = {
totalSpent,
pendingAmount,
nextPaymentDate: nextPending ? nextPending.dueDate : null,
nextPaymentAmount: nextPending ? nextPending.totalAmount : null,
activeSponsorships: sponsorships.filter(s => s.status === 'active').length,
averageMonthlySpend: this.calculateAverageMonthlySpend(invoices),
};
// NOTE: Payment methods are not yet persisted in core. For now, we expose
// an empty list so the UI can still render correctly. A dedicated
// payment-methods port can be added later when the concept exists in core.
const paymentMethods: SponsorPaymentMethodSummary[] = [];
return {
paymentMethods,
invoices,
stats,
};
}
private calculateAverageMonthlySpend(invoices: SponsorInvoiceSummary[]): number {
if (invoices.length === 0) return 0;
const sorted = [...invoices].sort((a, b) => a.date.localeCompare(b.date));
const first = new Date(sorted[0].date);
const last = new Date(sorted[sorted.length - 1].date);
const months = this.monthDiff(first, last) || 1;
const total = sorted.reduce((sum, inv) => sum + inv.totalAmount, 0);
return Math.round((total / months) * 100) / 100;
}
private monthDiff(d1: Date, d2: Date): number {
const years = d2.getFullYear() - d1.getFullYear();
const months = d2.getMonth() - d1.getMonth();
return years * 12 + months + 1;
}
}

View File

@@ -0,0 +1,3 @@
export interface ReopenRaceCommandDTO {
raceId: string;
}

View File

@@ -0,0 +1,55 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ReopenRaceCommandDTO } from '../dto/ReopenRaceCommandDTO';
/**
* Use Case: ReopenRaceUseCase
*
* Encapsulates the workflow for re-opening a race:
* - loads the race by id
* - returns error if the race does not exist
* - delegates transition rules to the Race domain entity via `reopen()`
* - persists the updated race via the repository.
*/
export class ReopenRaceUseCase
implements AsyncUseCase<ReopenRaceCommandDTO, void, string> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly logger: Logger,
) {}
async execute(command: ReopenRaceCommandDTO): Promise<Result<void, ApplicationErrorCode<string>>> {
const { raceId } = command;
this.logger.debug(`[ReopenRaceUseCase] Executing for raceId: ${raceId}`);
try {
const race = await this.raceRepository.findById(raceId);
if (!race) {
this.logger.warn(`[ReopenRaceUseCase] Race with ID ${raceId} not found.`);
return Result.err({ code: 'RACE_NOT_FOUND' });
}
const reopenedRace = race.reopen();
await this.raceRepository.update(reopenedRace);
this.logger.info(`[ReopenRaceUseCase] Race ${raceId} re-opened successfully.`);
return Result.ok(undefined);
} catch (error) {
if (error instanceof Error && error.message.includes('already scheduled')) {
this.logger.warn(`[ReopenRaceUseCase] Domain error re-opening race ${raceId}: ${error.message}`);
return Result.err({ code: 'RACE_ALREADY_SCHEDULED' });
}
if (error instanceof Error && error.message.includes('running race')) {
this.logger.warn(`[ReopenRaceUseCase] Domain error re-opening race ${raceId}: ${error.message}`);
return Result.err({ code: 'CANNOT_REOPEN_RUNNING_RACE' });
}
this.logger.error(
`[ReopenRaceUseCase] Unexpected error re-opening race ${raceId}`,
error instanceof Error ? error : new Error(String(error)),
);
return Result.err({ code: 'UNEXPECTED_ERROR' });
}
}
}

View File

@@ -240,6 +240,48 @@ export class Race implements IEntity<string> {
return Race.create(props);
}
/**
* Re-open a previously completed or cancelled race
*/
reopen(): Race {
if (this.status === 'scheduled') {
throw new RacingDomainInvariantError('Race is already scheduled');
}
if (this.status === 'running') {
throw new RacingDomainInvariantError('Cannot re-open a running race');
}
const base = {
id: this.id,
leagueId: this.leagueId,
scheduledAt: this.scheduledAt,
track: this.track,
car: this.car,
sessionType: this.sessionType,
status: 'scheduled' as RaceStatus,
};
const withTrackId =
this.trackId !== undefined ? { ...base, trackId: this.trackId } : base;
const withCarId =
this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId;
const withSof =
this.strengthOfField !== undefined
? { ...withCarId, strengthOfField: this.strengthOfField }
: withCarId;
const withRegistered =
this.registeredCount !== undefined
? { ...withSof, registeredCount: this.registeredCount }
: withSof;
const props =
this.maxParticipants !== undefined
? { ...withRegistered, maxParticipants: this.maxParticipants }
: withRegistered;
return Race.create(props);
}
/**
* Update SOF and participant count
*/