website refactor
This commit is contained in:
47
apps/website/lib/view-models/AdminViewModelPresenter.ts
Normal file
47
apps/website/lib/view-models/AdminViewModelPresenter.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import type { UserDto, DashboardStats, UserListResponse } from '@/lib/api/admin/AdminApiClient';
|
||||
import { AdminUserViewModel, DashboardStatsViewModel, UserListViewModel } from './AdminUserViewModel';
|
||||
|
||||
/**
|
||||
* AdminViewModelPresenter
|
||||
*
|
||||
* Presenter layer for transforming API DTOs to ViewModels.
|
||||
* Runs in client code only ('use client').
|
||||
* Deterministic, side-effect free transformations.
|
||||
*/
|
||||
export class AdminViewModelPresenter {
|
||||
/**
|
||||
* Map a single user DTO to a View Model
|
||||
*/
|
||||
static mapUser(apiDto: UserDto): AdminUserViewModel {
|
||||
return new AdminUserViewModel(apiDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an array of user DTOs to View Models
|
||||
*/
|
||||
static mapUsers(apiDtos: UserDto[]): AdminUserViewModel[] {
|
||||
return apiDtos.map(apiDto => this.mapUser(apiDto));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map dashboard stats DTO to View Model
|
||||
*/
|
||||
static mapDashboardStats(apiDto: DashboardStats): DashboardStatsViewModel {
|
||||
return new DashboardStatsViewModel(apiDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map user list response to View Model
|
||||
*/
|
||||
static mapUserList(viewData: UserListResponse): UserListViewModel {
|
||||
return new UserListViewModel({
|
||||
users: viewData.users,
|
||||
total: viewData.total,
|
||||
page: viewData.page,
|
||||
limit: viewData.limit,
|
||||
totalPages: viewData.totalPages,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DashboardOverviewViewModel,
|
||||
DriverViewModel,
|
||||
RaceViewModel,
|
||||
LeagueStandingViewModel,
|
||||
DashboardFeedItemSummaryViewModel,
|
||||
FriendViewModel,
|
||||
} from './DashboardOverviewViewModel';
|
||||
import type { DashboardOverviewDto } from '../api/dashboard/DashboardApiClient';
|
||||
|
||||
const createDashboardOverviewDto = (): DashboardOverviewDto => ({
|
||||
currentDriver: {
|
||||
id: 'driver-1',
|
||||
name: 'Test Driver',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
country: 'DE',
|
||||
totalRaces: 10,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
rating: 2500,
|
||||
globalRank: 42,
|
||||
consistency: 88,
|
||||
},
|
||||
nextRace: {
|
||||
id: 'race-1',
|
||||
track: 'Spa-Francorchamps',
|
||||
car: 'GT3',
|
||||
scheduledAt: '2025-01-01T12:00:00Z',
|
||||
isMyLeague: true,
|
||||
leagueName: 'Pro League',
|
||||
},
|
||||
upcomingRaces: [
|
||||
{
|
||||
id: 'race-2',
|
||||
track: 'Nürburgring',
|
||||
car: 'GT4',
|
||||
scheduledAt: '2025-01-02T12:00:00Z',
|
||||
isMyLeague: false,
|
||||
leagueName: undefined,
|
||||
},
|
||||
],
|
||||
leagueStandings: [
|
||||
{
|
||||
leagueId: 'league-1',
|
||||
leagueName: 'Pro League',
|
||||
position: 1,
|
||||
points: 120,
|
||||
totalDrivers: 50,
|
||||
},
|
||||
],
|
||||
feedItems: [
|
||||
{
|
||||
id: 'feed-1',
|
||||
type: 'news',
|
||||
headline: 'Big race announced',
|
||||
body: 'Details about the big race.',
|
||||
timestamp: '2025-01-01T10:00:00Z',
|
||||
ctaHref: '/races/race-1',
|
||||
ctaLabel: 'View race',
|
||||
},
|
||||
],
|
||||
friends: [
|
||||
{
|
||||
id: 'friend-1',
|
||||
name: 'Racing Buddy',
|
||||
avatarUrl: 'https://example.com/friend.jpg',
|
||||
country: 'US',
|
||||
},
|
||||
],
|
||||
activeLeaguesCount: 3,
|
||||
});
|
||||
|
||||
describe('DashboardOverviewViewModel', () => {
|
||||
it('wraps the current driver DTO in a DriverViewModel', () => {
|
||||
const dto = createDashboardOverviewDto();
|
||||
const viewModel = new DashboardOverviewViewModel(dto);
|
||||
|
||||
const currentDriver = viewModel.currentDriver;
|
||||
|
||||
expect(currentDriver).toBeInstanceOf(DriverViewModel);
|
||||
expect(currentDriver.id).toBe('driver-1');
|
||||
expect(currentDriver.name).toBe('Test Driver');
|
||||
expect(currentDriver.avatarUrl).toBe('https://example.com/avatar.jpg');
|
||||
expect(currentDriver.country).toBe('DE');
|
||||
expect(currentDriver.totalRaces).toBe(10);
|
||||
expect(currentDriver.wins).toBe(3);
|
||||
expect(currentDriver.podiums).toBe(5);
|
||||
expect(currentDriver.rating).toBe(2500);
|
||||
expect(currentDriver.globalRank).toBe(42);
|
||||
expect(currentDriver.consistency).toBe(88);
|
||||
});
|
||||
|
||||
it('wraps nextRace DTO into a RaceViewModel and returns null when absent', () => {
|
||||
const dtoWithRace = createDashboardOverviewDto();
|
||||
const viewModelWithRace = new DashboardOverviewViewModel(dtoWithRace);
|
||||
|
||||
const nextRace = viewModelWithRace.nextRace;
|
||||
|
||||
expect(nextRace).toBeInstanceOf(RaceViewModel);
|
||||
expect(nextRace?.id).toBe('race-1');
|
||||
expect(nextRace?.track).toBe('Spa-Francorchamps');
|
||||
expect(nextRace?.car).toBe('GT3');
|
||||
expect(nextRace?.isMyLeague).toBe(true);
|
||||
expect(nextRace?.leagueName).toBe('Pro League');
|
||||
expect(nextRace?.scheduledAt).toBeInstanceOf(Date);
|
||||
|
||||
const dtoWithoutRace: DashboardOverviewDto = {
|
||||
...dtoWithRace,
|
||||
nextRace: null,
|
||||
};
|
||||
|
||||
const viewModelWithoutRace = new DashboardOverviewViewModel(dtoWithoutRace);
|
||||
|
||||
expect(viewModelWithoutRace.nextRace).toBeNull();
|
||||
});
|
||||
|
||||
it('maps upcoming races, league standings, feed items and friends into their respective view models', () => {
|
||||
const dto = createDashboardOverviewDto();
|
||||
const viewModel = new DashboardOverviewViewModel(dto);
|
||||
|
||||
expect(viewModel.upcomingRaces).toHaveLength(1);
|
||||
expect(viewModel.upcomingRaces[0]).toBeInstanceOf(RaceViewModel);
|
||||
expect(viewModel.upcomingRaces[0].id).toBe('race-2');
|
||||
|
||||
expect(viewModel.leagueStandings).toHaveLength(1);
|
||||
expect(viewModel.leagueStandings[0]).toBeInstanceOf(LeagueStandingViewModel);
|
||||
expect(viewModel.leagueStandings[0].leagueId).toBe('league-1');
|
||||
|
||||
expect(viewModel.feedItems).toHaveLength(1);
|
||||
expect(viewModel.feedItems[0]).toBeInstanceOf(DashboardFeedItemSummaryViewModel);
|
||||
expect(viewModel.feedItems[0].id).toBe('feed-1');
|
||||
expect(viewModel.feedItems[0].timestamp).toBeInstanceOf(Date);
|
||||
|
||||
expect(viewModel.friends).toHaveLength(1);
|
||||
expect(viewModel.friends[0]).toBeInstanceOf(FriendViewModel);
|
||||
expect(viewModel.friends[0].id).toBe('friend-1');
|
||||
});
|
||||
|
||||
it('exposes the activeLeaguesCount from the DTO', () => {
|
||||
const dto = createDashboardOverviewDto();
|
||||
const viewModel = new DashboardOverviewViewModel(dto);
|
||||
|
||||
expect(viewModel.activeLeaguesCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
import type { DashboardOverviewViewModelData } from './DashboardOverviewViewModelData';
|
||||
import {
|
||||
dashboardStatDisplay,
|
||||
formatDashboardDate,
|
||||
formatRating,
|
||||
formatRank,
|
||||
formatConsistency,
|
||||
formatRaceCount,
|
||||
formatFriendCount,
|
||||
formatLeaguePosition,
|
||||
formatPoints,
|
||||
formatTotalDrivers,
|
||||
} from '@/lib/display-objects/DashboardDisplay';
|
||||
|
||||
/**
|
||||
* Dashboard Overview ViewModel
|
||||
*
|
||||
* Clean class that accepts DTO only and exposes derived values.
|
||||
* This is client-only and instantiated after hydration.
|
||||
*/
|
||||
export class DashboardOverviewViewModel {
|
||||
constructor(private readonly dto: DashboardOverviewViewModelData) {}
|
||||
|
||||
// Current Driver - Derived Values
|
||||
get currentDriverName(): string {
|
||||
return this.dto.currentDriver?.name || '';
|
||||
}
|
||||
|
||||
get currentDriverAvatarUrl(): string {
|
||||
return this.dto.currentDriver?.avatarUrl || '';
|
||||
}
|
||||
|
||||
get currentDriverCountry(): string {
|
||||
return this.dto.currentDriver?.country || '';
|
||||
}
|
||||
|
||||
get currentDriverRating(): string {
|
||||
return this.dto.currentDriver ? formatRating(this.dto.currentDriver.rating) : '0.0';
|
||||
}
|
||||
|
||||
get currentDriverRank(): string {
|
||||
return this.dto.currentDriver ? formatRank(this.dto.currentDriver.globalRank) : '0';
|
||||
}
|
||||
|
||||
get currentDriverTotalRaces(): string {
|
||||
return this.dto.currentDriver ? formatRaceCount(this.dto.currentDriver.totalRaces) : '0';
|
||||
}
|
||||
|
||||
get currentDriverWins(): string {
|
||||
return this.dto.currentDriver ? formatRaceCount(this.dto.currentDriver.wins) : '0';
|
||||
}
|
||||
|
||||
get currentDriverPodiums(): string {
|
||||
return this.dto.currentDriver ? formatRaceCount(this.dto.currentDriver.podiums) : '0';
|
||||
}
|
||||
|
||||
get currentDriverConsistency(): string {
|
||||
return this.dto.currentDriver ? formatConsistency(this.dto.currentDriver.consistency) : '0%';
|
||||
}
|
||||
|
||||
// Next Race - Derived Values
|
||||
get nextRace(): {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
isMyLeague: boolean;
|
||||
formattedDate: string;
|
||||
formattedTime: string;
|
||||
timeUntil: string;
|
||||
} | null {
|
||||
if (!this.dto.nextRace) return null;
|
||||
|
||||
const dateInfo = formatDashboardDate(new Date(this.dto.nextRace.scheduledAt));
|
||||
|
||||
return {
|
||||
id: this.dto.nextRace.id,
|
||||
track: this.dto.nextRace.track,
|
||||
car: this.dto.nextRace.car,
|
||||
scheduledAt: this.dto.nextRace.scheduledAt,
|
||||
status: this.dto.nextRace.status,
|
||||
isMyLeague: this.dto.nextRace.isMyLeague,
|
||||
formattedDate: dateInfo.date,
|
||||
formattedTime: dateInfo.time,
|
||||
timeUntil: dateInfo.relative,
|
||||
};
|
||||
}
|
||||
|
||||
// Upcoming Races - Derived Values
|
||||
get upcomingRaces(): Array<{
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
isMyLeague: boolean;
|
||||
formattedDate: string;
|
||||
formattedTime: string;
|
||||
timeUntil: string;
|
||||
}> {
|
||||
return this.dto.upcomingRaces.map((race) => {
|
||||
const dateInfo = formatDashboardDate(new Date(race.scheduledAt));
|
||||
return {
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status,
|
||||
isMyLeague: race.isMyLeague,
|
||||
formattedDate: dateInfo.date,
|
||||
formattedTime: dateInfo.time,
|
||||
timeUntil: dateInfo.relative,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// League Standings - Derived Values
|
||||
get leagueStandings(): Array<{
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
position: string;
|
||||
points: string;
|
||||
totalDrivers: string;
|
||||
}> {
|
||||
return this.dto.leagueStandingsSummaries.map((standing) => ({
|
||||
leagueId: standing.leagueId,
|
||||
leagueName: standing.leagueName,
|
||||
position: formatLeaguePosition(standing.position),
|
||||
points: formatPoints(standing.points),
|
||||
totalDrivers: formatTotalDrivers(standing.totalDrivers),
|
||||
}));
|
||||
}
|
||||
|
||||
// Feed Items - Derived Values
|
||||
get feedItems(): Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
headline: string;
|
||||
body?: string;
|
||||
timestamp: string;
|
||||
ctaHref?: string;
|
||||
ctaLabel?: string;
|
||||
formattedTime: string;
|
||||
}> {
|
||||
return this.dto.feedSummary.items.map((item) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
headline: item.headline,
|
||||
body: item.body,
|
||||
timestamp: item.timestamp,
|
||||
ctaHref: item.ctaHref,
|
||||
ctaLabel: item.ctaLabel,
|
||||
formattedTime: formatDashboardDate(new Date(item.timestamp)).relative,
|
||||
}));
|
||||
}
|
||||
|
||||
// Friends - Derived Values
|
||||
get friends(): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
country: string;
|
||||
}> {
|
||||
// No additional formatting needed for friends
|
||||
return this.dto.friends;
|
||||
}
|
||||
|
||||
// Active Leagues Count
|
||||
get activeLeaguesCount(): string {
|
||||
return formatRaceCount(this.dto.activeLeaguesCount);
|
||||
}
|
||||
|
||||
// Convenience getters for display
|
||||
get hasNextRace(): boolean {
|
||||
return this.dto.nextRace !== undefined;
|
||||
}
|
||||
|
||||
get hasUpcomingRaces(): boolean {
|
||||
return this.dto.upcomingRaces.length > 0;
|
||||
}
|
||||
|
||||
get hasLeagueStandings(): boolean {
|
||||
return this.dto.leagueStandingsSummaries.length > 0;
|
||||
}
|
||||
|
||||
get hasFeedItems(): boolean {
|
||||
return this.dto.feedSummary.items.length > 0;
|
||||
}
|
||||
|
||||
get hasFriends(): boolean {
|
||||
return this.dto.friends.length > 0;
|
||||
}
|
||||
|
||||
get friendCount(): string {
|
||||
return formatFriendCount(this.dto.friends.length);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Dashboard Page DTO
|
||||
* This is the data transfer object that gets passed from server to client
|
||||
* Contains ISO string timestamps for JSON serialization
|
||||
*/
|
||||
export interface DashboardOverviewViewModelData {
|
||||
currentDriver?: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
country: string;
|
||||
totalRaces: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
rating: number;
|
||||
globalRank: number;
|
||||
consistency: number;
|
||||
};
|
||||
myUpcomingRaces: Array<{
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string; // ISO string
|
||||
status: string;
|
||||
isMyLeague: boolean;
|
||||
}>;
|
||||
otherUpcomingRaces: Array<{
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string; // ISO string
|
||||
status: string;
|
||||
isMyLeague: boolean;
|
||||
}>;
|
||||
upcomingRaces: Array<{
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string; // ISO string
|
||||
status: string;
|
||||
isMyLeague: boolean;
|
||||
}>;
|
||||
activeLeaguesCount: number;
|
||||
nextRace?: {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string; // ISO string
|
||||
status: string;
|
||||
isMyLeague: boolean;
|
||||
};
|
||||
recentResults: Array<{
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
position: number;
|
||||
date: string; // ISO string
|
||||
}>;
|
||||
leagueStandingsSummaries: Array<{
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
totalDrivers: number;
|
||||
}>;
|
||||
feedSummary: {
|
||||
notificationCount: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
headline: string;
|
||||
body?: string;
|
||||
timestamp: string; // ISO string
|
||||
ctaHref?: string;
|
||||
ctaLabel?: string;
|
||||
}>;
|
||||
};
|
||||
friends: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
country: string;
|
||||
}>;
|
||||
}
|
||||
3
apps/website/lib/view-models/OnboardingViewModel.ts
Normal file
3
apps/website/lib/view-models/OnboardingViewModel.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface OnboardingViewModel {
|
||||
isAlreadyOnboarded: boolean;
|
||||
}
|
||||
87
apps/website/lib/view-models/TeamDetailPresenter.ts
Normal file
87
apps/website/lib/view-models/TeamDetailPresenter.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { TeamDetailPageDto } from '@/lib/page-queries/page-queries/TeamDetailPageQuery';
|
||||
import type { TeamDetailViewData, TeamDetailData, TeamMemberData, SponsorMetric, TeamTab } from '@/lib/view-data/TeamDetailViewData';
|
||||
import { Users, Zap, Calendar } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* TeamDetailPresenter - Client-side presenter for team detail page
|
||||
* Transforms PageQuery DTO into ViewData for the template
|
||||
* Deterministic; no hooks; no side effects
|
||||
*/
|
||||
export class TeamDetailPresenter {
|
||||
static createViewData(pageDto: TeamDetailPageDto): TeamDetailViewData {
|
||||
const team: TeamDetailData = {
|
||||
id: pageDto.team.id,
|
||||
name: pageDto.team.name,
|
||||
tag: pageDto.team.tag,
|
||||
description: pageDto.team.description,
|
||||
ownerId: pageDto.team.ownerId,
|
||||
leagues: pageDto.team.leagues,
|
||||
createdAt: pageDto.team.createdAt,
|
||||
specialization: pageDto.team.specialization,
|
||||
region: pageDto.team.region,
|
||||
languages: pageDto.team.languages,
|
||||
category: pageDto.team.category,
|
||||
membership: pageDto.team.membership,
|
||||
canManage: pageDto.team.canManage,
|
||||
};
|
||||
|
||||
const memberships: TeamMemberData[] = pageDto.memberships.map(membership => ({
|
||||
driverId: membership.driverId,
|
||||
driverName: membership.driverName,
|
||||
role: membership.role,
|
||||
joinedAt: membership.joinedAt,
|
||||
isActive: membership.isActive,
|
||||
avatarUrl: membership.avatarUrl,
|
||||
}));
|
||||
|
||||
// Calculate isAdmin based on current driver's role
|
||||
const currentDriverMembership = memberships.find(m => m.driverId === pageDto.currentDriverId);
|
||||
const isAdmin = currentDriverMembership?.role === 'owner' || currentDriverMembership?.role === 'manager';
|
||||
|
||||
// Build sponsor metrics
|
||||
const leagueCount = team.leagues?.length ?? 0;
|
||||
const teamMetrics: SponsorMetric[] = [
|
||||
{
|
||||
icon: Users,
|
||||
label: 'Members',
|
||||
value: memberships.length,
|
||||
color: 'text-primary-blue',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
label: 'Est. Reach',
|
||||
value: memberships.length * 15,
|
||||
color: 'text-purple-400',
|
||||
},
|
||||
{
|
||||
icon: Calendar,
|
||||
label: 'Races',
|
||||
value: leagueCount,
|
||||
color: 'text-neon-aqua',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
label: 'Engagement',
|
||||
value: '82%',
|
||||
color: 'text-performance-green',
|
||||
},
|
||||
];
|
||||
|
||||
// Build tabs
|
||||
const tabs: TeamTab[] = [
|
||||
{ id: 'overview', label: 'Overview', visible: true },
|
||||
{ id: 'roster', label: 'Roster', visible: true },
|
||||
{ id: 'standings', label: 'Standings', visible: true },
|
||||
{ id: 'admin', label: 'Admin', visible: isAdmin },
|
||||
];
|
||||
|
||||
return {
|
||||
team,
|
||||
memberships,
|
||||
currentDriverId: pageDto.currentDriverId,
|
||||
isAdmin,
|
||||
teamMetrics,
|
||||
tabs,
|
||||
};
|
||||
}
|
||||
}
|
||||
79
apps/website/lib/view-models/auth/ForgotPasswordViewModel.ts
Normal file
79
apps/website/lib/view-models/auth/ForgotPasswordViewModel.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Forgot Password ViewModel
|
||||
*
|
||||
* Client-side state management for forgot password flow.
|
||||
* Immutable, class-based, contains only UI state.
|
||||
*/
|
||||
|
||||
export interface ForgotPasswordFormField {
|
||||
value: string;
|
||||
error?: string;
|
||||
touched: boolean;
|
||||
validating: boolean;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordFormState {
|
||||
fields: {
|
||||
email: ForgotPasswordFormField;
|
||||
};
|
||||
isValid: boolean;
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
submitCount: number;
|
||||
}
|
||||
|
||||
export class ForgotPasswordViewModel {
|
||||
constructor(
|
||||
public readonly returnTo: string,
|
||||
public readonly formState: ForgotPasswordFormState,
|
||||
public readonly showSuccess: boolean = false,
|
||||
public readonly successMessage: string | null = null,
|
||||
public readonly magicLink: string | null = null,
|
||||
public readonly mutationPending: boolean = false,
|
||||
public readonly mutationError: string | null = null
|
||||
) {}
|
||||
|
||||
withFormState(formState: ForgotPasswordFormState): ForgotPasswordViewModel {
|
||||
return new ForgotPasswordViewModel(
|
||||
this.returnTo,
|
||||
formState,
|
||||
this.showSuccess,
|
||||
this.successMessage,
|
||||
this.magicLink,
|
||||
this.mutationPending,
|
||||
this.mutationError
|
||||
);
|
||||
}
|
||||
|
||||
withSuccess(successMessage: string, magicLink: string | null = null): ForgotPasswordViewModel {
|
||||
return new ForgotPasswordViewModel(
|
||||
this.returnTo,
|
||||
this.formState,
|
||||
true,
|
||||
successMessage,
|
||||
magicLink,
|
||||
false,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
withMutationState(pending: boolean, error: string | null): ForgotPasswordViewModel {
|
||||
return new ForgotPasswordViewModel(
|
||||
this.returnTo,
|
||||
this.formState,
|
||||
this.showSuccess,
|
||||
this.successMessage,
|
||||
this.magicLink,
|
||||
pending,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
get isSubmitting(): boolean {
|
||||
return this.formState.isSubmitting || this.mutationPending;
|
||||
}
|
||||
|
||||
get submitError(): string | undefined {
|
||||
return this.formState.submitError || this.mutationError || undefined;
|
||||
}
|
||||
}
|
||||
96
apps/website/lib/view-models/auth/LoginViewModel.ts
Normal file
96
apps/website/lib/view-models/auth/LoginViewModel.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Login ViewModel
|
||||
*
|
||||
* Client-side state management for login flow.
|
||||
* Immutable, class-based, contains only UI state.
|
||||
*/
|
||||
|
||||
export interface LoginFormField {
|
||||
value: string | boolean;
|
||||
error?: string;
|
||||
touched: boolean;
|
||||
validating: boolean;
|
||||
}
|
||||
|
||||
export interface LoginFormState {
|
||||
fields: {
|
||||
email: LoginFormField;
|
||||
password: LoginFormField;
|
||||
rememberMe: LoginFormField;
|
||||
};
|
||||
isValid: boolean;
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
submitCount: number;
|
||||
}
|
||||
|
||||
export interface LoginUIState {
|
||||
showPassword: boolean;
|
||||
showErrorDetails: boolean;
|
||||
}
|
||||
|
||||
export class LoginViewModel {
|
||||
constructor(
|
||||
public readonly returnTo: string,
|
||||
public readonly hasInsufficientPermissions: boolean,
|
||||
public readonly formState: LoginFormState,
|
||||
public readonly uiState: LoginUIState,
|
||||
public readonly mutationPending: boolean = false,
|
||||
public readonly mutationError: string | null = null
|
||||
) {}
|
||||
|
||||
// Immutable updates
|
||||
withFormState(formState: LoginFormState): LoginViewModel {
|
||||
return new LoginViewModel(
|
||||
this.returnTo,
|
||||
this.hasInsufficientPermissions,
|
||||
formState,
|
||||
this.uiState,
|
||||
this.mutationPending,
|
||||
this.mutationError
|
||||
);
|
||||
}
|
||||
|
||||
withUIState(uiState: LoginUIState): LoginViewModel {
|
||||
return new LoginViewModel(
|
||||
this.returnTo,
|
||||
this.hasInsufficientPermissions,
|
||||
this.formState,
|
||||
uiState,
|
||||
this.mutationPending,
|
||||
this.mutationError
|
||||
);
|
||||
}
|
||||
|
||||
withMutationState(pending: boolean, error: string | null): LoginViewModel {
|
||||
return new LoginViewModel(
|
||||
this.returnTo,
|
||||
this.hasInsufficientPermissions,
|
||||
this.formState,
|
||||
this.uiState,
|
||||
pending,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// Getters for template consumption
|
||||
get showPassword(): boolean {
|
||||
return this.uiState.showPassword;
|
||||
}
|
||||
|
||||
get showErrorDetails(): boolean {
|
||||
return this.uiState.showErrorDetails;
|
||||
}
|
||||
|
||||
get isSubmitting(): boolean {
|
||||
return this.formState.isSubmitting || this.mutationPending;
|
||||
}
|
||||
|
||||
get submitError(): string | undefined {
|
||||
return this.formState.submitError || this.mutationError || undefined;
|
||||
}
|
||||
|
||||
get formFields() {
|
||||
return this.formState.fields;
|
||||
}
|
||||
}
|
||||
102
apps/website/lib/view-models/auth/ResetPasswordViewModel.ts
Normal file
102
apps/website/lib/view-models/auth/ResetPasswordViewModel.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Reset Password ViewModel
|
||||
*
|
||||
* Client-side state management for reset password flow.
|
||||
* Immutable, class-based, contains only UI state.
|
||||
*/
|
||||
|
||||
export interface ResetPasswordFormField {
|
||||
value: string;
|
||||
error?: string;
|
||||
touched: boolean;
|
||||
validating: boolean;
|
||||
}
|
||||
|
||||
export interface ResetPasswordFormState {
|
||||
fields: {
|
||||
newPassword: ResetPasswordFormField;
|
||||
confirmPassword: ResetPasswordFormField;
|
||||
};
|
||||
isValid: boolean;
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
submitCount: number;
|
||||
}
|
||||
|
||||
export interface ResetPasswordUIState {
|
||||
showPassword: boolean;
|
||||
showConfirmPassword: boolean;
|
||||
}
|
||||
|
||||
export class ResetPasswordViewModel {
|
||||
constructor(
|
||||
public readonly token: string,
|
||||
public readonly returnTo: string,
|
||||
public readonly formState: ResetPasswordFormState,
|
||||
public readonly uiState: ResetPasswordUIState,
|
||||
public readonly showSuccess: boolean = false,
|
||||
public readonly successMessage: string | null = null,
|
||||
public readonly mutationPending: boolean = false,
|
||||
public readonly mutationError: string | null = null
|
||||
) {}
|
||||
|
||||
withFormState(formState: ResetPasswordFormState): ResetPasswordViewModel {
|
||||
return new ResetPasswordViewModel(
|
||||
this.token,
|
||||
this.returnTo,
|
||||
formState,
|
||||
this.uiState,
|
||||
this.showSuccess,
|
||||
this.successMessage,
|
||||
this.mutationPending,
|
||||
this.mutationError
|
||||
);
|
||||
}
|
||||
|
||||
withUIState(uiState: ResetPasswordUIState): ResetPasswordViewModel {
|
||||
return new ResetPasswordViewModel(
|
||||
this.token,
|
||||
this.returnTo,
|
||||
this.formState,
|
||||
uiState,
|
||||
this.showSuccess,
|
||||
this.successMessage,
|
||||
this.mutationPending,
|
||||
this.mutationError
|
||||
);
|
||||
}
|
||||
|
||||
withSuccess(successMessage: string): ResetPasswordViewModel {
|
||||
return new ResetPasswordViewModel(
|
||||
this.token,
|
||||
this.returnTo,
|
||||
this.formState,
|
||||
this.uiState,
|
||||
true,
|
||||
successMessage,
|
||||
false,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
withMutationState(pending: boolean, error: string | null): ResetPasswordViewModel {
|
||||
return new ResetPasswordViewModel(
|
||||
this.token,
|
||||
this.returnTo,
|
||||
this.formState,
|
||||
this.uiState,
|
||||
this.showSuccess,
|
||||
this.successMessage,
|
||||
pending,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
get isSubmitting(): boolean {
|
||||
return this.formState.isSubmitting || this.mutationPending;
|
||||
}
|
||||
|
||||
get submitError(): string | undefined {
|
||||
return this.formState.submitError || this.mutationError || undefined;
|
||||
}
|
||||
}
|
||||
80
apps/website/lib/view-models/auth/SignupViewModel.ts
Normal file
80
apps/website/lib/view-models/auth/SignupViewModel.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Signup ViewModel
|
||||
*
|
||||
* Client-side state management for signup flow.
|
||||
* Immutable, class-based, contains only UI state.
|
||||
*/
|
||||
|
||||
export interface SignupFormField {
|
||||
value: string;
|
||||
error?: string;
|
||||
touched: boolean;
|
||||
validating: boolean;
|
||||
}
|
||||
|
||||
export interface SignupFormState {
|
||||
fields: {
|
||||
firstName: SignupFormField;
|
||||
lastName: SignupFormField;
|
||||
email: SignupFormField;
|
||||
password: SignupFormField;
|
||||
confirmPassword: SignupFormField;
|
||||
};
|
||||
isValid: boolean;
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
submitCount: number;
|
||||
}
|
||||
|
||||
export interface SignupUIState {
|
||||
showPassword: boolean;
|
||||
showConfirmPassword: boolean;
|
||||
}
|
||||
|
||||
export class SignupViewModel {
|
||||
constructor(
|
||||
public readonly returnTo: string,
|
||||
public readonly formState: SignupFormState,
|
||||
public readonly uiState: SignupUIState,
|
||||
public readonly mutationPending: boolean = false,
|
||||
public readonly mutationError: string | null = null
|
||||
) {}
|
||||
|
||||
withFormState(formState: SignupFormState): SignupViewModel {
|
||||
return new SignupViewModel(
|
||||
this.returnTo,
|
||||
formState,
|
||||
this.uiState,
|
||||
this.mutationPending,
|
||||
this.mutationError
|
||||
);
|
||||
}
|
||||
|
||||
withUIState(uiState: SignupUIState): SignupViewModel {
|
||||
return new SignupViewModel(
|
||||
this.returnTo,
|
||||
this.formState,
|
||||
uiState,
|
||||
this.mutationPending,
|
||||
this.mutationError
|
||||
);
|
||||
}
|
||||
|
||||
withMutationState(pending: boolean, error: string | null): SignupViewModel {
|
||||
return new SignupViewModel(
|
||||
this.returnTo,
|
||||
this.formState,
|
||||
this.uiState,
|
||||
pending,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
get isSubmitting(): boolean {
|
||||
return this.formState.isSubmitting || this.mutationPending;
|
||||
}
|
||||
|
||||
get submitError(): string | undefined {
|
||||
return this.formState.submitError || this.mutationError || undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,100 +1,91 @@
|
||||
export * from './view-models/ActivityItemViewModel';
|
||||
export * from './view-models/AnalyticsDashboardViewModel';
|
||||
export * from './view-models/AnalyticsMetricsViewModel';
|
||||
export * from './view-models/AvailableLeaguesViewModel';
|
||||
export * from './view-models/AvatarGenerationViewModel';
|
||||
export * from './view-models/AvatarViewModel';
|
||||
export * from './view-models/BillingViewModel';
|
||||
export * from './view-models/CompleteOnboardingViewModel';
|
||||
export * from './view-models/CreateLeagueViewModel';
|
||||
export * from './view-models/CreateTeamViewModel';
|
||||
export {
|
||||
DashboardOverviewViewModel,
|
||||
} from './view-models/DashboardOverviewViewModel';
|
||||
export * from './view-models/DashboardOverviewViewModelData';
|
||||
export * from './view-models/DeleteMediaViewModel';
|
||||
export * from './view-models/DriverLeaderboardItemViewModel';
|
||||
export * from './view-models/DriverLeaderboardViewModel';
|
||||
export * from './view-models/DriverProfileViewModel';
|
||||
export * from './view-models/DriverRegistrationStatusViewModel';
|
||||
export * from './view-models/DriverSummaryViewModel';
|
||||
export * from './view-models/DriverTeamViewModel';
|
||||
export * from './view-models/DriverViewModel';
|
||||
export * from './view-models/EmailSignupViewModel';
|
||||
export * from './view-models/HomeDiscoveryViewModel';
|
||||
export * from './view-models/ImportRaceResultsSummaryViewModel';
|
||||
export * from './view-models/LeagueAdminViewModel';
|
||||
export * from './view-models/LeagueCardViewModel';
|
||||
export * from './view-models/LeagueDetailPageViewModel';
|
||||
export { LeagueDetailViewModel, LeagueViewModel } from './view-models/LeagueDetailViewModel';
|
||||
export * from './view-models/LeagueJoinRequestViewModel';
|
||||
export * from './view-models/LeagueMembershipsViewModel';
|
||||
export * from './view-models/LeagueMemberViewModel';
|
||||
export * from './view-models/LeaguePageDetailViewModel';
|
||||
export * from './view-models/LeagueScheduleViewModel';
|
||||
export * from './view-models/LeagueScoringChampionshipViewModel';
|
||||
export * from './view-models/LeagueScoringConfigViewModel';
|
||||
export * from './view-models/LeagueScoringPresetsViewModel';
|
||||
export * from './view-models/LeagueScoringPresetViewModel';
|
||||
export * from './view-models/LeagueScoringSectionViewModel';
|
||||
export * from './view-models/LeagueSettingsViewModel';
|
||||
export * from './view-models/LeagueStandingsViewModel';
|
||||
export * from './view-models/LeagueStatsViewModel';
|
||||
export * from './view-models/LeagueStewardingViewModel';
|
||||
export * from './view-models/LeagueSummaryViewModel';
|
||||
export * from './view-models/LeagueWalletViewModel';
|
||||
export * from './view-models/MediaViewModel';
|
||||
export * from './view-models/MembershipFeeViewModel';
|
||||
export * from './view-models/PaymentViewModel';
|
||||
export * from './view-models/PrizeViewModel';
|
||||
export * from './view-models/ProfileOverviewViewModel';
|
||||
export * from './view-models/ProtestDriverViewModel';
|
||||
export * from './view-models/ProtestViewModel';
|
||||
export * from './view-models/RaceDetailEntryViewModel';
|
||||
export * from './view-models/RaceDetailUserResultViewModel';
|
||||
export * from './view-models/RaceDetailViewModel';
|
||||
export * from './view-models/RaceListItemViewModel';
|
||||
export * from './view-models/RaceResultsDataTransformer';
|
||||
export * from './view-models/RaceResultsDetailViewModel';
|
||||
export * from './view-models/RaceResultViewModel';
|
||||
export * from './view-models/RacesPageViewModel';
|
||||
export * from './view-models/RaceStatsViewModel';
|
||||
export * from './view-models/RaceStewardingViewModel';
|
||||
export * from './view-models/RaceViewModel';
|
||||
export * from './view-models/RaceWithSOFViewModel';
|
||||
export * from './view-models/RecordEngagementInputViewModel';
|
||||
export * from './view-models/RecordEngagementOutputViewModel';
|
||||
export * from './view-models/RecordPageViewInputViewModel';
|
||||
export * from './view-models/RecordPageViewOutputViewModel';
|
||||
export * from './view-models/RemoveMemberViewModel';
|
||||
export * from './view-models/RenewalAlertViewModel';
|
||||
export * from './view-models/RequestAvatarGenerationViewModel';
|
||||
export * from './view-models/ScoringConfigurationViewModel';
|
||||
export * from './view-models/SessionViewModel';
|
||||
export * from './view-models/SponsorDashboardViewModel';
|
||||
export * from './view-models/SponsorSettingsViewModel';
|
||||
export * from './view-models/SponsorshipDetailViewModel';
|
||||
export * from './view-models/SponsorshipPricingViewModel';
|
||||
export * from './view-models/SponsorshipRequestViewModel';
|
||||
export * from './view-models/SponsorshipViewModel';
|
||||
export * from './view-models/SponsorSponsorshipsViewModel';
|
||||
export * from './view-models/SponsorViewModel';
|
||||
export * from './view-models/StandingEntryViewModel';
|
||||
export * from './view-models/TeamCardViewModel';
|
||||
export * from './view-models/TeamDetailsViewModel';
|
||||
export * from './view-models/TeamJoinRequestViewModel';
|
||||
export * from './view-models/TeamMemberViewModel';
|
||||
export * from './view-models/TeamSummaryViewModel';
|
||||
export * from './view-models/UpcomingRaceCardViewModel';
|
||||
export * from './view-models/UpdateAvatarViewModel';
|
||||
export * from './view-models/UpdateTeamViewModel';
|
||||
export * from './view-models/UploadMediaViewModel';
|
||||
export * from './view-models/UserProfileViewModel';
|
||||
export * from './view-models/WalletTransactionViewModel';
|
||||
export * from './view-models/WalletViewModel';
|
||||
export * from './ActivityItemViewModel';
|
||||
export * from './AnalyticsDashboardViewModel';
|
||||
export * from './AnalyticsMetricsViewModel';
|
||||
export * from './AvailableLeaguesViewModel';
|
||||
export * from './AvatarGenerationViewModel';
|
||||
export * from './AvatarViewModel';
|
||||
export * from './BillingViewModel';
|
||||
export * from './CompleteOnboardingViewModel';
|
||||
export * from './CreateLeagueViewModel';
|
||||
export * from './CreateTeamViewModel';
|
||||
export * from './DeleteMediaViewModel';
|
||||
export * from './DriverLeaderboardItemViewModel';
|
||||
export * from './DriverLeaderboardViewModel';
|
||||
export * from './DriverProfileViewModel';
|
||||
export * from './DriverRegistrationStatusViewModel';
|
||||
export * from './DriverSummaryViewModel';
|
||||
export * from './DriverTeamViewModel';
|
||||
export * from './DriverViewModel';
|
||||
export * from './EmailSignupViewModel';
|
||||
export * from './HomeDiscoveryViewModel';
|
||||
export * from './ImportRaceResultsSummaryViewModel';
|
||||
export * from './LeagueAdminViewModel';
|
||||
export * from './LeagueCardViewModel';
|
||||
export * from './LeagueDetailPageViewModel';
|
||||
export { LeagueDetailViewModel, LeagueViewModel } from './LeagueDetailViewModel';
|
||||
export * from './LeagueJoinRequestViewModel';
|
||||
export * from './LeagueMembershipsViewModel';
|
||||
export * from './LeagueMemberViewModel';
|
||||
export * from './LeaguePageDetailViewModel';
|
||||
export * from './LeagueScheduleViewModel';
|
||||
export * from './LeagueScoringChampionshipViewModel';
|
||||
export * from './LeagueScoringConfigViewModel';
|
||||
export * from './LeagueScoringPresetsViewModel';
|
||||
export * from './LeagueScoringPresetViewModel';
|
||||
export * from './LeagueScoringSectionViewModel';
|
||||
export * from './LeagueSettingsViewModel';
|
||||
export * from './LeagueStandingsViewModel';
|
||||
export * from './LeagueStatsViewModel';
|
||||
export * from './LeagueStewardingViewModel';
|
||||
export * from './LeagueSummaryViewModel';
|
||||
export * from './LeagueWalletViewModel';
|
||||
export * from './MediaViewModel';
|
||||
export * from './MembershipFeeViewModel';
|
||||
export * from './PaymentViewModel';
|
||||
export * from './PrizeViewModel';
|
||||
export * from './ProfileOverviewViewModel';
|
||||
export * from './ProtestDriverViewModel';
|
||||
export * from './ProtestViewModel';
|
||||
export * from './RaceDetailEntryViewModel';
|
||||
export * from './RaceDetailUserResultViewModel';
|
||||
export * from './RaceDetailsViewModel';
|
||||
export * from './RaceListItemViewModel';
|
||||
export * from './RaceResultsDataTransformer';
|
||||
export * from './RaceResultsDetailViewModel';
|
||||
export * from './RaceResultViewModel';
|
||||
export * from './RacesPageViewModel';
|
||||
export * from './RaceStatsViewModel';
|
||||
export * from './RaceStewardingViewModel';
|
||||
export * from './RaceViewModel';
|
||||
export * from './RaceWithSOFViewModel';
|
||||
export * from './RecordEngagementInputViewModel';
|
||||
export * from './RecordEngagementOutputViewModel';
|
||||
export * from './RecordPageViewInputViewModel';
|
||||
export * from './RecordPageViewOutputViewModel';
|
||||
export * from './RemoveMemberViewModel';
|
||||
export * from './RenewalAlertViewModel';
|
||||
export * from './RequestAvatarGenerationViewModel';
|
||||
export * from './ScoringConfigurationViewModel';
|
||||
export * from './SessionViewModel';
|
||||
export * from './SponsorSettingsViewModel';
|
||||
export * from './SponsorshipDetailViewModel';
|
||||
export * from './SponsorshipPricingViewModel';
|
||||
export * from './SponsorshipRequestViewModel';
|
||||
export * from './SponsorshipViewModel';
|
||||
export * from './SponsorSponsorshipsViewModel';
|
||||
export * from './SponsorViewModel';
|
||||
export * from './StandingEntryViewModel';
|
||||
export * from './TeamCardViewModel';
|
||||
export * from './TeamDetailsViewModel';
|
||||
export * from './TeamJoinRequestViewModel';
|
||||
export * from './TeamMemberViewModel';
|
||||
export * from './TeamSummaryViewModel';
|
||||
export * from './UpcomingRaceCardViewModel';
|
||||
export * from './UpdateAvatarViewModel';
|
||||
export * from './UpdateTeamViewModel';
|
||||
export * from './UploadMediaViewModel';
|
||||
export * from './UserProfileViewModel';
|
||||
export * from './WalletTransactionViewModel';
|
||||
export * from './WalletViewModel';
|
||||
|
||||
export * from './presenters/DashboardPresenter';
|
||||
export * from './presenters/ProfileLeaguesPresenter';
|
||||
export * from './presenters/TeamDetailPresenter';
|
||||
export * from './presenters/TeamsPresenter';
|
||||
export * from './presenters/AdminViewModelPresenter';
|
||||
export * from './AdminViewModelPresenter';
|
||||
Reference in New Issue
Block a user