diff --git a/apps/api/src/domain/sponsor/SponsorController.ts b/apps/api/src/domain/sponsor/SponsorController.ts index d47641838..44b92ab3f 100644 --- a/apps/api/src/domain/sponsor/SponsorController.ts +++ b/apps/api/src/domain/sponsor/SponsorController.ts @@ -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 { 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 { + 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; + notifications?: Partial; + privacy?: Partial; + } + ): Promise { + return this.sponsorService.updateSponsorSettings(sponsorId, input); + } } diff --git a/apps/api/src/domain/sponsor/SponsorProviders.ts b/apps/api/src/domain/sponsor/SponsorProviders.ts index f384b9a0c..2ca817311 100644 --- a/apps/api/src/domain/sponsor/SponsorProviders.ts +++ b/apps/api/src/domain/sponsor/SponsorProviders.ts @@ -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, diff --git a/apps/api/src/domain/sponsor/SponsorService.ts b/apps/api/src/domain/sponsor/SponsorService.ts index 7896bba95..61dd5780e 100644 --- a/apps/api/src/domain/sponsor/SponsorService.ts +++ b/apps/api/src/domain/sponsor/SponsorService.ts @@ -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 { + 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; + notifications?: Partial; + privacy?: Partial; + } + ): Promise { + 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 }); + } } diff --git a/apps/api/src/domain/sponsor/dtos/ActivityItemDTO.ts b/apps/api/src/domain/sponsor/dtos/ActivityItemDTO.ts new file mode 100644 index 000000000..8fd97e102 --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/ActivityItemDTO.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/AvailableLeagueDTO.ts b/apps/api/src/domain/sponsor/dtos/AvailableLeagueDTO.ts new file mode 100644 index 000000000..b72738dac --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/AvailableLeagueDTO.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/BillingStatsDTO.ts b/apps/api/src/domain/sponsor/dtos/BillingStatsDTO.ts new file mode 100644 index 000000000..48e593968 --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/BillingStatsDTO.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/DriverDTO.ts b/apps/api/src/domain/sponsor/dtos/DriverDTO.ts new file mode 100644 index 000000000..d7c240c43 --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/DriverDTO.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/InvoiceDTO.ts b/apps/api/src/domain/sponsor/dtos/InvoiceDTO.ts new file mode 100644 index 000000000..63a7df77e --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/InvoiceDTO.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/LeagueDetailDTO.ts b/apps/api/src/domain/sponsor/dtos/LeagueDetailDTO.ts new file mode 100644 index 000000000..d98a98cc7 --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/LeagueDetailDTO.ts @@ -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[]; + }; + }; +} \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/NotificationSettingsDTO.ts b/apps/api/src/domain/sponsor/dtos/NotificationSettingsDTO.ts new file mode 100644 index 000000000..81f3d5828 --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/NotificationSettingsDTO.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/PaymentMethodDTO.ts b/apps/api/src/domain/sponsor/dtos/PaymentMethodDTO.ts new file mode 100644 index 000000000..e9472416c --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/PaymentMethodDTO.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/PrivacySettingsDTO.ts b/apps/api/src/domain/sponsor/dtos/PrivacySettingsDTO.ts new file mode 100644 index 000000000..4d5b21120 --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/PrivacySettingsDTO.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/RaceDTO.ts b/apps/api/src/domain/sponsor/dtos/RaceDTO.ts new file mode 100644 index 000000000..3e723a4c6 --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/RaceDTO.ts @@ -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'; +} \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/RenewalAlertDTO.ts b/apps/api/src/domain/sponsor/dtos/RenewalAlertDTO.ts new file mode 100644 index 000000000..d4c55d2c1 --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/RenewalAlertDTO.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/SponsorDashboardDTO.ts b/apps/api/src/domain/sponsor/dtos/SponsorDashboardDTO.ts index cea1eab56..0e13e473f 100644 --- a/apps/api/src/domain/sponsor/dtos/SponsorDashboardDTO.ts +++ b/apps/api/src/domain/sponsor/dtos/SponsorDashboardDTO.ts @@ -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[]; } \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/SponsorProfileDTO.ts b/apps/api/src/domain/sponsor/dtos/SponsorProfileDTO.ts new file mode 100644 index 000000000..ae9fcf430 --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/SponsorProfileDTO.ts @@ -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; + }; +} \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/dtos/SponsorshipDTO.ts b/apps/api/src/domain/sponsor/dtos/SponsorshipDTO.ts new file mode 100644 index 000000000..038d858e5 --- /dev/null +++ b/apps/api/src/domain/sponsor/dtos/SponsorshipDTO.ts @@ -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; +} \ No newline at end of file diff --git a/apps/website/app/onboarding/page.tsx b/apps/website/app/onboarding/page.tsx index a8171bd16..88b895114 100644 --- a/apps/website/app/onboarding/page.tsx +++ b/apps/website/app/onboarding/page.tsx @@ -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 (
diff --git a/apps/website/app/page.tsx b/apps/website/app/page.tsx index d9f36599d..2dbafb7bd 100644 --- a/apps/website/app/page.tsx +++ b/apps/website/app/page.tsx @@ -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 (
@@ -332,10 +352,7 @@ export default async function HomePage() {

{race.car}

- {race.scheduledAt.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric' - })} + {race.formattedDate}
))} diff --git a/apps/website/app/sponsor/billing/page.tsx b/apps/website/app/sponsor/billing/page.tsx index 7b0bf2d7f..eb82a6d68 100644 --- a/apps/website/app/sponsor/billing/page.tsx +++ b/apps/website/app/sponsor/billing/page.tsx @@ -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({
- {getLabel()} + {method.displayLabel} {method.isDefault && ( Default )}
- {method.expiryMonth && method.expiryYear && ( + {method.expiryDisplay && ( - Expires {String(method.expiryMonth).padStart(2, '0')}/{method.expiryYear} + Expires {method.expiryDisplay} )} {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 }) { {invoice.invoiceNumber} - {invoice.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} + {invoice.formattedDate}
@@ -322,13 +234,13 @@ function InvoiceRow({ invoice, index }: { invoice: Invoice; index: number }) {
- €{invoice.totalAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })} + {invoice.formattedTotalAmount}
- incl. €{invoice.vatAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })} VAT + incl. {invoice.formattedVatAmount} VAT
- +
{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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

Loading billing data...

+
+
+ ); + } + + if (error || !data) { + return ( +
+
+

{error || 'No billing data available'}

+
+
+ ); + } + 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() { 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" />
- {paymentMethods.map((method) => ( - ( + handleSetDefault(method.id)} onRemove={() => handleRemoveMethod(method.id)} /> @@ -482,18 +444,18 @@ export default function SponsorBillingPage() { } />
- {displayedInvoices.map((invoice, index) => ( + {data.invoices.slice(0, showAllInvoices ? data.invoices.length : 4).map((invoice, index) => ( ))}
- {MOCK_INVOICES.length > 4 && ( + {data.invoices.length > 4 && (
-
diff --git a/apps/website/app/sponsor/campaigns/page.tsx b/apps/website/app/sponsor/campaigns/page.tsx index 4fd5a1fc1..9ade3f3c6 100644 --- a/apps/website/app/sponsor/campaigns/page.tsx +++ b/apps/website/app/sponsor/campaigns/page.tsx @@ -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
- {sponsorship.impressions.toLocaleString()} + {sponsorship.formattedImpressions} {sponsorship.impressionsChange !== undefined && sponsorship.impressionsChange !== 0 && ( 0 ? 'text-performance-green' : 'text-racing-red' @@ -529,7 +297,7 @@ function SponsorshipCard({ sponsorship }: { sponsorship: Sponsorship }) { Investment
-
${sponsorship.price}
+
{sponsorship.formattedPrice}
)} @@ -539,11 +307,11 @@ function SponsorshipCard({ sponsorship }: { sponsorship: Sponsorship }) {
- {sponsorship.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - {sponsorship.endDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} + {sponsorship.periodDisplay}
- ${sponsorship.price} + {sponsorship.formattedPrice}
)} @@ -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(initialType); const [statusFilter, setStatusFilter] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

Loading sponsorships...

+
+
+ ); + } + + if (error || !data) { + return ( +
+
+

{error || 'No sponsorships data available'}

+
+
+ ); + } + // 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 ( @@ -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(null); + const [error, setError] = useState(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 ( +
+
+ +

Loading dashboard...

+
+
+ ); + } - // 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 ( +
+
+

{error || 'No dashboard data available'}

+
+
+ ); + } + + const categoryData = data.categoryData; if (loading) { return ( @@ -317,7 +280,7 @@ export default function SponsorDashboardPage() {
@@ -432,17 +395,17 @@ export default function SponsorDashboardPage() {
- {league.name} + {league.entityName}
-
{league.drivers} drivers
+
{league.details}
-
{league.impressions.toLocaleString()}
+
{league.formattedImpressions}
impressions
- + @@ -450,7 +413,7 @@ export default function SponsorDashboardPage() {
))} - + {/* Teams */} {data.sponsorships.teams.map((team) => (
@@ -461,14 +424,14 @@ export default function SponsorDashboardPage() {
- {team.name} + {team.entityName}
-
{team.drivers} drivers
+
{team.details}
-
{team.impressions.toLocaleString()}
+
{team.formattedImpressions}
impressions
-
{driver.impressions.toLocaleString()}
+
{driver.formattedImpressions}
impressions
-

{race.name}

-

{race.league} • {race.date}

+

{race.entityName}

+

{race.details}

@@ -620,16 +583,16 @@ export default function SponsorDashboardPage() {
Active Sponsorships - {data.metrics.activeSponsors} + {data.activeSponsorships}
Total Investment - ${data.metrics.totalInvestment.toLocaleString()} + {data.formattedTotalInvestment}
Cost per 1K Views - ${(data.metrics.totalInvestment / data.metrics.totalImpressions * 1000).toFixed(2)} + {data.costPerThousandViews}
diff --git a/apps/website/app/sponsor/leagues/[id]/page.tsx b/apps/website/app/sponsor/leagues/[id]/page.tsx index fe303d5bf..174903d29 100644 --- a/apps/website/app/sponsor/leagues/[id]/page.tsx +++ b/apps/website/app/sponsor/leagues/[id]/page.tsx @@ -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(showSponsorAction ? 'sponsor' : 'overview'); const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

Loading league details...

+
+
+ ); + } + + if (error || !data) { + return ( +
+
+

{error || 'No league data available'}

+
+
+ ); + } + + const config = data.league.tierConfig; return (
@@ -134,9 +117,9 @@ export default function SponsorLeagueDetailPage() { {league.rating}
-

{league.name}

-

{league.game} • {league.season} • {league.completedRaces}/{league.races} races completed

-

{league.description}

+

{data.league.name}

+

{data.league.game} • {data.league.season} • {data.league.completedRaces}/{data.league.races} races completed

+

{data.league.description}

@@ -167,7 +150,7 @@ export default function SponsorLeagueDetailPage() {
-
{league.totalImpressions.toLocaleString()}
+
{data.league.formattedTotalImpressions}
Total Views
@@ -184,7 +167,7 @@ export default function SponsorLeagueDetailPage() {
-
{league.avgViewsPerRace.toLocaleString()}
+
{data.league.formattedAvgViewsPerRace}
Avg/Race
@@ -201,7 +184,7 @@ export default function SponsorLeagueDetailPage() {
-
{league.drivers}
+
{data.league.drivers}
Drivers
@@ -218,7 +201,7 @@ export default function SponsorLeagueDetailPage() {
-
{league.engagement}%
+
{data.league.engagement}%
Engagement
@@ -235,7 +218,7 @@ export default function SponsorLeagueDetailPage() {
-
{league.races - league.completedRaces}
+
{data.league.racesLeft}
Races Left
@@ -271,7 +254,7 @@ export default function SponsorLeagueDetailPage() {
Platform - {league.game} + {data.league.game}
Season @@ -300,16 +283,16 @@ export default function SponsorLeagueDetailPage() {
Total Season Views - {league.totalImpressions.toLocaleString()} + {data.league.formattedTotalImpressions}
Projected Total - {Math.round(league.avgViewsPerRace * league.races).toLocaleString()} + {data.league.formattedProjectedTotal}
Main Sponsor CPM - ${((league.sponsorSlots.main.price / (league.avgViewsPerRace * league.races)) * 1000).toFixed(2)} + {data.league.formattedMainSponsorCpm}
@@ -359,7 +342,7 @@ export default function SponsorLeagueDetailPage() {

Top drivers carrying sponsor branding

- {MOCK_DRIVERS.map((driver) => ( + {data.drivers.map((driver) => (
@@ -376,7 +359,7 @@ export default function SponsorLeagueDetailPage() {
races
-
{driver.impressions.toLocaleString()}
+
{driver.formattedImpressions}
views
@@ -393,7 +376,7 @@ export default function SponsorLeagueDetailPage() {

Season schedule with view statistics

- {MOCK_RACES.map((race) => ( + {data.races.map((race) => (
Drivers
-
{(league.avgViewsPerRace / 1000).toFixed(1)}k
+
{league.formattedAvgViews}
Avg Views
-
${cpm}
+
{league.formattedCpm}
CPM
@@ -282,9 +198,50 @@ export default function SponsorLeaguesPage() { const [tierFilter, setTierFilter] = useState('all'); const [availabilityFilter, setAvailabilityFilter] = useState('all'); const [sortBy, setSortBy] = useState('rating'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

Loading leagues...

+
+
+ ); + } + + if (error || !data) { + return ( +
+
+

{error || 'No leagues data available'}

+
+
+ ); + } // 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 */}

- Showing {filteredLeagues.length} of {MOCK_AVAILABLE_LEAGUES.length} leagues + Showing {filteredLeagues.length} of {data.leagues.length} leagues

diff --git a/apps/website/lib/api/sponsors/SponsorsApiClient.ts b/apps/website/lib/api/sponsors/SponsorsApiClient.ts index 1e8c94d25..b75a1583b 100644 --- a/apps/website/lib/api/sponsors/SponsorsApiClient.ts +++ b/apps/website/lib/api/sponsors/SponsorsApiClient.ts @@ -63,4 +63,41 @@ export class SponsorsApiClient extends BaseApiClient { rejectSponsorshipRequest(requestId: string, input: RejectSponsorshipRequestInputDTO): Promise { 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 { + 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 { + return this.put(`/sponsors/settings/${sponsorId}`, input); + } } \ No newline at end of file diff --git a/apps/website/lib/command-models/auth/LoginCommandModel.ts b/apps/website/lib/command-models/auth/LoginCommandModel.ts new file mode 100644 index 000000000..b565d6afb --- /dev/null +++ b/apps/website/lib/command-models/auth/LoginCommandModel.ts @@ -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, + }; + } +} diff --git a/apps/website/lib/command-models/auth/SignupCommandModel.ts b/apps/website/lib/command-models/auth/SignupCommandModel.ts new file mode 100644 index 000000000..cd4356f8e --- /dev/null +++ b/apps/website/lib/command-models/auth/SignupCommandModel.ts @@ -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, + }; + } +} diff --git a/apps/website/lib/services/landing/LandingService.ts b/apps/website/lib/services/landing/LandingService.ts new file mode 100644 index 000000000..2cde9ed7c --- /dev/null +++ b/apps/website/lib/services/landing/LandingService.ts @@ -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 { + const [racesDto, leaguesDto, teamsDto] = await Promise.all([ + this.racesApi.getPageData() as Promise, + 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, + }); + } +} diff --git a/apps/website/lib/services/sponsors/SponsorService.ts b/apps/website/lib/services/sponsors/SponsorService.ts index 2eec40961..059154546 100644 --- a/apps/website/lib/services/sponsors/SponsorService.ts +++ b/apps/website/lib/services/sponsors/SponsorService.ts @@ -58,4 +58,51 @@ export class SponsorService { async getSponsorshipPricing(): Promise { 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 { + 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 { + return await this.apiClient.updateSettings(sponsorId, input); + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/ActivityItemViewModel.ts b/apps/website/lib/view-models/ActivityItemViewModel.ts new file mode 100644 index 000000000..22ab385b2 --- /dev/null +++ b/apps/website/lib/view-models/ActivityItemViewModel.ts @@ -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; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/AvailableLeaguesViewModel.ts b/apps/website/lib/view-models/AvailableLeaguesViewModel.ts new file mode 100644 index 000000000..e26b7522d --- /dev/null +++ b/apps/website/lib/view-models/AvailableLeaguesViewModel.ts @@ -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]; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/BillingViewModel.ts b/apps/website/lib/view-models/BillingViewModel.ts new file mode 100644 index 000000000..41e4848b8 --- /dev/null +++ b/apps/website/lib/view-models/BillingViewModel.ts @@ -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' }); + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/HomeDiscoveryViewModel.ts b/apps/website/lib/view-models/HomeDiscoveryViewModel.ts new file mode 100644 index 000000000..8038199c7 --- /dev/null +++ b/apps/website/lib/view-models/HomeDiscoveryViewModel.ts @@ -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; + } +} diff --git a/apps/website/lib/view-models/LeagueCardViewModel.ts b/apps/website/lib/view-models/LeagueCardViewModel.ts new file mode 100644 index 000000000..62e389cb3 --- /dev/null +++ b/apps/website/lib/view-models/LeagueCardViewModel.ts @@ -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'; + } +} diff --git a/apps/website/lib/view-models/LeagueDetailViewModel.ts b/apps/website/lib/view-models/LeagueDetailViewModel.ts index 4c8c235f1..5b9174555 100644 --- a/apps/website/lib/view-models/LeagueDetailViewModel.ts +++ b/apps/website/lib/view-models/LeagueDetailViewModel.ts @@ -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(); + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/RenewalAlertViewModel.ts b/apps/website/lib/view-models/RenewalAlertViewModel.ts new file mode 100644 index 000000000..f066afb61 --- /dev/null +++ b/apps/website/lib/view-models/RenewalAlertViewModel.ts @@ -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; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/SponsorDashboardViewModel.ts b/apps/website/lib/view-models/SponsorDashboardViewModel.ts index 798a51130..28bb70e92 100644 --- a/apps/website/lib/view-models/SponsorDashboardViewModel.ts +++ b/apps/website/lib/view-models/SponsorDashboardViewModel.ts @@ -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), + }, + }; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/SponsorSettingsViewModel.ts b/apps/website/lib/view-models/SponsorSettingsViewModel.ts new file mode 100644 index 000000000..c47b205f3 --- /dev/null +++ b/apps/website/lib/view-models/SponsorSettingsViewModel.ts @@ -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; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/SponsorshipViewModel.ts b/apps/website/lib/view-models/SponsorshipViewModel.ts new file mode 100644 index 000000000..d4402b1ea --- /dev/null +++ b/apps/website/lib/view-models/SponsorshipViewModel.ts @@ -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}`; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/TeamCardViewModel.ts b/apps/website/lib/view-models/TeamCardViewModel.ts new file mode 100644 index 000000000..fdccbc39d --- /dev/null +++ b/apps/website/lib/view-models/TeamCardViewModel.ts @@ -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; + } +} diff --git a/apps/website/lib/view-models/UpcomingRaceCardViewModel.ts b/apps/website/lib/view-models/UpcomingRaceCardViewModel.ts new file mode 100644 index 000000000..477400293 --- /dev/null +++ b/apps/website/lib/view-models/UpcomingRaceCardViewModel.ts @@ -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', + }); + } +} diff --git a/core/payments/application/services/SponsorBillingService.ts b/core/payments/application/services/SponsorBillingService.ts new file mode 100644 index 000000000..601fdbefc --- /dev/null +++ b/core/payments/application/services/SponsorBillingService.ts @@ -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 { + // 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; + } +} diff --git a/core/racing/application/dto/ReopenRaceCommandDTO.ts b/core/racing/application/dto/ReopenRaceCommandDTO.ts new file mode 100644 index 000000000..5fd1b9fb9 --- /dev/null +++ b/core/racing/application/dto/ReopenRaceCommandDTO.ts @@ -0,0 +1,3 @@ +export interface ReopenRaceCommandDTO { + raceId: string; +} diff --git a/core/racing/application/use-cases/ReopenRaceUseCase.ts b/core/racing/application/use-cases/ReopenRaceUseCase.ts new file mode 100644 index 000000000..a975b3135 --- /dev/null +++ b/core/racing/application/use-cases/ReopenRaceUseCase.ts @@ -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 { + constructor( + private readonly raceRepository: IRaceRepository, + private readonly logger: Logger, + ) {} + + async execute(command: ReopenRaceCommandDTO): Promise>> { + 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' }); + } + } +} diff --git a/core/racing/domain/entities/Race.ts b/core/racing/domain/entities/Race.ts index 8d021bd65..ea9cefeb3 100644 --- a/core/racing/domain/entities/Race.ts +++ b/core/racing/domain/entities/Race.ts @@ -240,6 +240,48 @@ export class Race implements IEntity { 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 */