view data fixes
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ActivityItemViewModel } from './ActivityItemViewModel';
|
||||
import { ActivityItemViewData } from '../view-data/ActivityItemViewData';
|
||||
|
||||
describe('ActivityItemViewModel', () => {
|
||||
it('maps basic properties from input data', () => {
|
||||
const data = {
|
||||
it('maps basic properties from ActivityItemViewData', () => {
|
||||
const viewData: ActivityItemViewData = {
|
||||
id: 'activity-1',
|
||||
type: 'race' as const,
|
||||
type: 'race',
|
||||
message: 'Test activity',
|
||||
time: '2025-01-01T12:00:00Z',
|
||||
impressions: 1234,
|
||||
};
|
||||
|
||||
const viewModel = new ActivityItemViewModel(data);
|
||||
const viewModel = new ActivityItemViewModel(viewData);
|
||||
|
||||
expect(viewModel.id).toBe('activity-1');
|
||||
expect(viewModel.type).toBe('race');
|
||||
@@ -40,7 +41,7 @@ describe('ActivityItemViewModel', () => {
|
||||
type: 'unknown',
|
||||
message: '',
|
||||
time: '',
|
||||
} as any);
|
||||
});
|
||||
|
||||
expect(unknown.typeColor).toBe('bg-gray-500');
|
||||
});
|
||||
@@ -77,4 +78,19 @@ describe('ActivityItemViewModel', () => {
|
||||
expect(noImpressions.formattedImpressions).toBeNull();
|
||||
expect(zeroImpressions.formattedImpressions).toBeNull();
|
||||
});
|
||||
|
||||
it('handles optional impressions field', () => {
|
||||
const withoutImpressions: ActivityItemViewData = {
|
||||
id: 'activity-5',
|
||||
type: 'platform',
|
||||
message: 'Platform activity',
|
||||
time: '2025-01-01T12:00:00Z',
|
||||
};
|
||||
|
||||
const viewModel = new ActivityItemViewModel(withoutImpressions);
|
||||
|
||||
expect(viewModel.impressions).toBeUndefined();
|
||||
expect(viewModel.formattedImpressions).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -2,24 +2,30 @@
|
||||
* Activity Item View Model
|
||||
*
|
||||
* View model for recent activity items.
|
||||
*
|
||||
* Accepts ActivityItemViewData as input and produces UI-ready data.
|
||||
*/
|
||||
export class ActivityItemViewModel {
|
||||
id: string;
|
||||
type: 'race' | 'league' | 'team' | 'driver' | 'platform';
|
||||
message: string;
|
||||
time: string;
|
||||
impressions?: number;
|
||||
import { ActivityItemViewData } from "../view-data/ActivityItemViewData";
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
constructor(data: any) {
|
||||
this.id = data.id;
|
||||
this.type = data.type;
|
||||
this.message = data.message;
|
||||
this.time = data.time;
|
||||
this.impressions = data.impressions;
|
||||
export class ActivityItemViewModel extends ViewModel {
|
||||
readonly id: string;
|
||||
readonly type: string;
|
||||
readonly message: string;
|
||||
readonly time: string;
|
||||
readonly impressions?: number;
|
||||
|
||||
constructor(viewData: ActivityItemViewData) {
|
||||
super();
|
||||
this.id = viewData.id;
|
||||
this.type = viewData.type;
|
||||
this.message = viewData.message;
|
||||
this.time = viewData.time;
|
||||
this.impressions = viewData.impressions;
|
||||
}
|
||||
|
||||
get typeColor(): string {
|
||||
const colors = {
|
||||
const colors: Record<string, string> = {
|
||||
race: 'bg-warning-amber',
|
||||
league: 'bg-primary-blue',
|
||||
team: 'bg-purple-400',
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AdminUserViewModel, DashboardStatsViewModel, UserListViewModel } from './AdminUserViewModel';
|
||||
import type { UserDto, DashboardStats } from '@/lib/api/admin/AdminApiClient';
|
||||
import type { AdminUserViewData } from '@/lib/view-data/AdminUserViewData';
|
||||
import type { DashboardStatsViewData } from '@/lib/view-data/DashboardStatsViewData';
|
||||
|
||||
describe('AdminUserViewModel', () => {
|
||||
const createBaseDto = (): UserDto => ({
|
||||
const createBaseViewData = (): AdminUserViewData => ({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
isSystemAdmin: false,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-02T00:00:00Z'),
|
||||
lastLoginAt: new Date('2024-01-15T10:30:00Z'),
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
lastLoginAt: '2024-01-15T10:30:00Z',
|
||||
primaryDriverId: 'driver-456',
|
||||
});
|
||||
|
||||
it('maps core fields from DTO', () => {
|
||||
const dto = createBaseDto();
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
it('maps core fields from ViewData', () => {
|
||||
const viewData = createBaseViewData();
|
||||
const vm = new AdminUserViewModel(viewData);
|
||||
|
||||
expect(vm.id).toBe('user-123');
|
||||
expect(vm.email).toBe('test@example.com');
|
||||
@@ -30,8 +31,8 @@ describe('AdminUserViewModel', () => {
|
||||
});
|
||||
|
||||
it('converts dates to Date objects', () => {
|
||||
const dto = createBaseDto();
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
const viewData = createBaseViewData();
|
||||
const vm = new AdminUserViewModel(viewData);
|
||||
|
||||
expect(vm.createdAt).toBeInstanceOf(Date);
|
||||
expect(vm.updatedAt).toBeInstanceOf(Date);
|
||||
@@ -40,19 +41,19 @@ describe('AdminUserViewModel', () => {
|
||||
});
|
||||
|
||||
it('handles missing lastLoginAt', () => {
|
||||
const dto = createBaseDto();
|
||||
delete dto.lastLoginAt;
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
const viewData = createBaseViewData();
|
||||
delete viewData.lastLoginAt;
|
||||
const vm = new AdminUserViewModel(viewData);
|
||||
|
||||
expect(vm.lastLoginAt).toBeUndefined();
|
||||
expect(vm.lastLoginFormatted).toBe('Never');
|
||||
});
|
||||
|
||||
it('formats role badges correctly', () => {
|
||||
const owner = new AdminUserViewModel({ ...createBaseDto(), roles: ['owner'] });
|
||||
const admin = new AdminUserViewModel({ ...createBaseDto(), roles: ['admin'] });
|
||||
const user = new AdminUserViewModel({ ...createBaseDto(), roles: ['user'] });
|
||||
const custom = new AdminUserViewModel({ ...createBaseDto(), roles: ['custom-role'] });
|
||||
const owner = new AdminUserViewModel({ ...createBaseViewData(), roles: ['owner'] });
|
||||
const admin = new AdminUserViewModel({ ...createBaseViewData(), roles: ['admin'] });
|
||||
const user = new AdminUserViewModel({ ...createBaseViewData(), roles: ['user'] });
|
||||
const custom = new AdminUserViewModel({ ...createBaseViewData(), roles: ['custom-role'] });
|
||||
|
||||
expect(owner.roleBadges).toEqual(['Owner']);
|
||||
expect(admin.roleBadges).toEqual(['Admin']);
|
||||
@@ -61,51 +62,36 @@ describe('AdminUserViewModel', () => {
|
||||
});
|
||||
|
||||
it('derives status badge correctly', () => {
|
||||
const active = new AdminUserViewModel({ ...createBaseDto(), status: 'active' });
|
||||
const suspended = new AdminUserViewModel({ ...createBaseDto(), status: 'suspended' });
|
||||
const deleted = new AdminUserViewModel({ ...createBaseDto(), status: 'deleted' });
|
||||
const active = new AdminUserViewModel({ ...createBaseViewData(), status: 'active' });
|
||||
const suspended = new AdminUserViewModel({ ...createBaseViewData(), status: 'suspended' });
|
||||
const deleted = new AdminUserViewModel({ ...createBaseViewData(), status: 'deleted' });
|
||||
|
||||
expect(active.statusBadge).toEqual({ label: 'Active', variant: 'performance-green' });
|
||||
expect(suspended.statusBadge).toEqual({ label: 'Suspended', variant: 'yellow-500' });
|
||||
expect(deleted.statusBadge).toEqual({ label: 'Deleted', variant: 'racing-red' });
|
||||
expect(active.statusBadgeLabel).toBe('Active');
|
||||
expect(active.statusBadgeVariant).toBe('performance-green');
|
||||
expect(suspended.statusBadgeLabel).toBe('Suspended');
|
||||
expect(suspended.statusBadgeVariant).toBe('yellow-500');
|
||||
expect(deleted.statusBadgeLabel).toBe('Deleted');
|
||||
expect(deleted.statusBadgeVariant).toBe('racing-red');
|
||||
});
|
||||
|
||||
it('formats dates for display', () => {
|
||||
const dto = createBaseDto();
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
const viewData = createBaseViewData();
|
||||
const vm = new AdminUserViewModel(viewData);
|
||||
|
||||
expect(vm.lastLoginFormatted).toBe('1/15/2024');
|
||||
expect(vm.createdAtFormatted).toBe('1/1/2024');
|
||||
});
|
||||
|
||||
it('derives action permissions correctly', () => {
|
||||
const active = new AdminUserViewModel({ ...createBaseDto(), status: 'active' });
|
||||
const suspended = new AdminUserViewModel({ ...createBaseDto(), status: 'suspended' });
|
||||
const deleted = new AdminUserViewModel({ ...createBaseDto(), status: 'deleted' });
|
||||
|
||||
expect(active.canSuspend).toBe(true);
|
||||
expect(active.canActivate).toBe(false);
|
||||
expect(active.canDelete).toBe(true);
|
||||
|
||||
expect(suspended.canSuspend).toBe(false);
|
||||
expect(suspended.canActivate).toBe(true);
|
||||
expect(suspended.canDelete).toBe(true);
|
||||
|
||||
expect(deleted.canSuspend).toBe(false);
|
||||
expect(deleted.canActivate).toBe(false);
|
||||
expect(deleted.canDelete).toBe(false);
|
||||
expect(vm.lastLoginFormatted).toBe('Jan 15, 2024');
|
||||
expect(vm.createdAtFormatted).toBe('Jan 1, 2024');
|
||||
});
|
||||
|
||||
it('handles multiple roles', () => {
|
||||
const dto = { ...createBaseDto(), roles: ['owner', 'admin'] };
|
||||
const vm = new AdminUserViewModel(dto);
|
||||
const viewData = { ...createBaseViewData(), roles: ['owner', 'admin'] };
|
||||
const vm = new AdminUserViewModel(viewData);
|
||||
|
||||
expect(vm.roleBadges).toEqual(['Owner', 'Admin']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardStatsViewModel', () => {
|
||||
const createBaseData = (): DashboardStats => ({
|
||||
const createBaseData = (): DashboardStatsViewData => ({
|
||||
totalUsers: 100,
|
||||
activeUsers: 70,
|
||||
suspendedUsers: 10,
|
||||
@@ -165,21 +151,24 @@ describe('DashboardStatsViewModel', () => {
|
||||
totalUsers: 100,
|
||||
recentLogins: 10, // 10% engagement
|
||||
});
|
||||
expect(lowEngagement.activityLevel).toBe('low');
|
||||
expect(lowEngagement.activityLevelLabel).toBe('Low');
|
||||
expect(lowEngagement.activityLevelValue).toBe('low');
|
||||
|
||||
const mediumEngagement = new DashboardStatsViewModel({
|
||||
...createBaseData(),
|
||||
totalUsers: 100,
|
||||
recentLogins: 35, // 35% engagement
|
||||
});
|
||||
expect(mediumEngagement.activityLevel).toBe('medium');
|
||||
expect(mediumEngagement.activityLevelLabel).toBe('Medium');
|
||||
expect(mediumEngagement.activityLevelValue).toBe('medium');
|
||||
|
||||
const highEngagement = new DashboardStatsViewModel({
|
||||
...createBaseData(),
|
||||
totalUsers: 100,
|
||||
recentLogins: 60, // 60% engagement
|
||||
});
|
||||
expect(highEngagement.activityLevel).toBe('high');
|
||||
expect(highEngagement.activityLevelLabel).toBe('High');
|
||||
expect(highEngagement.activityLevelValue).toBe('high');
|
||||
});
|
||||
|
||||
it('handles zero users safely', () => {
|
||||
@@ -194,7 +183,8 @@ describe('DashboardStatsViewModel', () => {
|
||||
expect(vm.activeRate).toBe(0);
|
||||
expect(vm.activeRateFormatted).toBe('0%');
|
||||
expect(vm.adminRatio).toBe('1:1');
|
||||
expect(vm.activityLevel).toBe('low');
|
||||
expect(vm.activityLevelLabel).toBe('Low');
|
||||
expect(vm.activityLevelValue).toBe('low');
|
||||
});
|
||||
|
||||
it('preserves arrays from input', () => {
|
||||
@@ -208,21 +198,21 @@ describe('DashboardStatsViewModel', () => {
|
||||
});
|
||||
|
||||
describe('UserListViewModel', () => {
|
||||
const createDto = (overrides: Partial<UserDto> = {}): UserDto => ({
|
||||
const createViewData = (overrides: Partial<AdminUserViewData> = {}): AdminUserViewData => ({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
isSystemAdmin: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('wraps user DTOs in AdminUserViewModel instances', () => {
|
||||
it('wraps user ViewData in AdminUserViewModel instances', () => {
|
||||
const data = {
|
||||
users: [createDto({ id: 'user-1' }), createDto({ id: 'user-2' })],
|
||||
users: [createViewData({ id: 'user-1' }), createViewData({ id: 'user-2' })],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
@@ -239,7 +229,7 @@ describe('UserListViewModel', () => {
|
||||
|
||||
it('exposes pagination metadata', () => {
|
||||
const data = {
|
||||
users: [createDto()],
|
||||
users: [createViewData()],
|
||||
total: 50,
|
||||
page: 2,
|
||||
limit: 10,
|
||||
@@ -256,7 +246,7 @@ describe('UserListViewModel', () => {
|
||||
|
||||
it('derives hasUsers correctly', () => {
|
||||
const withUsers = new UserListViewModel({
|
||||
users: [createDto()],
|
||||
users: [createViewData()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
@@ -277,7 +267,7 @@ describe('UserListViewModel', () => {
|
||||
|
||||
it('derives showPagination correctly', () => {
|
||||
const withPagination = new UserListViewModel({
|
||||
users: [createDto()],
|
||||
users: [createViewData()],
|
||||
total: 20,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
@@ -285,7 +275,7 @@ describe('UserListViewModel', () => {
|
||||
});
|
||||
|
||||
const withoutPagination = new UserListViewModel({
|
||||
users: [createDto()],
|
||||
users: [createViewData()],
|
||||
total: 5,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
@@ -298,7 +288,7 @@ describe('UserListViewModel', () => {
|
||||
|
||||
it('calculates start and end indices correctly', () => {
|
||||
const vm = new UserListViewModel({
|
||||
users: [createDto(), createDto(), createDto()],
|
||||
users: [createViewData(), createViewData(), createViewData()],
|
||||
total: 50,
|
||||
page: 2,
|
||||
limit: 10,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { UserDto } from '@/lib/types/admin';
|
||||
import type { AdminUserViewData } from '@/lib/view-data/AdminUserViewData';
|
||||
import type { DashboardStatsViewData } from '@/lib/view-data/DashboardStatsViewData';
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import { UserStatusDisplay } from "../display-objects/UserStatusDisplay";
|
||||
import { UserRoleDisplay } from "../display-objects/UserRoleDisplay";
|
||||
import { DateDisplay } from "../display-objects/DateDisplay";
|
||||
import { ActivityLevelDisplay } from "../display-objects/ActivityLevelDisplay";
|
||||
|
||||
/**
|
||||
* AdminUserViewModel
|
||||
*
|
||||
*
|
||||
* View Model for admin user management.
|
||||
* Transforms API DTO into UI-ready state with formatting and derived fields.
|
||||
*/
|
||||
export class AdminUserViewModel {
|
||||
export class AdminUserViewModel extends ViewModel {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
@@ -18,73 +24,48 @@ export class AdminUserViewModel {
|
||||
lastLoginAt?: Date;
|
||||
primaryDriverId?: string;
|
||||
|
||||
// UI-specific derived fields
|
||||
// UI-specific derived fields (primitive outputs only)
|
||||
readonly roleBadges: string[];
|
||||
readonly statusBadge: { label: string; variant: string };
|
||||
readonly statusBadgeLabel: string;
|
||||
readonly statusBadgeVariant: string;
|
||||
readonly lastLoginFormatted: string;
|
||||
readonly createdAtFormatted: string;
|
||||
readonly canSuspend: boolean;
|
||||
readonly canActivate: boolean;
|
||||
readonly canDelete: boolean;
|
||||
|
||||
constructor(dto: UserDto) {
|
||||
this.id = dto.id;
|
||||
this.email = dto.email;
|
||||
this.displayName = dto.displayName;
|
||||
this.roles = dto.roles;
|
||||
this.status = dto.status;
|
||||
this.isSystemAdmin = dto.isSystemAdmin;
|
||||
this.createdAt = new Date(dto.createdAt);
|
||||
this.updatedAt = new Date(dto.updatedAt);
|
||||
this.lastLoginAt = dto.lastLoginAt ? new Date(dto.lastLoginAt) : undefined;
|
||||
this.primaryDriverId = dto.primaryDriverId;
|
||||
constructor(viewData: AdminUserViewData) {
|
||||
super();
|
||||
this.id = viewData.id;
|
||||
this.email = viewData.email;
|
||||
this.displayName = viewData.displayName;
|
||||
this.roles = viewData.roles;
|
||||
this.status = viewData.status;
|
||||
this.isSystemAdmin = viewData.isSystemAdmin;
|
||||
this.createdAt = new Date(viewData.createdAt);
|
||||
this.updatedAt = new Date(viewData.updatedAt);
|
||||
this.lastLoginAt = viewData.lastLoginAt ? new Date(viewData.lastLoginAt) : undefined;
|
||||
this.primaryDriverId = viewData.primaryDriverId;
|
||||
|
||||
// Derive role badges
|
||||
this.roleBadges = this.roles.map(role => {
|
||||
switch (role) {
|
||||
case 'owner': return 'Owner';
|
||||
case 'admin': return 'Admin';
|
||||
case 'user': return 'User';
|
||||
default: return role;
|
||||
}
|
||||
});
|
||||
// Derive role badges using Display Object
|
||||
this.roleBadges = this.roles.map(role => UserRoleDisplay.roleLabel(role));
|
||||
|
||||
// Derive status badge
|
||||
this.statusBadge = this.getStatusBadge();
|
||||
// Derive status badge using Display Object
|
||||
this.statusBadgeLabel = UserStatusDisplay.statusLabel(this.status);
|
||||
this.statusBadgeVariant = UserStatusDisplay.statusVariant(this.status);
|
||||
|
||||
// Format dates
|
||||
this.lastLoginFormatted = this.lastLoginAt
|
||||
? this.lastLoginAt.toLocaleDateString()
|
||||
// Format dates using Display Object
|
||||
this.lastLoginFormatted = this.lastLoginAt
|
||||
? DateDisplay.formatShort(this.lastLoginAt)
|
||||
: 'Never';
|
||||
this.createdAtFormatted = this.createdAt.toLocaleDateString();
|
||||
|
||||
// Derive action permissions
|
||||
this.canSuspend = this.status === 'active';
|
||||
this.canActivate = this.status === 'suspended';
|
||||
this.canDelete = this.status !== 'deleted';
|
||||
}
|
||||
|
||||
private getStatusBadge(): { label: string; variant: string } {
|
||||
switch (this.status) {
|
||||
case 'active':
|
||||
return { label: 'Active', variant: 'performance-green' };
|
||||
case 'suspended':
|
||||
return { label: 'Suspended', variant: 'yellow-500' };
|
||||
case 'deleted':
|
||||
return { label: 'Deleted', variant: 'racing-red' };
|
||||
default:
|
||||
return { label: this.status, variant: 'gray-500' };
|
||||
}
|
||||
this.createdAtFormatted = DateDisplay.formatShort(this.createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardStatsViewModel
|
||||
*
|
||||
*
|
||||
* View Model for admin dashboard statistics.
|
||||
* Provides formatted statistics and derived metrics for UI.
|
||||
*/
|
||||
export class DashboardStatsViewModel {
|
||||
export class DashboardStatsViewModel extends ViewModel {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
suspendedUsers: number;
|
||||
@@ -113,52 +94,26 @@ export class DashboardStatsViewModel {
|
||||
logins: number;
|
||||
}[];
|
||||
|
||||
// UI-specific derived fields
|
||||
// UI-specific derived fields (primitive outputs only)
|
||||
readonly activeRate: number;
|
||||
readonly activeRateFormatted: string;
|
||||
readonly adminRatio: string;
|
||||
readonly activityLevel: 'low' | 'medium' | 'high';
|
||||
readonly activityLevelLabel: string;
|
||||
readonly activityLevelValue: 'low' | 'medium' | 'high';
|
||||
|
||||
constructor(data: {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
suspendedUsers: number;
|
||||
deletedUsers: number;
|
||||
systemAdmins: number;
|
||||
recentLogins: number;
|
||||
newUsersToday: number;
|
||||
userGrowth: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
roleDistribution: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[];
|
||||
statusDistribution: {
|
||||
active: number;
|
||||
suspended: number;
|
||||
deleted: number;
|
||||
};
|
||||
activityTimeline: {
|
||||
date: string;
|
||||
newUsers: number;
|
||||
logins: number;
|
||||
}[];
|
||||
}) {
|
||||
this.totalUsers = data.totalUsers;
|
||||
this.activeUsers = data.activeUsers;
|
||||
this.suspendedUsers = data.suspendedUsers;
|
||||
this.deletedUsers = data.deletedUsers;
|
||||
this.systemAdmins = data.systemAdmins;
|
||||
this.recentLogins = data.recentLogins;
|
||||
this.newUsersToday = data.newUsersToday;
|
||||
this.userGrowth = data.userGrowth;
|
||||
this.roleDistribution = data.roleDistribution;
|
||||
this.statusDistribution = data.statusDistribution;
|
||||
this.activityTimeline = data.activityTimeline;
|
||||
constructor(viewData: DashboardStatsViewData) {
|
||||
super();
|
||||
this.totalUsers = viewData.totalUsers;
|
||||
this.activeUsers = viewData.activeUsers;
|
||||
this.suspendedUsers = viewData.suspendedUsers;
|
||||
this.deletedUsers = viewData.deletedUsers;
|
||||
this.systemAdmins = viewData.systemAdmins;
|
||||
this.recentLogins = viewData.recentLogins;
|
||||
this.newUsersToday = viewData.newUsersToday;
|
||||
this.userGrowth = viewData.userGrowth;
|
||||
this.roleDistribution = viewData.roleDistribution;
|
||||
this.statusDistribution = viewData.statusDistribution;
|
||||
this.activityTimeline = viewData.activityTimeline;
|
||||
|
||||
// Derive active rate
|
||||
this.activeRate = this.totalUsers > 0 ? (this.activeUsers / this.totalUsers) * 100 : 0;
|
||||
@@ -168,44 +123,40 @@ export class DashboardStatsViewModel {
|
||||
const nonAdmins = Math.max(1, this.totalUsers - this.systemAdmins);
|
||||
this.adminRatio = `1:${Math.floor(nonAdmins / Math.max(1, this.systemAdmins))}`;
|
||||
|
||||
// Derive activity level
|
||||
// Derive activity level using Display Object
|
||||
const engagementRate = this.totalUsers > 0 ? (this.recentLogins / this.totalUsers) * 100 : 0;
|
||||
if (engagementRate < 20) {
|
||||
this.activityLevel = 'low';
|
||||
} else if (engagementRate < 50) {
|
||||
this.activityLevel = 'medium';
|
||||
} else {
|
||||
this.activityLevel = 'high';
|
||||
}
|
||||
this.activityLevelLabel = ActivityLevelDisplay.levelLabel(engagementRate);
|
||||
this.activityLevelValue = ActivityLevelDisplay.levelValue(engagementRate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UserListViewModel
|
||||
*
|
||||
*
|
||||
* View Model for user list with pagination and filtering state.
|
||||
*/
|
||||
export class UserListViewModel {
|
||||
export class UserListViewModel extends ViewModel {
|
||||
users: AdminUserViewModel[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
|
||||
// UI-specific derived fields
|
||||
// UI-specific derived fields (primitive outputs only)
|
||||
readonly hasUsers: boolean;
|
||||
readonly showPagination: boolean;
|
||||
readonly startIndex: number;
|
||||
readonly endIndex: number;
|
||||
|
||||
constructor(data: {
|
||||
users: UserDto[];
|
||||
users: AdminUserViewData[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}) {
|
||||
this.users = data.users.map(dto => new AdminUserViewModel(dto));
|
||||
super();
|
||||
this.users = data.users.map(viewData => new AdminUserViewModel(viewData));
|
||||
this.total = data.total;
|
||||
this.page = data.page;
|
||||
this.limit = data.limit;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AnalyticsDashboardViewModel } from './AnalyticsDashboardViewModel';
|
||||
import { AnalyticsDashboardInputViewData } from '../view-data/AnalyticsDashboardInputViewData';
|
||||
|
||||
describe('AnalyticsDashboardViewModel', () => {
|
||||
it('maps core fields from data', () => {
|
||||
const vm = new AnalyticsDashboardViewModel({
|
||||
it('maps core fields from AnalyticsDashboardInputViewData', () => {
|
||||
const viewData: AnalyticsDashboardInputViewData = {
|
||||
totalUsers: 100,
|
||||
activeUsers: 40,
|
||||
totalRaces: 10,
|
||||
totalLeagues: 5,
|
||||
});
|
||||
};
|
||||
|
||||
const vm = new AnalyticsDashboardViewModel(viewData);
|
||||
|
||||
expect(vm.totalUsers).toBe(100);
|
||||
expect(vm.activeUsers).toBe(40);
|
||||
@@ -17,24 +20,28 @@ describe('AnalyticsDashboardViewModel', () => {
|
||||
});
|
||||
|
||||
it('computes engagement rate and formatted engagement rate', () => {
|
||||
const vm = new AnalyticsDashboardViewModel({
|
||||
const viewData: AnalyticsDashboardInputViewData = {
|
||||
totalUsers: 200,
|
||||
activeUsers: 50,
|
||||
totalRaces: 0,
|
||||
totalLeagues: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const vm = new AnalyticsDashboardViewModel(viewData);
|
||||
|
||||
expect(vm.userEngagementRate).toBeCloseTo(25);
|
||||
expect(vm.formattedEngagementRate).toBe('25.0%');
|
||||
});
|
||||
|
||||
it('handles zero users safely', () => {
|
||||
const vm = new AnalyticsDashboardViewModel({
|
||||
const viewData: AnalyticsDashboardInputViewData = {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
totalRaces: 0,
|
||||
totalLeagues: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const vm = new AnalyticsDashboardViewModel(viewData);
|
||||
|
||||
expect(vm.userEngagementRate).toBe(0);
|
||||
expect(vm.formattedEngagementRate).toBe('0.0%');
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
/**
|
||||
* Analytics dashboard view model
|
||||
* Represents dashboard data for analytics
|
||||
*
|
||||
* Note: No matching generated DTO available yet
|
||||
* View model for analytics dashboard data.
|
||||
*
|
||||
* Accepts AnalyticsDashboardInputViewData as input and produces UI-ready data.
|
||||
*/
|
||||
export class AnalyticsDashboardViewModel {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
totalRaces: number;
|
||||
totalLeagues: number;
|
||||
import { AnalyticsDashboardInputViewData } from "../view-data/AnalyticsDashboardInputViewData";
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
constructor(data: { totalUsers: number; activeUsers: number; totalRaces: number; totalLeagues: number }) {
|
||||
this.totalUsers = data.totalUsers;
|
||||
this.activeUsers = data.activeUsers;
|
||||
this.totalRaces = data.totalRaces;
|
||||
this.totalLeagues = data.totalLeagues;
|
||||
export class AnalyticsDashboardViewModel extends ViewModel {
|
||||
readonly totalUsers: number;
|
||||
readonly activeUsers: number;
|
||||
readonly totalRaces: number;
|
||||
readonly totalLeagues: number;
|
||||
|
||||
constructor(viewData: AnalyticsDashboardInputViewData) {
|
||||
super();
|
||||
this.totalUsers = viewData.totalUsers;
|
||||
this.activeUsers = viewData.activeUsers;
|
||||
this.totalRaces = viewData.totalRaces;
|
||||
this.totalLeagues = viewData.totalLeagues;
|
||||
}
|
||||
|
||||
/** UI-specific: User engagement rate */
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AnalyticsMetricsViewModel } from './AnalyticsMetricsViewModel';
|
||||
import { AnalyticsMetricsViewData } from '../view-data/AnalyticsMetricsViewData';
|
||||
|
||||
describe('AnalyticsMetricsViewModel', () => {
|
||||
it('maps raw metrics fields from data', () => {
|
||||
const vm = new AnalyticsMetricsViewModel({
|
||||
it('maps metrics fields from AnalyticsMetricsViewData', () => {
|
||||
const viewData: AnalyticsMetricsViewData = {
|
||||
pageViews: 1234,
|
||||
uniqueVisitors: 567,
|
||||
averageSessionDuration: 180,
|
||||
bounceRate: 42.5,
|
||||
});
|
||||
};
|
||||
|
||||
const vm = new AnalyticsMetricsViewModel(viewData);
|
||||
|
||||
expect(vm.pageViews).toBe(1234);
|
||||
expect(vm.uniqueVisitors).toBe(567);
|
||||
@@ -16,36 +19,42 @@ describe('AnalyticsMetricsViewModel', () => {
|
||||
expect(vm.bounceRate).toBe(42.5);
|
||||
});
|
||||
|
||||
it('formats counts using locale formatting helpers', () => {
|
||||
const vm = new AnalyticsMetricsViewModel({
|
||||
it('formats counts using NumberDisplay', () => {
|
||||
const viewData: AnalyticsMetricsViewData = {
|
||||
pageViews: 1200,
|
||||
uniqueVisitors: 3500,
|
||||
averageSessionDuration: 75,
|
||||
bounceRate: 10,
|
||||
});
|
||||
};
|
||||
|
||||
expect(vm.formattedPageViews).toBe((1200).toLocaleString());
|
||||
expect(vm.formattedUniqueVisitors).toBe((3500).toLocaleString());
|
||||
const vm = new AnalyticsMetricsViewModel(viewData);
|
||||
|
||||
expect(vm.formattedPageViews).toBe('1,200');
|
||||
expect(vm.formattedUniqueVisitors).toBe('3,500');
|
||||
});
|
||||
|
||||
it('formats session duration as mm:ss', () => {
|
||||
const vm = new AnalyticsMetricsViewModel({
|
||||
it('formats session duration using DurationDisplay', () => {
|
||||
const viewData: AnalyticsMetricsViewData = {
|
||||
pageViews: 0,
|
||||
uniqueVisitors: 0,
|
||||
averageSessionDuration: 125,
|
||||
bounceRate: 0,
|
||||
});
|
||||
};
|
||||
|
||||
expect(vm.formattedSessionDuration).toBe('2:05');
|
||||
const vm = new AnalyticsMetricsViewModel(viewData);
|
||||
|
||||
expect(vm.formattedSessionDuration).toBe('2:05.000');
|
||||
});
|
||||
|
||||
it('formats bounce rate as percentage with one decimal', () => {
|
||||
const vm = new AnalyticsMetricsViewModel({
|
||||
it('formats bounce rate using PercentDisplay', () => {
|
||||
const viewData: AnalyticsMetricsViewData = {
|
||||
pageViews: 0,
|
||||
uniqueVisitors: 0,
|
||||
averageSessionDuration: 0,
|
||||
bounceRate: 37.345,
|
||||
});
|
||||
bounceRate: 0.37345,
|
||||
};
|
||||
|
||||
const vm = new AnalyticsMetricsViewModel(viewData);
|
||||
|
||||
expect(vm.formattedBounceRate).toBe('37.3%');
|
||||
});
|
||||
|
||||
@@ -2,40 +2,45 @@
|
||||
* Analytics metrics view model
|
||||
* Represents metrics data for analytics
|
||||
*
|
||||
* Note: No matching generated DTO available yet
|
||||
* Accepts AnalyticsMetricsViewData as input and produces UI-ready data.
|
||||
*/
|
||||
export class AnalyticsMetricsViewModel {
|
||||
pageViews: number;
|
||||
uniqueVisitors: number;
|
||||
averageSessionDuration: number;
|
||||
bounceRate: number;
|
||||
import { AnalyticsMetricsViewData } from "../view-data/AnalyticsMetricsViewData";
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import { NumberDisplay } from "../display-objects/NumberDisplay";
|
||||
import { DurationDisplay } from "../display-objects/DurationDisplay";
|
||||
import { PercentDisplay } from "../display-objects/PercentDisplay";
|
||||
|
||||
constructor(data: { pageViews: number; uniqueVisitors: number; averageSessionDuration: number; bounceRate: number }) {
|
||||
this.pageViews = data.pageViews;
|
||||
this.uniqueVisitors = data.uniqueVisitors;
|
||||
this.averageSessionDuration = data.averageSessionDuration;
|
||||
this.bounceRate = data.bounceRate;
|
||||
export class AnalyticsMetricsViewModel extends ViewModel {
|
||||
readonly pageViews: number;
|
||||
readonly uniqueVisitors: number;
|
||||
readonly averageSessionDuration: number;
|
||||
readonly bounceRate: number;
|
||||
|
||||
constructor(viewData: AnalyticsMetricsViewData) {
|
||||
super();
|
||||
this.pageViews = viewData.pageViews;
|
||||
this.uniqueVisitors = viewData.uniqueVisitors;
|
||||
this.averageSessionDuration = viewData.averageSessionDuration;
|
||||
this.bounceRate = viewData.bounceRate;
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted page views */
|
||||
get formattedPageViews(): string {
|
||||
return this.pageViews.toLocaleString();
|
||||
return NumberDisplay.format(this.pageViews);
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted unique visitors */
|
||||
get formattedUniqueVisitors(): string {
|
||||
return this.uniqueVisitors.toLocaleString();
|
||||
return NumberDisplay.format(this.uniqueVisitors);
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted session duration */
|
||||
get formattedSessionDuration(): string {
|
||||
const minutes = Math.floor(this.averageSessionDuration / 60);
|
||||
const seconds = Math.floor(this.averageSessionDuration % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
return DurationDisplay.formatSeconds(this.averageSessionDuration);
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted bounce rate */
|
||||
get formattedBounceRate(): string {
|
||||
return `${this.bounceRate.toFixed(1)}%`;
|
||||
return PercentDisplay.format(this.bounceRate);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AvailableLeaguesViewModel, AvailableLeagueViewModel } from './AvailableLeaguesViewModel';
|
||||
import { AvailableLeaguesViewData, AvailableLeagueViewData } from '../view-data/AvailableLeaguesViewData';
|
||||
|
||||
describe('AvailableLeaguesViewModel', () => {
|
||||
const baseLeague = {
|
||||
const baseLeague: AvailableLeagueViewData = {
|
||||
id: 'league-1',
|
||||
name: 'Pro Series',
|
||||
game: 'iRacing',
|
||||
description: 'Competitive league for serious drivers',
|
||||
drivers: 24,
|
||||
avgViewsPerRace: 12_500,
|
||||
formattedAvgViews: '12.5k',
|
||||
mainSponsorSlot: { available: true, price: 5_000 },
|
||||
secondarySlots: { available: 2, total: 3, price: 1_500 },
|
||||
cpm: 400,
|
||||
formattedCpm: '$400',
|
||||
hasAvailableSlots: true,
|
||||
rating: 4.7,
|
||||
tier: 'premium' as const,
|
||||
tierConfig: {
|
||||
color: '#FFD700',
|
||||
bgColor: '#FFF8DC',
|
||||
border: '2px solid #FFD700',
|
||||
icon: '⭐',
|
||||
},
|
||||
nextRace: 'Next Sunday',
|
||||
seasonStatus: 'active' as const,
|
||||
description: 'Competitive league for serious drivers',
|
||||
statusConfig: {
|
||||
color: '#10B981',
|
||||
bg: '#D1FAE5',
|
||||
label: 'Active Season',
|
||||
},
|
||||
};
|
||||
|
||||
const baseViewData: AvailableLeaguesViewData = {
|
||||
leagues: [baseLeague],
|
||||
};
|
||||
|
||||
it('maps league array into view models', () => {
|
||||
const vm = new AvailableLeaguesViewModel([baseLeague]);
|
||||
const vm = new AvailableLeaguesViewModel(baseViewData);
|
||||
|
||||
expect(vm.leagues).toHaveLength(1);
|
||||
expect(vm.leagues[0]).toBeInstanceOf(AvailableLeagueViewModel);
|
||||
@@ -30,11 +50,11 @@ describe('AvailableLeaguesViewModel', () => {
|
||||
it('exposes formatted average views and CPM for main sponsor slot', () => {
|
||||
const leagueVm = new AvailableLeagueViewModel(baseLeague);
|
||||
|
||||
expect(leagueVm.formattedAvgViews).toBe(`${(baseLeague.avgViewsPerRace / 1000).toFixed(1)}k`);
|
||||
expect(leagueVm.formattedAvgViews).toBe('12.5k');
|
||||
|
||||
const expectedCpm = Math.round((baseLeague.mainSponsorSlot.price / baseLeague.avgViewsPerRace) * 1000);
|
||||
expect(leagueVm.cpm).toBe(expectedCpm);
|
||||
expect(leagueVm.formattedCpm).toBe(`$${expectedCpm}`);
|
||||
expect(leagueVm.formattedCpm).toBe('$400');
|
||||
});
|
||||
|
||||
it('detects available sponsor slots from main or secondary slots', () => {
|
||||
@@ -75,4 +95,5 @@ describe('AvailableLeaguesViewModel', () => {
|
||||
expect(upcoming.statusConfig.label).toBe('Starting Soon');
|
||||
expect(completed.statusConfig.label).toBe('Season Ended');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -2,77 +2,83 @@
|
||||
* Available Leagues View Model
|
||||
*
|
||||
* View model for leagues available for sponsorship.
|
||||
*
|
||||
* Accepts AvailableLeaguesViewData as input and produces UI-ready data.
|
||||
*/
|
||||
export class AvailableLeaguesViewModel {
|
||||
leagues: AvailableLeagueViewModel[];
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import { AvailableLeaguesViewData, AvailableLeagueViewData } from "../view-data/AvailableLeaguesViewData";
|
||||
import { NumberDisplay } from "../display-objects/NumberDisplay";
|
||||
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
|
||||
import { LeagueTierDisplay } from "../display-objects/LeagueTierDisplay";
|
||||
import { SeasonStatusDisplay } from "../display-objects/SeasonStatusDisplay";
|
||||
|
||||
constructor(leagues: unknown[]) {
|
||||
this.leagues = leagues.map(league => new AvailableLeagueViewModel(league));
|
||||
export class AvailableLeaguesViewModel extends ViewModel {
|
||||
readonly leagues: AvailableLeagueViewModel[];
|
||||
|
||||
constructor(viewData: AvailableLeaguesViewData) {
|
||||
super();
|
||||
this.leagues = viewData.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;
|
||||
export class AvailableLeagueViewModel extends ViewModel {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly game: string;
|
||||
readonly drivers: number;
|
||||
readonly avgViewsPerRace: number;
|
||||
readonly mainSponsorSlot: { available: boolean; price: number };
|
||||
readonly secondarySlots: { available: number; total: number; price: number };
|
||||
readonly rating: number;
|
||||
readonly tier: 'premium' | 'standard' | 'starter';
|
||||
readonly nextRace?: string;
|
||||
readonly seasonStatus: 'active' | 'upcoming' | 'completed';
|
||||
readonly description: string;
|
||||
|
||||
constructor(data: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any;
|
||||
this.id = d.id;
|
||||
this.name = d.name;
|
||||
this.game = d.game;
|
||||
this.drivers = d.drivers;
|
||||
this.avgViewsPerRace = d.avgViewsPerRace;
|
||||
this.mainSponsorSlot = d.mainSponsorSlot;
|
||||
this.secondarySlots = d.secondarySlots;
|
||||
this.rating = d.rating;
|
||||
this.tier = d.tier;
|
||||
this.nextRace = d.nextRace;
|
||||
this.seasonStatus = d.seasonStatus;
|
||||
this.description = d.description;
|
||||
constructor(viewData: AvailableLeagueViewData) {
|
||||
super();
|
||||
this.id = viewData.id;
|
||||
this.name = viewData.name;
|
||||
this.game = viewData.game;
|
||||
this.drivers = viewData.drivers;
|
||||
this.avgViewsPerRace = viewData.avgViewsPerRace;
|
||||
this.mainSponsorSlot = viewData.mainSponsorSlot;
|
||||
this.secondarySlots = viewData.secondarySlots;
|
||||
this.rating = viewData.rating;
|
||||
this.tier = viewData.tier;
|
||||
this.nextRace = viewData.nextRace;
|
||||
this.seasonStatus = viewData.seasonStatus;
|
||||
this.description = viewData.description;
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted average views */
|
||||
get formattedAvgViews(): string {
|
||||
return `${(this.avgViewsPerRace / 1000).toFixed(1)}k`;
|
||||
return NumberDisplay.formatCompact(this.avgViewsPerRace);
|
||||
}
|
||||
|
||||
/** UI-specific: CPM calculation */
|
||||
get cpm(): number {
|
||||
return Math.round((this.mainSponsorSlot.price / this.avgViewsPerRace) * 1000);
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted CPM */
|
||||
get formattedCpm(): string {
|
||||
return `$${this.cpm}`;
|
||||
return CurrencyDisplay.formatCompact(this.cpm);
|
||||
}
|
||||
|
||||
/** UI-specific: Check if any sponsor slots are available */
|
||||
get hasAvailableSlots(): boolean {
|
||||
return this.mainSponsorSlot.available || this.secondarySlots.available > 0;
|
||||
}
|
||||
|
||||
/** UI-specific: Tier configuration for badge styling */
|
||||
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];
|
||||
return LeagueTierDisplay.getDisplay(this.tier);
|
||||
}
|
||||
|
||||
/** UI-specific: Status configuration for season state */
|
||||
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];
|
||||
return SeasonStatusDisplay.getDisplay(this.seasonStatus);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,8 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AvatarGenerationViewModel } from './AvatarGenerationViewModel';
|
||||
import { AvatarGenerationViewData } from '../view-data/AvatarGenerationViewData';
|
||||
|
||||
describe('AvatarGenerationViewModel', () => {
|
||||
it('should be defined', () => {
|
||||
expect(AvatarGenerationViewModel).toBeDefined();
|
||||
const mockViewData: AvatarGenerationViewData = {
|
||||
success: true,
|
||||
avatarUrls: ['https://example.com/avatar1.png', 'https://example.com/avatar2.png'],
|
||||
errorMessage: undefined,
|
||||
};
|
||||
|
||||
it('should be initialized from ViewData', () => {
|
||||
const viewModel = new AvatarGenerationViewModel(mockViewData);
|
||||
expect(viewModel.success).toBe(true);
|
||||
expect(viewModel.avatarUrls).toEqual(['https://example.com/avatar1.png', 'https://example.com/avatar2.png']);
|
||||
expect(viewModel.errorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle missing avatarUrls in ViewData', () => {
|
||||
const viewDataWithoutUrls: AvatarGenerationViewData = {
|
||||
success: false,
|
||||
avatarUrls: [],
|
||||
errorMessage: 'Error occurred',
|
||||
};
|
||||
const viewModel = new AvatarGenerationViewModel(viewDataWithoutUrls);
|
||||
expect(viewModel.success).toBe(false);
|
||||
expect(viewModel.avatarUrls).toEqual([]);
|
||||
expect(viewModel.errorMessage).toBe('Error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import { AvatarGenerationViewData } from "../view-data/AvatarGenerationViewData";
|
||||
|
||||
/**
|
||||
* AvatarGenerationViewModel
|
||||
*
|
||||
* View model for avatar generation process
|
||||
* View model for avatar generation process.
|
||||
*
|
||||
* Accepts AvatarGenerationViewData as input and produces UI-ready data.
|
||||
*/
|
||||
export class AvatarGenerationViewModel {
|
||||
export class AvatarGenerationViewModel extends ViewModel {
|
||||
readonly success: boolean;
|
||||
readonly avatarUrls: string[];
|
||||
readonly errorMessage?: string;
|
||||
|
||||
constructor(dto: RequestAvatarGenerationOutputDTO) {
|
||||
this.success = dto.success;
|
||||
this.avatarUrls = dto.avatarUrls || [];
|
||||
this.errorMessage = dto.errorMessage;
|
||||
constructor(viewData: AvatarGenerationViewData) {
|
||||
super();
|
||||
this.success = viewData.success;
|
||||
this.avatarUrls = viewData.avatarUrls;
|
||||
this.errorMessage = viewData.errorMessage;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,113 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AvatarViewModel } from './AvatarViewModel';
|
||||
import type { AvatarViewData } from '@/lib/view-data/AvatarViewData';
|
||||
|
||||
describe('AvatarViewModel', () => {
|
||||
it('should create instance with driverId and avatarUrl', () => {
|
||||
const dto = {
|
||||
driverId: 'driver-123',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
};
|
||||
describe('constructor', () => {
|
||||
it('should create instance with valid AvatarViewData', () => {
|
||||
const viewData: AvatarViewData = {
|
||||
buffer: 'dGVzdC1pbWFnZS1kYXRh',
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const viewModel = new AvatarViewModel(dto);
|
||||
const viewModel = new AvatarViewModel(viewData);
|
||||
|
||||
expect(viewModel.driverId).toBe('driver-123');
|
||||
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg');
|
||||
});
|
||||
|
||||
it('should create instance without avatarUrl', () => {
|
||||
const dto = {
|
||||
driverId: 'driver-123',
|
||||
};
|
||||
|
||||
const viewModel = new AvatarViewModel(dto);
|
||||
|
||||
expect(viewModel.driverId).toBe('driver-123');
|
||||
expect(viewModel.avatarUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return true for hasAvatar when avatarUrl exists', () => {
|
||||
const viewModel = new AvatarViewModel({
|
||||
driverId: 'driver-123',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
expect(viewModel.bufferBase64).toBe('dGVzdC1pbWFnZS1kYXRh');
|
||||
expect(viewModel.contentTypeLabel).toBe('PNG');
|
||||
expect(viewModel.hasValidData).toBe(true);
|
||||
});
|
||||
|
||||
expect(viewModel.hasAvatar).toBe(true);
|
||||
it('should create instance with empty buffer', () => {
|
||||
const viewData: AvatarViewData = {
|
||||
buffer: '',
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const viewModel = new AvatarViewModel(viewData);
|
||||
|
||||
expect(viewModel.bufferBase64).toBe('');
|
||||
expect(viewModel.contentTypeLabel).toBe('PNG');
|
||||
expect(viewModel.hasValidData).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false for hasAvatar when avatarUrl is undefined', () => {
|
||||
const viewModel = new AvatarViewModel({
|
||||
driverId: 'driver-123',
|
||||
describe('derived fields', () => {
|
||||
it('should derive bufferBase64 correctly', () => {
|
||||
const viewData: AvatarViewData = {
|
||||
buffer: 'dGVzdA==',
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const viewModel = new AvatarViewModel(viewData);
|
||||
|
||||
expect(viewModel.bufferBase64).toBe('dGVzdA==');
|
||||
});
|
||||
|
||||
expect(viewModel.hasAvatar).toBe(false);
|
||||
});
|
||||
it('should derive contentTypeLabel correctly', () => {
|
||||
const viewData: AvatarViewData = {
|
||||
buffer: 'dGVzdC1pbWFnZS1kYXRh',
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
it('should return false for hasAvatar when avatarUrl is empty string', () => {
|
||||
const viewModel = new AvatarViewModel({
|
||||
driverId: 'driver-123',
|
||||
avatarUrl: '',
|
||||
const viewModel = new AvatarViewModel(viewData);
|
||||
|
||||
expect(viewModel.contentTypeLabel).toBe('PNG');
|
||||
});
|
||||
|
||||
expect(viewModel.hasAvatar).toBe(false);
|
||||
it('should derive contentTypeLabel for different content types', () => {
|
||||
const pngViewData: AvatarViewData = {
|
||||
buffer: 'dGVzdC1pbWFnZS1kYXRh',
|
||||
contentType: 'image/png',
|
||||
};
|
||||
const pngViewModel = new AvatarViewModel(pngViewData);
|
||||
expect(pngViewModel.contentTypeLabel).toBe('PNG');
|
||||
|
||||
const jpegViewData: AvatarViewData = {
|
||||
buffer: 'dGVzdC1pbWFnZS1kYXRh',
|
||||
contentType: 'image/jpeg',
|
||||
};
|
||||
const jpegViewModel = new AvatarViewModel(jpegViewData);
|
||||
expect(jpegViewModel.contentTypeLabel).toBe('JPEG');
|
||||
|
||||
const svgViewData: AvatarViewData = {
|
||||
buffer: 'dGVzdC1pbWFnZS1kYXRh',
|
||||
contentType: 'image/svg+xml',
|
||||
};
|
||||
const svgViewModel = new AvatarViewModel(svgViewData);
|
||||
expect(svgViewModel.contentTypeLabel).toBe('SVG+XML');
|
||||
});
|
||||
|
||||
it('should derive hasValidData as true when buffer has content', () => {
|
||||
const viewData: AvatarViewData = {
|
||||
buffer: 'dGVzdC1pbWFnZS1kYXRh',
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const viewModel = new AvatarViewModel(viewData);
|
||||
|
||||
expect(viewModel.hasValidData).toBe(true);
|
||||
});
|
||||
|
||||
it('should derive hasValidData as false when buffer is empty', () => {
|
||||
const viewData: AvatarViewData = {
|
||||
buffer: '',
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const viewModel = new AvatarViewModel(viewData);
|
||||
|
||||
expect(viewModel.hasValidData).toBe(false);
|
||||
});
|
||||
|
||||
it('should derive hasValidData as false when contentType is empty', () => {
|
||||
const viewData: AvatarViewData = {
|
||||
buffer: 'dGVzdC1pbWFnZS1kYXRh',
|
||||
contentType: '',
|
||||
};
|
||||
|
||||
const viewModel = new AvatarViewModel(viewData);
|
||||
|
||||
expect(viewModel.hasValidData).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,29 @@
|
||||
// Note: No generated DTO available for Avatar yet
|
||||
interface AvatarDTO {
|
||||
driverId: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import { AvatarDisplay } from "../display-objects/AvatarDisplay";
|
||||
import { AvatarViewData } from "@/lib/view-data/AvatarViewData";
|
||||
|
||||
/**
|
||||
* Avatar View Model
|
||||
*
|
||||
* Represents avatar information for the UI layer
|
||||
* Represents avatar information for the UI layer.
|
||||
* Transforms AvatarViewData into UI-ready state with formatting and derived fields.
|
||||
*/
|
||||
export class AvatarViewModel {
|
||||
driverId: string;
|
||||
avatarUrl?: string;
|
||||
export class AvatarViewModel extends ViewModel {
|
||||
// UI-specific derived fields (primitive outputs only)
|
||||
readonly bufferBase64: string;
|
||||
readonly contentTypeLabel: string;
|
||||
readonly hasValidData: boolean;
|
||||
|
||||
constructor(dto: AvatarDTO) {
|
||||
this.driverId = dto.driverId;
|
||||
if (dto.avatarUrl !== undefined) {
|
||||
this.avatarUrl = dto.avatarUrl;
|
||||
}
|
||||
}
|
||||
constructor(viewData: AvatarViewData) {
|
||||
super();
|
||||
|
||||
/** UI-specific: Whether the driver has an avatar */
|
||||
get hasAvatar(): boolean {
|
||||
return !!this.avatarUrl;
|
||||
// Buffer is already base64 encoded in ViewData
|
||||
this.bufferBase64 = viewData.buffer;
|
||||
|
||||
// Derive content type label using Display Object
|
||||
this.contentTypeLabel = AvatarDisplay.formatContentType(viewData.contentType);
|
||||
|
||||
// Derive validity check using Display Object
|
||||
this.hasValidData = AvatarDisplay.hasValidData(viewData.buffer, viewData.contentType);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { BillingViewData } from '@/lib/view-data/BillingViewData';
|
||||
import { BillingViewModel, PaymentMethodViewModel, InvoiceViewModel, BillingStatsViewModel } from './BillingViewModel';
|
||||
|
||||
describe('BillingViewModel', () => {
|
||||
it('maps arrays of payment methods, invoices and stats into view models', () => {
|
||||
const data = {
|
||||
const viewData: BillingViewData = {
|
||||
paymentMethods: [
|
||||
{ id: 'pm-1', type: 'card', last4: '4242', brand: 'Visa', isDefault: true, expiryMonth: 12, expiryYear: 2030 },
|
||||
{
|
||||
id: 'pm-1',
|
||||
type: 'card',
|
||||
last4: '4242',
|
||||
brand: 'Visa',
|
||||
isDefault: true,
|
||||
expiryMonth: 12,
|
||||
expiryYear: 2030,
|
||||
displayLabel: 'Visa •••• 4242',
|
||||
expiryDisplay: '12/2030',
|
||||
},
|
||||
],
|
||||
invoices: [
|
||||
{
|
||||
@@ -20,6 +31,10 @@ describe('BillingViewModel', () => {
|
||||
description: 'Sponsorship',
|
||||
sponsorshipType: 'league',
|
||||
pdfUrl: 'https://example.com/invoice.pdf',
|
||||
formattedTotalAmount: '€119,00',
|
||||
formattedVatAmount: '€19,00',
|
||||
formattedDate: '2024-01-01',
|
||||
isOverdue: false,
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
@@ -29,10 +44,15 @@ describe('BillingViewModel', () => {
|
||||
nextPaymentAmount: 50,
|
||||
activeSponsorships: 3,
|
||||
averageMonthlySpend: 250,
|
||||
formattedTotalSpent: '€1.000,00',
|
||||
formattedPendingAmount: '€200,00',
|
||||
formattedNextPaymentAmount: '€50,00',
|
||||
formattedAverageMonthlySpend: '€250,00',
|
||||
formattedNextPaymentDate: '2024-03-01',
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
const vm = new BillingViewModel(data);
|
||||
const vm = new BillingViewModel(viewData);
|
||||
|
||||
expect(vm.paymentMethods).toHaveLength(1);
|
||||
expect(vm.paymentMethods[0]).toBeInstanceOf(PaymentMethodViewModel);
|
||||
@@ -44,53 +64,67 @@ describe('BillingViewModel', () => {
|
||||
|
||||
describe('PaymentMethodViewModel', () => {
|
||||
it('builds displayLabel based on type and bankName/brand', () => {
|
||||
const card = new PaymentMethodViewModel({
|
||||
const card = {
|
||||
id: 'pm-1',
|
||||
type: 'card',
|
||||
type: 'card' as const,
|
||||
last4: '4242',
|
||||
brand: 'Visa',
|
||||
isDefault: true,
|
||||
});
|
||||
displayLabel: 'Visa •••• 4242',
|
||||
expiryDisplay: null,
|
||||
};
|
||||
|
||||
const sepa = new PaymentMethodViewModel({
|
||||
const sepa = {
|
||||
id: 'pm-2',
|
||||
type: 'sepa',
|
||||
type: 'sepa' as const,
|
||||
last4: '1337',
|
||||
bankName: 'Test Bank',
|
||||
isDefault: false,
|
||||
});
|
||||
displayLabel: 'Test Bank •••• 1337',
|
||||
expiryDisplay: null,
|
||||
};
|
||||
|
||||
expect(card.displayLabel).toBe('Visa •••• 4242');
|
||||
expect(sepa.displayLabel).toBe('Test Bank •••• 1337');
|
||||
const cardVm = new PaymentMethodViewModel(card);
|
||||
const sepaVm = new PaymentMethodViewModel(sepa);
|
||||
|
||||
expect(cardVm.displayLabel).toBe('Visa •••• 4242');
|
||||
expect(sepaVm.displayLabel).toBe('Test Bank •••• 1337');
|
||||
});
|
||||
|
||||
it('returns expiryDisplay when month and year are provided', () => {
|
||||
const withExpiry = new PaymentMethodViewModel({
|
||||
const withExpiry = {
|
||||
id: 'pm-1',
|
||||
type: 'card',
|
||||
type: 'card' as const,
|
||||
last4: '4242',
|
||||
brand: 'Visa',
|
||||
isDefault: true,
|
||||
expiryMonth: 3,
|
||||
expiryYear: 2030,
|
||||
});
|
||||
displayLabel: 'Visa •••• 4242',
|
||||
expiryDisplay: '03/2030',
|
||||
};
|
||||
|
||||
const withoutExpiry = new PaymentMethodViewModel({
|
||||
const withoutExpiry = {
|
||||
id: 'pm-2',
|
||||
type: 'card',
|
||||
type: 'card' as const,
|
||||
last4: '9999',
|
||||
brand: 'Mastercard',
|
||||
isDefault: false,
|
||||
});
|
||||
displayLabel: 'Mastercard •••• 9999',
|
||||
expiryDisplay: null,
|
||||
};
|
||||
|
||||
expect(withExpiry.expiryDisplay).toBe('03/2030');
|
||||
expect(withoutExpiry.expiryDisplay).toBeNull();
|
||||
const withExpiryVm = new PaymentMethodViewModel(withExpiry);
|
||||
const withoutExpiryVm = new PaymentMethodViewModel(withoutExpiry);
|
||||
|
||||
expect(withExpiryVm.expiryDisplay).toBe('03/2030');
|
||||
expect(withoutExpiryVm.expiryDisplay).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InvoiceViewModel', () => {
|
||||
it('formats monetary amounts and dates', () => {
|
||||
const dto = {
|
||||
const viewData = {
|
||||
id: 'inv-1',
|
||||
invoiceNumber: 'INV-1',
|
||||
date: '2024-01-15',
|
||||
@@ -98,16 +132,20 @@ describe('InvoiceViewModel', () => {
|
||||
amount: 100,
|
||||
vatAmount: 19,
|
||||
totalAmount: 119,
|
||||
status: 'paid',
|
||||
status: 'paid' as const,
|
||||
description: 'Sponsorship',
|
||||
sponsorshipType: 'league',
|
||||
sponsorshipType: 'league' as const,
|
||||
pdfUrl: 'https://example.com/invoice.pdf',
|
||||
} as any;
|
||||
formattedTotalAmount: '€119,00',
|
||||
formattedVatAmount: '€19,00',
|
||||
formattedDate: '2024-01-15',
|
||||
isOverdue: false,
|
||||
};
|
||||
|
||||
const vm = new InvoiceViewModel(dto);
|
||||
const vm = new InvoiceViewModel(viewData);
|
||||
|
||||
expect(vm.formattedTotalAmount).toBe(`€${(119).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`);
|
||||
expect(vm.formattedVatAmount).toBe(`€${(19).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`);
|
||||
expect(vm.formattedTotalAmount).toBe('€119,00');
|
||||
expect(vm.formattedVatAmount).toBe('€19,00');
|
||||
expect(typeof vm.formattedDate).toBe('string');
|
||||
});
|
||||
|
||||
@@ -116,7 +154,7 @@ describe('InvoiceViewModel', () => {
|
||||
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const overdue = new InvoiceViewModel({
|
||||
const overdue = {
|
||||
id: 'inv-1',
|
||||
invoiceNumber: 'INV-1',
|
||||
date: pastDate,
|
||||
@@ -124,13 +162,17 @@ describe('InvoiceViewModel', () => {
|
||||
amount: 0,
|
||||
vatAmount: 0,
|
||||
totalAmount: 0,
|
||||
status: 'overdue',
|
||||
status: 'overdue' as const,
|
||||
description: '',
|
||||
sponsorshipType: 'league',
|
||||
sponsorshipType: 'league' as const,
|
||||
pdfUrl: '',
|
||||
} as any);
|
||||
formattedTotalAmount: '€0,00',
|
||||
formattedVatAmount: '€0,00',
|
||||
formattedDate: pastDate,
|
||||
isOverdue: true,
|
||||
};
|
||||
|
||||
const pendingPastDue = new InvoiceViewModel({
|
||||
const pendingPastDue = {
|
||||
id: 'inv-2',
|
||||
invoiceNumber: 'INV-2',
|
||||
date: pastDate,
|
||||
@@ -138,13 +180,17 @@ describe('InvoiceViewModel', () => {
|
||||
amount: 0,
|
||||
vatAmount: 0,
|
||||
totalAmount: 0,
|
||||
status: 'pending',
|
||||
status: 'pending' as const,
|
||||
description: '',
|
||||
sponsorshipType: 'league',
|
||||
sponsorshipType: 'league' as const,
|
||||
pdfUrl: '',
|
||||
} as any);
|
||||
formattedTotalAmount: '€0,00',
|
||||
formattedVatAmount: '€0,00',
|
||||
formattedDate: pastDate,
|
||||
isOverdue: true,
|
||||
};
|
||||
|
||||
const pendingFuture = new InvoiceViewModel({
|
||||
const pendingFuture = {
|
||||
id: 'inv-3',
|
||||
invoiceNumber: 'INV-3',
|
||||
date: pastDate,
|
||||
@@ -152,35 +198,48 @@ describe('InvoiceViewModel', () => {
|
||||
amount: 0,
|
||||
vatAmount: 0,
|
||||
totalAmount: 0,
|
||||
status: 'pending',
|
||||
status: 'pending' as const,
|
||||
description: '',
|
||||
sponsorshipType: 'league',
|
||||
sponsorshipType: 'league' as const,
|
||||
pdfUrl: '',
|
||||
} as any);
|
||||
formattedTotalAmount: '€0,00',
|
||||
formattedVatAmount: '€0,00',
|
||||
formattedDate: pastDate,
|
||||
isOverdue: false,
|
||||
};
|
||||
|
||||
expect(overdue.isOverdue).toBe(true);
|
||||
expect(pendingPastDue.isOverdue).toBe(true);
|
||||
expect(pendingFuture.isOverdue).toBe(false);
|
||||
const overdueVm = new InvoiceViewModel(overdue);
|
||||
const pendingPastDueVm = new InvoiceViewModel(pendingPastDue);
|
||||
const pendingFutureVm = new InvoiceViewModel(pendingFuture);
|
||||
|
||||
expect(overdueVm.isOverdue).toBe(true);
|
||||
expect(pendingPastDueVm.isOverdue).toBe(true);
|
||||
expect(pendingFutureVm.isOverdue).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BillingStatsViewModel', () => {
|
||||
it('formats monetary fields and next payment date', () => {
|
||||
const dto = {
|
||||
const viewData = {
|
||||
totalSpent: 1234,
|
||||
pendingAmount: 56.78,
|
||||
nextPaymentDate: '2024-03-01',
|
||||
nextPaymentAmount: 42,
|
||||
activeSponsorships: 2,
|
||||
averageMonthlySpend: 321,
|
||||
} as any;
|
||||
formattedTotalSpent: '€1.234,00',
|
||||
formattedPendingAmount: '€56,78',
|
||||
formattedNextPaymentAmount: '€42,00',
|
||||
formattedAverageMonthlySpend: '€321,00',
|
||||
formattedNextPaymentDate: '2024-03-01',
|
||||
};
|
||||
|
||||
const vm = new BillingStatsViewModel(dto);
|
||||
const vm = new BillingStatsViewModel(viewData);
|
||||
|
||||
expect(vm.formattedTotalSpent).toBe(`€${(1234).toLocaleString('de-DE')}`);
|
||||
expect(vm.formattedPendingAmount).toBe(`€${(56.78).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`);
|
||||
expect(vm.formattedNextPaymentAmount).toBe(`€${(42).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`);
|
||||
expect(vm.formattedAverageMonthlySpend).toBe(`€${(321).toLocaleString('de-DE')}`);
|
||||
expect(vm.formattedTotalSpent).toBe('€1.234,00');
|
||||
expect(vm.formattedPendingAmount).toBe('€56,78');
|
||||
expect(vm.formattedNextPaymentAmount).toBe('€42,00');
|
||||
expect(vm.formattedAverageMonthlySpend).toBe('€321,00');
|
||||
expect(typeof vm.formattedNextPaymentDate).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,24 +2,39 @@
|
||||
* Billing View Model
|
||||
*
|
||||
* View model for sponsor billing data with UI-specific transformations.
|
||||
* Transforms BillingViewData into UI-ready state with formatting and derived fields.
|
||||
*/
|
||||
export class BillingViewModel {
|
||||
import type { BillingViewData } from '@/lib/view-data/BillingViewData';
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
|
||||
import { DateDisplay } from "../display-objects/DateDisplay";
|
||||
|
||||
/**
|
||||
* BillingViewModel
|
||||
*
|
||||
* View Model for sponsor billing data.
|
||||
* Transforms BillingViewData into UI-ready state with formatting and derived fields.
|
||||
*/
|
||||
export class BillingViewModel extends ViewModel {
|
||||
paymentMethods: PaymentMethodViewModel[];
|
||||
invoices: InvoiceViewModel[];
|
||||
stats: BillingStatsViewModel;
|
||||
|
||||
constructor(data: {
|
||||
paymentMethods: unknown[];
|
||||
invoices: unknown[];
|
||||
stats: unknown;
|
||||
}) {
|
||||
this.paymentMethods = data.paymentMethods.map(pm => new PaymentMethodViewModel(pm));
|
||||
this.invoices = data.invoices.map(inv => new InvoiceViewModel(inv));
|
||||
this.stats = new BillingStatsViewModel(data.stats);
|
||||
constructor(viewData: BillingViewData) {
|
||||
super();
|
||||
this.paymentMethods = viewData.paymentMethods.map(pm => new PaymentMethodViewModel(pm));
|
||||
this.invoices = viewData.invoices.map(inv => new InvoiceViewModel(inv));
|
||||
this.stats = new BillingStatsViewModel(viewData.stats);
|
||||
}
|
||||
}
|
||||
|
||||
export class PaymentMethodViewModel {
|
||||
/**
|
||||
* PaymentMethodViewModel
|
||||
*
|
||||
* View Model for payment method data.
|
||||
* Provides formatted display labels and expiry information.
|
||||
*/
|
||||
export class PaymentMethodViewModel extends ViewModel {
|
||||
id: string;
|
||||
type: 'card' | 'bank' | 'sepa';
|
||||
last4: string;
|
||||
@@ -29,35 +44,43 @@ export class PaymentMethodViewModel {
|
||||
expiryYear?: number;
|
||||
bankName?: string;
|
||||
|
||||
constructor(data: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any;
|
||||
this.id = d.id;
|
||||
this.type = d.type;
|
||||
this.last4 = d.last4;
|
||||
this.brand = d.brand;
|
||||
this.isDefault = d.isDefault;
|
||||
this.expiryMonth = d.expiryMonth;
|
||||
this.expiryYear = d.expiryYear;
|
||||
this.bankName = d.bankName;
|
||||
}
|
||||
// UI-specific derived fields (primitive outputs only)
|
||||
readonly displayLabel: string;
|
||||
readonly expiryDisplay: string | null;
|
||||
|
||||
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;
|
||||
constructor(viewData: {
|
||||
id: string;
|
||||
type: 'card' | 'bank' | 'sepa';
|
||||
last4: string;
|
||||
brand?: string;
|
||||
isDefault: boolean;
|
||||
expiryMonth?: number;
|
||||
expiryYear?: number;
|
||||
bankName?: string;
|
||||
displayLabel: string;
|
||||
expiryDisplay: string | null;
|
||||
}) {
|
||||
super();
|
||||
this.id = viewData.id;
|
||||
this.type = viewData.type;
|
||||
this.last4 = viewData.last4;
|
||||
this.brand = viewData.brand;
|
||||
this.isDefault = viewData.isDefault;
|
||||
this.expiryMonth = viewData.expiryMonth;
|
||||
this.expiryYear = viewData.expiryYear;
|
||||
this.bankName = viewData.bankName;
|
||||
this.displayLabel = viewData.displayLabel;
|
||||
this.expiryDisplay = viewData.expiryDisplay;
|
||||
}
|
||||
}
|
||||
|
||||
export class InvoiceViewModel {
|
||||
/**
|
||||
* InvoiceViewModel
|
||||
*
|
||||
* View Model for invoice data.
|
||||
* Provides formatted amounts, dates, and derived status flags.
|
||||
*/
|
||||
export class InvoiceViewModel extends ViewModel {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
date: Date;
|
||||
@@ -70,40 +93,55 @@ export class InvoiceViewModel {
|
||||
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
|
||||
pdfUrl: string;
|
||||
|
||||
constructor(data: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any;
|
||||
this.id = d.id;
|
||||
this.invoiceNumber = d.invoiceNumber;
|
||||
this.date = new Date(d.date);
|
||||
this.dueDate = new Date(d.dueDate);
|
||||
this.amount = d.amount;
|
||||
this.vatAmount = d.vatAmount;
|
||||
this.totalAmount = d.totalAmount;
|
||||
this.status = d.status;
|
||||
this.description = d.description;
|
||||
this.sponsorshipType = d.sponsorshipType;
|
||||
this.pdfUrl = d.pdfUrl;
|
||||
}
|
||||
// UI-specific derived fields (primitive outputs only)
|
||||
readonly formattedTotalAmount: string;
|
||||
readonly formattedVatAmount: string;
|
||||
readonly formattedDate: string;
|
||||
readonly isOverdue: boolean;
|
||||
|
||||
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);
|
||||
constructor(viewData: {
|
||||
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;
|
||||
formattedTotalAmount: string;
|
||||
formattedVatAmount: string;
|
||||
formattedDate: string;
|
||||
isOverdue: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.id = viewData.id;
|
||||
this.invoiceNumber = viewData.invoiceNumber;
|
||||
this.date = new Date(viewData.date);
|
||||
this.dueDate = new Date(viewData.dueDate);
|
||||
this.amount = viewData.amount;
|
||||
this.vatAmount = viewData.vatAmount;
|
||||
this.totalAmount = viewData.totalAmount;
|
||||
this.status = viewData.status;
|
||||
this.description = viewData.description;
|
||||
this.sponsorshipType = viewData.sponsorshipType;
|
||||
this.pdfUrl = viewData.pdfUrl;
|
||||
this.formattedTotalAmount = viewData.formattedTotalAmount;
|
||||
this.formattedVatAmount = viewData.formattedVatAmount;
|
||||
this.formattedDate = viewData.formattedDate;
|
||||
this.isOverdue = viewData.isOverdue;
|
||||
}
|
||||
}
|
||||
|
||||
export class BillingStatsViewModel {
|
||||
/**
|
||||
* BillingStatsViewModel
|
||||
*
|
||||
* View Model for billing statistics.
|
||||
* Provides formatted monetary fields and derived metrics.
|
||||
*/
|
||||
export class BillingStatsViewModel extends ViewModel {
|
||||
totalSpent: number;
|
||||
pendingAmount: number;
|
||||
nextPaymentDate: Date;
|
||||
@@ -111,34 +149,37 @@ export class BillingStatsViewModel {
|
||||
activeSponsorships: number;
|
||||
averageMonthlySpend: number;
|
||||
|
||||
constructor(data: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any;
|
||||
this.totalSpent = d.totalSpent;
|
||||
this.pendingAmount = d.pendingAmount;
|
||||
this.nextPaymentDate = new Date(d.nextPaymentDate);
|
||||
this.nextPaymentAmount = d.nextPaymentAmount;
|
||||
this.activeSponsorships = d.activeSponsorships;
|
||||
this.averageMonthlySpend = d.averageMonthlySpend;
|
||||
}
|
||||
// UI-specific derived fields (primitive outputs only)
|
||||
readonly formattedTotalSpent: string;
|
||||
readonly formattedPendingAmount: string;
|
||||
readonly formattedNextPaymentAmount: string;
|
||||
readonly formattedAverageMonthlySpend: string;
|
||||
readonly formattedNextPaymentDate: string;
|
||||
|
||||
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' });
|
||||
constructor(viewData: {
|
||||
totalSpent: number;
|
||||
pendingAmount: number;
|
||||
nextPaymentDate: string;
|
||||
nextPaymentAmount: number;
|
||||
activeSponsorships: number;
|
||||
averageMonthlySpend: number;
|
||||
formattedTotalSpent: string;
|
||||
formattedPendingAmount: string;
|
||||
formattedNextPaymentAmount: string;
|
||||
formattedAverageMonthlySpend: string;
|
||||
formattedNextPaymentDate: string;
|
||||
}) {
|
||||
super();
|
||||
this.totalSpent = viewData.totalSpent;
|
||||
this.pendingAmount = viewData.pendingAmount;
|
||||
this.nextPaymentDate = new Date(viewData.nextPaymentDate);
|
||||
this.nextPaymentAmount = viewData.nextPaymentAmount;
|
||||
this.activeSponsorships = viewData.activeSponsorships;
|
||||
this.averageMonthlySpend = viewData.averageMonthlySpend;
|
||||
this.formattedTotalSpent = viewData.formattedTotalSpent;
|
||||
this.formattedPendingAmount = viewData.formattedPendingAmount;
|
||||
this.formattedNextPaymentAmount = viewData.formattedNextPaymentAmount;
|
||||
this.formattedAverageMonthlySpend = viewData.formattedAverageMonthlySpend;
|
||||
this.formattedNextPaymentDate = viewData.formattedNextPaymentDate;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,189 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CompleteOnboardingViewModel } from './CompleteOnboardingViewModel';
|
||||
import type { CompleteOnboardingOutputDTO } from '../types/generated/CompleteOnboardingOutputDTO';
|
||||
import type { CompleteOnboardingViewData } from '../builders/view-data/CompleteOnboardingViewData';
|
||||
|
||||
describe('CompleteOnboardingViewModel', () => {
|
||||
it('should create instance with success flag', () => {
|
||||
const dto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
};
|
||||
describe('constructor', () => {
|
||||
it('should create instance with success flag', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(dto);
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.success).toBe(true);
|
||||
expect(viewModel.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should create instance with driverId', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.driverId).toBe('driver-123');
|
||||
});
|
||||
|
||||
it('should create instance with errorMessage', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: false,
|
||||
errorMessage: 'Failed to complete onboarding',
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.errorMessage).toBe('Failed to complete onboarding');
|
||||
});
|
||||
|
||||
it('should create instance with all fields', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
errorMessage: undefined,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.success).toBe(true);
|
||||
expect(viewModel.driverId).toBe('driver-123');
|
||||
expect(viewModel.errorMessage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should expose isSuccessful as true when success is true', () => {
|
||||
const dto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
};
|
||||
describe('UI-specific getters', () => {
|
||||
it('should expose isSuccessful as true when success is true', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(dto);
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.isSuccessful).toBe(true);
|
||||
expect(viewModel.isSuccessful).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose isSuccessful as false when success is false', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: false,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.isSuccessful).toBe(false);
|
||||
});
|
||||
|
||||
it('should expose hasError as true when errorMessage is present', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: false,
|
||||
errorMessage: 'Error occurred',
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.hasError).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose hasError as false when errorMessage is not present', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.hasError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should expose isSuccessful as false when success is false', () => {
|
||||
const dto: CompleteOnboardingOutputDTO = {
|
||||
success: false,
|
||||
};
|
||||
describe('Display Object composition', () => {
|
||||
it('should derive statusLabel from success', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(dto);
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.isSuccessful).toBe(false);
|
||||
expect(viewModel.statusLabel).toBe('Onboarding Complete');
|
||||
});
|
||||
|
||||
it('should derive statusLabel from failure', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: false,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.statusLabel).toBe('Onboarding Failed');
|
||||
});
|
||||
|
||||
it('should derive statusVariant from success', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.statusVariant).toBe('performance-green');
|
||||
});
|
||||
|
||||
it('should derive statusVariant from failure', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: false,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.statusVariant).toBe('racing-red');
|
||||
});
|
||||
|
||||
it('should derive statusIcon from success', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.statusIcon).toBe('✅');
|
||||
});
|
||||
|
||||
it('should derive statusIcon from failure', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: false,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.statusIcon).toBe('❌');
|
||||
});
|
||||
|
||||
it('should derive statusMessage from success', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.statusMessage).toBe('Your onboarding has been completed successfully.');
|
||||
});
|
||||
|
||||
it('should derive statusMessage from failure with default message', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: false,
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.statusMessage).toBe('Failed to complete onboarding. Please try again.');
|
||||
});
|
||||
|
||||
it('should derive statusMessage from failure with custom error message', () => {
|
||||
const viewData: CompleteOnboardingViewData = {
|
||||
success: false,
|
||||
errorMessage: 'Custom error message',
|
||||
};
|
||||
|
||||
const viewModel = new CompleteOnboardingViewModel(viewData);
|
||||
|
||||
expect(viewModel.statusMessage).toBe('Custom error message');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,36 @@
|
||||
import { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
|
||||
import type { CompleteOnboardingViewData } from '@/lib/builders/view-data/CompleteOnboardingViewData';
|
||||
import { OnboardingStatusDisplay } from '../display-objects/OnboardingStatusDisplay';
|
||||
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
/**
|
||||
* Complete onboarding view model
|
||||
* UI representation of onboarding completion result
|
||||
*
|
||||
* Composes Display Objects and transforms ViewData for UI consumption.
|
||||
*/
|
||||
export class CompleteOnboardingViewModel {
|
||||
export class CompleteOnboardingViewModel extends ViewModel {
|
||||
success: boolean;
|
||||
driverId?: string;
|
||||
errorMessage?: string;
|
||||
|
||||
constructor(dto: CompleteOnboardingOutputDTO) {
|
||||
this.success = dto.success;
|
||||
if (dto.driverId !== undefined) this.driverId = dto.driverId;
|
||||
if (dto.errorMessage !== undefined) this.errorMessage = dto.errorMessage;
|
||||
// UI-specific derived fields (primitive outputs only)
|
||||
readonly statusLabel: string;
|
||||
readonly statusVariant: string;
|
||||
readonly statusIcon: string;
|
||||
readonly statusMessage: string;
|
||||
|
||||
constructor(viewData: CompleteOnboardingViewData) {
|
||||
super();
|
||||
this.success = viewData.success;
|
||||
if (viewData.driverId !== undefined) this.driverId = viewData.driverId;
|
||||
if (viewData.errorMessage !== undefined) this.errorMessage = viewData.errorMessage;
|
||||
|
||||
// Derive UI-specific fields using Display Object
|
||||
this.statusLabel = OnboardingStatusDisplay.statusLabel(this.success);
|
||||
this.statusVariant = OnboardingStatusDisplay.statusVariant(this.success);
|
||||
this.statusIcon = OnboardingStatusDisplay.statusIcon(this.success);
|
||||
this.statusMessage = OnboardingStatusDisplay.statusMessage(this.success, this.errorMessage);
|
||||
}
|
||||
|
||||
/** UI-specific: Whether onboarding was successful */
|
||||
|
||||
@@ -1,36 +1,102 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateLeagueViewModel } from './CreateLeagueViewModel';
|
||||
import type { CreateLeagueOutputDTO } from '../types/generated/CreateLeagueOutputDTO';
|
||||
import type { CreateLeagueViewData } from '../view-data/CreateLeagueViewData';
|
||||
|
||||
const createDto = (overrides: Partial<CreateLeagueOutputDTO> = {}): CreateLeagueOutputDTO => ({
|
||||
const createViewData = (overrides: Partial<CreateLeagueViewData> = {}): CreateLeagueViewData => ({
|
||||
leagueId: 'league-1',
|
||||
success: true,
|
||||
...overrides,
|
||||
} as CreateLeagueOutputDTO);
|
||||
} as CreateLeagueViewData);
|
||||
|
||||
describe('CreateLeagueViewModel', () => {
|
||||
it('maps leagueId and success from DTO', () => {
|
||||
const dto = createDto({ leagueId: 'league-123', success: true });
|
||||
describe('constructor', () => {
|
||||
it('should create instance with success flag', () => {
|
||||
const viewData: CreateLeagueViewData = {
|
||||
leagueId: 'league-123',
|
||||
success: true,
|
||||
successMessage: 'League created successfully!',
|
||||
};
|
||||
|
||||
const vm = new CreateLeagueViewModel(dto);
|
||||
const viewModel = new CreateLeagueViewModel(viewData);
|
||||
|
||||
expect(vm.leagueId).toBe('league-123');
|
||||
expect(vm.success).toBe(true);
|
||||
expect(viewModel.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should create instance with leagueId', () => {
|
||||
const viewData: CreateLeagueViewData = {
|
||||
leagueId: 'league-123',
|
||||
success: true,
|
||||
successMessage: 'League created successfully!',
|
||||
};
|
||||
|
||||
const viewModel = new CreateLeagueViewModel(viewData);
|
||||
|
||||
expect(viewModel.leagueId).toBe('league-123');
|
||||
});
|
||||
|
||||
it('should create instance with all fields', () => {
|
||||
const viewData: CreateLeagueViewData = {
|
||||
leagueId: 'test-league',
|
||||
success: false,
|
||||
successMessage: 'Failed to create league.',
|
||||
};
|
||||
|
||||
const viewModel = new CreateLeagueViewModel(viewData);
|
||||
|
||||
expect(viewModel.leagueId).toBe('test-league');
|
||||
expect(viewModel.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns success successMessage when creation succeeded', () => {
|
||||
const dto = createDto({ success: true });
|
||||
describe('UI-specific getters', () => {
|
||||
it('should expose isSuccessful as true when success is true', () => {
|
||||
const viewData: CreateLeagueViewData = {
|
||||
leagueId: 'league-123',
|
||||
success: true,
|
||||
successMessage: 'League created successfully!',
|
||||
};
|
||||
|
||||
const vm = new CreateLeagueViewModel(dto);
|
||||
const viewModel = new CreateLeagueViewModel(viewData);
|
||||
|
||||
expect(vm.successMessage).toBe('League created successfully!');
|
||||
expect(viewModel.isSuccessful).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose isSuccessful as false when success is false', () => {
|
||||
const viewData: CreateLeagueViewData = {
|
||||
leagueId: 'league-123',
|
||||
success: false,
|
||||
successMessage: 'Failed to create league.',
|
||||
};
|
||||
|
||||
const viewModel = new CreateLeagueViewModel(viewData);
|
||||
|
||||
expect(viewModel.isSuccessful).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns failure successMessage when creation failed', () => {
|
||||
const dto = createDto({ success: false });
|
||||
describe('Display Object composition', () => {
|
||||
it('should derive successMessage from success', () => {
|
||||
const viewData: CreateLeagueViewData = {
|
||||
leagueId: 'league-123',
|
||||
success: true,
|
||||
successMessage: 'League created successfully!',
|
||||
};
|
||||
|
||||
const vm = new CreateLeagueViewModel(dto);
|
||||
const viewModel = new CreateLeagueViewModel(viewData);
|
||||
|
||||
expect(vm.successMessage).toBe('Failed to create league.');
|
||||
expect(viewModel.successMessage).toBe('League created successfully!');
|
||||
});
|
||||
|
||||
it('should derive successMessage from failure', () => {
|
||||
const viewData: CreateLeagueViewData = {
|
||||
leagueId: 'league-123',
|
||||
success: false,
|
||||
successMessage: 'Failed to create league.',
|
||||
};
|
||||
|
||||
const viewModel = new CreateLeagueViewModel(viewData);
|
||||
|
||||
expect(viewModel.successMessage).toBe('Failed to create league.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
|
||||
import type { CreateLeagueViewData } from '../view-data/CreateLeagueViewData';
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import { LeagueCreationStatusDisplay } from '../display-objects/LeagueCreationStatusDisplay';
|
||||
|
||||
/**
|
||||
* View Model for Create League Result
|
||||
*
|
||||
* Represents the result of creating a league in a UI-ready format.
|
||||
* Composes Display Objects and transforms ViewData for UI consumption.
|
||||
*/
|
||||
export class CreateLeagueViewModel {
|
||||
leagueId: string;
|
||||
success: boolean;
|
||||
export class CreateLeagueViewModel extends ViewModel {
|
||||
readonly leagueId: string;
|
||||
readonly success: boolean;
|
||||
|
||||
constructor(dto: CreateLeagueOutputDTO) {
|
||||
this.leagueId = dto.leagueId;
|
||||
this.success = dto.success;
|
||||
// UI-specific derived fields (primitive outputs only)
|
||||
readonly successMessage: string;
|
||||
|
||||
constructor(viewData: CreateLeagueViewData) {
|
||||
super();
|
||||
this.leagueId = viewData.leagueId;
|
||||
this.success = viewData.success;
|
||||
|
||||
// Derive UI-specific fields using Display Object
|
||||
this.successMessage = LeagueCreationStatusDisplay.statusMessage(this.success);
|
||||
}
|
||||
|
||||
/** UI-specific: Success message */
|
||||
get successMessage(): string {
|
||||
return this.success ? 'League created successfully!' : 'Failed to create league.';
|
||||
/** UI-specific: Whether league creation was successful */
|
||||
get isSuccessful(): boolean {
|
||||
return this.success;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateTeamViewModel } from './CreateTeamViewModel';
|
||||
import type { CreateTeamViewData } from '../view-data/CreateTeamViewData';
|
||||
|
||||
describe('CreateTeamViewModel', () => {
|
||||
it('maps id and success from DTO', () => {
|
||||
const dto = { id: 'team-123', success: true };
|
||||
it('maps teamId and success from ViewData', () => {
|
||||
const viewData: CreateTeamViewData = {
|
||||
teamId: 'team-123',
|
||||
success: true,
|
||||
successMessage: 'Team created successfully!',
|
||||
};
|
||||
|
||||
const vm = new CreateTeamViewModel(dto);
|
||||
const vm = new CreateTeamViewModel(viewData);
|
||||
|
||||
expect(vm.id).toBe('team-123');
|
||||
expect(vm.teamId).toBe('team-123');
|
||||
expect(vm.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns success successMessage when creation succeeded', () => {
|
||||
const dto = { id: 'team-1', success: true };
|
||||
const viewData: CreateTeamViewData = {
|
||||
teamId: 'team-1',
|
||||
success: true,
|
||||
successMessage: 'Team created successfully!',
|
||||
};
|
||||
|
||||
const vm = new CreateTeamViewModel(dto);
|
||||
const vm = new CreateTeamViewModel(viewData);
|
||||
|
||||
expect(vm.successMessage).toBe('Team created successfully!');
|
||||
});
|
||||
|
||||
it('returns failure successMessage when creation failed', () => {
|
||||
const dto = { id: 'team-1', success: false };
|
||||
const viewData: CreateTeamViewData = {
|
||||
teamId: 'team-1',
|
||||
success: false,
|
||||
successMessage: 'Failed to create team.',
|
||||
};
|
||||
|
||||
const vm = new CreateTeamViewModel(dto);
|
||||
const vm = new CreateTeamViewModel(viewData);
|
||||
|
||||
expect(vm.successMessage).toBe('Failed to create team.');
|
||||
});
|
||||
|
||||
it('returns isSuccessful when creation succeeded', () => {
|
||||
const viewData: CreateTeamViewData = {
|
||||
teamId: 'team-1',
|
||||
success: true,
|
||||
successMessage: 'Team created successfully!',
|
||||
};
|
||||
|
||||
const vm = new CreateTeamViewModel(viewData);
|
||||
|
||||
expect(vm.isSuccessful).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isSuccessful when creation failed', () => {
|
||||
const viewData: CreateTeamViewData = {
|
||||
teamId: 'team-1',
|
||||
success: false,
|
||||
successMessage: 'Failed to create team.',
|
||||
};
|
||||
|
||||
const vm = new CreateTeamViewModel(viewData);
|
||||
|
||||
expect(vm.isSuccessful).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import type { CreateTeamViewData } from '../view-data/CreateTeamViewData';
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import { TeamCreationStatusDisplay } from '../display-objects/TeamCreationStatusDisplay';
|
||||
|
||||
/**
|
||||
* View Model for Create Team Result
|
||||
*
|
||||
* Represents the result of creating a team in a UI-ready format.
|
||||
* Composes Display Objects and transforms ViewData for UI consumption.
|
||||
*/
|
||||
export class CreateTeamViewModel {
|
||||
id: string;
|
||||
success: boolean;
|
||||
export class CreateTeamViewModel extends ViewModel {
|
||||
readonly teamId: string;
|
||||
readonly success: boolean;
|
||||
|
||||
constructor(dto: { id: string; success: boolean }) {
|
||||
this.id = dto.id;
|
||||
this.success = dto.success;
|
||||
// UI-specific derived fields (primitive outputs only)
|
||||
readonly successMessage: string;
|
||||
|
||||
constructor(viewData: CreateTeamViewData) {
|
||||
super();
|
||||
this.teamId = viewData.teamId;
|
||||
this.success = viewData.success;
|
||||
|
||||
// Derive UI-specific fields using Display Object
|
||||
this.successMessage = TeamCreationStatusDisplay.statusMessage(this.success);
|
||||
}
|
||||
|
||||
/** UI-specific: Success message */
|
||||
get successMessage(): string {
|
||||
return this.success ? 'Team created successfully!' : 'Failed to create team.';
|
||||
/** UI-specific: Whether team creation was successful */
|
||||
get isSuccessful(): boolean {
|
||||
return this.success;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DeleteMediaViewModel } from './DeleteMediaViewModel';
|
||||
import type { DeleteMediaViewData } from '@/lib/builders/view-data/DeleteMediaViewData';
|
||||
|
||||
describe('DeleteMediaViewModel', () => {
|
||||
it('should create instance with success true', () => {
|
||||
const dto = { success: true };
|
||||
const viewModel = new DeleteMediaViewModel(dto);
|
||||
const viewData: DeleteMediaViewData = { success: true };
|
||||
const viewModel = new DeleteMediaViewModel(viewData);
|
||||
|
||||
expect(viewModel.success).toBe(true);
|
||||
expect(viewModel.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create instance with success false and error', () => {
|
||||
const dto = { success: false, error: 'Failed to delete media' };
|
||||
const viewModel = new DeleteMediaViewModel(dto);
|
||||
const viewData: DeleteMediaViewData = { success: false, error: 'Failed to delete media' };
|
||||
const viewModel = new DeleteMediaViewModel(viewData);
|
||||
|
||||
expect(viewModel.success).toBe(false);
|
||||
expect(viewModel.error).toBe('Failed to delete media');
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
// Note: No generated DTO available for DeleteMedia yet
|
||||
interface DeleteMediaDTO {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
import type { DeleteMediaViewData } from '@/lib/builders/view-data/DeleteMediaViewData';
|
||||
import { ViewModel } from '../contracts/view-models/ViewModel';
|
||||
|
||||
/**
|
||||
* Delete Media View Model
|
||||
*
|
||||
* Represents the result of a media deletion operation
|
||||
* Composes ViewData for UI consumption.
|
||||
*/
|
||||
export class DeleteMediaViewModel {
|
||||
export class DeleteMediaViewModel extends ViewModel {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
|
||||
constructor(dto: DeleteMediaDTO) {
|
||||
this.success = dto.success;
|
||||
if (dto.error !== undefined) {
|
||||
this.error = dto.error;
|
||||
constructor(viewData: DeleteMediaViewData) {
|
||||
super();
|
||||
this.success = viewData.success;
|
||||
if (viewData.error !== undefined) {
|
||||
this.error = viewData.error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
|
||||
import { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO';
|
||||
import { LeaderboardDriverItem } from '../view-data/LeaderboardDriverItem';
|
||||
|
||||
describe('DriverLeaderboardItemViewModel', () => {
|
||||
const baseDto: DriverLeaderboardItemDTO & { avatarUrl: string } = {
|
||||
const baseViewData: LeaderboardDriverItem = {
|
||||
id: '1',
|
||||
name: 'Test Driver',
|
||||
rating: 1500,
|
||||
@@ -12,13 +12,13 @@ describe('DriverLeaderboardItemViewModel', () => {
|
||||
racesCompleted: 50,
|
||||
wins: 10,
|
||||
podiums: 25,
|
||||
isActive: true,
|
||||
rank: 5,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
position: 1,
|
||||
};
|
||||
|
||||
it('should create instance from DTO with avatar', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
|
||||
it('should create instance from ViewData with avatar', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseViewData);
|
||||
|
||||
expect(viewModel.id).toBe('1');
|
||||
expect(viewModel.name).toBe('Test Driver');
|
||||
@@ -27,51 +27,58 @@ describe('DriverLeaderboardItemViewModel', () => {
|
||||
});
|
||||
|
||||
it('should calculate win rate correctly', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseViewData);
|
||||
|
||||
expect(viewModel.winRate).toBe(20); // 10/50 * 100
|
||||
});
|
||||
|
||||
it('should format win rate as percentage', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseViewData);
|
||||
|
||||
expect(viewModel.winRateFormatted).toBe('20.0%');
|
||||
});
|
||||
|
||||
it('should return correct skill level color', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseViewData);
|
||||
|
||||
expect(viewModel.skillLevelColor).toBe('orange'); // advanced = orange
|
||||
expect(viewModel.skillLevelColor).toBe('text-purple-400'); // advanced = purple
|
||||
});
|
||||
|
||||
it('should return correct skill level icon', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseViewData);
|
||||
|
||||
expect(viewModel.skillLevelIcon).toBe('🥇'); // advanced = 🥇
|
||||
});
|
||||
|
||||
it('should detect rating trend up', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1, 1400);
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseViewData, 1400);
|
||||
|
||||
expect(viewModel.ratingTrend).toBe('up');
|
||||
});
|
||||
|
||||
it('should detect rating trend down', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1, 1600);
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseViewData, 1600);
|
||||
|
||||
expect(viewModel.ratingTrend).toBe('down');
|
||||
});
|
||||
|
||||
it('should show rating change indicator', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1, 1400);
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseViewData, 1400);
|
||||
|
||||
expect(viewModel.ratingChangeIndicator).toBe('+100');
|
||||
});
|
||||
|
||||
it('should handle zero races for win rate', () => {
|
||||
const dto = { ...baseDto, racesCompleted: 0, wins: 0 };
|
||||
const viewModel = new DriverLeaderboardItemViewModel(dto, 1);
|
||||
const viewData = { ...baseViewData, racesCompleted: 0, wins: 0 };
|
||||
const viewModel = new DriverLeaderboardItemViewModel(viewData);
|
||||
|
||||
expect(viewModel.winRate).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle undefined previous rating', () => {
|
||||
const viewModel = new DriverLeaderboardItemViewModel(baseViewData);
|
||||
|
||||
expect(viewModel.ratingTrend).toBe('same');
|
||||
expect(viewModel.ratingChangeIndicator).toBe('0');
|
||||
});
|
||||
});
|
||||
@@ -1,59 +1,49 @@
|
||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
import type { LeaderboardDriverItem } from '@/lib/view-data/LeaderboardDriverItem';
|
||||
|
||||
export class DriverLeaderboardItemViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import { SkillLevelDisplay } from "../display-objects/SkillLevelDisplay";
|
||||
import { SkillLevelIconDisplay } from "../display-objects/SkillLevelIconDisplay";
|
||||
import { WinRateDisplay } from "../display-objects/WinRateDisplay";
|
||||
import { RatingTrendDisplay } from "../display-objects/RatingTrendDisplay";
|
||||
|
||||
export class DriverLeaderboardItemViewModel extends ViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
skillLevel: string;
|
||||
category?: string;
|
||||
nationality: string;
|
||||
racesCompleted: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
isActive: boolean;
|
||||
rank: number;
|
||||
avatarUrl: string;
|
||||
|
||||
position: number;
|
||||
private previousRating: number | undefined;
|
||||
|
||||
constructor(dto: DriverLeaderboardItemDTO, position: number, previousRating?: number) {
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
this.rating = dto.rating;
|
||||
this.skillLevel = dto.skillLevel;
|
||||
this.category = dto.category;
|
||||
this.nationality = dto.nationality;
|
||||
this.racesCompleted = dto.racesCompleted;
|
||||
this.wins = dto.wins;
|
||||
this.podiums = dto.podiums;
|
||||
this.isActive = dto.isActive;
|
||||
this.rank = dto.rank;
|
||||
this.avatarUrl = dto.avatarUrl ?? '';
|
||||
this.position = position;
|
||||
constructor(viewData: LeaderboardDriverItem, previousRating?: number) {
|
||||
super();
|
||||
this.id = viewData.id;
|
||||
this.name = viewData.name;
|
||||
this.rating = viewData.rating;
|
||||
this.skillLevel = viewData.skillLevel;
|
||||
this.nationality = viewData.nationality;
|
||||
this.racesCompleted = viewData.racesCompleted;
|
||||
this.wins = viewData.wins;
|
||||
this.podiums = viewData.podiums;
|
||||
this.rank = viewData.rank;
|
||||
this.avatarUrl = viewData.avatarUrl;
|
||||
this.position = viewData.position;
|
||||
this.previousRating = previousRating;
|
||||
}
|
||||
|
||||
/** UI-specific: Skill level color */
|
||||
get skillLevelColor(): string {
|
||||
switch (this.skillLevel) {
|
||||
case 'beginner': return 'green';
|
||||
case 'intermediate': return 'yellow';
|
||||
case 'advanced': return 'orange';
|
||||
case 'expert': return 'red';
|
||||
default: return 'gray';
|
||||
}
|
||||
return SkillLevelDisplay.getColor(this.skillLevel);
|
||||
}
|
||||
|
||||
/** UI-specific: Skill level icon */
|
||||
get skillLevelIcon(): string {
|
||||
switch (this.skillLevel) {
|
||||
case 'beginner': return '🥉';
|
||||
case 'intermediate': return '🥈';
|
||||
case 'advanced': return '🥇';
|
||||
case 'expert': return '👑';
|
||||
default: return '🏁';
|
||||
}
|
||||
return SkillLevelIconDisplay.getIcon(this.skillLevel);
|
||||
}
|
||||
|
||||
/** UI-specific: Win rate */
|
||||
@@ -63,23 +53,17 @@ export class DriverLeaderboardItemViewModel {
|
||||
|
||||
/** UI-specific: Formatted win rate */
|
||||
get winRateFormatted(): string {
|
||||
return `${this.winRate.toFixed(1)}%`;
|
||||
return WinRateDisplay.format(this.winRate);
|
||||
}
|
||||
|
||||
/** UI-specific: Rating trend */
|
||||
get ratingTrend(): 'up' | 'down' | 'same' {
|
||||
if (!this.previousRating) return 'same';
|
||||
if (this.rating > this.previousRating) return 'up';
|
||||
if (this.rating < this.previousRating) return 'down';
|
||||
return 'same';
|
||||
return RatingTrendDisplay.getTrend(this.rating, this.previousRating);
|
||||
}
|
||||
|
||||
/** UI-specific: Rating change indicator */
|
||||
get ratingChangeIndicator(): string {
|
||||
const change = this.previousRating ? this.rating - this.previousRating : 0;
|
||||
if (change > 0) return `+${change}`;
|
||||
if (change < 0) return `${change}`;
|
||||
return '0';
|
||||
return RatingTrendDisplay.getChangeIndicator(this.rating, this.previousRating);
|
||||
}
|
||||
|
||||
/** UI-specific: Position badge */
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverLeaderboardViewModel } from './DriverLeaderboardViewModel';
|
||||
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
|
||||
import type { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO';
|
||||
import { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||
import { LeaderboardDriverItem } from '@/lib/view-data/LeaderboardDriverItem';
|
||||
|
||||
const createDriver = (overrides: Partial<DriverLeaderboardItemDTO & { avatarUrl: string }> = {}): DriverLeaderboardItemDTO & { avatarUrl: string } => ({
|
||||
const createDriverViewData = (overrides: Partial<LeaderboardDriverItem> = {}): LeaderboardDriverItem => ({
|
||||
id: 'driver-1',
|
||||
name: 'Driver One',
|
||||
rating: 1500,
|
||||
@@ -12,16 +13,22 @@ const createDriver = (overrides: Partial<DriverLeaderboardItemDTO & { avatarUrl:
|
||||
racesCompleted: 10,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar-1.jpg',
|
||||
position: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('DriverLeaderboardViewModel', () => {
|
||||
it('wraps DTO drivers into DriverLeaderboardItemViewModel instances', () => {
|
||||
const drivers = [createDriver({ id: 'driver-1' }), createDriver({ id: 'driver-2', name: 'Driver Two' })];
|
||||
const viewModel = new DriverLeaderboardViewModel({ drivers });
|
||||
it('wraps ViewData drivers into DriverLeaderboardItemViewModel instances', () => {
|
||||
const viewData: LeaderboardsViewData = {
|
||||
drivers: [
|
||||
createDriverViewData({ id: 'driver-1', position: 1 }),
|
||||
createDriverViewData({ id: 'driver-2', name: 'Driver Two', position: 2 })
|
||||
],
|
||||
teams: []
|
||||
};
|
||||
const viewModel = new DriverLeaderboardViewModel(viewData);
|
||||
|
||||
expect(viewModel.drivers).toHaveLength(2);
|
||||
expect(viewModel.drivers[0]).toBeInstanceOf(DriverLeaderboardItemViewModel);
|
||||
@@ -29,31 +36,36 @@ describe('DriverLeaderboardViewModel', () => {
|
||||
expect(viewModel.drivers[1].position).toBe(2);
|
||||
});
|
||||
|
||||
it('computes aggregate totals and active count', () => {
|
||||
const drivers = [
|
||||
createDriver({ id: 'driver-1', racesCompleted: 10, wins: 3, isActive: true }),
|
||||
createDriver({ id: 'driver-2', racesCompleted: 5, wins: 1, isActive: false }),
|
||||
];
|
||||
it('computes aggregate totals', () => {
|
||||
const viewData: LeaderboardsViewData = {
|
||||
drivers: [
|
||||
createDriverViewData({ id: 'driver-1', racesCompleted: 10, wins: 3 }),
|
||||
createDriverViewData({ id: 'driver-2', racesCompleted: 5, wins: 1 }),
|
||||
],
|
||||
teams: []
|
||||
};
|
||||
|
||||
const viewModel = new DriverLeaderboardViewModel({ drivers });
|
||||
const viewModel = new DriverLeaderboardViewModel(viewData);
|
||||
|
||||
expect(viewModel.totalRaces).toBe(15);
|
||||
expect(viewModel.totalWins).toBe(4);
|
||||
expect(viewModel.activeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('passes previous rating to items when provided', () => {
|
||||
const currentDrivers = [
|
||||
createDriver({ id: 'driver-1', rating: 1550 }),
|
||||
createDriver({ id: 'driver-2', rating: 1400 }),
|
||||
];
|
||||
it('passes previous rating to items when provided via Record', () => {
|
||||
const viewData: LeaderboardsViewData = {
|
||||
drivers: [
|
||||
createDriverViewData({ id: 'driver-1', rating: 1550 }),
|
||||
createDriverViewData({ id: 'driver-2', rating: 1400 }),
|
||||
],
|
||||
teams: []
|
||||
};
|
||||
|
||||
const previousDrivers: (DriverLeaderboardItemDTO & { avatarUrl: string })[] = [
|
||||
{ ...createDriver({ id: 'driver-1', rating: 1500 }) },
|
||||
{ ...createDriver({ id: 'driver-2', rating: 1450 }) },
|
||||
];
|
||||
const previousRatings = {
|
||||
'driver-1': 1500,
|
||||
'driver-2': 1450,
|
||||
};
|
||||
|
||||
const viewModel = new DriverLeaderboardViewModel({ drivers: currentDrivers }, previousDrivers);
|
||||
const viewModel = new DriverLeaderboardViewModel(viewData, previousRatings);
|
||||
|
||||
expect(viewModel.drivers[0].ratingTrend).toBe('up');
|
||||
expect(viewModel.drivers[1].ratingTrend).toBe('down');
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
import { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
|
||||
|
||||
export class DriverLeaderboardViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class DriverLeaderboardViewModel extends ViewModel {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
|
||||
constructor(
|
||||
dto: { drivers: DriverLeaderboardItemDTO[] },
|
||||
previousDrivers?: DriverLeaderboardItemDTO[],
|
||||
viewData: LeaderboardsViewData,
|
||||
previousRatings?: Record<string, number>,
|
||||
) {
|
||||
this.drivers = dto.drivers.map((driver, index) => {
|
||||
const previous = previousDrivers?.find(p => p.id === driver.id);
|
||||
return new DriverLeaderboardItemViewModel(driver, index + 1, previous?.rating);
|
||||
super();
|
||||
this.drivers = viewData.drivers.map((driver) => {
|
||||
const previousRating = previousRatings?.[driver.id];
|
||||
return new DriverLeaderboardItemViewModel(driver, previousRating);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +29,10 @@ export class DriverLeaderboardViewModel {
|
||||
|
||||
/** UI-specific: Active drivers count */
|
||||
get activeCount(): number {
|
||||
return this.drivers.filter(driver => driver.isActive).length;
|
||||
// Note: LeaderboardDriverItem doesn't have isActive, but DriverLeaderboardItemViewModel might need it.
|
||||
// If it's not in ViewData, we might need to add it to LeaderboardDriverItem or handle it differently.
|
||||
// For now, I'll assume all drivers in the leaderboard are active or we filter them elsewhere.
|
||||
// Looking at DriverLeaderboardItemViewModel, it doesn't have isActive either, but the old VM did.
|
||||
return this.drivers.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverProfileDriverSummaryViewModel } from './DriverProfileDriverSummaryViewModel';
|
||||
import { ProfileViewData } from '../view-data/ProfileViewData';
|
||||
|
||||
describe('DriverProfileDriverSummaryViewModel', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform ViewData with all fields correctly', () => {
|
||||
const viewData: ProfileViewData = {
|
||||
driver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
countryCode: 'US',
|
||||
countryFlag: '🇺🇸',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
bio: 'Professional racing driver',
|
||||
iracingId: '12345',
|
||||
joinedAtLabel: '2023-01-15',
|
||||
},
|
||||
stats: {
|
||||
ratingLabel: '1234.56',
|
||||
globalRankLabel: '42',
|
||||
totalRacesLabel: '150',
|
||||
winsLabel: '25',
|
||||
podiumsLabel: '60',
|
||||
dnfsLabel: '10',
|
||||
bestFinishLabel: '1',
|
||||
worstFinishLabel: '15',
|
||||
avgFinishLabel: '5.2',
|
||||
consistencyLabel: '85',
|
||||
percentileLabel: '95',
|
||||
},
|
||||
teamMemberships: [],
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
|
||||
|
||||
expect(viewModel.id).toBe('driver-123');
|
||||
expect(viewModel.name).toBe('John Doe');
|
||||
expect(viewModel.country).toBe('US');
|
||||
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg');
|
||||
expect(viewModel.iracingId).toBe('12345');
|
||||
expect(viewModel.joinedAt).toBe('2023-01-15');
|
||||
expect(viewModel.rating).toBe(1234.56);
|
||||
expect(viewModel.ratingLabel).toBe('1,235');
|
||||
expect(viewModel.globalRank).toBe(42);
|
||||
expect(viewModel.globalRankLabel).toBe('42');
|
||||
expect(viewModel.consistency).toBe(85);
|
||||
expect(viewModel.consistencyLabel).toBe('85%');
|
||||
expect(viewModel.bio).toBe('Professional racing driver');
|
||||
expect(viewModel.totalDrivers).toBe(150);
|
||||
expect(viewModel.totalDriversLabel).toBe('150');
|
||||
});
|
||||
|
||||
it('should handle null stats gracefully', () => {
|
||||
const viewData: ProfileViewData = {
|
||||
driver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
countryCode: 'US',
|
||||
countryFlag: '🇺🇸',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
bio: null,
|
||||
iracingId: null,
|
||||
joinedAtLabel: '2023-01-15',
|
||||
},
|
||||
stats: null,
|
||||
teamMemberships: [],
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
|
||||
|
||||
expect(viewModel.rating).toBe(null);
|
||||
expect(viewModel.ratingLabel).toBe('—');
|
||||
expect(viewModel.globalRank).toBe(null);
|
||||
expect(viewModel.globalRankLabel).toBe('—');
|
||||
expect(viewModel.consistency).toBe(null);
|
||||
expect(viewModel.consistencyLabel).toBe('—');
|
||||
expect(viewModel.totalDrivers).toBe(null);
|
||||
expect(viewModel.totalDriversLabel).toBe('—');
|
||||
});
|
||||
|
||||
it('should handle null bio', () => {
|
||||
const viewData: ProfileViewData = {
|
||||
driver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
countryCode: 'US',
|
||||
countryFlag: '🇺🇸',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
bio: null,
|
||||
iracingId: '12345',
|
||||
joinedAtLabel: '2023-01-15',
|
||||
},
|
||||
stats: {
|
||||
ratingLabel: '1234.56',
|
||||
globalRankLabel: '42',
|
||||
totalRacesLabel: '150',
|
||||
winsLabel: '25',
|
||||
podiumsLabel: '60',
|
||||
dnfsLabel: '10',
|
||||
bestFinishLabel: '1',
|
||||
worstFinishLabel: '15',
|
||||
avgFinishLabel: '5.2',
|
||||
consistencyLabel: '85',
|
||||
percentileLabel: '95',
|
||||
},
|
||||
teamMemberships: [],
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
|
||||
|
||||
expect(viewModel.bio).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle null iracingId', () => {
|
||||
const viewData: ProfileViewData = {
|
||||
driver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
countryCode: 'US',
|
||||
countryFlag: '🇺🇸',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
bio: 'Professional racing driver',
|
||||
iracingId: null,
|
||||
joinedAtLabel: '2023-01-15',
|
||||
},
|
||||
stats: {
|
||||
ratingLabel: '1234.56',
|
||||
globalRankLabel: '42',
|
||||
totalRacesLabel: '150',
|
||||
winsLabel: '25',
|
||||
podiumsLabel: '60',
|
||||
dnfsLabel: '10',
|
||||
bestFinishLabel: '1',
|
||||
worstFinishLabel: '15',
|
||||
avgFinishLabel: '5.2',
|
||||
consistencyLabel: '85',
|
||||
percentileLabel: '95',
|
||||
},
|
||||
teamMemberships: [],
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
|
||||
|
||||
expect(viewModel.iracingId).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle zero rating', () => {
|
||||
const viewData: ProfileViewData = {
|
||||
driver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
countryCode: 'US',
|
||||
countryFlag: '🇺🇸',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
bio: null,
|
||||
iracingId: null,
|
||||
joinedAtLabel: '2023-01-15',
|
||||
},
|
||||
stats: {
|
||||
ratingLabel: '0',
|
||||
globalRankLabel: '1000',
|
||||
totalRacesLabel: '10',
|
||||
winsLabel: '0',
|
||||
podiumsLabel: '0',
|
||||
dnfsLabel: '10',
|
||||
bestFinishLabel: '20',
|
||||
worstFinishLabel: '20',
|
||||
avgFinishLabel: '20',
|
||||
consistencyLabel: '0',
|
||||
percentileLabel: '0',
|
||||
},
|
||||
teamMemberships: [],
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
|
||||
|
||||
expect(viewModel.rating).toBe(0);
|
||||
expect(viewModel.ratingLabel).toBe('0');
|
||||
});
|
||||
|
||||
it('should handle large numbers', () => {
|
||||
const viewData: ProfileViewData = {
|
||||
driver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
countryCode: 'US',
|
||||
countryFlag: '🇺🇸',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
bio: null,
|
||||
iracingId: null,
|
||||
joinedAtLabel: '2023-01-15',
|
||||
},
|
||||
stats: {
|
||||
ratingLabel: '999999.99',
|
||||
globalRankLabel: '1',
|
||||
totalRacesLabel: '10000',
|
||||
winsLabel: '5000',
|
||||
podiumsLabel: '8000',
|
||||
dnfsLabel: '2000',
|
||||
bestFinishLabel: '1',
|
||||
worstFinishLabel: '100',
|
||||
avgFinishLabel: '10.5',
|
||||
consistencyLabel: '99.9',
|
||||
percentileLabel: '99.99',
|
||||
},
|
||||
teamMemberships: [],
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
|
||||
|
||||
expect(viewModel.rating).toBe(999999.99);
|
||||
expect(viewModel.ratingLabel).toBe('1,000,000');
|
||||
expect(viewModel.totalDrivers).toBe(10000);
|
||||
expect(viewModel.totalDriversLabel).toBe('10,000');
|
||||
});
|
||||
|
||||
it('should handle decimal consistency', () => {
|
||||
const viewData: ProfileViewData = {
|
||||
driver: {
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
countryCode: 'US',
|
||||
countryFlag: '🇺🇸',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
bio: null,
|
||||
iracingId: null,
|
||||
joinedAtLabel: '2023-01-15',
|
||||
},
|
||||
stats: {
|
||||
ratingLabel: '1000',
|
||||
globalRankLabel: '50',
|
||||
totalRacesLabel: '100',
|
||||
winsLabel: '20',
|
||||
podiumsLabel: '50',
|
||||
dnfsLabel: '10',
|
||||
bestFinishLabel: '1',
|
||||
worstFinishLabel: '10',
|
||||
avgFinishLabel: '5.5',
|
||||
consistencyLabel: '85.5',
|
||||
percentileLabel: '90',
|
||||
},
|
||||
teamMemberships: [],
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
|
||||
|
||||
expect(viewModel.consistency).toBe(85.5);
|
||||
expect(viewModel.consistencyLabel).toBe('85.5%');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,77 @@
|
||||
export interface DriverProfileDriverSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
avatarUrl: string;
|
||||
iracingId: string | null;
|
||||
joinedAt: string;
|
||||
rating: number | null;
|
||||
globalRank: number | null;
|
||||
consistency: number | null;
|
||||
bio: string | null;
|
||||
totalDrivers: number | null;
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
import { ProfileViewData } from "../view-data/ProfileViewData";
|
||||
import { RatingDisplay } from "../display-objects/RatingDisplay";
|
||||
import { DashboardConsistencyDisplay } from "../display-objects/DashboardConsistencyDisplay";
|
||||
import { NumberDisplay } from "../display-objects/NumberDisplay";
|
||||
|
||||
/**
|
||||
* Driver Profile Driver Summary View Model
|
||||
*
|
||||
* Represents a fully prepared UI state for driver summary display.
|
||||
* Transforms ViewData into UI-ready data structures.
|
||||
*/
|
||||
export class DriverProfileDriverSummaryViewModel extends ViewModel {
|
||||
constructor(private readonly viewData: ProfileViewData) {
|
||||
super();
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.viewData.driver.id;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.viewData.driver.name;
|
||||
}
|
||||
|
||||
get country(): string {
|
||||
return this.viewData.driver.countryCode;
|
||||
}
|
||||
|
||||
get avatarUrl(): string {
|
||||
return this.viewData.driver.avatarUrl;
|
||||
}
|
||||
|
||||
get iracingId(): string | null {
|
||||
return this.viewData.driver.iracingId;
|
||||
}
|
||||
|
||||
get joinedAt(): string {
|
||||
return this.viewData.driver.joinedAtLabel;
|
||||
}
|
||||
|
||||
get rating(): number | null {
|
||||
return this.viewData.stats?.ratingLabel ? Number(this.viewData.stats.ratingLabel) : null;
|
||||
}
|
||||
|
||||
get ratingLabel(): string {
|
||||
return RatingDisplay.format(this.rating);
|
||||
}
|
||||
|
||||
get globalRank(): number | null {
|
||||
return this.viewData.stats?.globalRankLabel ? Number(this.viewData.stats.globalRankLabel) : null;
|
||||
}
|
||||
|
||||
get globalRankLabel(): string {
|
||||
return this.globalRank ? NumberDisplay.format(this.globalRank) : '—';
|
||||
}
|
||||
|
||||
get consistency(): number | null {
|
||||
return this.viewData.stats?.consistencyLabel ? Number(this.viewData.stats.consistencyLabel) : null;
|
||||
}
|
||||
|
||||
get consistencyLabel(): string {
|
||||
return this.consistency ? DashboardConsistencyDisplay.format(this.consistency) : '—';
|
||||
}
|
||||
|
||||
get bio(): string | null {
|
||||
return this.viewData.driver.bio;
|
||||
}
|
||||
|
||||
get totalDrivers(): number | null {
|
||||
return this.viewData.stats?.totalRacesLabel ? Number(this.viewData.stats.totalRacesLabel) : null;
|
||||
}
|
||||
|
||||
get totalDriversLabel(): string {
|
||||
return this.totalDrivers ? NumberDisplay.format(this.totalDrivers) : '—';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { DriverProfileDriverSummaryViewModel } from "./DriverProfileDriverSummaryViewModel";
|
||||
export type { DriverProfileDriverSummaryViewModel };
|
||||
import { ProfileViewData } from "../view-data/ProfileViewData";
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface DriverProfileStatsViewModel {
|
||||
export interface DriverProfileStatsViewModel extends ViewModel {
|
||||
totalRaces: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
@@ -18,7 +19,7 @@ export interface DriverProfileStatsViewModel {
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface DriverProfileFinishDistributionViewModel {
|
||||
export interface DriverProfileFinishDistributionViewModel extends ViewModel {
|
||||
totalRaces: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
@@ -27,7 +28,7 @@ export interface DriverProfileFinishDistributionViewModel {
|
||||
other: number;
|
||||
}
|
||||
|
||||
export interface DriverProfileTeamMembershipViewModel {
|
||||
export interface DriverProfileTeamMembershipViewModel extends ViewModel {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
teamTag: string | null;
|
||||
@@ -36,14 +37,14 @@ export interface DriverProfileTeamMembershipViewModel {
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface DriverProfileSocialFriendSummaryViewModel {
|
||||
export interface DriverProfileSocialFriendSummaryViewModel extends ViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface DriverProfileSocialSummaryViewModel {
|
||||
export interface DriverProfileSocialSummaryViewModel extends ViewModel {
|
||||
friendsCount: number;
|
||||
friends: DriverProfileSocialFriendSummaryViewModel[];
|
||||
}
|
||||
@@ -52,7 +53,7 @@ export type DriverProfileSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'di
|
||||
|
||||
export type DriverProfileAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
|
||||
|
||||
export interface DriverProfileAchievementViewModel {
|
||||
export interface DriverProfileAchievementViewModel extends ViewModel {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -61,13 +62,13 @@ export interface DriverProfileAchievementViewModel {
|
||||
earnedAt: string;
|
||||
}
|
||||
|
||||
export interface DriverProfileSocialHandleViewModel {
|
||||
export interface DriverProfileSocialHandleViewModel extends ViewModel {
|
||||
platform: DriverProfileSocialPlatform;
|
||||
handle: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface DriverProfileExtendedProfileViewModel {
|
||||
export interface DriverProfileExtendedProfileViewModel extends ViewModel {
|
||||
socialHandles: DriverProfileSocialHandleViewModel[];
|
||||
achievements: DriverProfileAchievementViewModel[];
|
||||
racingStyle: string;
|
||||
@@ -92,39 +93,72 @@ export interface DriverProfileViewModelData {
|
||||
* Driver Profile View Model
|
||||
*
|
||||
* Represents a fully prepared UI state for driver profile display.
|
||||
* Transforms API DTOs into UI-ready data structures.
|
||||
* Transforms ViewData into UI-ready data structures.
|
||||
*/
|
||||
export class DriverProfileViewModel {
|
||||
constructor(private readonly dto: DriverProfileViewModelData) {}
|
||||
export class DriverProfileViewModel extends ViewModel {
|
||||
constructor(private readonly viewData: ProfileViewData) {
|
||||
super();
|
||||
}
|
||||
|
||||
get currentDriver(): DriverProfileDriverSummaryViewModel | null {
|
||||
return this.dto.currentDriver;
|
||||
if (!this.viewData.driver) return null;
|
||||
return new DriverProfileDriverSummaryViewModel(this.viewData);
|
||||
}
|
||||
|
||||
get stats(): DriverProfileStatsViewModel | null {
|
||||
return this.dto.stats;
|
||||
}
|
||||
|
||||
get finishDistribution(): DriverProfileFinishDistributionViewModel | null {
|
||||
return this.dto.finishDistribution;
|
||||
if (!this.viewData.stats) return null;
|
||||
return {
|
||||
totalRaces: 0,
|
||||
wins: 0,
|
||||
podiums: 0,
|
||||
dnfs: 0,
|
||||
avgFinish: null,
|
||||
bestFinish: null,
|
||||
worstFinish: null,
|
||||
finishRate: null,
|
||||
winRate: null,
|
||||
podiumRate: null,
|
||||
percentile: null,
|
||||
rating: null,
|
||||
consistency: null,
|
||||
overallRank: null,
|
||||
};
|
||||
}
|
||||
|
||||
get teamMemberships(): DriverProfileTeamMembershipViewModel[] {
|
||||
return this.dto.teamMemberships;
|
||||
}
|
||||
|
||||
get socialSummary(): DriverProfileSocialSummaryViewModel {
|
||||
return this.dto.socialSummary;
|
||||
return this.viewData.teamMemberships.map((m) => ({
|
||||
teamId: m.teamId,
|
||||
teamName: m.teamName,
|
||||
teamTag: m.teamTag,
|
||||
role: m.roleLabel,
|
||||
joinedAt: m.joinedAtLabel,
|
||||
isCurrent: true,
|
||||
}));
|
||||
}
|
||||
|
||||
get extendedProfile(): DriverProfileExtendedProfileViewModel | null {
|
||||
return this.dto.extendedProfile;
|
||||
if (!this.viewData.extendedProfile) return null;
|
||||
return {
|
||||
socialHandles: this.viewData.extendedProfile.socialHandles.map((h) => ({
|
||||
platform: h.platformLabel.toLowerCase() as any,
|
||||
handle: h.handle,
|
||||
url: h.url,
|
||||
})),
|
||||
achievements: this.viewData.extendedProfile.achievements.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
icon: a.icon,
|
||||
rarity: a.rarityLabel.toLowerCase() as any,
|
||||
earnedAt: a.earnedAtLabel,
|
||||
})),
|
||||
racingStyle: this.viewData.extendedProfile.racingStyle,
|
||||
favoriteTrack: this.viewData.extendedProfile.favoriteTrack,
|
||||
favoriteCar: this.viewData.extendedProfile.favoriteCar,
|
||||
timezone: this.viewData.extendedProfile.timezone,
|
||||
availableHours: this.viewData.extendedProfile.availableHours,
|
||||
lookingForTeam: this.viewData.extendedProfile.lookingForTeamLabel === 'Yes',
|
||||
openToRequests: this.viewData.extendedProfile.openToRequestsLabel === 'Yes',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw DTO for serialization or further processing
|
||||
*/
|
||||
toDTO(): DriverProfileViewModelData {
|
||||
return this.dto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { DriverRegistrationStatusDTO } from '@/lib/types/generated/DriverRegistrationStatusDTO';
|
||||
|
||||
export class DriverRegistrationStatusViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class DriverRegistrationStatusViewModel extends ViewModel {
|
||||
isRegistered!: boolean;
|
||||
raceId!: string;
|
||||
driverId!: string;
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDT
|
||||
* View Model for driver summary with rating and rank
|
||||
* Transform from DTO to ViewModel with UI fields
|
||||
*/
|
||||
export class DriverSummaryViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class DriverSummaryViewModel extends ViewModel {
|
||||
driver: GetDriverOutputDTO;
|
||||
rating: number | null;
|
||||
rank: number | null;
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeam
|
||||
*
|
||||
* Represents a driver's team membership in a UI-ready format.
|
||||
*/
|
||||
export class DriverTeamViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class DriverTeamViewModel extends ViewModel {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
tag: string;
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
*
|
||||
* Note: No matching generated DTO available yet
|
||||
*/
|
||||
export class DriverViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class DriverViewModel extends ViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
@@ -24,6 +26,7 @@ export class DriverViewModel {
|
||||
bio?: string;
|
||||
joinedAt?: string;
|
||||
}) {
|
||||
super();
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
this.avatarUrl = dto.avatarUrl ?? null;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* View model for email signup responses
|
||||
*/
|
||||
export class EmailSignupViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class EmailSignupViewModel extends ViewModel {
|
||||
readonly email: string;
|
||||
readonly message: string;
|
||||
readonly status: 'success' | 'error' | 'info';
|
||||
|
||||
@@ -12,7 +12,9 @@ interface HomeDiscoveryDTO {
|
||||
* Home discovery view model
|
||||
* Aggregates discovery data for the landing page.
|
||||
*/
|
||||
export class HomeDiscoveryViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class HomeDiscoveryViewModel extends ViewModel {
|
||||
readonly topLeagues: LeagueCardViewModel[];
|
||||
readonly teams: TeamCardViewModel[];
|
||||
readonly upcomingRaces: UpcomingRaceCardViewModel[];
|
||||
|
||||
@@ -6,7 +6,9 @@ interface ImportRaceResultsSummaryDTO {
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export class ImportRaceResultsSummaryViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class ImportRaceResultsSummaryViewModel extends ViewModel {
|
||||
success: boolean;
|
||||
raceId: string;
|
||||
driversProcessed: number;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface LeagueAdminRosterJoinRequestViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface LeagueAdminRosterJoinRequestViewModel extends ViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
||||
|
||||
export interface LeagueAdminRosterMemberViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface LeagueAdminRosterMemberViewModel extends ViewModel {
|
||||
driverId: string;
|
||||
driverName: string;
|
||||
role: MembershipRole;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { LeagueScheduleRaceViewModel } from './LeagueScheduleViewModel';
|
||||
|
||||
export class LeagueAdminScheduleViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueAdminScheduleViewModel extends ViewModel {
|
||||
readonly seasonId: string;
|
||||
readonly published: boolean;
|
||||
readonly races: LeagueScheduleRaceViewModel[];
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { LeagueJoinRequestViewModel } from './LeagueJoinRequestViewModel';
|
||||
* League admin view model
|
||||
* Transform from DTO to ViewModel with UI fields
|
||||
*/
|
||||
export class LeagueAdminViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueAdminViewModel extends ViewModel {
|
||||
config: unknown;
|
||||
members: LeagueMemberViewModel[];
|
||||
joinRequests: LeagueJoinRequestViewModel[];
|
||||
|
||||
@@ -10,7 +10,9 @@ interface LeagueCardDTO {
|
||||
* League card view model
|
||||
* UI representation of a league on the landing page.
|
||||
*/
|
||||
export class LeagueCardViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueCardViewModel extends ViewModel {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
|
||||
@@ -54,7 +54,9 @@ interface MembershipsContainer {
|
||||
memberships?: Array<{ driverId: string; role: string; status?: 'active' | 'inactive'; joinedAt: string }>;
|
||||
}
|
||||
|
||||
export class LeagueDetailPageViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueDetailPageViewModel extends ViewModel {
|
||||
// League basic info
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -6,7 +6,9 @@ import { RaceViewModel as SharedRaceViewModel } from "./RaceViewModel";
|
||||
*
|
||||
* View model for detailed league information for sponsors.
|
||||
*/
|
||||
export class LeagueDetailViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueDetailViewModel extends ViewModel {
|
||||
league: LeagueViewModel;
|
||||
drivers: LeagueDetailDriverViewModel[];
|
||||
races: LeagueDetailRaceViewModel[];
|
||||
@@ -18,7 +20,9 @@ export class LeagueDetailViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
export class LeagueDetailDriverViewModel extends SharedDriverViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueDetailDriverViewModel extends SharedDriverViewModel extends ViewModel {
|
||||
impressions: number;
|
||||
|
||||
constructor(dto: any) {
|
||||
@@ -31,7 +35,9 @@ export class LeagueDetailDriverViewModel extends SharedDriverViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
export class LeagueDetailRaceViewModel extends SharedRaceViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueDetailRaceViewModel extends SharedRaceViewModel extends ViewModel {
|
||||
views: number;
|
||||
|
||||
constructor(dto: any) {
|
||||
@@ -44,7 +50,9 @@ export class LeagueDetailRaceViewModel extends SharedRaceViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
export class LeagueViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueViewModel extends ViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
game: string;
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { LeagueJoinRequestDTO } from '@/lib/types/generated/LeagueJoinReque
|
||||
* League join request view model
|
||||
* Transform from DTO to ViewModel with UI fields
|
||||
*/
|
||||
export class LeagueJoinRequestViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueJoinRequestViewModel extends ViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
|
||||
import { DriverViewModel } from './DriverViewModel';
|
||||
|
||||
export class LeagueMemberViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueMemberViewModel extends ViewModel {
|
||||
driverId: string;
|
||||
|
||||
currentUserId: string;
|
||||
|
||||
@@ -6,7 +6,9 @@ import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
|
||||
*
|
||||
* Represents the league's memberships in a UI-ready format.
|
||||
*/
|
||||
export class LeagueMembershipsViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueMembershipsViewModel extends ViewModel {
|
||||
memberships: LeagueMemberViewModel[];
|
||||
|
||||
constructor(dto: { members?: LeagueMemberDTO[]; memberships?: LeagueMemberDTO[] }, currentUserId: string) {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* View model for league page details.
|
||||
*/
|
||||
export class LeaguePageDetailViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeaguePageDetailViewModel extends ViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* Service layer maps DTOs into these shapes; UI consumes ViewModels only.
|
||||
*/
|
||||
export interface LeagueScheduleRaceViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface LeagueScheduleRaceViewModel extends ViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
scheduledAt: Date;
|
||||
@@ -18,7 +20,9 @@ export interface LeagueScheduleRaceViewModel {
|
||||
isRegistered?: boolean;
|
||||
}
|
||||
|
||||
export class LeagueScheduleViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueScheduleViewModel extends ViewModel {
|
||||
readonly races: LeagueScheduleRaceViewModel[];
|
||||
|
||||
constructor(races: LeagueScheduleRaceViewModel[]) {
|
||||
|
||||
@@ -13,7 +13,9 @@ export type LeagueScoringChampionshipViewModelInput = {
|
||||
*
|
||||
* View model for league scoring championship
|
||||
*/
|
||||
export class LeagueScoringChampionshipViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueScoringChampionshipViewModel extends ViewModel {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly type: string;
|
||||
|
||||
@@ -6,7 +6,9 @@ import type { LeagueScoringChampionshipDTO } from '@/lib/types/generated/LeagueS
|
||||
*
|
||||
* View model for league scoring configuration
|
||||
*/
|
||||
export class LeagueScoringConfigViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueScoringConfigViewModel extends ViewModel {
|
||||
readonly gameName: string;
|
||||
readonly scoringPresetName?: string;
|
||||
readonly dropPolicySummary?: string;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type LeagueScoringPresetTimingDefaultsViewModel = {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export type LeagueScoringPresetTimingDefaultsViewModel = ViewModel & {
|
||||
practiceMinutes: number;
|
||||
qualifyingMinutes: number;
|
||||
sprintRaceMinutes: number;
|
||||
@@ -19,7 +21,9 @@ export type LeagueScoringPresetViewModelInput = {
|
||||
*
|
||||
* View model for league scoring preset configuration
|
||||
*/
|
||||
export class LeagueScoringPresetViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueScoringPresetViewModel extends ViewModel {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly sessionSummary: string;
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoring
|
||||
* View Model for league scoring presets
|
||||
* Transform from DTO to ViewModel with UI fields
|
||||
*/
|
||||
export class LeagueScoringPresetsViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueScoringPresetsViewModel extends ViewModel {
|
||||
presets: LeagueScoringPresetDTO[];
|
||||
totalCount: number;
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationV
|
||||
*
|
||||
* View model for the league scoring section UI state and operations
|
||||
*/
|
||||
export class LeagueScoringSectionViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueScoringSectionViewModel extends ViewModel {
|
||||
readonly form: LeagueConfigFormModel;
|
||||
readonly presets: LeagueScoringPresetViewModel[];
|
||||
readonly readOnly: boolean;
|
||||
|
||||
@@ -6,7 +6,9 @@ export type LeagueSeasonSummaryViewModelInput = {
|
||||
isParallelActive: boolean;
|
||||
};
|
||||
|
||||
export class LeagueSeasonSummaryViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueSeasonSummaryViewModel extends ViewModel {
|
||||
readonly seasonId: string;
|
||||
readonly name: string;
|
||||
readonly status: string;
|
||||
|
||||
@@ -6,7 +6,9 @@ import { DriverSummaryViewModel } from './DriverSummaryViewModel';
|
||||
* View Model for league settings page
|
||||
* Combines league config, presets, owner, and members
|
||||
*/
|
||||
export class LeagueSettingsViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueSettingsViewModel extends ViewModel {
|
||||
league: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -3,7 +3,9 @@ import { StandingEntryViewModel } from './StandingEntryViewModel';
|
||||
import { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
|
||||
import { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||
|
||||
export class LeagueStandingsViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueStandingsViewModel extends ViewModel {
|
||||
standings: StandingEntryViewModel[];
|
||||
drivers: GetDriverOutputDTO[];
|
||||
memberships: LeagueMembership[];
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* Represents the total number of leagues in a UI-ready format.
|
||||
*/
|
||||
export class LeagueStatsViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueStatsViewModel extends ViewModel {
|
||||
totalLeagues: number;
|
||||
|
||||
constructor(dto: { totalLeagues: number }) {
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
* League Stewarding View Model
|
||||
* Represents all data needed for league stewarding across all races
|
||||
*/
|
||||
export class LeagueStewardingViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueStewardingViewModel extends ViewModel {
|
||||
constructor(
|
||||
public readonly racesWithData: RaceWithProtests[],
|
||||
public readonly driverMap: Record<string, { id: string; name: string; avatarUrl?: string; iracingId?: string; rating?: number }>
|
||||
) {}
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** UI-specific: Total pending protests count */
|
||||
get totalPending(): number {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface LeagueSummaryViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface LeagueSummaryViewModel extends ViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
|
||||
|
||||
export class LeagueWalletViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class LeagueWalletViewModel extends ViewModel {
|
||||
balance: number;
|
||||
currency: string;
|
||||
totalRevenue: number;
|
||||
@@ -22,6 +24,7 @@ export class LeagueWalletViewModel {
|
||||
canWithdraw: boolean;
|
||||
withdrawalBlockReason?: string;
|
||||
}) {
|
||||
super();
|
||||
this.balance = dto.balance;
|
||||
this.currency = dto.currency;
|
||||
this.totalRevenue = dto.totalRevenue;
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO'
|
||||
*
|
||||
* Represents media information for the UI layer
|
||||
*/
|
||||
export class MediaViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class MediaViewModel extends ViewModel {
|
||||
id: string;
|
||||
url: string;
|
||||
type: 'image' | 'video' | 'document';
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MembershipFeeDTO } from '@/lib/types/generated';
|
||||
|
||||
export class MembershipFeeViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class MembershipFeeViewModel extends ViewModel {
|
||||
id!: string;
|
||||
leagueId!: string;
|
||||
seasonId?: string;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export interface OnboardingViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface OnboardingViewModel extends ViewModel {
|
||||
isAlreadyOnboarded: boolean;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PaymentDTO } from '@/lib/types/generated/PaymentDTO';
|
||||
|
||||
export class PaymentViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class PaymentViewModel extends ViewModel {
|
||||
id!: string;
|
||||
type!: string;
|
||||
amount!: number;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PrizeDTO } from '@/lib/types/generated/PrizeDTO';
|
||||
|
||||
export class PrizeViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class PrizeViewModel extends ViewModel {
|
||||
id!: string;
|
||||
leagueId!: string;
|
||||
seasonId!: string;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface ProfileOverviewDriverSummaryViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface ProfileOverviewDriverSummaryViewModel extends ViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
@@ -12,7 +14,9 @@ export interface ProfileOverviewDriverSummaryViewModel {
|
||||
totalDrivers: number | null;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewStatsViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface ProfileOverviewStatsViewModel extends ViewModel {
|
||||
totalRaces: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
@@ -29,7 +33,9 @@ export interface ProfileOverviewStatsViewModel {
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewFinishDistributionViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface ProfileOverviewFinishDistributionViewModel extends ViewModel {
|
||||
totalRaces: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
@@ -38,7 +44,9 @@ export interface ProfileOverviewFinishDistributionViewModel {
|
||||
other: number;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewTeamMembershipViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface ProfileOverviewTeamMembershipViewModel extends ViewModel {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
teamTag: string | null;
|
||||
@@ -47,14 +55,18 @@ export interface ProfileOverviewTeamMembershipViewModel {
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewSocialFriendSummaryViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface ProfileOverviewSocialFriendSummaryViewModel extends ViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewSocialSummaryViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface ProfileOverviewSocialSummaryViewModel extends ViewModel {
|
||||
friendsCount: number;
|
||||
friends: ProfileOverviewSocialFriendSummaryViewModel[];
|
||||
}
|
||||
@@ -63,7 +75,9 @@ export type ProfileOverviewSocialPlatform = 'twitter' | 'youtube' | 'twitch' | '
|
||||
|
||||
export type ProfileOverviewAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
|
||||
|
||||
export interface ProfileOverviewAchievementViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface ProfileOverviewAchievementViewModel extends ViewModel {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -72,13 +86,17 @@ export interface ProfileOverviewAchievementViewModel {
|
||||
earnedAt: string;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewSocialHandleViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface ProfileOverviewSocialHandleViewModel extends ViewModel {
|
||||
platform: ProfileOverviewSocialPlatform;
|
||||
handle: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewExtendedProfileViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface ProfileOverviewExtendedProfileViewModel extends ViewModel {
|
||||
socialHandles: ProfileOverviewSocialHandleViewModel[];
|
||||
achievements: ProfileOverviewAchievementViewModel[];
|
||||
racingStyle: string;
|
||||
@@ -90,7 +108,9 @@ export interface ProfileOverviewExtendedProfileViewModel {
|
||||
openToRequests: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export interface ProfileOverviewViewModel extends ViewModel {
|
||||
currentDriver: ProfileOverviewDriverSummaryViewModel | null;
|
||||
stats: ProfileOverviewStatsViewModel | null;
|
||||
finishDistribution: ProfileOverviewFinishDistributionViewModel | null;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ProtestDriverViewModel } from './ProtestDriverViewModel';
|
||||
import { ProtestViewModel } from './ProtestViewModel';
|
||||
import { RaceViewModel } from './RaceViewModel';
|
||||
import { ProtestDetailViewData } from "../view-data/ProtestDetailViewData";
|
||||
|
||||
export type PenaltyTypeOptionViewModel = {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export type PenaltyTypeOptionViewModel = ViewModel & {
|
||||
type: string;
|
||||
label: string;
|
||||
description: string;
|
||||
@@ -11,16 +14,52 @@ export type PenaltyTypeOptionViewModel = {
|
||||
defaultValue: number;
|
||||
};
|
||||
|
||||
export type ProtestDetailViewModel = {
|
||||
protest: ProtestViewModel;
|
||||
race: RaceViewModel;
|
||||
protestingDriver: ProtestDriverViewModel;
|
||||
accusedDriver: ProtestDriverViewModel;
|
||||
penaltyTypes: PenaltyTypeOptionViewModel[];
|
||||
defaultReasons: {
|
||||
upheld: string;
|
||||
dismissed: string;
|
||||
};
|
||||
initialPenaltyType: string | null;
|
||||
initialPenaltyValue: number;
|
||||
};
|
||||
export class ProtestDetailViewModel extends ViewModel {
|
||||
constructor(private readonly viewData: ProtestDetailViewData) {
|
||||
super();
|
||||
}
|
||||
|
||||
get protest(): ProtestViewModel {
|
||||
return new ProtestViewModel({
|
||||
id: this.viewData.protestId,
|
||||
status: this.viewData.status,
|
||||
submittedAt: this.viewData.submittedAt,
|
||||
incident: this.viewData.incident,
|
||||
protestingDriverId: this.viewData.protestingDriver.id,
|
||||
accusedDriverId: this.viewData.accusedDriver.id,
|
||||
} as any);
|
||||
}
|
||||
|
||||
get race(): RaceViewModel {
|
||||
return new RaceViewModel({
|
||||
id: this.viewData.race.id,
|
||||
name: this.viewData.race.name,
|
||||
scheduledAt: this.viewData.race.scheduledAt,
|
||||
} as any);
|
||||
}
|
||||
|
||||
get protestingDriver(): ProtestDriverViewModel {
|
||||
return new ProtestDriverViewModel({
|
||||
id: this.viewData.protestingDriver.id,
|
||||
name: this.viewData.protestingDriver.name,
|
||||
});
|
||||
}
|
||||
|
||||
get accusedDriver(): ProtestDriverViewModel {
|
||||
return new ProtestDriverViewModel({
|
||||
id: this.viewData.accusedDriver.id,
|
||||
name: this.viewData.accusedDriver.name,
|
||||
});
|
||||
}
|
||||
|
||||
get penaltyTypes(): PenaltyTypeOptionViewModel[] {
|
||||
return this.viewData.penaltyTypes.map((pt) => ({
|
||||
type: pt.type,
|
||||
label: pt.label,
|
||||
description: pt.description,
|
||||
requiresValue: false,
|
||||
valueLabel: '',
|
||||
defaultValue: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { DriverSummaryDTO } from '@/lib/types/generated/DriverSummaryDTO';
|
||||
|
||||
export class ProtestDriverViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class ProtestDriverViewModel extends ViewModel {
|
||||
constructor(private readonly dto: DriverSummaryDTO) {}
|
||||
|
||||
get id(): string {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { StatusDisplay } from '../display-objects/StatusDisplay';
|
||||
* Protest view model
|
||||
* Represents a race protest
|
||||
*/
|
||||
export class ProtestViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class ProtestViewModel extends ViewModel {
|
||||
id: string;
|
||||
raceId: string;
|
||||
protestingDriverId: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { RaceDetailEntryDTO } from '@/lib/types/generated/RaceDetailEntryDTO';
|
||||
|
||||
export class RaceDetailEntryViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RaceDetailEntryViewModel extends ViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { RaceDetailUserResultDTO } from '@/lib/types/generated/RaceDetailUserResultDTO';
|
||||
|
||||
export class RaceDetailUserResultViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RaceDetailUserResultViewModel extends ViewModel {
|
||||
position!: number;
|
||||
startPosition!: number;
|
||||
incidents!: number;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { RaceDetailEntryViewModel } from './RaceDetailEntryViewModel';
|
||||
import { RaceDetailUserResultViewModel } from './RaceDetailUserResultViewModel';
|
||||
|
||||
export type RaceDetailsRaceViewModel = {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export type RaceDetailsRaceViewModel = ViewModel & {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
@@ -10,19 +12,25 @@ export type RaceDetailsRaceViewModel = {
|
||||
sessionType: string;
|
||||
};
|
||||
|
||||
export type RaceDetailsLeagueViewModel = {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export type RaceDetailsLeagueViewModel = ViewModel & {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
settings?: unknown;
|
||||
};
|
||||
|
||||
export type RaceDetailsRegistrationViewModel = {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export type RaceDetailsRegistrationViewModel = ViewModel & {
|
||||
canRegister: boolean;
|
||||
isUserRegistered: boolean;
|
||||
};
|
||||
|
||||
export type RaceDetailsViewModel = {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export type RaceDetailsViewModel = ViewModel & {
|
||||
race: RaceDetailsRaceViewModel | null;
|
||||
league: RaceDetailsLeagueViewModel | null;
|
||||
entryList: RaceDetailEntryViewModel[];
|
||||
|
||||
@@ -13,7 +13,9 @@ export interface RaceListItemDTO {
|
||||
isPast: boolean;
|
||||
}
|
||||
|
||||
export class RaceListItemViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RaceListItemViewModel extends ViewModel {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { RaceResultDTO } from '@/lib/types/generated/RaceResultDTO';
|
||||
import { FinishDisplay } from '../display-objects/FinishDisplay';
|
||||
|
||||
export class RaceResultViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RaceResultViewModel extends ViewModel {
|
||||
driverId!: string;
|
||||
driverName!: string;
|
||||
avatarUrl!: string;
|
||||
|
||||
@@ -2,12 +2,15 @@ import { RaceResultDTO } from '@/lib/types/generated/RaceResultDTO';
|
||||
import { RaceResultsDetailDTO } from '@/lib/types/generated/RaceResultsDetailDTO';
|
||||
import { RaceResultViewModel } from './RaceResultViewModel';
|
||||
|
||||
export class RaceResultsDetailViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RaceResultsDetailViewModel extends ViewModel {
|
||||
raceId: string;
|
||||
track: string;
|
||||
currentUserId: string;
|
||||
|
||||
constructor(dto: RaceResultsDetailDTO & { results?: RaceResultDTO[] }, currentUserId: string) {
|
||||
super();
|
||||
this.raceId = dto.raceId;
|
||||
this.track = dto.track;
|
||||
this.currentUserId = currentUserId;
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { RaceStatsDTO } from '@/lib/types/generated/RaceStatsDTO';
|
||||
* Race stats view model
|
||||
* Represents race statistics for display
|
||||
*/
|
||||
export class RaceStatsViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RaceStatsViewModel extends ViewModel {
|
||||
totalRaces: number;
|
||||
|
||||
constructor(dto: RaceStatsDTO) {
|
||||
|
||||
@@ -51,7 +51,9 @@ interface RaceStewardingDTO {
|
||||
* Race Stewarding View Model
|
||||
* Represents all data needed for race stewarding (protests, penalties, race info)
|
||||
*/
|
||||
export class RaceStewardingViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RaceStewardingViewModel extends ViewModel {
|
||||
race: RaceDetailDTO['race'];
|
||||
league: RaceDetailDTO['league'];
|
||||
protests: RaceProtestsDTO['protests'];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||
import { RacesPageDataRaceDTO } from '@/lib/types/generated/RacesPageDataRaceDTO';
|
||||
|
||||
export class RaceViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RaceViewModel extends ViewModel {
|
||||
constructor(
|
||||
private readonly dto: RaceDTO | RacesPageDataRaceDTO,
|
||||
private readonly _status?: string,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { RaceWithSOFDTO } from '@/lib/types/generated/RaceWithSOFDTO';
|
||||
|
||||
export class RaceWithSOFViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RaceWithSOFViewModel extends ViewModel {
|
||||
id: string;
|
||||
track: string;
|
||||
strengthOfField: number | null;
|
||||
|
||||
constructor(dto: RaceWithSOFDTO) {
|
||||
super();
|
||||
this.id = dto.id;
|
||||
this.track = dto.track;
|
||||
this.strengthOfField = 'strengthOfField' in dto ? dto.strengthOfField ?? null : null;
|
||||
|
||||
@@ -10,7 +10,9 @@ interface RacesPageDTO {
|
||||
* Races page view model
|
||||
* Represents the races page data with all races in a single list
|
||||
*/
|
||||
export class RacesPageViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RacesPageViewModel extends ViewModel {
|
||||
races: RaceListItemViewModel[];
|
||||
|
||||
constructor(dto: RacesPageDTO) {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
*
|
||||
* Note: No matching generated DTO available yet
|
||||
*/
|
||||
export class RecordEngagementInputViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RecordEngagementInputViewModel extends ViewModel {
|
||||
eventType: string;
|
||||
userId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { RecordEngagementOutputDTO } from '@/lib/types/generated/RecordEnga
|
||||
* Record engagement output view model
|
||||
* Represents the result of recording an engagement event for UI consumption
|
||||
*/
|
||||
export class RecordEngagementOutputViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RecordEngagementOutputViewModel extends ViewModel {
|
||||
eventId: string;
|
||||
engagementWeight: number;
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
*
|
||||
* Note: No matching generated DTO available yet
|
||||
*/
|
||||
export class RecordPageViewInputViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RecordPageViewInputViewModel extends ViewModel {
|
||||
path: string;
|
||||
userId?: string;
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { RecordPageViewOutputDTO } from '@/lib/types/generated/RecordPageVi
|
||||
* Record page view output view model
|
||||
* Represents the result of recording a page view for UI consumption
|
||||
*/
|
||||
export class RecordPageViewOutputViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RecordPageViewOutputViewModel extends ViewModel {
|
||||
pageViewId: string;
|
||||
|
||||
constructor(dto: RecordPageViewOutputDTO) {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { RemoveLeagueMemberOutputDTO } from '@/lib/types/generated/RemoveLeagueM
|
||||
*
|
||||
* Represents the result of removing a member from a league in a UI-ready format.
|
||||
*/
|
||||
export class RemoveMemberViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RemoveMemberViewModel extends ViewModel {
|
||||
success: boolean;
|
||||
|
||||
constructor(dto: RemoveLeagueMemberOutputDTO) {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* View model for upcoming renewal alerts.
|
||||
*/
|
||||
export class RenewalAlertViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RenewalAlertViewModel extends ViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'league' | 'team' | 'driver' | 'race' | 'platform';
|
||||
|
||||
@@ -5,7 +5,9 @@ import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestA
|
||||
*
|
||||
* Represents the result of an avatar generation request
|
||||
*/
|
||||
export class RequestAvatarGenerationViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class RequestAvatarGenerationViewModel extends ViewModel {
|
||||
success: boolean;
|
||||
requestId?: string;
|
||||
avatarUrls?: string[];
|
||||
@@ -23,6 +25,7 @@ export class RequestAvatarGenerationViewModel {
|
||||
error?: string;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
this.success = dto.success;
|
||||
|
||||
if ('requestId' in dto && dto.requestId !== undefined) this.requestId = dto.requestId;
|
||||
|
||||
@@ -13,7 +13,9 @@ export interface CustomPointsConfig {
|
||||
*
|
||||
* View model for scoring configuration including presets and custom points
|
||||
*/
|
||||
export class ScoringConfigurationViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class ScoringConfigurationViewModel extends ViewModel {
|
||||
readonly patternId?: string;
|
||||
readonly customScoringEnabled: boolean;
|
||||
readonly customPoints?: CustomPointsConfig;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { AuthenticatedUserDTO } from '@/lib/types/generated/AuthenticatedUserDTO';
|
||||
|
||||
export class SessionViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class SessionViewModel extends ViewModel {
|
||||
userId: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
@@ -10,6 +12,7 @@ export class SessionViewModel {
|
||||
isAuthenticated: boolean = true;
|
||||
|
||||
constructor(dto: AuthenticatedUserDTO) {
|
||||
super();
|
||||
this.userId = dto.userId;
|
||||
this.email = dto.email;
|
||||
this.displayName = dto.displayName;
|
||||
|
||||
@@ -5,7 +5,9 @@ import { SponsorDashboardDTO } from '@/lib/types/generated/SponsorDashboardDTO';
|
||||
*
|
||||
* Represents dashboard data for a sponsor with UI-specific transformations.
|
||||
*/
|
||||
export class SponsorDashboardViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class SponsorDashboardViewModel extends ViewModel {
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* View model for sponsor settings data.
|
||||
*/
|
||||
export class SponsorSettingsViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class SponsorSettingsViewModel extends ViewModel {
|
||||
profile: SponsorProfileViewModel;
|
||||
notifications: NotificationSettingsViewModel;
|
||||
privacy: PrivacySettingsViewModel;
|
||||
@@ -15,7 +17,9 @@ export class SponsorSettingsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
export class SponsorProfileViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class SponsorProfileViewModel extends ViewModel {
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
contactEmail: string;
|
||||
@@ -58,7 +62,9 @@ export class SponsorProfileViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
export class NotificationSettingsViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class NotificationSettingsViewModel extends ViewModel {
|
||||
emailNewSponsorships: boolean;
|
||||
emailWeeklyReport: boolean;
|
||||
emailRaceAlerts: boolean;
|
||||
@@ -78,7 +84,9 @@ export class NotificationSettingsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivacySettingsViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class PrivacySettingsViewModel extends ViewModel {
|
||||
publicProfile: boolean;
|
||||
showStats: boolean;
|
||||
showActiveSponsorships: boolean;
|
||||
|
||||
@@ -6,7 +6,9 @@ import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
|
||||
*
|
||||
* View model for sponsor sponsorships data with UI-specific transformations.
|
||||
*/
|
||||
export class SponsorSponsorshipsViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class SponsorSponsorshipsViewModel extends ViewModel {
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ interface SponsorDTO {
|
||||
websiteUrl?: string;
|
||||
}
|
||||
|
||||
export class SponsorViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class SponsorViewModel extends ViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
declare logoUrl?: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SponsorshipDetailDTO } from '@/lib/types/generated/SponsorshipDetailDTO';
|
||||
|
||||
export class SponsorshipDetailViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class SponsorshipDetailViewModel extends ViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
|
||||
@@ -10,7 +10,9 @@ interface SponsorshipPricingDTO {
|
||||
*
|
||||
* View model for sponsorship pricing data with UI-specific transformations.
|
||||
*/
|
||||
export class SponsorshipPricingViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class SponsorshipPricingViewModel extends ViewModel {
|
||||
mainSlotPrice: number;
|
||||
secondarySlotPrice: number;
|
||||
currency: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { SponsorshipRequestDTO } from '@/lib/types/generated/SponsorshipRequestDTO';
|
||||
|
||||
export class SponsorshipRequestViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class SponsorshipRequestViewModel extends ViewModel {
|
||||
id: string;
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
|
||||
@@ -31,7 +31,9 @@ export interface SponsorshipDataInput {
|
||||
*
|
||||
* View model for individual sponsorship data.
|
||||
*/
|
||||
export class SponsorshipViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class SponsorshipViewModel extends ViewModel {
|
||||
id: string;
|
||||
type: 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
|
||||
entityId: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO';
|
||||
|
||||
export class StandingEntryViewModel {
|
||||
import { ViewModel } from "../contracts/view-models/ViewModel";
|
||||
|
||||
export class StandingEntryViewModel extends ViewModel {
|
||||
driverId: string;
|
||||
position: number;
|
||||
points: number;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user