fix data flow issues
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
25
apps/api/src/domain/sponsor/dtos/ActivityItemDTO.ts
Normal file
25
apps/api/src/domain/sponsor/dtos/ActivityItemDTO.ts
Normal 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;
|
||||
}
|
||||
58
apps/api/src/domain/sponsor/dtos/AvailableLeagueDTO.ts
Normal file
58
apps/api/src/domain/sponsor/dtos/AvailableLeagueDTO.ts
Normal 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;
|
||||
}
|
||||
28
apps/api/src/domain/sponsor/dtos/BillingStatsDTO.ts
Normal file
28
apps/api/src/domain/sponsor/dtos/BillingStatsDTO.ts
Normal 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;
|
||||
}
|
||||
32
apps/api/src/domain/sponsor/dtos/DriverDTO.ts
Normal file
32
apps/api/src/domain/sponsor/dtos/DriverDTO.ts
Normal 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;
|
||||
}
|
||||
48
apps/api/src/domain/sponsor/dtos/InvoiceDTO.ts
Normal file
48
apps/api/src/domain/sponsor/dtos/InvoiceDTO.ts
Normal 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;
|
||||
}
|
||||
88
apps/api/src/domain/sponsor/dtos/LeagueDetailDTO.ts
Normal file
88
apps/api/src/domain/sponsor/dtos/LeagueDetailDTO.ts
Normal 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[];
|
||||
};
|
||||
};
|
||||
}
|
||||
28
apps/api/src/domain/sponsor/dtos/NotificationSettingsDTO.ts
Normal file
28
apps/api/src/domain/sponsor/dtos/NotificationSettingsDTO.ts
Normal 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;
|
||||
}
|
||||
40
apps/api/src/domain/sponsor/dtos/PaymentMethodDTO.ts
Normal file
40
apps/api/src/domain/sponsor/dtos/PaymentMethodDTO.ts
Normal 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;
|
||||
}
|
||||
20
apps/api/src/domain/sponsor/dtos/PrivacySettingsDTO.ts
Normal file
20
apps/api/src/domain/sponsor/dtos/PrivacySettingsDTO.ts
Normal 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;
|
||||
}
|
||||
24
apps/api/src/domain/sponsor/dtos/RaceDTO.ts
Normal file
24
apps/api/src/domain/sponsor/dtos/RaceDTO.ts
Normal 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';
|
||||
}
|
||||
24
apps/api/src/domain/sponsor/dtos/RenewalAlertDTO.ts
Normal file
24
apps/api/src/domain/sponsor/dtos/RenewalAlertDTO.ts
Normal 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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
56
apps/api/src/domain/sponsor/dtos/SponsorProfileDTO.ts
Normal file
56
apps/api/src/domain/sponsor/dtos/SponsorProfileDTO.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
85
apps/api/src/domain/sponsor/dtos/SponsorshipDTO.ts
Normal file
85
apps/api/src/domain/sponsor/dtos/SponsorshipDTO.ts
Normal 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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
66
apps/website/lib/command-models/auth/LoginCommandModel.ts
Normal file
66
apps/website/lib/command-models/auth/LoginCommandModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
89
apps/website/lib/command-models/auth/SignupCommandModel.ts
Normal file
89
apps/website/lib/command-models/auth/SignupCommandModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
79
apps/website/lib/services/landing/LandingService.ts
Normal file
79
apps/website/lib/services/landing/LandingService.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
35
apps/website/lib/view-models/ActivityItemViewModel.ts
Normal file
35
apps/website/lib/view-models/ActivityItemViewModel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
76
apps/website/lib/view-models/AvailableLeaguesViewModel.ts
Normal file
76
apps/website/lib/view-models/AvailableLeaguesViewModel.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
138
apps/website/lib/view-models/BillingViewModel.ts
Normal file
138
apps/website/lib/view-models/BillingViewModel.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
25
apps/website/lib/view-models/HomeDiscoveryViewModel.ts
Normal file
25
apps/website/lib/view-models/HomeDiscoveryViewModel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
23
apps/website/lib/view-models/LeagueCardViewModel.ts
Normal file
23
apps/website/lib/view-models/LeagueCardViewModel.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
49
apps/website/lib/view-models/RenewalAlertViewModel.ts
Normal file
49
apps/website/lib/view-models/RenewalAlertViewModel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
89
apps/website/lib/view-models/SponsorSettingsViewModel.ts
Normal file
89
apps/website/lib/view-models/SponsorSettingsViewModel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
92
apps/website/lib/view-models/SponsorshipViewModel.ts
Normal file
92
apps/website/lib/view-models/SponsorshipViewModel.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
26
apps/website/lib/view-models/TeamCardViewModel.ts
Normal file
26
apps/website/lib/view-models/TeamCardViewModel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
32
apps/website/lib/view-models/UpcomingRaceCardViewModel.ts
Normal file
32
apps/website/lib/view-models/UpcomingRaceCardViewModel.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
150
core/payments/application/services/SponsorBillingService.ts
Normal file
150
core/payments/application/services/SponsorBillingService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
3
core/racing/application/dto/ReopenRaceCommandDTO.ts
Normal file
3
core/racing/application/dto/ReopenRaceCommandDTO.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface ReopenRaceCommandDTO {
|
||||
raceId: string;
|
||||
}
|
||||
55
core/racing/application/use-cases/ReopenRaceUseCase.ts
Normal file
55
core/racing/application/use-cases/ReopenRaceUseCase.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user