view data fixes
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import type { DashboardStats } from '@/lib/types/admin';
|
||||
import type { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
|
||||
|
||||
@@ -7,7 +8,14 @@ import type { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewD
|
||||
* Transforms DashboardStats API DTO into AdminDashboardViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class AdminDashboardViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class AdminDashboardViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return AdminDashboardViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: DashboardStats): AdminDashboardViewData {
|
||||
return {
|
||||
stats: {
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import type { UserListResponse } from '@/lib/types/admin';
|
||||
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
||||
|
||||
/**
|
||||
* AdminUsersViewDataBuilder
|
||||
*
|
||||
* Server-side builder that transforms API DTO
|
||||
* into ViewData for the AdminUsersTemplate.
|
||||
*
|
||||
* Deterministic, side-effect free.
|
||||
*/
|
||||
export class AdminUsersViewDataBuilder {
|
||||
static build(apiDto: UserListResponse): AdminUsersViewData {
|
||||
const users = apiDto.users.map(user => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
roles: user.roles,
|
||||
status: user.status,
|
||||
isSystemAdmin: user.isSystemAdmin,
|
||||
createdAt: typeof user.createdAt === 'string' ? user.createdAt : (user.createdAt as unknown as Date).toISOString(),
|
||||
updatedAt: typeof user.updatedAt === 'string' ? user.updatedAt : (user.updatedAt as unknown as Date).toISOString(),
|
||||
lastLoginAt: user.lastLoginAt ? (typeof user.lastLoginAt === 'string' ? user.lastLoginAt : (user.lastLoginAt as unknown as Date).toISOString()) : undefined,
|
||||
primaryDriverId: user.primaryDriverId,
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class AdminUsersViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return AdminUsersViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
public static build(apiDto: UserListResponse): AdminUsersViewData {
|
||||
const users = apiDto.users.map(u => ({
|
||||
...u,
|
||||
joinedAt: new Date(u.joinedAt),
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -35,4 +27,4 @@ export class AdminUsersViewDataBuilder {
|
||||
adminCount: users.filter(u => u.isSystemAdmin).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AnalyticsDashboardViewDataBuilder } from './AnalyticsDashboardViewDataBuilder';
|
||||
import { AnalyticsDashboardInputViewData } from '@/lib/view-data/AnalyticsDashboardInputViewData';
|
||||
|
||||
describe('AnalyticsDashboardViewDataBuilder', () => {
|
||||
it('builds ViewData from AnalyticsDashboardInputViewData', () => {
|
||||
const inputViewData: AnalyticsDashboardInputViewData = {
|
||||
totalUsers: 100,
|
||||
activeUsers: 40,
|
||||
totalRaces: 10,
|
||||
totalLeagues: 5,
|
||||
};
|
||||
|
||||
const viewData = AnalyticsDashboardViewDataBuilder.build(inputViewData);
|
||||
|
||||
expect(viewData.metrics.totalUsers).toBe(100);
|
||||
expect(viewData.metrics.activeUsers).toBe(40);
|
||||
expect(viewData.metrics.totalRaces).toBe(10);
|
||||
expect(viewData.metrics.totalLeagues).toBe(5);
|
||||
expect(viewData.metrics.userEngagementRate).toBeCloseTo(40);
|
||||
expect(viewData.metrics.formattedEngagementRate).toBe('40.0%');
|
||||
expect(viewData.metrics.activityLevel).toBe('Low');
|
||||
});
|
||||
|
||||
it('computes engagement rate and formatted engagement rate', () => {
|
||||
const inputViewData: AnalyticsDashboardInputViewData = {
|
||||
totalUsers: 200,
|
||||
activeUsers: 50,
|
||||
totalRaces: 0,
|
||||
totalLeagues: 0,
|
||||
};
|
||||
|
||||
const viewData = AnalyticsDashboardViewDataBuilder.build(inputViewData);
|
||||
|
||||
expect(viewData.metrics.userEngagementRate).toBeCloseTo(25);
|
||||
expect(viewData.metrics.formattedEngagementRate).toBe('25.0%');
|
||||
});
|
||||
|
||||
it('handles zero users safely', () => {
|
||||
const inputViewData: AnalyticsDashboardInputViewData = {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
totalRaces: 0,
|
||||
totalLeagues: 0,
|
||||
};
|
||||
|
||||
const viewData = AnalyticsDashboardViewDataBuilder.build(inputViewData);
|
||||
|
||||
expect(viewData.metrics.userEngagementRate).toBe(0);
|
||||
expect(viewData.metrics.formattedEngagementRate).toBe('0.0%');
|
||||
expect(viewData.metrics.activityLevel).toBe('Low');
|
||||
});
|
||||
|
||||
it('derives activity level buckets from engagement rate', () => {
|
||||
const low = AnalyticsDashboardViewDataBuilder.build({
|
||||
totalUsers: 100,
|
||||
activeUsers: 30,
|
||||
totalRaces: 0,
|
||||
totalLeagues: 0,
|
||||
});
|
||||
const medium = AnalyticsDashboardViewDataBuilder.build({
|
||||
totalUsers: 100,
|
||||
activeUsers: 50,
|
||||
totalRaces: 0,
|
||||
totalLeagues: 0,
|
||||
});
|
||||
const high = AnalyticsDashboardViewDataBuilder.build({
|
||||
totalUsers: 100,
|
||||
activeUsers: 90,
|
||||
totalRaces: 0,
|
||||
totalLeagues: 0,
|
||||
});
|
||||
|
||||
expect(low.metrics.activityLevel).toBe('Low');
|
||||
expect(medium.metrics.activityLevel).toBe('Medium');
|
||||
expect(high.metrics.activityLevel).toBe('High');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AnalyticsDashboardInputViewData } from '@/lib/view-data/AnalyticsDashboardInputViewData';
|
||||
import { AnalyticsDashboardViewData } from '@/lib/view-data/AnalyticsDashboardViewData';
|
||||
|
||||
/**
|
||||
* AnalyticsDashboardViewDataBuilder
|
||||
*
|
||||
* Transforms AnalyticsDashboardInputViewData into AnalyticsDashboardViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class AnalyticsDashboardViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return AnalyticsDashboardViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(viewData: AnalyticsDashboardInputViewData): AnalyticsDashboardViewData {
|
||||
const userEngagementRate = viewData.totalUsers > 0 ? (viewData.activeUsers / viewData.totalUsers) * 100 : 0;
|
||||
const formattedEngagementRate = `${userEngagementRate.toFixed(1)}%`;
|
||||
const activityLevel = userEngagementRate > 70 ? 'High' : userEngagementRate > 40 ? 'Medium' : 'Low';
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
totalUsers: viewData.totalUsers,
|
||||
activeUsers: viewData.activeUsers,
|
||||
totalRaces: viewData.totalRaces,
|
||||
totalLeagues: viewData.totalLeagues,
|
||||
userEngagementRate,
|
||||
formattedEngagementRate,
|
||||
activityLevel,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,14 @@
|
||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
import { AvatarViewData } from '@/lib/view-data/AvatarViewData';
|
||||
|
||||
export class AvatarViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class AvatarViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return AvatarViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: MediaBinaryDTO): AvatarViewData {
|
||||
return {
|
||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
import { CategoryIconViewData } from '@/lib/view-data/CategoryIconViewData';
|
||||
|
||||
export class CategoryIconViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class CategoryIconViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return CategoryIconViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: MediaBinaryDTO): CategoryIconViewData {
|
||||
return {
|
||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface CompleteOnboardingViewData {
|
||||
import { ViewData } from "@/lib/contracts/view-data/ViewData";
|
||||
|
||||
export interface CompleteOnboardingViewData extends ViewData {
|
||||
success: boolean;
|
||||
driverId?: string;
|
||||
errorMessage?: string;
|
||||
|
||||
@@ -6,8 +6,16 @@
|
||||
|
||||
import { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
|
||||
import { CompleteOnboardingViewData } from './CompleteOnboardingViewData';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export class CompleteOnboardingViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class CompleteOnboardingViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return CompleteOnboardingViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
/**
|
||||
* Transform DTO into ViewData
|
||||
*
|
||||
|
||||
@@ -6,6 +6,8 @@ import { DashboardRankDisplay } from '@/lib/display-objects/DashboardRankDisplay
|
||||
import { DashboardConsistencyDisplay } from '@/lib/display-objects/DashboardConsistencyDisplay';
|
||||
import { DashboardCountDisplay } from '@/lib/display-objects/DashboardCountDisplay';
|
||||
import { DashboardLeaguePositionDisplay } from '@/lib/display-objects/DashboardLeaguePositionDisplay';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import { number } from 'zod';
|
||||
|
||||
/**
|
||||
* DashboardViewDataBuilder
|
||||
@@ -13,7 +15,14 @@ import { DashboardLeaguePositionDisplay } from '@/lib/display-objects/DashboardL
|
||||
* Transforms DashboardOverviewDTO (API DTO) into DashboardViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class DashboardViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class DashboardViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return DashboardViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: DashboardOverviewDTO): DashboardViewData {
|
||||
return {
|
||||
currentDriver: {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ViewData } from "@/lib/contracts/view-data/ViewData";
|
||||
|
||||
export interface DeleteMediaViewData extends ViewData {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DeleteMediaViewDataBuilder } from './DeleteMediaViewDataBuilder';
|
||||
import type { DeleteMediaOutputDTO } from '@/lib/types/generated/DeleteMediaOutputDTO';
|
||||
|
||||
describe('DeleteMediaViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform successful deletion DTO to ViewData correctly', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle deletion with error message', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: 'Failed to delete media',
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Failed to delete media',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle deletion with only success field', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: 'Something went wrong',
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(apiDto.success);
|
||||
expect(result.error).toBe(apiDto.error);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: 'Error',
|
||||
};
|
||||
|
||||
const originalDto = { ...apiDto };
|
||||
DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(apiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle false success value', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: 'Error occurred',
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Error occurred');
|
||||
});
|
||||
|
||||
it('should handle empty string error message', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: '',
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('');
|
||||
});
|
||||
|
||||
it('should handle very long error message', () => {
|
||||
const longError = 'Error: ' + 'a'.repeat(1000);
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: longError,
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.error).toBe(longError);
|
||||
});
|
||||
|
||||
it('should handle special characters in error message', () => {
|
||||
const apiDto: DeleteMediaOutputDTO = {
|
||||
success: false,
|
||||
error: 'Error: "Failed to delete media" (code: 500)',
|
||||
};
|
||||
|
||||
const result = DeleteMediaViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.error).toBe('Error: "Failed to delete media" (code: 500)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* DeleteMedia ViewData Builder
|
||||
*
|
||||
* Transforms media deletion result into ViewData for templates.
|
||||
*/
|
||||
|
||||
import { DeleteMediaOutputDTO } from '@/lib/types/generated/DeleteMediaOutputDTO';
|
||||
import { DeleteMediaViewData } from './DeleteMediaViewData';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class DeleteMediaViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return DeleteMediaViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform DTO into ViewData
|
||||
*
|
||||
* @param apiDto - The API DTO to transform
|
||||
* @returns ViewData for templates
|
||||
*/
|
||||
static build(apiDto: DeleteMediaOutputDTO): DeleteMediaViewData {
|
||||
return {
|
||||
success: apiDto.success,
|
||||
error: apiDto.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
||||
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
|
||||
import type { DriverProfileViewData } from '@/lib/view-data/DriverProfileViewData';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
@@ -12,7 +12,14 @@ import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
|
||||
* Transforms GetDriverProfileOutputDTO into ViewData for the driver profile page.
|
||||
* Deterministic, side-effect free, no HTTP calls.
|
||||
*/
|
||||
export class DriverProfileViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class DriverProfileViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return DriverProfileViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: GetDriverProfileOutputDTO): DriverProfileViewData {
|
||||
return {
|
||||
currentDriver: apiDto.currentDriver ? {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
|
||||
import { WinRateDisplay } from '@/lib/display-objects/WinRateDisplay';
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export class DriverRankingsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class DriverRankingsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return DriverRankingsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: DriverLeaderboardItemDTO[]): DriverRankingsViewData {
|
||||
if (!apiDto || apiDto.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
|
||||
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
|
||||
import type { DriversViewData } from '@/lib/view-data/DriversViewData';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
|
||||
export class DriversViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class DriversViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return DriversViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(dto: DriversLeaderboardDTO): DriversViewData {
|
||||
return {
|
||||
drivers: dto.drivers.map(driver => ({
|
||||
|
||||
@@ -6,9 +6,18 @@
|
||||
*/
|
||||
|
||||
import { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
|
||||
import { ForgotPasswordViewData } from './types/ForgotPasswordViewData';
|
||||
import { ForgotPasswordViewData } from '../../view-data/ForgotPasswordViewData';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import { error } from 'console';
|
||||
|
||||
export class ForgotPasswordViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class ForgotPasswordViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return ForgotPasswordViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: ForgotPasswordPageDTO): ForgotPasswordViewData {
|
||||
return {
|
||||
returnTo: apiDto.returnTo,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface GenerateAvatarsViewData {
|
||||
import { ViewData } from "@/lib/contracts/view-data/ViewData";
|
||||
|
||||
export interface GenerateAvatarsViewData extends ViewData {
|
||||
success: boolean;
|
||||
avatarUrls: string[];
|
||||
errorMessage?: string;
|
||||
|
||||
@@ -7,8 +7,16 @@
|
||||
|
||||
import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
|
||||
import { GenerateAvatarsViewData } from './GenerateAvatarsViewData';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export class GenerateAvatarsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class GenerateAvatarsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return GenerateAvatarsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
/**
|
||||
* Transform DTO into ViewData
|
||||
*
|
||||
|
||||
@@ -37,7 +37,14 @@ export interface HealthDTO {
|
||||
}>;
|
||||
}
|
||||
|
||||
export class HealthViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class HealthViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return HealthViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(dto: HealthDTO): HealthViewData {
|
||||
const now = new Date();
|
||||
const lastUpdated = dto.timestamp || now.toISOString();
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import type { HomeViewData } from '@/templates/HomeTemplate';
|
||||
import type { HomeDataDTO } from '@/lib/types/dtos/HomeDataDTO';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
/**
|
||||
* HomeViewDataBuilder
|
||||
*
|
||||
* Transforms HomeDataDTO to HomeViewData.
|
||||
*/
|
||||
export class HomeViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class HomeViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return HomeViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
/**
|
||||
* Build HomeViewData from HomeDataDTO
|
||||
*
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
import type { GetTeamsLeaderboardOutputDTO } from '@/lib/types/generated/GetTeamsLeaderboardOutputDTO';
|
||||
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||
|
||||
export class LeaderboardsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeaderboardsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeaderboardsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(
|
||||
apiDto: { drivers: { drivers: DriverLeaderboardItemDTO[] }; teams: GetTeamsLeaderboardOutputDTO }
|
||||
): LeaderboardsViewData {
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
import { LeagueCoverViewData } from '@/lib/view-data/LeagueCoverViewData';
|
||||
|
||||
export class LeagueCoverViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueCoverViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueCoverViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: MediaBinaryDTO): LeagueCoverViewData {
|
||||
return {
|
||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
||||
|
||||
@@ -11,7 +11,14 @@ import type { LeagueDetailViewData, LeagueInfoData, LiveRaceData, DriverSummaryD
|
||||
* Transforms API DTOs into LeagueDetailViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class LeagueDetailViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueDetailViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueDetailViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(input: {
|
||||
league: LeagueWithCapacityAndScoringDTO;
|
||||
owner: GetDriverOutputDTO | null;
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
import { LeagueLogoViewData } from '@/lib/view-data/LeagueLogoViewData';
|
||||
|
||||
export class LeagueLogoViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueLogoViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueLogoViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: MediaBinaryDTO): LeagueLogoViewData {
|
||||
return {
|
||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
||||
|
||||
@@ -9,7 +9,14 @@ import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
* Transforms API DTOs into LeagueRosterAdminViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class LeagueRosterAdminViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueRosterAdminViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueRosterAdminViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(input: {
|
||||
leagueId: string;
|
||||
members: LeagueRosterMemberDTO[];
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleViewData';
|
||||
import { LeagueScheduleApiDto } from '@/lib/types/tbd/LeagueScheduleApiDto';
|
||||
|
||||
export class LeagueScheduleViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueScheduleViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueScheduleViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: LeagueScheduleApiDto, currentDriverId?: string, isAdmin: boolean = false): LeagueScheduleViewData {
|
||||
const now = new Date();
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { LeagueSettingsViewData } from '@/lib/view-data/leagues/LeagueSettingsViewData';
|
||||
import { LeagueSettingsApiDto } from '@/lib/types/tbd/LeagueSettingsApiDto';
|
||||
import { LeagueSettingsViewData } from '@/lib/view-data/LeagueSettingsViewData';
|
||||
|
||||
export class LeagueSettingsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueSettingsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueSettingsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: LeagueSettingsApiDto): LeagueSettingsViewData {
|
||||
return {
|
||||
leagueId: apiDto.leagueId,
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { LeagueSponsorshipsViewData } from '@/lib/view-data/leagues/LeagueSponsorshipsViewData';
|
||||
import { LeagueSponsorshipsApiDto } from '@/lib/types/tbd/LeagueSponsorshipsApiDto';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { StatusDisplay } from '@/lib/display-objects/StatusDisplay';
|
||||
import { LeagueSponsorshipsApiDto } from '@/lib/types/tbd/LeagueSponsorshipsApiDto';
|
||||
import { LeagueSponsorshipsViewData } from '@/lib/view-data/LeagueSponsorshipsViewData';
|
||||
|
||||
export class LeagueSponsorshipsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueSponsorshipsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueSponsorshipsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: LeagueSponsorshipsApiDto): LeagueSponsorshipsViewData {
|
||||
return {
|
||||
leagueId: apiDto.leagueId,
|
||||
|
||||
@@ -16,7 +16,14 @@ interface LeagueMembershipsApiDto {
|
||||
* Transforms API DTOs into LeagueStandingsViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class LeagueStandingsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueStandingsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueStandingsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(
|
||||
standingsDto: LeagueStandingsApiDto,
|
||||
membershipsDto: LeagueMembershipsApiDto,
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { LeagueWalletViewData, LeagueWalletTransactionViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
|
||||
import { LeagueWalletApiDto } from '@/lib/types/tbd/LeagueWalletApiDto';
|
||||
import { CurrencyDisplay } from '@/lib/display-objects/CurrencyDisplay';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { LeagueWalletApiDto } from '@/lib/types/tbd/LeagueWalletApiDto';
|
||||
import { LeagueWalletTransactionViewData, LeagueWalletViewData } from '@/lib/view-data/LeagueWalletViewData';
|
||||
|
||||
export class LeagueWalletViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeagueWalletViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueWalletViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: LeagueWalletApiDto): LeagueWalletViewData {
|
||||
const transactions: LeagueWalletTransactionViewData[] = apiDto.transactions.map(t => ({
|
||||
...t,
|
||||
|
||||
@@ -7,7 +7,14 @@ import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
* Transforms AllLeaguesWithCapacityAndScoringDTO (API DTO) into LeaguesViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class LeaguesViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LeaguesViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeaguesViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: AllLeaguesWithCapacityAndScoringDTO): LeaguesViewData {
|
||||
return {
|
||||
leagues: apiDto.leagues.map((league) => ({
|
||||
|
||||
@@ -6,9 +6,18 @@
|
||||
*/
|
||||
|
||||
import { LoginPageDTO } from '@/lib/services/auth/types/LoginPageDTO';
|
||||
import { LoginViewData } from './types/LoginViewData';
|
||||
import { LoginViewData } from '../../view-data/LoginViewData';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import { error } from 'console';
|
||||
|
||||
export class LoginViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class LoginViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LoginViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: LoginPageDTO): LoginViewData {
|
||||
return {
|
||||
returnTo: apiDto.returnTo,
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
|
||||
import { OnboardingPageViewData } from '@/lib/view-data/OnboardingPageViewData';
|
||||
|
||||
export class OnboardingPageViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class OnboardingPageViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return OnboardingPageViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
/**
|
||||
* Transform driver data into ViewData
|
||||
*
|
||||
|
||||
@@ -9,7 +9,14 @@ import { Result } from '@/lib/contracts/Result';
|
||||
import { PresentationError } from '@/lib/contracts/page-queries/PresentationError';
|
||||
import { OnboardingPageViewData } from '@/lib/view-data/OnboardingPageViewData';
|
||||
|
||||
export class OnboardingViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class OnboardingViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return OnboardingViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: Result<{ isAlreadyOnboarded: boolean }, PresentationError>): Result<OnboardingPageViewData, PresentationError> {
|
||||
if (apiDto.isErr()) {
|
||||
return Result.err(apiDto.getError());
|
||||
|
||||
@@ -19,7 +19,14 @@ interface ProfileLeaguesPageDto {
|
||||
* ViewData Builder for Profile Leagues page
|
||||
* Transforms Page DTO to ViewData for templates
|
||||
*/
|
||||
export class ProfileLeaguesViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class ProfileLeaguesViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return ProfileLeaguesViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: ProfileLeaguesPageDto): ProfileLeaguesViewData {
|
||||
return {
|
||||
ownedLeagues: apiDto.ownedLeagues.map((league: { leagueId: string; name: string; description: string; membershipRole: 'owner' | 'admin' | 'steward' | 'member'; }) => ({
|
||||
|
||||
@@ -8,7 +8,14 @@ import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
|
||||
export class ProfileViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class ProfileViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return ProfileViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: GetDriverProfileOutputDTO): ProfileViewData {
|
||||
const driver = apiDto.currentDriver;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProtestDetailViewData } from '@/lib/view-data/leagues/ProtestDetailViewData';
|
||||
import { ProtestDetailViewData } from '@/lib/view-data/ProtestDetailViewData';
|
||||
|
||||
interface ProtestDetailApiDto {
|
||||
id: string;
|
||||
@@ -29,7 +29,14 @@ interface ProtestDetailApiDto {
|
||||
}>;
|
||||
}
|
||||
|
||||
export class ProtestDetailViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class ProtestDetailViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return ProtestDetailViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: ProtestDetailApiDto): ProtestDetailViewData {
|
||||
return {
|
||||
protestId: apiDto.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RaceDetailViewData, RaceDetailRace, RaceDetailLeague, RaceDetailEntry, RaceDetailRegistration, RaceDetailUserResult } from '@/lib/view-data/races/RaceDetailViewData';
|
||||
import { RaceDetailEntry, RaceDetailLeague, RaceDetailRace, RaceDetailRegistration, RaceDetailUserResult, RaceDetailViewData } from '@/lib/view-data/RaceDetailViewData';
|
||||
|
||||
/**
|
||||
* Race Detail View Data Builder
|
||||
@@ -6,7 +6,14 @@ import { RaceDetailViewData, RaceDetailRace, RaceDetailLeague, RaceDetailEntry,
|
||||
* Transforms API DTO into ViewData for the race detail template.
|
||||
* Deterministic, side-effect free.
|
||||
*/
|
||||
export class RaceDetailViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class RaceDetailViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return RaceDetailViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: any): RaceDetailViewData {
|
||||
if (!apiDto || !apiDto.race) {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RaceResultsViewData, RaceResultsResult, RaceResultsPenalty } from '@/lib/view-data/races/RaceResultsViewData';
|
||||
import { RaceResultsPenalty, RaceResultsResult, RaceResultsViewData } from '@/lib/view-data/RaceResultsViewData';
|
||||
|
||||
/**
|
||||
* Race Results View Data Builder
|
||||
@@ -6,7 +6,14 @@ import { RaceResultsViewData, RaceResultsResult, RaceResultsPenalty } from '@/li
|
||||
* Transforms API DTO into ViewData for the race results template.
|
||||
* Deterministic, side-effect free.
|
||||
*/
|
||||
export class RaceResultsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class RaceResultsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return RaceResultsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: unknown): RaceResultsViewData {
|
||||
if (!apiDto) {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RaceStewardingViewData, Protest, Penalty, Driver } from '@/lib/view-data/races/RaceStewardingViewData';
|
||||
import { Driver, Penalty, Protest, RaceStewardingViewData } from '@/lib/view-data/RaceStewardingViewData';
|
||||
|
||||
/**
|
||||
* Race Stewarding View Data Builder
|
||||
@@ -6,7 +6,14 @@ import { RaceStewardingViewData, Protest, Penalty, Driver } from '@/lib/view-dat
|
||||
* Transforms API DTO into ViewData for the race stewarding template.
|
||||
* Deterministic, side-effect free.
|
||||
*/
|
||||
export class RaceStewardingViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class RaceStewardingViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return RaceStewardingViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: unknown): RaceStewardingViewData {
|
||||
if (!apiDto) {
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,14 @@ import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { RaceStatusDisplay } from '@/lib/display-objects/RaceStatusDisplay';
|
||||
import { RelativeTimeDisplay } from '@/lib/display-objects/RelativeTimeDisplay';
|
||||
|
||||
export class RacesViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class RacesViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return RacesViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: RacesPageDataDTO): RacesViewData {
|
||||
const now = new Date();
|
||||
const races = apiDto.races.map((race): RaceViewData => {
|
||||
|
||||
@@ -6,9 +6,18 @@
|
||||
*/
|
||||
|
||||
import { ResetPasswordPageDTO } from '@/lib/services/auth/types/ResetPasswordPageDTO';
|
||||
import { ResetPasswordViewData } from './types/ResetPasswordViewData';
|
||||
import { ResetPasswordViewData } from '../../view-data/ResetPasswordViewData';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import { error } from 'console';
|
||||
|
||||
export class ResetPasswordViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class ResetPasswordViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return ResetPasswordViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: ResetPasswordPageDTO): ResetPasswordViewData {
|
||||
return {
|
||||
token: apiDto.token,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData';
|
||||
import { RulebookApiDto } from '@/lib/types/tbd/RulebookApiDto';
|
||||
import { RulebookViewData } from '@/lib/view-data/RulebookViewData';
|
||||
|
||||
export class RulebookViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class RulebookViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return RulebookViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: RulebookApiDto): RulebookViewData {
|
||||
const primaryChampionship = apiDto.scoringConfig.championships.find(c => c.type === 'driver') ?? apiDto.scoringConfig.championships[0];
|
||||
const positionPoints: { position: number; points: number }[] = primaryChampionship?.pointsPreview
|
||||
|
||||
@@ -6,9 +6,14 @@
|
||||
*/
|
||||
|
||||
import { SignupPageDTO } from '@/lib/services/auth/types/SignupPageDTO';
|
||||
import { SignupViewData } from './types/SignupViewData';
|
||||
import { SignupViewData } from '../../view-data/SignupViewData';
|
||||
import { ViewDataBuilder } from '../../contracts/builders/ViewDataBuilder';
|
||||
|
||||
export class SignupViewDataBuilder implements ViewDataBuilder<SignupPageDTO, SignupViewData> {
|
||||
build(apiDto: SignupPageDTO): SignupViewData {
|
||||
return SignupViewDataBuilder.build(apiDto);
|
||||
}
|
||||
|
||||
export class SignupViewDataBuilder {
|
||||
static build(apiDto: SignupPageDTO): SignupViewData {
|
||||
return {
|
||||
returnTo: apiDto.returnTo,
|
||||
|
||||
@@ -9,7 +9,14 @@ import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
* Transforms SponsorDashboardDTO into ViewData for templates.
|
||||
* Deterministic and side-effect free.
|
||||
*/
|
||||
export class SponsorDashboardViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class SponsorDashboardViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return SponsorDashboardViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: SponsorDashboardDTO): SponsorDashboardViewData {
|
||||
const totalInvestmentValue = apiDto.investment.activeSponsorships * 1000;
|
||||
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
import { SponsorLogoViewData } from '@/lib/view-data/SponsorLogoViewData';
|
||||
|
||||
export class SponsorLogoViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class SponsorLogoViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return SponsorLogoViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: MediaBinaryDTO): SponsorLogoViewData {
|
||||
return {
|
||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
||||
|
||||
@@ -5,7 +5,14 @@ import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipReq
|
||||
* ViewData Builder for Sponsorship Requests page
|
||||
* Transforms API DTO to ViewData for templates
|
||||
*/
|
||||
export class SponsorshipRequestsPageViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class SponsorshipRequestsPageViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return SponsorshipRequestsPageViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: GetPendingSponsorshipRequestsOutputDTO): SponsorshipRequestsViewData {
|
||||
return {
|
||||
sections: [{
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { GetPendingSponsorshipRequestsOutputDTO } from '@/lib/types/generated/GetPendingSponsorshipRequestsOutputDTO';
|
||||
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
|
||||
|
||||
export class SponsorshipRequestsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class SponsorshipRequestsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return SponsorshipRequestsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: GetPendingSponsorshipRequestsOutputDTO): SponsorshipRequestsViewData {
|
||||
return {
|
||||
sections: [
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { StewardingApiDto } from '@/lib/types/tbd/StewardingApiDto';
|
||||
import { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
|
||||
import { StewardingViewData } from '@/lib/view-data/StewardingViewData';
|
||||
|
||||
|
||||
export class StewardingViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class StewardingViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return StewardingViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: StewardingApiDto): StewardingViewData {
|
||||
return {
|
||||
leagueId: apiDto.leagueId,
|
||||
|
||||
@@ -9,7 +9,14 @@ import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
* TeamDetailViewDataBuilder - Transforms TeamDetailPageDto into ViewData
|
||||
* Deterministic; side-effect free; no HTTP calls
|
||||
*/
|
||||
export class TeamDetailViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class TeamDetailViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return TeamDetailViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: TeamDetailPageDto): TeamDetailViewData {
|
||||
const team: TeamDetailData = {
|
||||
id: apiDto.team.id,
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
import { TeamLogoViewData } from '@/lib/view-data/TeamLogoViewData';
|
||||
|
||||
export class TeamLogoViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class TeamLogoViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return TeamLogoViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: MediaBinaryDTO): TeamLogoViewData {
|
||||
return {
|
||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
import type { GetTeamsLeaderboardOutputDTO } from '@/lib/types/generated/GetTeamsLeaderboardOutputDTO';
|
||||
import type { TeamRankingsViewData } from '@/lib/view-data/TeamRankingsViewData';
|
||||
|
||||
export class TeamRankingsViewDataBuilder {
|
||||
static build(apiDto: GetTeamsLeaderboardOutputDTO): TeamRankingsViewData {
|
||||
const allTeams = apiDto.teams.map((team, index) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
memberCount: team.memberCount,
|
||||
category: undefined,
|
||||
totalWins: team.totalWins || 0,
|
||||
logoUrl: team.logoUrl || '',
|
||||
position: index + 1,
|
||||
isRecruiting: team.isRecruiting,
|
||||
performanceLevel: team.performanceLevel || 'N/A',
|
||||
rating: team.rating || 0,
|
||||
totalRaces: team.totalRaces || 0,
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class TeamRankingsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return TeamRankingsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
public static build(apiDto: GetTeamsLeaderboardOutputDTO): TeamRankingsViewData {
|
||||
const allTeams = apiDto.teams.map(t => ({
|
||||
...t,
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,14 @@ import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
* TeamsViewDataBuilder - Transforms TeamsPageDto into ViewData for TeamsTemplate
|
||||
* Deterministic; side-effect free; no HTTP calls
|
||||
*/
|
||||
export class TeamsViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class TeamsViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return TeamsViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: TeamsPageDto): TeamsViewData {
|
||||
const teams: TeamSummaryData[] = apiDto.teams.map((team: TeamListItemDTO): TeamSummaryData => ({
|
||||
teamId: team.id,
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
import { TrackImageViewData } from '@/lib/view-data/TrackImageViewData';
|
||||
|
||||
export class TrackImageViewDataBuilder {
|
||||
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
|
||||
|
||||
export class TrackImageViewDataBuilder implements ViewDataBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return TrackImageViewDataBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: MediaBinaryDTO): TrackImageViewData {
|
||||
return {
|
||||
buffer: Buffer.from(apiDto.buffer).toString('base64'),
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Forgot Password View Data
|
||||
*
|
||||
* ViewData for the forgot password template.
|
||||
*/
|
||||
|
||||
export interface ForgotPasswordViewData {
|
||||
returnTo: string;
|
||||
showSuccess: boolean;
|
||||
successMessage?: string;
|
||||
magicLink?: string;
|
||||
formState: any; // Will be managed by client component
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Login View Data
|
||||
*
|
||||
* ViewData for the login template.
|
||||
*/
|
||||
|
||||
import { FormState } from './FormState';
|
||||
|
||||
export interface LoginViewData {
|
||||
returnTo: string;
|
||||
hasInsufficientPermissions: boolean;
|
||||
showPassword: boolean;
|
||||
showErrorDetails: boolean;
|
||||
formState: FormState;
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Reset Password View Data
|
||||
*
|
||||
* ViewData for the reset password template.
|
||||
*/
|
||||
|
||||
export interface ResetPasswordViewData {
|
||||
token: string;
|
||||
returnTo: string;
|
||||
showSuccess: boolean;
|
||||
successMessage?: string;
|
||||
formState: any; // Will be managed by client component
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Signup View Data
|
||||
*
|
||||
* ViewData for the signup template.
|
||||
*/
|
||||
|
||||
export interface SignupViewData {
|
||||
returnTo: string;
|
||||
formState: any; // Will be managed by client component
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
}
|
||||
@@ -21,7 +21,14 @@ import type {
|
||||
* Transforms GetDriverProfileOutputDTO into DriverProfileViewModel.
|
||||
* Deterministic, side-effect free, no HTTP calls.
|
||||
*/
|
||||
export class DriverProfileViewModelBuilder {
|
||||
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
|
||||
|
||||
export class DriverProfileViewModelBuilder implements ViewModelBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return DriverProfileViewModelBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
/**
|
||||
* Build ViewModel from API DTO
|
||||
*
|
||||
|
||||
@@ -7,7 +7,14 @@ import { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardV
|
||||
* Transforms DriversLeaderboardDTO into DriverLeaderboardViewModel.
|
||||
* Deterministic, side-effect free, no HTTP calls.
|
||||
*/
|
||||
export class DriversViewModelBuilder {
|
||||
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
|
||||
|
||||
export class DriversViewModelBuilder implements ViewModelBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return DriversViewModelBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: DriversLeaderboardDTO): DriverLeaderboardViewModel {
|
||||
return new DriverLeaderboardViewModel({
|
||||
drivers: apiDto.drivers,
|
||||
|
||||
@@ -5,10 +5,16 @@
|
||||
* Deterministic, side-effect free, no business logic.
|
||||
*/
|
||||
|
||||
import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
|
||||
import { ForgotPasswordViewModel, ForgotPasswordFormState } from '@/lib/view-models/auth/ForgotPasswordViewModel';
|
||||
import { ForgotPasswordViewData } from '@/lib/view-data/ForgotPasswordViewData';
|
||||
import { ForgotPasswordFormState, ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel';
|
||||
|
||||
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
|
||||
|
||||
export class ForgotPasswordViewModelBuilder implements ViewModelBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return ForgotPasswordViewModelBuilder.build(input);
|
||||
}
|
||||
|
||||
export class ForgotPasswordViewModelBuilder {
|
||||
static build(viewData: ForgotPasswordViewData): ForgotPasswordViewModel {
|
||||
const formState: ForgotPasswordFormState = {
|
||||
fields: {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
import { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
|
||||
export class LeagueSummaryViewModelBuilder {
|
||||
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
|
||||
|
||||
export class LeagueSummaryViewModelBuilder implements ViewModelBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LeagueSummaryViewModelBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(league: LeaguesViewData['leagues'][number]): LeagueSummaryViewModel {
|
||||
return {
|
||||
id: league.id,
|
||||
|
||||
@@ -5,10 +5,16 @@
|
||||
* Deterministic, side-effect free, no business logic.
|
||||
*/
|
||||
|
||||
import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
|
||||
import { LoginViewModel, LoginFormState, LoginUIState } from '@/lib/view-models/auth/LoginViewModel';
|
||||
import { LoginViewData } from '@/lib/view-data/LoginViewData';
|
||||
import { LoginFormState, LoginUIState, LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
|
||||
|
||||
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
|
||||
|
||||
export class LoginViewModelBuilder implements ViewModelBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return LoginViewModelBuilder.build(input);
|
||||
}
|
||||
|
||||
export class LoginViewModelBuilder {
|
||||
static build(viewData: LoginViewData): LoginViewModel {
|
||||
const formState: LoginFormState = {
|
||||
fields: {
|
||||
|
||||
@@ -9,7 +9,14 @@ import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
import { OnboardingViewModel } from '@/lib/view-models/OnboardingViewModel';
|
||||
|
||||
export class OnboardingViewModelBuilder {
|
||||
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
|
||||
|
||||
export class OnboardingViewModelBuilder implements ViewModelBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return OnboardingViewModelBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(apiDto: { isAlreadyOnboarded: boolean }): Result<OnboardingViewModel, DomainError> {
|
||||
try {
|
||||
return Result.ok({
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
* Deterministic, side-effect free, no business logic.
|
||||
*/
|
||||
|
||||
import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
|
||||
import { ResetPasswordViewModel, ResetPasswordFormState, ResetPasswordUIState } from '@/lib/view-models/auth/ResetPasswordViewModel';
|
||||
import { ResetPasswordViewData } from '@/lib/view-data/ResetPasswordViewData';
|
||||
import { ResetPasswordFormState, ResetPasswordUIState, ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
|
||||
|
||||
export class ResetPasswordViewModelBuilder {
|
||||
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
|
||||
|
||||
export class ResetPasswordViewModelBuilder implements ViewModelBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return ResetPasswordViewModelBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(viewData: ResetPasswordViewData): ResetPasswordViewModel {
|
||||
const formState: ResetPasswordFormState = {
|
||||
fields: {
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
* Deterministic, side-effect free, no business logic.
|
||||
*/
|
||||
|
||||
import { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
|
||||
import { SignupViewModel, SignupFormState, SignupUIState } from '@/lib/view-models/auth/SignupViewModel';
|
||||
import { SignupViewData } from '@/lib/view-data/SignupViewData';
|
||||
import { SignupFormState, SignupUIState, SignupViewModel } from '@/lib/view-models/auth/SignupViewModel';
|
||||
|
||||
export class SignupViewModelBuilder {
|
||||
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
|
||||
|
||||
export class SignupViewModelBuilder implements ViewModelBuilder<any, any> {
|
||||
build(input: any): any {
|
||||
return SignupViewModelBuilder.build(input);
|
||||
}
|
||||
|
||||
static build(
|
||||
static build(viewData: SignupViewData): SignupViewModel {
|
||||
const formState: SignupFormState = {
|
||||
fields: {
|
||||
|
||||
@@ -19,9 +19,10 @@ export class AvatarDisplay {
|
||||
|
||||
/**
|
||||
* Determines if avatar data is valid for display.
|
||||
* Accepts base64-encoded string buffer.
|
||||
*/
|
||||
static hasValidData(buffer: ArrayBuffer, contentType: string): boolean {
|
||||
return buffer.byteLength > 0 && contentType.length > 0;
|
||||
static hasValidData(buffer: string, contentType: string): boolean {
|
||||
return buffer.length > 0 && contentType.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* LeagueCreationStatusDisplay
|
||||
*
|
||||
* Deterministic mapping of league creation status to display messages.
|
||||
*/
|
||||
|
||||
export class LeagueCreationStatusDisplay {
|
||||
/**
|
||||
* Maps league creation success status to display message.
|
||||
*/
|
||||
static statusMessage(success: boolean): string {
|
||||
return success ? 'League created successfully!' : 'Failed to create league.';
|
||||
}
|
||||
}
|
||||
15
apps/website/lib/display-objects/RatingTrendDisplay.ts
Normal file
15
apps/website/lib/display-objects/RatingTrendDisplay.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export class RatingTrendDisplay {
|
||||
static getTrend(currentRating: number, previousRating: number | undefined): 'up' | 'down' | 'same' {
|
||||
if (!previousRating) return 'same';
|
||||
if (currentRating > previousRating) return 'up';
|
||||
if (currentRating < previousRating) return 'down';
|
||||
return 'same';
|
||||
}
|
||||
|
||||
static getChangeIndicator(currentRating: number, previousRating: number | undefined): string {
|
||||
const change = previousRating ? currentRating - previousRating : 0;
|
||||
if (change > 0) return `+${change}`;
|
||||
if (change < 0) return `${change}`;
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
11
apps/website/lib/display-objects/SkillLevelIconDisplay.ts
Normal file
11
apps/website/lib/display-objects/SkillLevelIconDisplay.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export class SkillLevelIconDisplay {
|
||||
static getIcon(skillLevel: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
beginner: '🥉',
|
||||
intermediate: '🥈',
|
||||
advanced: '🥇',
|
||||
expert: '👑',
|
||||
};
|
||||
return icons[skillLevel] || '🏁';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* TeamCreationStatusDisplay
|
||||
*
|
||||
* Deterministic mapping of team creation status to display messages.
|
||||
*/
|
||||
|
||||
export class TeamCreationStatusDisplay {
|
||||
/**
|
||||
* Maps team creation success status to display message.
|
||||
*/
|
||||
static statusMessage(success: boolean): string {
|
||||
return success ? 'Team created successfully!' : 'Failed to create team.';
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,21 @@ export interface AvailableLeaguesDTO {
|
||||
export interface AvailableLeagueDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
game: string;
|
||||
description: string;
|
||||
drivers: number;
|
||||
avgViewsPerRace: number;
|
||||
mainSponsorSlot: {
|
||||
available: boolean;
|
||||
price: number;
|
||||
};
|
||||
secondarySlots: {
|
||||
available: number;
|
||||
total: number;
|
||||
price: number;
|
||||
};
|
||||
cpm: number;
|
||||
season: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
rating: number;
|
||||
tier: 'premium' | 'standard' | 'starter';
|
||||
nextRace?: string;
|
||||
seasonStatus: 'active' | 'upcoming' | 'completed';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ViewData } from "../contracts/view-data/ViewData";
|
||||
|
||||
export interface AnalyticsDashboardInputViewData extends ViewData {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
totalRaces: number;
|
||||
totalLeagues: number;
|
||||
}
|
||||
8
apps/website/lib/view-data/AnalyticsMetricsViewData.ts
Normal file
8
apps/website/lib/view-data/AnalyticsMetricsViewData.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ViewData } from "../contracts/view-data/ViewData";
|
||||
|
||||
export interface AnalyticsMetricsViewData extends ViewData {
|
||||
pageViews: number;
|
||||
uniqueVisitors: number;
|
||||
averageSessionDuration: number;
|
||||
bounceRate: number;
|
||||
}
|
||||
47
apps/website/lib/view-data/BillingViewData.ts
Normal file
47
apps/website/lib/view-data/BillingViewData.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ViewData } from "../contracts/view-data/ViewData";
|
||||
|
||||
|
||||
export interface BillingViewData extends ViewData {
|
||||
paymentMethods: Array<{
|
||||
id: string;
|
||||
type: 'card' | 'bank' | 'sepa';
|
||||
last4: string;
|
||||
brand?: string;
|
||||
isDefault: boolean;
|
||||
expiryMonth?: number;
|
||||
expiryYear?: number;
|
||||
bankName?: string;
|
||||
displayLabel: string;
|
||||
expiryDisplay: string | null;
|
||||
}>;
|
||||
invoices: Array<{
|
||||
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;
|
||||
}>;
|
||||
stats: {
|
||||
totalSpent: number;
|
||||
pendingAmount: number;
|
||||
nextPaymentDate: string;
|
||||
nextPaymentAmount: number;
|
||||
activeSponsorships: number;
|
||||
averageMonthlySpend: number;
|
||||
formattedTotalSpent: string;
|
||||
formattedPendingAmount: string;
|
||||
formattedNextPaymentAmount: string;
|
||||
formattedAverageMonthlySpend: string;
|
||||
formattedNextPaymentDate: string;
|
||||
};
|
||||
}
|
||||
14
apps/website/lib/view-data/CreateTeamViewData.ts
Normal file
14
apps/website/lib/view-data/CreateTeamViewData.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ViewData } from '../contracts/view-data/ViewData';
|
||||
|
||||
/**
|
||||
* CreateTeamViewData
|
||||
*
|
||||
* ViewData for the create team result page.
|
||||
* Contains only raw serializable data, no methods or computed properties
|
||||
*/
|
||||
|
||||
export interface CreateTeamViewData extends ViewData {
|
||||
teamId: string;
|
||||
success: boolean;
|
||||
successMessage: string;
|
||||
}
|
||||
38
apps/website/lib/view-data/DashboardStatsViewData.ts
Normal file
38
apps/website/lib/view-data/DashboardStatsViewData.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
/**
|
||||
* DashboardStatsViewData
|
||||
*
|
||||
* ViewData for DashboardStatsViewModel.
|
||||
* Template-ready data structure with only primitives.
|
||||
*/
|
||||
|
||||
export interface DashboardStatsViewData extends ViewData {
|
||||
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;
|
||||
}[];
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user