fix data flow issues
This commit is contained in:
@@ -63,4 +63,41 @@ export class SponsorsApiClient extends BaseApiClient {
|
||||
rejectSponsorshipRequest(requestId: string, input: RejectSponsorshipRequestInputDTO): Promise<void> {
|
||||
return this.post(`/sponsors/requests/${requestId}/reject`, input);
|
||||
}
|
||||
|
||||
/** Get sponsor billing information */
|
||||
getBilling(sponsorId: string): Promise<{
|
||||
paymentMethods: any[];
|
||||
invoices: any[];
|
||||
stats: any;
|
||||
}> {
|
||||
return this.get(`/sponsors/billing/${sponsorId}`);
|
||||
}
|
||||
|
||||
/** Get available leagues for sponsorship */
|
||||
getAvailableLeagues(): Promise<any[]> {
|
||||
return this.get('/sponsors/leagues/available');
|
||||
}
|
||||
|
||||
/** Get detailed league information */
|
||||
getLeagueDetail(leagueId: string): Promise<{
|
||||
league: any;
|
||||
drivers: any[];
|
||||
races: any[];
|
||||
}> {
|
||||
return this.get(`/sponsors/leagues/${leagueId}/detail`);
|
||||
}
|
||||
|
||||
/** Get sponsor settings */
|
||||
getSettings(sponsorId: string): Promise<{
|
||||
profile: any;
|
||||
notifications: any;
|
||||
privacy: any;
|
||||
}> {
|
||||
return this.get(`/sponsors/settings/${sponsorId}`);
|
||||
}
|
||||
|
||||
/** Update sponsor settings */
|
||||
updateSettings(sponsorId: string, input: any): Promise<void> {
|
||||
return this.put(`/sponsors/settings/${sponsorId}`, input);
|
||||
}
|
||||
}
|
||||
66
apps/website/lib/command-models/auth/LoginCommandModel.ts
Normal file
66
apps/website/lib/command-models/auth/LoginCommandModel.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export interface LoginFormData {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginValidationErrors {
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LoginCommandModel
|
||||
*
|
||||
* Encapsulates login form state, client-side validation, and
|
||||
* prepares data for submission to the AuthService.
|
||||
*/
|
||||
export class LoginCommandModel {
|
||||
private _email: string;
|
||||
private _password: string;
|
||||
|
||||
constructor(initial: LoginFormData = { email: '', password: '' }) {
|
||||
this._email = initial.email;
|
||||
this._password = initial.password;
|
||||
}
|
||||
|
||||
get email(): string {
|
||||
return this._email;
|
||||
}
|
||||
|
||||
set email(value: string) {
|
||||
this._email = value;
|
||||
}
|
||||
|
||||
get password(): string {
|
||||
return this._password;
|
||||
}
|
||||
|
||||
set password(value: string) {
|
||||
this._password = value;
|
||||
}
|
||||
|
||||
/** Basic client-side validation for login form */
|
||||
validate(): LoginValidationErrors {
|
||||
const errors: LoginValidationErrors = {};
|
||||
|
||||
if (!this._email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this._email)) {
|
||||
errors.email = 'Invalid email format';
|
||||
}
|
||||
|
||||
if (!this._password) {
|
||||
errors.password = 'Password is required';
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Convert to API LoginParams DTO */
|
||||
toRequestDto(): { email: string; password: string } {
|
||||
return {
|
||||
email: this._email,
|
||||
password: this._password,
|
||||
};
|
||||
}
|
||||
}
|
||||
89
apps/website/lib/command-models/auth/SignupCommandModel.ts
Normal file
89
apps/website/lib/command-models/auth/SignupCommandModel.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export interface SignupFormData {
|
||||
displayName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export interface SignupValidationErrors {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SignupCommandModel
|
||||
*
|
||||
* Encapsulates signup form state, client-side validation, and
|
||||
* prepares data for submission to the AuthService.
|
||||
*/
|
||||
export class SignupCommandModel {
|
||||
private _displayName: string;
|
||||
private _email: string;
|
||||
private _password: string;
|
||||
private _confirmPassword: string;
|
||||
|
||||
constructor(initial: SignupFormData) {
|
||||
this._displayName = initial.displayName;
|
||||
this._email = initial.email;
|
||||
this._password = initial.password;
|
||||
this._confirmPassword = initial.confirmPassword;
|
||||
}
|
||||
|
||||
get displayName(): string {
|
||||
return this._displayName;
|
||||
}
|
||||
|
||||
get email(): string {
|
||||
return this._email;
|
||||
}
|
||||
|
||||
get password(): string {
|
||||
return this._password;
|
||||
}
|
||||
|
||||
get confirmPassword(): string {
|
||||
return this._confirmPassword;
|
||||
}
|
||||
|
||||
/** Basic client-side validation for signup form */
|
||||
validate(): SignupValidationErrors {
|
||||
const errors: SignupValidationErrors = {};
|
||||
|
||||
if (!this._displayName.trim()) {
|
||||
errors.displayName = 'Display name is required';
|
||||
} else if (this._displayName.trim().length < 3) {
|
||||
errors.displayName = 'Display name must be at least 3 characters';
|
||||
}
|
||||
|
||||
if (!this._email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this._email)) {
|
||||
errors.email = 'Invalid email format';
|
||||
}
|
||||
|
||||
if (!this._password) {
|
||||
errors.password = 'Password is required';
|
||||
} else if (this._password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (!this._confirmPassword) {
|
||||
errors.confirmPassword = 'Please confirm your password';
|
||||
} else if (this._password !== this._confirmPassword) {
|
||||
errors.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Convert to API SignupParams DTO */
|
||||
toRequestDto(): { email: string; password: string; displayName: string } {
|
||||
return {
|
||||
email: this._email,
|
||||
password: this._password,
|
||||
displayName: this._displayName,
|
||||
};
|
||||
}
|
||||
}
|
||||
79
apps/website/lib/services/landing/LandingService.ts
Normal file
79
apps/website/lib/services/landing/LandingService.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
||||
import type { LeagueSummaryDTO } from '@/lib/types/generated/LeagueSummaryDTO';
|
||||
import type { GetAllTeamsOutputDTO, TeamListItemDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO';
|
||||
import { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel';
|
||||
import { HomeDiscoveryViewModel } from '@/lib/view-models/HomeDiscoveryViewModel';
|
||||
import { LeagueCardViewModel } from '@/lib/view-models/LeagueCardViewModel';
|
||||
import { TeamCardViewModel } from '@/lib/view-models/TeamCardViewModel';
|
||||
import { UpcomingRaceCardViewModel } from '@/lib/view-models/UpcomingRaceCardViewModel';
|
||||
|
||||
// DTO matching backend RacesPageDataDTO for discovery usage
|
||||
interface RacesPageDataDTO {
|
||||
races: {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
strengthOfField: number | null;
|
||||
isUpcoming: boolean;
|
||||
isLive: boolean;
|
||||
isPast: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export class LandingService {
|
||||
constructor(
|
||||
private readonly racesApi: RacesApiClient,
|
||||
private readonly leaguesApi: LeaguesApiClient,
|
||||
private readonly teamsApi: TeamsApiClient,
|
||||
) {}
|
||||
|
||||
async getHomeDiscovery(): Promise<HomeDiscoveryViewModel> {
|
||||
const [racesDto, leaguesDto, teamsDto] = await Promise.all([
|
||||
this.racesApi.getPageData() as Promise<RacesPageDataDTO>,
|
||||
this.leaguesApi.getAllWithCapacity() as Promise<{ leagues: LeagueSummaryDTO[] }>,
|
||||
this.teamsApi.getAll(),
|
||||
]);
|
||||
|
||||
const racesVm = new RacesPageViewModel(racesDto);
|
||||
|
||||
const topLeagues = leaguesDto.leagues.slice(0, 4).map(
|
||||
league => new LeagueCardViewModel({
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: 'Competitive iRacing league',
|
||||
}),
|
||||
);
|
||||
|
||||
const teams = (teamsDto as GetAllTeamsOutputDTO).teams.slice(0, 4).map(
|
||||
(team: TeamListItemDTO) =>
|
||||
new TeamCardViewModel({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
}),
|
||||
);
|
||||
|
||||
const upcomingRaces = racesVm.upcomingRaces.slice(0, 4).map(
|
||||
race =>
|
||||
new UpcomingRaceCardViewModel({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
}),
|
||||
);
|
||||
|
||||
return new HomeDiscoveryViewModel({
|
||||
topLeagues,
|
||||
teams,
|
||||
upcomingRaces,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -58,4 +58,51 @@ export class SponsorService {
|
||||
async getSponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDto> {
|
||||
return await this.apiClient.getPricing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sponsor billing information
|
||||
*/
|
||||
async getBilling(sponsorId: string): Promise<{
|
||||
paymentMethods: any[];
|
||||
invoices: any[];
|
||||
stats: any;
|
||||
}> {
|
||||
return await this.apiClient.getBilling(sponsorId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available leagues for sponsorship
|
||||
*/
|
||||
async getAvailableLeagues(): Promise<any[]> {
|
||||
return await this.apiClient.getAvailableLeagues();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed league information
|
||||
*/
|
||||
async getLeagueDetail(leagueId: string): Promise<{
|
||||
league: any;
|
||||
drivers: any[];
|
||||
races: any[];
|
||||
}> {
|
||||
return await this.apiClient.getLeagueDetail(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sponsor settings
|
||||
*/
|
||||
async getSettings(sponsorId: string): Promise<{
|
||||
profile: any;
|
||||
notifications: any;
|
||||
privacy: any;
|
||||
}> {
|
||||
return await this.apiClient.getSettings(sponsorId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sponsor settings
|
||||
*/
|
||||
async updateSettings(sponsorId: string, input: any): Promise<void> {
|
||||
return await this.apiClient.updateSettings(sponsorId, input);
|
||||
}
|
||||
}
|
||||
35
apps/website/lib/view-models/ActivityItemViewModel.ts
Normal file
35
apps/website/lib/view-models/ActivityItemViewModel.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Activity Item View Model
|
||||
*
|
||||
* View model for recent activity items.
|
||||
*/
|
||||
export class ActivityItemViewModel {
|
||||
id: string;
|
||||
type: 'race' | 'league' | 'team' | 'driver' | 'platform';
|
||||
message: string;
|
||||
time: string;
|
||||
impressions?: number;
|
||||
|
||||
constructor(data: any) {
|
||||
this.id = data.id;
|
||||
this.type = data.type;
|
||||
this.message = data.message;
|
||||
this.time = data.time;
|
||||
this.impressions = data.impressions;
|
||||
}
|
||||
|
||||
get typeColor(): string {
|
||||
const colors = {
|
||||
race: 'bg-warning-amber',
|
||||
league: 'bg-primary-blue',
|
||||
team: 'bg-purple-400',
|
||||
driver: 'bg-performance-green',
|
||||
platform: 'bg-racing-red',
|
||||
};
|
||||
return colors[this.type] || 'bg-gray-500';
|
||||
}
|
||||
|
||||
get formattedImpressions(): string | null {
|
||||
return this.impressions ? this.impressions.toLocaleString() : null;
|
||||
}
|
||||
}
|
||||
76
apps/website/lib/view-models/AvailableLeaguesViewModel.ts
Normal file
76
apps/website/lib/view-models/AvailableLeaguesViewModel.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Available Leagues View Model
|
||||
*
|
||||
* View model for leagues available for sponsorship.
|
||||
*/
|
||||
export class AvailableLeaguesViewModel {
|
||||
leagues: AvailableLeagueViewModel[];
|
||||
|
||||
constructor(leagues: any[]) {
|
||||
this.leagues = leagues.map(league => new AvailableLeagueViewModel(league));
|
||||
}
|
||||
}
|
||||
|
||||
export class AvailableLeagueViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
game: string;
|
||||
drivers: number;
|
||||
avgViewsPerRace: number;
|
||||
mainSponsorSlot: { available: boolean; price: number };
|
||||
secondarySlots: { available: number; total: number; price: number };
|
||||
rating: number;
|
||||
tier: 'premium' | 'standard' | 'starter';
|
||||
nextRace?: string;
|
||||
seasonStatus: 'active' | 'upcoming' | 'completed';
|
||||
description: string;
|
||||
|
||||
constructor(data: any) {
|
||||
this.id = data.id;
|
||||
this.name = data.name;
|
||||
this.game = data.game;
|
||||
this.drivers = data.drivers;
|
||||
this.avgViewsPerRace = data.avgViewsPerRace;
|
||||
this.mainSponsorSlot = data.mainSponsorSlot;
|
||||
this.secondarySlots = data.secondarySlots;
|
||||
this.rating = data.rating;
|
||||
this.tier = data.tier;
|
||||
this.nextRace = data.nextRace;
|
||||
this.seasonStatus = data.seasonStatus;
|
||||
this.description = data.description;
|
||||
}
|
||||
|
||||
get formattedAvgViews(): string {
|
||||
return `${(this.avgViewsPerRace / 1000).toFixed(1)}k`;
|
||||
}
|
||||
|
||||
get cpm(): number {
|
||||
return Math.round((this.mainSponsorSlot.price / this.avgViewsPerRace) * 1000);
|
||||
}
|
||||
|
||||
get formattedCpm(): string {
|
||||
return `$${this.cpm}`;
|
||||
}
|
||||
|
||||
get hasAvailableSlots(): boolean {
|
||||
return this.mainSponsorSlot.available || this.secondarySlots.available > 0;
|
||||
}
|
||||
|
||||
get tierConfig() {
|
||||
const configs = {
|
||||
premium: { color: 'text-yellow-400', bgColor: 'bg-yellow-500/10', border: 'border-yellow-500/30', icon: '⭐' },
|
||||
standard: { color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', border: 'border-primary-blue/30', icon: '🏆' },
|
||||
starter: { color: 'text-gray-400', bgColor: 'bg-gray-500/10', border: 'border-gray-500/30', icon: '🚀' },
|
||||
};
|
||||
return configs[this.tier];
|
||||
}
|
||||
|
||||
get statusConfig() {
|
||||
const configs = {
|
||||
active: { color: 'text-performance-green', bg: 'bg-performance-green/10', label: 'Active Season' },
|
||||
upcoming: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', label: 'Starting Soon' },
|
||||
completed: { color: 'text-gray-400', bg: 'bg-gray-400/10', label: 'Season Ended' },
|
||||
};
|
||||
return configs[this.seasonStatus];
|
||||
}
|
||||
}
|
||||
138
apps/website/lib/view-models/BillingViewModel.ts
Normal file
138
apps/website/lib/view-models/BillingViewModel.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Billing View Model
|
||||
*
|
||||
* View model for sponsor billing data with UI-specific transformations.
|
||||
*/
|
||||
export class BillingViewModel {
|
||||
paymentMethods: PaymentMethodViewModel[];
|
||||
invoices: InvoiceViewModel[];
|
||||
stats: BillingStatsViewModel;
|
||||
|
||||
constructor(data: {
|
||||
paymentMethods: any[];
|
||||
invoices: any[];
|
||||
stats: any;
|
||||
}) {
|
||||
this.paymentMethods = data.paymentMethods.map(pm => new PaymentMethodViewModel(pm));
|
||||
this.invoices = data.invoices.map(inv => new InvoiceViewModel(inv));
|
||||
this.stats = new BillingStatsViewModel(data.stats);
|
||||
}
|
||||
}
|
||||
|
||||
export class PaymentMethodViewModel {
|
||||
id: string;
|
||||
type: 'card' | 'bank' | 'sepa';
|
||||
last4: string;
|
||||
brand?: string;
|
||||
isDefault: boolean;
|
||||
expiryMonth?: number;
|
||||
expiryYear?: number;
|
||||
bankName?: string;
|
||||
|
||||
constructor(data: any) {
|
||||
this.id = data.id;
|
||||
this.type = data.type;
|
||||
this.last4 = data.last4;
|
||||
this.brand = data.brand;
|
||||
this.isDefault = data.isDefault;
|
||||
this.expiryMonth = data.expiryMonth;
|
||||
this.expiryYear = data.expiryYear;
|
||||
this.bankName = data.bankName;
|
||||
}
|
||||
|
||||
get displayLabel(): string {
|
||||
if (this.type === 'sepa' && this.bankName) {
|
||||
return `${this.bankName} •••• ${this.last4}`;
|
||||
}
|
||||
return `${this.brand} •••• ${this.last4}`;
|
||||
}
|
||||
|
||||
get expiryDisplay(): string | null {
|
||||
if (this.expiryMonth && this.expiryYear) {
|
||||
return `${String(this.expiryMonth).padStart(2, '0')}/${this.expiryYear}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class InvoiceViewModel {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
date: Date;
|
||||
dueDate: Date;
|
||||
amount: number;
|
||||
vatAmount: number;
|
||||
totalAmount: number;
|
||||
status: 'paid' | 'pending' | 'overdue' | 'failed';
|
||||
description: string;
|
||||
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
|
||||
pdfUrl: string;
|
||||
|
||||
constructor(data: any) {
|
||||
this.id = data.id;
|
||||
this.invoiceNumber = data.invoiceNumber;
|
||||
this.date = new Date(data.date);
|
||||
this.dueDate = new Date(data.dueDate);
|
||||
this.amount = data.amount;
|
||||
this.vatAmount = data.vatAmount;
|
||||
this.totalAmount = data.totalAmount;
|
||||
this.status = data.status;
|
||||
this.description = data.description;
|
||||
this.sponsorshipType = data.sponsorshipType;
|
||||
this.pdfUrl = data.pdfUrl;
|
||||
}
|
||||
|
||||
get formattedTotalAmount(): string {
|
||||
return `€${this.totalAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
get formattedVatAmount(): string {
|
||||
return `€${this.vatAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
get formattedDate(): string {
|
||||
return this.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
get isOverdue(): boolean {
|
||||
return this.status === 'overdue' || (this.status === 'pending' && new Date() > this.dueDate);
|
||||
}
|
||||
}
|
||||
|
||||
export class BillingStatsViewModel {
|
||||
totalSpent: number;
|
||||
pendingAmount: number;
|
||||
nextPaymentDate: Date;
|
||||
nextPaymentAmount: number;
|
||||
activeSponsorships: number;
|
||||
averageMonthlySpend: number;
|
||||
|
||||
constructor(data: any) {
|
||||
this.totalSpent = data.totalSpent;
|
||||
this.pendingAmount = data.pendingAmount;
|
||||
this.nextPaymentDate = new Date(data.nextPaymentDate);
|
||||
this.nextPaymentAmount = data.nextPaymentAmount;
|
||||
this.activeSponsorships = data.activeSponsorships;
|
||||
this.averageMonthlySpend = data.averageMonthlySpend;
|
||||
}
|
||||
|
||||
get formattedTotalSpent(): string {
|
||||
return `€${this.totalSpent.toLocaleString('de-DE')}`;
|
||||
}
|
||||
|
||||
get formattedPendingAmount(): string {
|
||||
return `€${this.pendingAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
get formattedNextPaymentAmount(): string {
|
||||
return `€${this.nextPaymentAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
get formattedAverageMonthlySpend(): string {
|
||||
return `€${this.averageMonthlySpend.toLocaleString('de-DE')}`;
|
||||
}
|
||||
|
||||
get formattedNextPaymentDate(): string {
|
||||
return this.nextPaymentDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
}
|
||||
25
apps/website/lib/view-models/HomeDiscoveryViewModel.ts
Normal file
25
apps/website/lib/view-models/HomeDiscoveryViewModel.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { LeagueCardViewModel } from './LeagueCardViewModel';
|
||||
import { TeamCardViewModel } from './TeamCardViewModel';
|
||||
import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel';
|
||||
|
||||
interface HomeDiscoveryDTO {
|
||||
topLeagues: LeagueCardViewModel[];
|
||||
teams: TeamCardViewModel[];
|
||||
upcomingRaces: UpcomingRaceCardViewModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Home discovery view model
|
||||
* Aggregates discovery data for the landing page.
|
||||
*/
|
||||
export class HomeDiscoveryViewModel {
|
||||
readonly topLeagues: LeagueCardViewModel[];
|
||||
readonly teams: TeamCardViewModel[];
|
||||
readonly upcomingRaces: UpcomingRaceCardViewModel[];
|
||||
|
||||
constructor(dto: HomeDiscoveryDTO) {
|
||||
this.topLeagues = dto.topLeagues;
|
||||
this.teams = dto.teams;
|
||||
this.upcomingRaces = dto.upcomingRaces;
|
||||
}
|
||||
}
|
||||
23
apps/website/lib/view-models/LeagueCardViewModel.ts
Normal file
23
apps/website/lib/view-models/LeagueCardViewModel.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { LeagueSummaryDTO } from '@/lib/types/generated/LeagueSummaryDTO';
|
||||
|
||||
interface LeagueCardDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* League card view model
|
||||
* UI representation of a league on the landing page.
|
||||
*/
|
||||
export class LeagueCardViewModel {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
|
||||
constructor(dto: LeagueCardDTO | LeagueSummaryDTO & { description?: string }) {
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
this.description = dto.description ?? 'Competitive iRacing league';
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,144 @@
|
||||
export interface MainSponsorInfo {
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
websiteUrl: string;
|
||||
/**
|
||||
* League Detail View Model
|
||||
*
|
||||
* View model for detailed league information for sponsors.
|
||||
*/
|
||||
export class LeagueDetailViewModel {
|
||||
league: LeagueViewModel;
|
||||
drivers: DriverViewModel[];
|
||||
races: RaceViewModel[];
|
||||
|
||||
constructor(data: { league: any; drivers: any[]; races: any[] }) {
|
||||
this.league = new LeagueViewModel(data.league);
|
||||
this.drivers = data.drivers.map(driver => new DriverViewModel(driver));
|
||||
this.races = data.races.map(race => new RaceViewModel(race));
|
||||
}
|
||||
}
|
||||
|
||||
export class LeagueDetailViewModel {
|
||||
export class LeagueViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
game: string;
|
||||
tier: 'premium' | 'standard' | 'starter';
|
||||
season: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
mainSponsor: MainSponsorInfo | null;
|
||||
isAdmin: boolean;
|
||||
drivers: number;
|
||||
races: number;
|
||||
completedRaces: number;
|
||||
totalImpressions: number;
|
||||
avgViewsPerRace: number;
|
||||
engagement: number;
|
||||
rating: number;
|
||||
seasonStatus: 'active' | 'upcoming' | 'completed';
|
||||
seasonDates: { start: string; end: string };
|
||||
nextRace?: { name: string; date: string };
|
||||
sponsorSlots: {
|
||||
main: { available: boolean; price: number; benefits: string[] };
|
||||
secondary: { available: number; total: number; price: number; benefits: string[] };
|
||||
};
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
ownerId: string,
|
||||
ownerName: string,
|
||||
mainSponsor: MainSponsorInfo | null,
|
||||
isAdmin: boolean
|
||||
) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.ownerId = ownerId;
|
||||
this.ownerName = ownerName;
|
||||
this.mainSponsor = mainSponsor;
|
||||
this.isAdmin = isAdmin;
|
||||
constructor(data: any) {
|
||||
this.id = data.id;
|
||||
this.name = data.name;
|
||||
this.game = data.game;
|
||||
this.tier = data.tier;
|
||||
this.season = data.season;
|
||||
this.description = data.description;
|
||||
this.drivers = data.drivers;
|
||||
this.races = data.races;
|
||||
this.completedRaces = data.completedRaces;
|
||||
this.totalImpressions = data.totalImpressions;
|
||||
this.avgViewsPerRace = data.avgViewsPerRace;
|
||||
this.engagement = data.engagement;
|
||||
this.rating = data.rating;
|
||||
this.seasonStatus = data.seasonStatus;
|
||||
this.seasonDates = data.seasonDates;
|
||||
this.nextRace = data.nextRace;
|
||||
this.sponsorSlots = data.sponsorSlots;
|
||||
}
|
||||
|
||||
// UI-specific getters can be added here if needed
|
||||
get formattedTotalImpressions(): string {
|
||||
return this.totalImpressions.toLocaleString();
|
||||
}
|
||||
|
||||
get formattedAvgViewsPerRace(): string {
|
||||
return this.avgViewsPerRace.toLocaleString();
|
||||
}
|
||||
|
||||
get projectedTotalViews(): number {
|
||||
return Math.round(this.avgViewsPerRace * this.races);
|
||||
}
|
||||
|
||||
get formattedProjectedTotal(): string {
|
||||
return this.projectedTotalViews.toLocaleString();
|
||||
}
|
||||
|
||||
get mainSponsorCpm(): number {
|
||||
return Math.round((this.sponsorSlots.main.price / this.projectedTotalViews) * 1000);
|
||||
}
|
||||
|
||||
get formattedMainSponsorCpm(): string {
|
||||
return `$${this.mainSponsorCpm.toFixed(2)}`;
|
||||
}
|
||||
|
||||
get racesLeft(): number {
|
||||
return this.races - this.completedRaces;
|
||||
}
|
||||
|
||||
get tierConfig() {
|
||||
const configs = {
|
||||
premium: { color: 'text-yellow-400', bgColor: 'bg-yellow-500/10', border: 'border-yellow-500/30' },
|
||||
standard: { color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', border: 'border-primary-blue/30' },
|
||||
starter: { color: 'text-gray-400', bgColor: 'bg-gray-500/10', border: 'border-gray-500/30' },
|
||||
};
|
||||
return configs[this.tier];
|
||||
}
|
||||
}
|
||||
|
||||
export class DriverViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
position: number;
|
||||
races: number;
|
||||
impressions: number;
|
||||
team: string;
|
||||
|
||||
constructor(data: any) {
|
||||
this.id = data.id;
|
||||
this.name = data.name;
|
||||
this.country = data.country;
|
||||
this.position = data.position;
|
||||
this.races = data.races;
|
||||
this.impressions = data.impressions;
|
||||
this.team = data.team;
|
||||
}
|
||||
|
||||
get formattedImpressions(): string {
|
||||
return this.impressions.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
export class RaceViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
date: Date;
|
||||
views: number;
|
||||
status: 'upcoming' | 'completed';
|
||||
|
||||
constructor(data: any) {
|
||||
this.id = data.id;
|
||||
this.name = data.name;
|
||||
this.date = new Date(data.date);
|
||||
this.views = data.views;
|
||||
this.status = data.status;
|
||||
}
|
||||
|
||||
get formattedDate(): string {
|
||||
return this.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
get formattedViews(): string {
|
||||
return this.views.toLocaleString();
|
||||
}
|
||||
}
|
||||
49
apps/website/lib/view-models/RenewalAlertViewModel.ts
Normal file
49
apps/website/lib/view-models/RenewalAlertViewModel.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Renewal Alert View Model
|
||||
*
|
||||
* View model for upcoming renewal alerts.
|
||||
*/
|
||||
export class RenewalAlertViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'league' | 'team' | 'driver' | 'race' | 'platform';
|
||||
renewDate: Date;
|
||||
price: number;
|
||||
|
||||
constructor(data: any) {
|
||||
this.id = data.id;
|
||||
this.name = data.name;
|
||||
this.type = data.type;
|
||||
this.renewDate = new Date(data.renewDate);
|
||||
this.price = data.price;
|
||||
}
|
||||
|
||||
get formattedPrice(): string {
|
||||
return `$${this.price}`;
|
||||
}
|
||||
|
||||
get formattedRenewDate(): string {
|
||||
return this.renewDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
get typeIcon() {
|
||||
const icons = {
|
||||
league: 'Trophy',
|
||||
team: 'Users',
|
||||
driver: 'Car',
|
||||
race: 'Flag',
|
||||
platform: 'Megaphone',
|
||||
};
|
||||
return icons[this.type] || 'Trophy';
|
||||
}
|
||||
|
||||
get daysUntilRenewal(): number {
|
||||
const now = new Date();
|
||||
const diffTime = this.renewDate.getTime() - now.getTime();
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
get isUrgent(): boolean {
|
||||
return this.daysUntilRenewal <= 30;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { SponsorDashboardDTO } from '../types/generated/SponsorDashboardDTO';
|
||||
import { SponsorshipViewModel } from './SponsorshipViewModel';
|
||||
import { ActivityItemViewModel } from './ActivityItemViewModel';
|
||||
import { RenewalAlertViewModel } from './RenewalAlertViewModel';
|
||||
|
||||
/**
|
||||
* Sponsor Dashboard View Model
|
||||
@@ -8,17 +11,72 @@ import type { SponsorDashboardDTO } from '../types/generated/SponsorDashboardDTO
|
||||
export class SponsorDashboardViewModel {
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
metrics: any;
|
||||
sponsorships: {
|
||||
leagues: SponsorshipViewModel[];
|
||||
teams: SponsorshipViewModel[];
|
||||
drivers: SponsorshipViewModel[];
|
||||
races: SponsorshipViewModel[];
|
||||
platform: SponsorshipViewModel[];
|
||||
};
|
||||
recentActivity: ActivityItemViewModel[];
|
||||
upcomingRenewals: RenewalAlertViewModel[];
|
||||
|
||||
constructor(dto: SponsorDashboardDTO) {
|
||||
this.sponsorId = dto.sponsorId;
|
||||
this.sponsorName = dto.sponsorName;
|
||||
this.metrics = dto.metrics;
|
||||
this.sponsorships = {
|
||||
leagues: (dto.sponsorships?.leagues || []).map(s => new SponsorshipViewModel(s)),
|
||||
teams: (dto.sponsorships?.teams || []).map(s => new SponsorshipViewModel(s)),
|
||||
drivers: (dto.sponsorships?.drivers || []).map(s => new SponsorshipViewModel(s)),
|
||||
races: (dto.sponsorships?.races || []).map(s => new SponsorshipViewModel(s)),
|
||||
platform: (dto.sponsorships?.platform || []).map(s => new SponsorshipViewModel(s)),
|
||||
};
|
||||
this.recentActivity = (dto.recentActivity || []).map(a => new ActivityItemViewModel(a));
|
||||
this.upcomingRenewals = (dto.upcomingRenewals || []).map(r => new RenewalAlertViewModel(r));
|
||||
}
|
||||
|
||||
// Note: The generated DTO doesn't include these fields yet
|
||||
// These will need to be added when the OpenAPI spec is updated
|
||||
totalSponsorships: number = 0;
|
||||
activeSponsorships: number = 0;
|
||||
totalInvestment: number = 0;
|
||||
get totalSponsorships(): number {
|
||||
return this.sponsorships.leagues.length +
|
||||
this.sponsorships.teams.length +
|
||||
this.sponsorships.drivers.length +
|
||||
this.sponsorships.races.length +
|
||||
this.sponsorships.platform.length;
|
||||
}
|
||||
|
||||
get activeSponsorships(): number {
|
||||
const all = [
|
||||
...this.sponsorships.leagues,
|
||||
...this.sponsorships.teams,
|
||||
...this.sponsorships.drivers,
|
||||
...this.sponsorships.races,
|
||||
...this.sponsorships.platform,
|
||||
];
|
||||
return all.filter(s => s.status === 'active').length;
|
||||
}
|
||||
|
||||
get totalInvestment(): number {
|
||||
const all = [
|
||||
...this.sponsorships.leagues,
|
||||
...this.sponsorships.teams,
|
||||
...this.sponsorships.drivers,
|
||||
...this.sponsorships.races,
|
||||
...this.sponsorships.platform,
|
||||
];
|
||||
return all.filter(s => s.status === 'active').reduce((sum, s) => sum + s.price, 0);
|
||||
}
|
||||
|
||||
get totalImpressions(): number {
|
||||
const all = [
|
||||
...this.sponsorships.leagues,
|
||||
...this.sponsorships.teams,
|
||||
...this.sponsorships.drivers,
|
||||
...this.sponsorships.races,
|
||||
...this.sponsorships.platform,
|
||||
];
|
||||
return all.reduce((sum, s) => sum + s.impressions, 0);
|
||||
}
|
||||
|
||||
/** UI-specific: Formatted total investment */
|
||||
get formattedTotalInvestment(): string {
|
||||
@@ -42,4 +100,36 @@ export class SponsorDashboardViewModel {
|
||||
if (this.activeSponsorships === this.totalSponsorships) return 'All sponsorships active';
|
||||
return `${this.activeSponsorships} of ${this.totalSponsorships} active`;
|
||||
}
|
||||
|
||||
/** UI-specific: Cost per 1K views */
|
||||
get costPerThousandViews(): string {
|
||||
if (this.totalImpressions === 0) return '$0.00';
|
||||
return `$${(this.totalInvestment / this.totalImpressions * 1000).toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** UI-specific: Category data for charts */
|
||||
get categoryData() {
|
||||
return {
|
||||
leagues: {
|
||||
count: this.sponsorships.leagues.length,
|
||||
impressions: this.sponsorships.leagues.reduce((sum, l) => sum + l.impressions, 0),
|
||||
},
|
||||
teams: {
|
||||
count: this.sponsorships.teams.length,
|
||||
impressions: this.sponsorships.teams.reduce((sum, t) => sum + t.impressions, 0),
|
||||
},
|
||||
drivers: {
|
||||
count: this.sponsorships.drivers.length,
|
||||
impressions: this.sponsorships.drivers.reduce((sum, d) => sum + d.impressions, 0),
|
||||
},
|
||||
races: {
|
||||
count: this.sponsorships.races.length,
|
||||
impressions: this.sponsorships.races.reduce((sum, r) => sum + r.impressions, 0),
|
||||
},
|
||||
platform: {
|
||||
count: this.sponsorships.platform.length,
|
||||
impressions: this.sponsorships.platform.reduce((sum, p) => sum + p.impressions, 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
89
apps/website/lib/view-models/SponsorSettingsViewModel.ts
Normal file
89
apps/website/lib/view-models/SponsorSettingsViewModel.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Sponsor Settings View Model
|
||||
*
|
||||
* View model for sponsor settings data.
|
||||
*/
|
||||
export class SponsorSettingsViewModel {
|
||||
profile: SponsorProfileViewModel;
|
||||
notifications: NotificationSettingsViewModel;
|
||||
privacy: PrivacySettingsViewModel;
|
||||
|
||||
constructor(data: { profile: any; notifications: any; privacy: any }) {
|
||||
this.profile = new SponsorProfileViewModel(data.profile);
|
||||
this.notifications = new NotificationSettingsViewModel(data.notifications);
|
||||
this.privacy = new PrivacySettingsViewModel(data.privacy);
|
||||
}
|
||||
}
|
||||
|
||||
export class SponsorProfileViewModel {
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
website: string;
|
||||
description: string;
|
||||
logoUrl: string | null;
|
||||
industry: string;
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
country: string;
|
||||
postalCode: string;
|
||||
};
|
||||
taxId: string;
|
||||
socialLinks: {
|
||||
twitter: string;
|
||||
linkedin: string;
|
||||
instagram: string;
|
||||
};
|
||||
|
||||
constructor(data: any) {
|
||||
this.companyName = data.companyName;
|
||||
this.contactName = data.contactName;
|
||||
this.contactEmail = data.contactEmail;
|
||||
this.contactPhone = data.contactPhone;
|
||||
this.website = data.website;
|
||||
this.description = data.description;
|
||||
this.logoUrl = data.logoUrl;
|
||||
this.industry = data.industry;
|
||||
this.address = data.address;
|
||||
this.taxId = data.taxId;
|
||||
this.socialLinks = data.socialLinks;
|
||||
}
|
||||
|
||||
get fullAddress(): string {
|
||||
return `${this.address.street}, ${this.address.city}, ${this.address.postalCode}, ${this.address.country}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotificationSettingsViewModel {
|
||||
emailNewSponsorships: boolean;
|
||||
emailWeeklyReport: boolean;
|
||||
emailRaceAlerts: boolean;
|
||||
emailPaymentAlerts: boolean;
|
||||
emailNewOpportunities: boolean;
|
||||
emailContractExpiry: boolean;
|
||||
|
||||
constructor(data: any) {
|
||||
this.emailNewSponsorships = data.emailNewSponsorships;
|
||||
this.emailWeeklyReport = data.emailWeeklyReport;
|
||||
this.emailRaceAlerts = data.emailRaceAlerts;
|
||||
this.emailPaymentAlerts = data.emailPaymentAlerts;
|
||||
this.emailNewOpportunities = data.emailNewOpportunities;
|
||||
this.emailContractExpiry = data.emailContractExpiry;
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivacySettingsViewModel {
|
||||
publicProfile: boolean;
|
||||
showStats: boolean;
|
||||
showActiveSponsorships: boolean;
|
||||
allowDirectContact: boolean;
|
||||
|
||||
constructor(data: any) {
|
||||
this.publicProfile = data.publicProfile;
|
||||
this.showStats = data.showStats;
|
||||
this.showActiveSponsorships = data.showActiveSponsorships;
|
||||
this.allowDirectContact = data.allowDirectContact;
|
||||
}
|
||||
}
|
||||
92
apps/website/lib/view-models/SponsorshipViewModel.ts
Normal file
92
apps/website/lib/view-models/SponsorshipViewModel.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Sponsorship View Model
|
||||
*
|
||||
* View model for individual sponsorship data.
|
||||
*/
|
||||
export class SponsorshipViewModel {
|
||||
id: string;
|
||||
type: 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
tier?: 'main' | 'secondary';
|
||||
status: 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired';
|
||||
applicationDate?: Date;
|
||||
approvalDate?: Date;
|
||||
rejectionReason?: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
price: number;
|
||||
impressions: number;
|
||||
impressionsChange?: number;
|
||||
engagement?: number;
|
||||
details?: string;
|
||||
entityOwner?: string;
|
||||
applicationMessage?: string;
|
||||
|
||||
constructor(data: any) {
|
||||
this.id = data.id;
|
||||
this.type = data.type;
|
||||
this.entityId = data.entityId;
|
||||
this.entityName = data.entityName;
|
||||
this.tier = data.tier;
|
||||
this.status = data.status;
|
||||
this.applicationDate = data.applicationDate ? new Date(data.applicationDate) : undefined;
|
||||
this.approvalDate = data.approvalDate ? new Date(data.approvalDate) : undefined;
|
||||
this.rejectionReason = data.rejectionReason;
|
||||
this.startDate = new Date(data.startDate);
|
||||
this.endDate = new Date(data.endDate);
|
||||
this.price = data.price;
|
||||
this.impressions = data.impressions;
|
||||
this.impressionsChange = data.impressionsChange;
|
||||
this.engagement = data.engagement;
|
||||
this.details = data.details;
|
||||
this.entityOwner = data.entityOwner;
|
||||
this.applicationMessage = data.applicationMessage;
|
||||
}
|
||||
|
||||
get formattedImpressions(): string {
|
||||
return this.impressions.toLocaleString();
|
||||
}
|
||||
|
||||
get formattedPrice(): string {
|
||||
return `$${this.price}`;
|
||||
}
|
||||
|
||||
get daysRemaining(): number {
|
||||
const now = new Date();
|
||||
const diffTime = this.endDate.getTime() - now.getTime();
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
get isExpiringSoon(): boolean {
|
||||
return this.daysRemaining > 0 && this.daysRemaining <= 30;
|
||||
}
|
||||
|
||||
get statusLabel(): string {
|
||||
const labels = {
|
||||
active: 'Active',
|
||||
pending_approval: 'Awaiting Approval',
|
||||
approved: 'Approved',
|
||||
rejected: 'Declined',
|
||||
expired: 'Expired',
|
||||
};
|
||||
return labels[this.status] || this.status;
|
||||
}
|
||||
|
||||
get typeLabel(): string {
|
||||
const labels = {
|
||||
leagues: 'League',
|
||||
teams: 'Team',
|
||||
drivers: 'Driver',
|
||||
races: 'Race',
|
||||
platform: 'Platform',
|
||||
};
|
||||
return labels[this.type] || this.type;
|
||||
}
|
||||
|
||||
get periodDisplay(): string {
|
||||
const start = this.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
const end = this.endDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
return `${start} - ${end}`;
|
||||
}
|
||||
}
|
||||
26
apps/website/lib/view-models/TeamCardViewModel.ts
Normal file
26
apps/website/lib/view-models/TeamCardViewModel.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { TeamListItemDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO';
|
||||
|
||||
interface TeamCardDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Team card view model
|
||||
* UI representation of a team on the landing page.
|
||||
*/
|
||||
export class TeamCardViewModel {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly tag: string;
|
||||
readonly description: string;
|
||||
|
||||
constructor(dto: TeamCardDTO | TeamListItemDTO) {
|
||||
this.id = dto.id;
|
||||
this.name = dto.name;
|
||||
this.tag = dto.tag;
|
||||
this.description = dto.description;
|
||||
}
|
||||
}
|
||||
32
apps/website/lib/view-models/UpcomingRaceCardViewModel.ts
Normal file
32
apps/website/lib/view-models/UpcomingRaceCardViewModel.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
interface UpcomingRaceCardDTO {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming race card view model
|
||||
* UI representation of an upcoming race on the landing page.
|
||||
*/
|
||||
export class UpcomingRaceCardViewModel {
|
||||
readonly id: string;
|
||||
readonly track: string;
|
||||
readonly car: string;
|
||||
readonly scheduledAt: string;
|
||||
|
||||
constructor(dto: UpcomingRaceCardDTO) {
|
||||
this.id = dto.id;
|
||||
this.track = dto.track;
|
||||
this.car = dto.car;
|
||||
this.scheduledAt = dto.scheduledAt;
|
||||
}
|
||||
|
||||
/** UI-specific: formatted date label */
|
||||
get formattedDate(): string {
|
||||
return new Date(this.scheduledAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user