fix data flow issues

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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