website refactor

This commit is contained in:
2026-01-19 14:07:49 +01:00
parent 54f42bab9f
commit 6154d54435
88 changed files with 755 additions and 566 deletions

View File

@@ -1,11 +1,15 @@
'use client';
import { useSponsorSponsorships } from "@/hooks/sponsor/useSponsorSponsorships";
import { SponsorCampaignsTemplate, SponsorshipType } from "@/templates/SponsorCampaignsTemplate";
import { SponsorCampaignsTemplate, SponsorshipType, SponsorCampaignsViewData } from "@/templates/SponsorCampaignsTemplate";
import { Box } from "@/ui/Box";
import { Button } from "@/ui/Button";
import { Text } from "@/ui/Text";
import { useState } from 'react';
import { CurrencyDisplay } from "@/lib/display-objects/CurrencyDisplay";
import { NumberDisplay } from "@/lib/display-objects/NumberDisplay";
import { DateDisplay } from "@/lib/display-objects/DateDisplay";
import { StatusDisplay } from "@/lib/display-objects/StatusDisplay";
export default function SponsorCampaignsPage() {
const [typeFilter, setTypeFilter] = useState<SponsorshipType>('all');
@@ -39,22 +43,33 @@ export default function SponsorCampaignsPage() {
}
// Calculate stats
const totalInvestment = sponsorshipsData.sponsorships.filter((s: any) => s.status === 'active').reduce((sum: number, s: any) => sum + s.price, 0);
const totalImpressions = sponsorshipsData.sponsorships.reduce((sum: number, s: any) => sum + s.impressions, 0);
const stats = {
total: sponsorshipsData.sponsorships.length,
active: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'active').length,
pending: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'pending_approval').length,
approved: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'approved').length,
rejected: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'rejected').length,
totalInvestment: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'active').reduce((sum: number, s: any) => sum + s.price, 0),
totalImpressions: sponsorshipsData.sponsorships.reduce((sum: number, s: any) => sum + s.impressions, 0),
formattedTotalInvestment: CurrencyDisplay.format(totalInvestment),
formattedTotalImpressions: NumberDisplay.formatCompact(totalImpressions),
};
const viewData = {
sponsorships: sponsorshipsData.sponsorships as any,
stats,
const sponsorships = sponsorshipsData.sponsorships.map((s: any) => ({
...s,
formattedInvestment: CurrencyDisplay.format(s.price),
formattedImpressions: NumberDisplay.format(s.impressions),
formattedStartDate: s.seasonStartDate ? DateDisplay.formatShort(s.seasonStartDate) : undefined,
formattedEndDate: s.seasonEndDate ? DateDisplay.formatShort(s.seasonEndDate) : undefined,
}));
const viewData: SponsorCampaignsViewData = {
sponsorships,
stats: stats as any,
};
const filteredSponsorships = sponsorshipsData.sponsorships.filter((s: any) => {
const filteredSponsorships = sponsorships.filter((s: any) => {
// For now, we only have leagues in the DTO
if (typeFilter !== 'all' && typeFilter !== 'leagues') return false;
if (searchQuery && !s.leagueName.toLowerCase().includes(searchQuery.toLowerCase())) return false;

View File

@@ -17,6 +17,8 @@ import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRos
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
export function RosterAdminPage({ viewData: initialViewData }: Partial<ClientWrapperProps<LeagueRosterAdminViewData>>) {
@@ -81,6 +83,7 @@ export function RosterAdminPage({ viewData: initialViewData }: Partial<ClientWra
id: req.id,
driver: req.driver as { id: string; name: string },
requestedAt: req.requestedAt,
formattedRequestedAt: DateDisplay.formatShort(req.requestedAt),
message: req.message || undefined,
})),
members: members.map((m: LeagueRosterMemberDTO): RosterMemberData => ({
@@ -88,6 +91,7 @@ export function RosterAdminPage({ viewData: initialViewData }: Partial<ClientWra
driver: m.driver as { id: string; name: string },
role: m.role,
joinedAt: m.joinedAt,
formattedJoinedAt: DateDisplay.formatShort(m.joinedAt),
})),
}), [leagueId, joinRequests, members]);

View File

@@ -17,7 +17,7 @@ interface Achievement {
description: string;
icon: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary' | string;
earnedAt: Date;
earnedAtLabel: string;
}
interface AchievementGridProps {
@@ -72,7 +72,7 @@ export function AchievementGrid({ achievements }: AchievementGridProps) {
</Text>
<Text size="xs" variant="low"></Text>
<Text size="xs" variant="low">
{AchievementDisplay.formatDate(achievement.earnedAt)}
{achievement.earnedAtLabel}
</Text>
</Group>
</Stack>

View File

@@ -9,7 +9,8 @@ interface DriverHeaderPanelProps {
avatarUrl?: string;
nationality: string;
rating: number;
globalRank?: number | null;
ratingLabel: string;
globalRankLabel?: string | null;
bio?: string | null;
actions?: React.ReactNode;
}
@@ -19,7 +20,8 @@ export function DriverHeaderPanel({
avatarUrl,
nationality,
rating,
globalRank,
ratingLabel,
globalRankLabel,
bio,
actions
}: DriverHeaderPanelProps) {
@@ -54,8 +56,8 @@ export function DriverHeaderPanel({
rounded="2xl"
overflow="hidden"
border
borderColor="border-charcoal-outline"
bg="bg-graphite-black"
borderColor="border-charcoal-outline"
bg="bg-graphite-black"
flexShrink={0}
>
<Image
@@ -73,16 +75,16 @@ export function DriverHeaderPanel({
<Text as="h1" size="3xl" weight="bold" color="text-white">
{name}
</Text>
<RatingBadge rating={rating} size="lg" />
<RatingBadge rating={rating} ratingLabel={ratingLabel} size="lg" />
</Stack>
<Stack direction="row" align="center" gap={4} wrap>
<Text size="sm" color="text-gray-400">
{nationality}
</Text>
{globalRank !== undefined && globalRank !== null && (
{globalRankLabel && (
<Text size="sm" color="text-gray-400">
Global Rank: <Text color="text-warning-amber" weight="semibold">#{globalRank}</Text>
Global Rank: <Text color="text-warning-amber" weight="semibold">{globalRankLabel}</Text>
</Text>
)}
</Stack>

View File

@@ -14,8 +14,10 @@ interface DriverProfileHeaderProps {
avatarUrl?: string | null;
nationality: string;
rating: number;
ratingLabel: string;
safetyRating?: number;
globalRank?: number;
safetyRatingLabel: string;
globalRankLabel?: string;
bio?: string | null;
friendRequestSent: boolean;
onAddFriend: () => void;
@@ -26,8 +28,10 @@ export function DriverProfileHeader({
avatarUrl,
nationality,
rating,
ratingLabel,
safetyRating = 92,
globalRank,
safetyRatingLabel,
globalRankLabel,
bio,
friendRequestSent,
onAddFriend,
@@ -56,11 +60,11 @@ export function DriverProfileHeader({
<Stack>
<Stack direction="row" align="center" gap={3} mb={1}>
<Heading level={1}>{name}</Heading>
{globalRank && (
{globalRankLabel && (
<Stack display="flex" alignItems="center" gap={1} rounded="md" bg="bg-warning-amber/10" px={2} py={0.5} border borderColor="border-warning-amber/20">
<Trophy size={12} color="#FFBE4D" />
<Text size="xs" weight="bold" font="mono" color="text-warning-amber">
#{globalRank}
{globalRankLabel}
</Text>
</Stack>
)}
@@ -72,8 +76,8 @@ export function DriverProfileHeader({
</Stack>
<Stack w="1" h="1" rounded="full" bg="bg-gray-700" />
<Stack direction="row" align="center" gap={2}>
<RatingBadge rating={rating} size="sm" />
<SafetyRatingBadge rating={safetyRating} size="sm" />
<RatingBadge rating={rating} ratingLabel={ratingLabel} size="sm" />
<SafetyRatingBadge rating={safetyRating} ratingLabel={safetyRatingLabel} size="sm" />
</Stack>
</Stack>
</Stack>

View File

@@ -13,7 +13,8 @@ interface DriverTableRowProps {
avatarUrl?: string | null;
nationality: string;
rating: number;
wins: number;
ratingLabel: string;
winsLabel: string;
onClick: () => void;
}
@@ -23,7 +24,8 @@ export function DriverTableRow({
avatarUrl,
nationality,
rating,
wins,
ratingLabel,
winsLabel,
onClick,
}: DriverTableRowProps) {
return (
@@ -58,11 +60,11 @@ export function DriverTableRow({
<Text size="xs" variant="low">{nationality}</Text>
</TableCell>
<TableCell textAlign="right">
<RatingBadge rating={rating} size="sm" />
<RatingBadge rating={rating} ratingLabel={ratingLabel} size="sm" />
</TableCell>
<TableCell textAlign="right">
<Text size="sm" weight="semibold" font="mono" variant="success">
{wins}
{winsLabel}
</Text>
</TableCell>
</TableRow>

View File

@@ -17,25 +17,25 @@ interface DriverStat {
}
interface DriversDirectoryHeaderProps {
totalDrivers: number;
activeDrivers: number;
totalWins: number;
totalRaces: number;
totalDriversLabel: string;
activeDriversLabel: string;
totalWinsLabel: string;
totalRacesLabel: string;
onViewLeaderboard: () => void;
}
export function DriversDirectoryHeader({
totalDrivers,
activeDrivers,
totalWins,
totalRaces,
totalDriversLabel,
activeDriversLabel,
totalWinsLabel,
totalRacesLabel,
onViewLeaderboard,
}: DriversDirectoryHeaderProps) {
const stats: DriverStat[] = [
{ label: 'drivers', value: totalDrivers, intent: 'primary' },
{ label: 'active', value: activeDrivers, intent: 'success' },
{ label: 'total wins', value: totalWins.toLocaleString(), intent: 'warning' },
{ label: 'races', value: totalRaces.toLocaleString(), intent: 'telemetry' },
{ label: 'drivers', value: totalDriversLabel, intent: 'primary' },
{ label: 'active', value: activeDriversLabel, intent: 'success' },
{ label: 'total wins', value: totalWinsLabel, intent: 'warning' },
{ label: 'races', value: totalRacesLabel, intent: 'telemetry' },
];
return (

View File

@@ -33,9 +33,9 @@ interface FeaturedDriverCardProps {
name: string;
nationality: string;
avatarUrl?: string;
rating: number;
wins: number;
podiums: number;
ratingLabel: string;
winsLabel: string;
podiumsLabel: string;
skillLevel?: string;
category?: string;
};
@@ -142,17 +142,17 @@ export function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriver
<Box display="grid" gridCols={3} gap={3}>
<MiniStat
label="Rating"
value={driver.rating.toLocaleString()}
value={driver.ratingLabel}
color="text-primary-blue"
/>
<MiniStat
label="Wins"
value={driver.wins}
value={driver.winsLabel}
color="text-performance-green"
/>
<MiniStat
label="Podiums"
value={driver.podiums}
value={driver.podiumsLabel}
color="text-warning-amber"
/>
</Box>

View File

@@ -17,12 +17,12 @@ interface ProfileHeroProps {
avatarUrl?: string;
country: string;
iracingId: number;
joinedAt: string | Date;
joinedAtLabel: string;
};
stats: {
rating: number;
ratingLabel: string;
} | null;
globalRank: number;
globalRankLabel: string;
timezone: string;
socialHandles: {
platform: string;
@@ -47,7 +47,7 @@ function getSocialIcon(platform: string) {
export function ProfileHero({
driver,
stats,
globalRank,
globalRankLabel,
timezone,
socialHandles,
onAddFriend,
@@ -87,14 +87,14 @@ export function ProfileHero({
<Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
<Stack direction="row" align="center" gap={2}>
<Star style={{ width: '1rem', height: '1rem', color: '#3b82f6' }} />
<Text font="mono" weight="bold" color="text-primary-blue">{stats.rating}</Text>
<Text font="mono" weight="bold" color="text-primary-blue">{stats.ratingLabel}</Text>
<Text size="xs" color="text-gray-400">Rating</Text>
</Stack>
</Surface>
<Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: 'rgba(250, 204, 21, 0.1)', border: '1px solid rgba(250, 204, 21, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
<Stack direction="row" align="center" gap={2}>
<Trophy style={{ width: '1rem', height: '1rem', color: '#facc15' }} />
<Text font="mono" weight="bold" style={{ color: '#facc15' }}>#{globalRank}</Text>
<Text font="mono" weight="bold" style={{ color: '#facc15' }}>{globalRankLabel}</Text>
<Text size="xs" color="text-gray-400">Global</Text>
</Stack>
</Surface>
@@ -111,11 +111,7 @@ export function ProfileHero({
<Stack direction="row" align="center" gap={1.5}>
<Calendar style={{ width: '1rem', height: '1rem' }} />
<Text size="sm">
Joined{' '}
{new Date(driver.joinedAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
})}
Joined {driver.joinedAtLabel}
</Text>
</Stack>
<Stack direction="row" align="center" gap={1.5}>

View File

@@ -3,10 +3,11 @@ import { Badge } from '@/ui/Badge';
interface RatingBadgeProps {
rating: number;
ratingLabel: string;
size?: 'sm' | 'md' | 'lg';
}
export function RatingBadge({ rating, size = 'md' }: RatingBadgeProps) {
export function RatingBadge({ rating, ratingLabel, size = 'md' }: RatingBadgeProps) {
const badgeSize = size === 'lg' ? 'md' : size;
const getVariant = (val: number): 'warning' | 'primary' | 'success' | 'default' => {
@@ -22,7 +23,7 @@ export function RatingBadge({ rating, size = 'md' }: RatingBadgeProps) {
variant={getVariant(rating)}
size={badgeSize}
>
{rating.toLocaleString()}
{ratingLabel}
</Badge>
);
}

View File

@@ -6,10 +6,11 @@ import { Shield } from 'lucide-react';
interface SafetyRatingBadgeProps {
rating: number;
ratingLabel: string;
size?: 'sm' | 'md' | 'lg';
}
export function SafetyRatingBadge({ rating, size = 'md' }: SafetyRatingBadgeProps) {
export function SafetyRatingBadge({ rating, ratingLabel, size = 'md' }: SafetyRatingBadgeProps) {
const getColor = (r: number) => {
if (r >= 90) return 'text-performance-green';
if (r >= 70) return 'text-warning-amber';
@@ -65,7 +66,7 @@ export function SafetyRatingBadge({ rating, size = 'md' }: SafetyRatingBadgeProp
font="mono"
color={colorClass}
>
SR {rating.toFixed(0)}
{ratingLabel}
</Text>
</Box>
);

View File

@@ -29,6 +29,12 @@ import {
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { DurationDisplay } from '@/lib/display-objects/DurationDisplay';
import { MemoryDisplay } from '@/lib/display-objects/MemoryDisplay';
import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
import { TimeDisplay } from '@/lib/display-objects/TimeDisplay';
interface ErrorAnalyticsDashboardProps {
/**
* Auto-refresh interval in milliseconds
@@ -41,16 +47,16 @@ interface ErrorAnalyticsDashboardProps {
}
function formatDuration(duration: number): string {
return duration.toFixed(2) + 'ms';
return DurationDisplay.formatMs(duration);
}
function formatPercentage(value: number, total: number): string {
if (total === 0) return '0%';
return ((value / total) * 100).toFixed(1) + '%';
return PercentDisplay.format(value / total);
}
function formatMemory(bytes: number): string {
return (bytes / 1024 / 1024).toFixed(1) + 'MB';
return MemoryDisplay.formatMB(bytes);
}
interface PerformanceWithMemory extends Performance {
@@ -321,7 +327,7 @@ export function ErrorAnalyticsDashboard({
<Stack display="flex" justifyContent="between" alignItems="start" gap={2} mb={1}>
<Text size="xs" font="mono" weight="bold" color="text-red-400" truncate>{error.type}</Text>
<Text size="xs" color="text-gray-500" fontSize="10px">
{new Date(error.timestamp).toLocaleTimeString()}
{DateDisplay.formatTime(error.timestamp)}
</Text>
</Stack>
<Text size="xs" color="text-gray-300" block mb={1}>{error.message}</Text>

View File

@@ -8,6 +8,7 @@ import { Grid } from '@/ui/Grid';
import { Stack } from '@/ui/Stack';
import { Section } from '@/ui/Section';
import { Text } from '@/ui/Text';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
interface FeedItemData {
id: string;
@@ -46,6 +47,16 @@ export function FeedLayout({
upcomingRaces,
latestResults
}: FeedLayoutProps) {
const formattedUpcomingRaces = upcomingRaces.map(r => ({
...r,
formattedDate: DateDisplay.formatShort(r.scheduledAt),
}));
const formattedLatestResults = latestResults.map(r => ({
...r,
formattedDate: DateDisplay.formatShort(r.scheduledAt),
}));
return (
<Section className="mt-16 mb-20">
<Container>
@@ -64,8 +75,8 @@ export function FeedLayout({
</Card>
</Stack>
<Stack as="aside" gap={6}>
<UpcomingRacesSidebar races={upcomingRaces} />
<LatestResultsSidebar results={latestResults} />
<UpcomingRacesSidebar races={formattedUpcomingRaces} />
<LatestResultsSidebar results={formattedLatestResults} />
</Stack>
</Grid>
</Container>

View File

@@ -4,7 +4,7 @@ import { Text } from '@/ui/Text';
interface JoinRequestItemProps {
driverId: string;
requestedAt: string | Date;
formattedRequestedAt: string;
onApprove: () => void;
onReject: () => void;
isApproving?: boolean;
@@ -13,7 +13,7 @@ interface JoinRequestItemProps {
export function JoinRequestItem({
driverId,
requestedAt,
formattedRequestedAt,
onApprove,
onReject,
isApproving,
@@ -47,7 +47,7 @@ export function JoinRequestItem({
<Stack flexGrow={1}>
<Text color="text-white" weight="medium" block>{driverId}</Text>
<Text size="sm" color="text-gray-400" block>
Requested {new Date(requestedAt).toLocaleDateString()}
Requested {formattedRequestedAt}
</Text>
</Stack>
</Stack>

View File

@@ -1,6 +1,7 @@
import { ActivityFeedItem } from '@/components/feed/ActivityFeedItem';
import { useLeagueRaces } from "@/hooks/league/useLeagueRaces";
import { LeagueActivityService } from '@/lib/services/league/LeagueActivityService';
import { RelativeTimeDisplay } from '@/lib/display-objects/RelativeTimeDisplay';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
@@ -20,19 +21,6 @@ interface LeagueActivityFeedProps {
limit?: number;
}
function timeAgo(timestamp: Date): string {
const diffMs = Date.now() - timestamp.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes} min ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
export function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) {
const { data: raceList = [], isLoading } = useLeagueRaces(leagueId);
@@ -140,7 +128,7 @@ function ActivityItem({ activity }: { activity: LeagueActivity }) {
<ActivityFeedItem
icon={getIcon()}
content={getContent()}
timestamp={timeAgo(activity.timestamp)}
timestamp={RelativeTimeDisplay.format(activity.timestamp, new Date())}
/>
);
}

View File

@@ -27,6 +27,9 @@ import { Icon } from '@/ui/Icon';
import { Card } from '@/ui/Card';
import { Grid } from '@/ui/Grid';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { DurationDisplay } from '@/lib/display-objects/DurationDisplay';
interface LeagueReviewSummaryProps {
form: LeagueConfigFormModel;
presets: LeagueScoringPresetViewModel[];
@@ -139,11 +142,7 @@ export function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps)
const seasonStartLabel =
timings.seasonStartDate
? new Date(timings.seasonStartDate).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
? DateDisplay.formatShort(timings.seasonStartDate)
: null;
const stewardingLabel = (() => {

View File

@@ -194,17 +194,10 @@ export function LeagueSchedule({ leagueId, onRaceClick }: LeagueScheduleProps) {
<Stack display="flex" alignItems="center" gap={3}>
<Stack textAlign="right">
<Text color="text-white" weight="medium" block>
{race.scheduledAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
{race.formattedDate}
</Text>
<Text size="sm" color="text-gray-400" block>
{race.scheduledAt.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
{race.formattedTime}
</Text>
{isPast && race.status === 'completed' && (
<Text size="xs" color="text-primary-blue" mt={1} block>View Results </Text>

View File

@@ -286,7 +286,7 @@ export function ReviewProtestModal({
<Stack direction="row" align="center" justify="between">
<Text size="sm" color="text-gray-400">Filed Date</Text>
<Text size="sm" color="text-white" weight="medium">
{new Date(protest.filedAt || protest.submittedAt).toLocaleString()}
{protest.formattedSubmittedAt}
</Text>
</Stack>
<Stack direction="row" align="center" justify="between">
@@ -299,7 +299,7 @@ export function ReviewProtestModal({
<Text size="sm" color="text-gray-400">Status</Text>
<Stack as="span" px={2} py={1} rounded="sm" bg="bg-orange-500/20">
<Text size="xs" weight="medium" color="text-orange-400">
{protest.status}
{protest.statusDisplay}
</Text>
</Stack>
</Stack>

View File

@@ -15,8 +15,10 @@ interface Race {
name: string;
track?: string;
car?: string;
scheduledAt: string;
formattedDate: string;
formattedTime: string;
status: string;
statusLabel: string;
sessionType?: string;
isPast?: boolean;
}
@@ -33,19 +35,19 @@ export function ScheduleRaceCard({ race }: ScheduleRaceCardProps) {
<Stack w="3" h="3" rounded="full" bg={race.isPast ? 'bg-performance-green' : 'bg-primary-blue'} />
<Heading level={3} fontSize="lg">{race.name}</Heading>
<Badge variant={race.status === 'completed' ? 'success' : 'primary'}>
{race.status === 'completed' ? 'Completed' : 'Scheduled'}
{race.statusLabel}
</Badge>
</Stack>
<Grid cols={4} gap={4}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Calendar} size={4} color="#9ca3af" />
<Text size="sm" color="text-gray-300">{new Date(race.scheduledAt).toLocaleDateString()}</Text>
<Text size="sm" color="text-gray-300">{race.formattedDate}</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Clock} size={4} color="#9ca3af" />
<Text size="sm" color="text-gray-300">{new Date(race.scheduledAt).toLocaleTimeString()}</Text>
<Text size="sm" color="text-gray-300">{race.formattedTime}</Text>
</Stack>
{race.track && (

View File

@@ -11,7 +11,8 @@ interface SponsorshipRequest {
id: string;
sponsorName: string;
status: 'pending' | 'approved' | 'rejected';
requestedAt: string;
statusLabel: string;
formattedRequestedAt: string;
slotName: string;
}
@@ -57,7 +58,7 @@ export function SponsorshipRequestCard({ request }: SponsorshipRequestCardProps)
<Icon icon={statusIcon} size={5} color={statusColor} />
<Text weight="semibold" color="text-white">{request.sponsorName}</Text>
<Badge variant={statusVariant}>
{request.status}
{request.statusLabel}
</Badge>
</Stack>
@@ -66,7 +67,7 @@ export function SponsorshipRequestCard({ request }: SponsorshipRequestCardProps)
</Text>
<Text size="xs" color="text-gray-400" block>
{new Date(request.requestedAt).toLocaleDateString()}
{request.formattedRequestedAt}
</Text>
</Stack>
</Stack>

View File

@@ -55,12 +55,12 @@ interface StandingsTableProps {
standings: Array<{
driverId: string;
position: number;
totalPoints: number;
racesFinished: number;
racesStarted: number;
avgFinish: number | null;
penaltyPoints: number;
bonusPoints: number;
positionLabel: string;
totalPointsLabel: string;
racesLabel: string;
avgFinishLabel: string;
penaltyPointsLabel: string;
bonusPointsLabel: string;
teamName?: string;
}>;
drivers: Array<{
@@ -508,7 +508,7 @@ export function StandingsTable({
'text-white'
}
>
{row.position}
{row.positionLabel}
</Stack>
</TableCell>
@@ -625,7 +625,7 @@ export function StandingsTable({
{/* Total Points with Hover Action */}
<TableCell textAlign="right" position="relative">
<Stack display="flex" alignItems="center" justifyContent="end" gap={2}>
<Text color="text-white" weight="bold" size="lg">{row.totalPoints}</Text>
<Text color="text-white" weight="bold" size="lg">{row.totalPointsLabel}</Text>
{isAdmin && canModify && (
<Stack
as="button"
@@ -650,28 +650,27 @@ export function StandingsTable({
{/* Races (Finished/Started) */}
<TableCell textAlign="center">
<Text color="text-white">{row.racesFinished}</Text>
<Text color="text-gray-500">/{row.racesStarted}</Text>
<Text color="text-white">{row.racesLabel}</Text>
</TableCell>
{/* Avg Finish */}
<TableCell textAlign="right">
<Text color="text-gray-300">
{row.avgFinish !== null ? row.avgFinish.toFixed(1) : '—'}
{row.avgFinishLabel}
</Text>
</TableCell>
{/* Penalty */}
<TableCell textAlign="right">
<Text color={row.penaltyPoints > 0 ? 'text-red-400' : 'text-gray-500'} weight={row.penaltyPoints > 0 ? 'medium' : 'normal'}>
{row.penaltyPoints > 0 ? `-${row.penaltyPoints}` : '—'}
<Text color="text-gray-500">
{row.penaltyPointsLabel}
</Text>
</TableCell>
{/* Bonus */}
<TableCell textAlign="right">
<Text color={row.bonusPoints !== 0 ? 'text-green-400' : 'text-gray-500'} weight={row.bonusPoints !== 0 ? 'medium' : 'normal'}>
{row.bonusPoints !== 0 ? `+${row.bonusPoints}` : '—'}
<Text color="text-gray-500">
{row.bonusPointsLabel}
</Text>
</TableCell>
</TableRow>

View File

@@ -13,7 +13,7 @@ interface Protest {
protestingDriver: string;
accusedDriver: string;
description: string;
submittedAt: string;
formattedSubmittedAt: string;
status: 'pending' | 'under_review' | 'resolved' | 'rejected';
}
@@ -63,7 +63,7 @@ export function StewardingQueuePanel({ protests, onReview }: StewardingQueuePane
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={Clock} size={3} color="text-gray-600" />
<Text size="xs" color="text-gray-500">
{new Date(protest.submittedAt).toLocaleString()}
{protest.formattedSubmittedAt}
</Text>
</Stack>
</Stack>

View File

@@ -9,21 +9,21 @@ import { ArrowDownLeft, ArrowUpRight, History, Wallet } from 'lucide-react';
interface Transaction {
id: string;
type: 'credit' | 'debit';
amount: number;
type: 'deposit' | 'withdrawal' | 'sponsorship' | 'prize';
formattedAmount: string;
description: string;
date: string;
formattedDate: string;
}
interface WalletSummaryPanelProps {
balance: number;
formattedBalance: string;
currency: string;
transactions: Transaction[];
onDeposit: () => void;
onWithdraw: () => void;
}
export function WalletSummaryPanel({ balance, currency, transactions, onDeposit, onWithdraw }: WalletSummaryPanelProps) {
export function WalletSummaryPanel({ formattedBalance, currency, transactions, onDeposit, onWithdraw }: WalletSummaryPanelProps) {
return (
<Stack gap={6}>
<Surface variant="dark" border rounded="lg" padding={8} position="relative" overflow="hidden">
@@ -48,7 +48,7 @@ export function WalletSummaryPanel({ balance, currency, transactions, onDeposit,
</Stack>
<Stack direction="row" align="baseline" gap={2}>
<Text size="4xl" weight="bold" color="text-white">
{balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{formattedBalance}
</Text>
<Text size="xl" weight="medium" color="text-gray-500">{currency}</Text>
</Stack>
@@ -87,37 +87,40 @@ export function WalletSummaryPanel({ balance, currency, transactions, onDeposit,
<Text color="text-gray-500">No recent transactions.</Text>
</Stack>
) : (
transactions.map((tx) => (
<Stack key={tx.id} p={4} borderBottom borderColor="border-charcoal-outline" hoverBg="bg-white/5" transition>
<Stack direction="row" justify="between" align="center">
<Stack direction="row" align="center" gap={4}>
<Stack
center
w={10}
h={10}
rounded="full"
bg={tx.type === 'credit' ? 'bg-performance-green/10' : 'bg-error-red/10'}
transactions.map((tx) => {
const isCredit = tx.type === 'deposit' || tx.type === 'sponsorship';
return (
<Stack key={tx.id} p={4} borderBottom borderColor="border-charcoal-outline" hoverBg="bg-white/5" transition>
<Stack direction="row" justify="between" align="center">
<Stack direction="row" align="center" gap={4}>
<Stack
center
w={10}
h={10}
rounded="full"
bg={isCredit ? 'bg-performance-green/10' : 'bg-error-red/10'}
>
<Icon
icon={isCredit ? ArrowDownLeft : ArrowUpRight}
size={4}
color={isCredit ? 'text-performance-green' : 'text-error-red'}
/>
</Stack>
<Stack gap={0.5}>
<Text weight="medium" color="text-white">{tx.description}</Text>
<Text size="xs" color="text-gray-500">{tx.formattedDate}</Text>
</Stack>
</Stack>
<Text
weight="bold"
color={isCredit ? 'text-performance-green' : 'text-white'}
>
<Icon
icon={tx.type === 'credit' ? ArrowDownLeft : ArrowUpRight}
size={4}
color={tx.type === 'credit' ? 'text-performance-green' : 'text-error-red'}
/>
</Stack>
<Stack gap={0.5}>
<Text weight="medium" color="text-white">{tx.description}</Text>
<Text size="xs" color="text-gray-500">{new Date(tx.date).toLocaleDateString()}</Text>
</Stack>
{tx.formattedAmount}
</Text>
</Stack>
<Text
weight="bold"
color={tx.type === 'credit' ? 'text-performance-green' : 'text-white'}
>
{tx.type === 'credit' ? '+' : '-'}{tx.amount.toFixed(2)}
</Text>
</Stack>
</Stack>
))
);
})
)}
</Stack>
</Surface>

View File

@@ -19,12 +19,12 @@ interface ProfileHeaderProps {
avatarUrl?: string;
country: string;
iracingId: number;
joinedAt: string | Date;
joinedAtLabel: string;
};
stats: {
rating: number;
ratingLabel: string;
} | null;
globalRank: number;
globalRankLabel: string;
onAddFriend?: () => void;
friendRequestSent?: boolean;
isOwnProfile?: boolean;
@@ -33,7 +33,7 @@ interface ProfileHeaderProps {
export function ProfileHeader({
driver,
stats,
globalRank,
globalRankLabel,
onAddFriend,
friendRequestSent,
isOwnProfile,
@@ -69,7 +69,7 @@ export function ProfileHeader({
<Group gap={1.5}>
<Calendar size={14} color="var(--ui-color-text-low)" />
<Text size="xs" variant="low">
Joined {new Date(driver.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
Joined {driver.joinedAtLabel}
</Text>
</Group>
</Group>
@@ -78,8 +78,8 @@ export function ProfileHeader({
<ProfileStatsGroup>
{stats && (
<React.Fragment>
<ProfileStat label="RATING" value={stats.rating} intent="primary" />
<ProfileStat label="GLOBAL RANK" value={`#${globalRank}`} intent="warning" />
<ProfileStat label="RATING" value={stats.ratingLabel} intent="primary" />
<ProfileStat label="GLOBAL RANK" value={globalRankLabel} intent="warning" />
</React.Fragment>
)}
</ProfileStatsGroup>

View File

@@ -9,7 +9,7 @@ type RaceWithResults = {
track: string;
car: string;
winnerName: string;
scheduledAt: string | Date;
formattedDate: string;
};
interface LatestResultsSidebarProps {
@@ -28,14 +28,12 @@ export function LatestResultsSidebar({ results }: LatestResultsSidebarProps) {
</Heading>
<RaceResultList>
{results.slice(0, 4).map((result) => {
const scheduledAt = typeof result.scheduledAt === 'string' ? new Date(result.scheduledAt) : result.scheduledAt;
return (
<Box as="li" key={result.raceId}>
<RaceSummaryItem
track={result.track}
meta={`${result.winnerName}${result.car}`}
dateLabel={scheduledAt.toLocaleDateString()}
dateLabel={result.formattedDate}
/>
</Box>
);

View File

@@ -10,7 +10,8 @@ import { Calendar, Car, Clock, LucideIcon } from 'lucide-react';
interface RaceHeroProps {
track: string;
scheduledAt: string;
formattedDate: string;
formattedTime: string;
car: string;
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
statusConfig: {
@@ -20,9 +21,8 @@ interface RaceHeroProps {
};
}
export function RaceHero({ track, scheduledAt, car, status, statusConfig }: RaceHeroProps) {
export function RaceHero({ track, formattedDate, formattedTime, car, status, statusConfig }: RaceHeroProps) {
const StatusIcon = statusConfig.icon;
const date = new Date(scheduledAt);
return (
<Hero variant="primary">
@@ -59,11 +59,11 @@ export function RaceHero({ track, scheduledAt, car, status, statusConfig }: Race
<Stack direction="row" align="center" gap={6} wrap>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Calendar} size={4} color="rgb(156, 163, 175)" />
<Text color="text-gray-400">{date.toLocaleDateString()}</Text>
<Text color="text-gray-400">{formattedDate}</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Clock} size={4} color="rgb(156, 163, 175)" />
<Text color="text-gray-400">{date.toLocaleTimeString()}</Text>
<Text color="text-gray-400">{formattedTime}</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Car} size={4} color="rgb(156, 163, 175)" />

View File

@@ -2,6 +2,7 @@
import { RaceHero as UiRaceHero } from '@/components/races/RaceHero';
import { LucideIcon } from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
interface RaceHeroProps {
track: string;
@@ -17,7 +18,7 @@ interface RaceHeroProps {
}
export function RaceHero(props: RaceHeroProps) {
const { statusConfig, ...rest } = props;
const { statusConfig, scheduledAt, ...rest } = props;
// Map variant to match UI component expectations
const mappedConfig: {
@@ -30,5 +31,12 @@ export function RaceHero(props: RaceHeroProps) {
variant: statusConfig.variant === 'default' ? 'default' : statusConfig.variant
};
return <UiRaceHero {...rest} statusConfig={mappedConfig} />;
return (
<UiRaceHero
{...rest}
formattedDate={DateDisplay.formatShort(scheduledAt)}
formattedTime={DateDisplay.formatTime(scheduledAt)}
statusConfig={mappedConfig}
/>
);
}

View File

@@ -2,7 +2,8 @@
import { routes } from '@/lib/routing/RouteConfig';
import { RaceListItem as UiRaceListItem } from '@/components/races/RaceListItem';
import { CheckCircle2, Clock, PlayCircle, XCircle } from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { StatusDisplay } from '@/lib/display-objects/StatusDisplay';
interface Race {
id: string;
@@ -26,45 +27,32 @@ export function RaceListItem({ race, onClick }: RaceListItemProps) {
scheduled: {
iconName: 'Clock',
variant: 'primary' as const,
label: 'Scheduled',
},
running: {
iconName: 'PlayCircle',
variant: 'success' as const,
label: 'LIVE',
},
completed: {
iconName: 'CheckCircle2',
variant: 'default' as const,
label: 'Completed',
},
cancelled: {
iconName: 'XCircle',
variant: 'warning' as const,
label: 'Cancelled',
},
};
const config = statusConfig[race.status];
const formatTime = (date: string) => {
return new Date(date).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const date = new Date(race.scheduledAt);
return (
<UiRaceListItem
track={race.track}
car={race.car}
dateLabel={date.toLocaleDateString('en-US', { month: 'short' })}
dayLabel={date.getDate().toString()}
timeLabel={formatTime(race.scheduledAt)}
dateLabel={DateDisplay.formatMonthDay(race.scheduledAt).split(' ')[0]}
dayLabel={DateDisplay.formatMonthDay(race.scheduledAt).split(' ')[1]}
timeLabel={DateDisplay.formatTime(race.scheduledAt)}
status={race.status}
statusLabel={config.label}
statusLabel={StatusDisplay.raceStatus(race.status)}
statusVariant={config.variant}
statusIconName={config.iconName}
leagueName={race.leagueName}

View File

@@ -12,10 +12,12 @@ interface RaceResultCardProps {
raceId: string;
track: string;
car: string;
scheduledAt: string | Date;
formattedDate: string;
position: number;
startPosition: number;
incidents: number;
positionLabel: string;
startPositionLabel: string;
incidentsLabel: string;
positionsGainedLabel?: string;
leagueName?: string;
showLeague?: boolean;
onClick?: () => void;
@@ -25,10 +27,12 @@ export function RaceResultCard({
raceId,
track,
car,
scheduledAt,
formattedDate,
position,
startPosition,
incidents,
positionLabel,
startPositionLabel,
incidentsLabel,
positionsGainedLabel,
leagueName,
showLeague = true,
onClick,
@@ -66,7 +70,7 @@ export function RaceResultCard({
border
borderColor="border-outline-steel"
>
P{position}
{positionLabel}
</Stack>
<Stack>
<Text color="text-white" weight="medium" block groupHoverTextColor="text-primary-accent" transition>
@@ -78,11 +82,7 @@ export function RaceResultCard({
<Stack direction="row" align="center" gap={3}>
<Stack textAlign="right">
<Text size="sm" color="text-gray-400" block>
{new Date(scheduledAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
{formattedDate}
</Text>
{showLeague && leagueName && (
<Text size="xs" color="text-gray-500" block>{leagueName}</Text>
@@ -92,16 +92,16 @@ export function RaceResultCard({
</Stack>
</Stack>
<Stack direction="row" align="center" gap={4}>
<Text size="xs" color="text-gray-500">Started P{startPosition}</Text>
<Text size="xs" color="text-gray-500">Started {startPositionLabel}</Text>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" color={incidents === 0 ? 'text-success-green' : incidents > 2 ? 'text-error-red' : 'text-gray-500'}>
{incidents}x incidents
<Text size="xs" color="text-gray-500">
{incidentsLabel}
</Text>
{position < startPosition && (
{positionsGainedLabel && (
<>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" color="text-success-green">
+{startPosition - position} positions
{positionsGainedLabel}
</Text>
</>
)}

View File

@@ -2,6 +2,7 @@
import { RaceResultViewModel } from '@/lib/view-models/RaceResultViewModel';
import { RaceResultCard as UiRaceResultCard } from './RaceResultCard';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
interface RaceResultCardProps {
race: {
@@ -28,10 +29,12 @@ export function RaceResultCard({
raceId={race.id}
track={race.track}
car={race.car}
scheduledAt={race.scheduledAt}
formattedDate={DateDisplay.formatShort(race.scheduledAt)}
position={result.position}
startPosition={result.startPosition}
incidents={result.incidents}
positionLabel={result.formattedPosition}
startPositionLabel={result.formattedStartPosition}
incidentsLabel={result.formattedIncidents}
positionsGainedLabel={result.formattedPositionsGained}
leagueName={league?.name}
showLeague={showLeague}
/>

View File

@@ -8,7 +8,7 @@ type UpcomingRace = {
id: string;
track: string;
car: string;
scheduledAt: string | Date;
formattedDate: string;
};
interface UpcomingRacesSidebarProps {
@@ -35,14 +35,12 @@ export function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProps) {
</Stack>
<Stack gap={3}>
{races.slice(0, 4).map((race) => {
const scheduledAt = typeof race.scheduledAt === 'string' ? new Date(race.scheduledAt) : race.scheduledAt;
return (
<RaceSummaryItem
key={race.id}
track={race.track}
meta={race.car}
dateLabel={scheduledAt.toLocaleDateString()}
dateLabel={race.formattedDate}
/>
);
})}

View File

@@ -8,6 +8,7 @@ export interface PricingTier {
id: string;
name: string;
price: number;
priceLabel: string;
period: string;
description: string;
features: string[];
@@ -69,7 +70,7 @@ export function PricingTableShell({ title, tiers, onSelect, selectedId }: Pricin
{tier.name}
</Text>
<Stack direction="row" align="baseline" gap={1}>
<Text size="3xl" weight="bold" color="text-white">${tier.price}</Text>
<Text size="3xl" weight="bold" color="text-white">{tier.priceLabel}</Text>
<Text size="sm" color="text-gray-500">/{tier.period}</Text>
</Stack>
<Text size="sm" color="text-gray-400" block mt={2}>

View File

@@ -1,62 +1,61 @@
export interface SponsorshipSlot {
tier: 'main' | 'secondary';
available: boolean;
price: number;
currency?: string;
priceLabel: string;
benefits: string[];
}
export const SlotTemplates = {
league: (mainAvailable: boolean, secondaryAvailable: number, mainPrice: number, secondaryPrice: number): SponsorshipSlot[] => [
league: (mainAvailable: boolean, secondaryAvailable: number, mainPriceLabel: string, secondaryPriceLabel: string): SponsorshipSlot[] => [
{
tier: 'main',
available: mainAvailable,
price: mainPrice,
priceLabel: mainPriceLabel,
benefits: ['Hood placement', 'League banner', 'Prominent logo'],
},
{
tier: 'secondary',
available: secondaryAvailable > 0,
price: secondaryPrice,
priceLabel: secondaryPriceLabel,
benefits: ['Side logo placement', 'League page listing'],
},
{
tier: 'secondary',
available: secondaryAvailable > 1,
price: secondaryPrice,
priceLabel: secondaryPriceLabel,
benefits: ['Side logo placement', 'League page listing'],
},
],
race: (mainAvailable: boolean, mainPrice: number): SponsorshipSlot[] => [
race: (mainAvailable: boolean, mainPriceLabel: string): SponsorshipSlot[] => [
{
tier: 'main',
available: mainAvailable,
price: mainPrice,
priceLabel: mainPriceLabel,
benefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
},
],
driver: (available: boolean, price: number): SponsorshipSlot[] => [
driver: (available: boolean, priceLabel: string): SponsorshipSlot[] => [
{
tier: 'main',
available,
price,
priceLabel,
benefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
},
],
team: (mainAvailable: boolean, secondaryAvailable: boolean, mainPrice: number, secondaryPrice: number): SponsorshipSlot[] => [
team: (mainAvailable: boolean, secondaryAvailable: boolean, mainPriceLabel: string, secondaryPriceLabel: string): SponsorshipSlot[] => [
{
tier: 'main',
available: mainAvailable,
price: mainPrice,
priceLabel: mainPriceLabel,
benefits: ['Team name suffix', 'Car livery', 'All driver suits'],
},
{
tier: 'secondary',
available: secondaryAvailable,
price: secondaryPrice,
priceLabel: secondaryPriceLabel,
benefits: ['Team page logo', 'Minor livery placement'],
},
],

View File

@@ -47,11 +47,11 @@ export interface SponsorInsightsProps {
slots: SponsorshipSlot[];
additionalStats?: {
label: string;
items: Array<{ label: string; value: string | number }>;
items: Array<{ label: string; value: string }>;
};
trustScore?: number;
discordMembers?: number;
monthlyActivity?: number;
trustScoreLabel?: string;
discordMembersLabel?: string;
monthlyActivityLabel?: string;
ctaLabel?: string;
ctaHref?: string;
currentSponsorId?: string;
@@ -67,9 +67,9 @@ export function SponsorInsightsCard({
metrics,
slots,
additionalStats,
trustScore,
discordMembers,
monthlyActivity,
trustScoreLabel,
discordMembersLabel,
monthlyActivityLabel,
ctaLabel,
ctaHref,
currentSponsorId,
@@ -111,22 +111,10 @@ export function SponsorInsightsCard({
setError(null);
try {
const slot = slotTier === 'main' ? mainSlot : secondarySlots[0];
const slotPrice = slot?.price ?? 0;
const request = {
sponsorId: currentSponsorId,
entityType: getSponsorableEntityType(entityType),
entityId,
tier: slotTier,
offeredAmount: slotPrice * 100,
currency: (slot?.currency as 'USD' | 'EUR' | 'GBP') ?? 'USD',
message: `Interested in sponsoring ${entityName} as ${slotTier} sponsor.`,
};
console.log('Sponsorship request:', request);
setAppliedTiers(prev => new Set([...prev, slotTier]));
// Note: In a real app, we would fetch the raw price from the API or a ViewModel
// For now, we assume the parent handles the actual request logic
onSponsorshipRequested?.(slotTier);
setAppliedTiers(prev => new Set([...prev, slotTier]));
} catch (err) {
console.error('Failed to apply for sponsorship:', err);
@@ -134,7 +122,7 @@ export function SponsorInsightsCard({
} finally {
setApplyingTier(null);
}
}, [currentSponsorId, ctaHref, entityType, entityId, entityName, onNavigate, mainSlot, secondarySlots, appliedTiers, getSponsorableEntityType, onSponsorshipRequested]);
}, [currentSponsorId, ctaHref, entityType, entityId, onNavigate, appliedTiers, onSponsorshipRequested]);
return (
<Card
@@ -171,27 +159,27 @@ export function SponsorInsightsCard({
})}
</Stack>
{(trustScore !== undefined || discordMembers !== undefined || monthlyActivity !== undefined) && (
{(trustScoreLabel || discordMembersLabel || monthlyActivityLabel) && (
<Stack display="flex" flexWrap="wrap" gap={4} mb={4} pb={4} borderBottom borderColor="border-charcoal-outline/50">
{trustScore !== undefined && (
{trustScoreLabel && (
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} color="rgb(16, 185, 129)" />
<Text size="sm" color="text-gray-400">Trust Score:</Text>
<Text size="sm" weight="semibold" color="text-white">{trustScore}/100</Text>
<Text size="sm" weight="semibold" color="text-white">{trustScoreLabel}</Text>
</Stack>
)}
{discordMembers !== undefined && (
{discordMembersLabel && (
<Stack direction="row" align="center" gap={2}>
<Icon icon={MessageCircle} size={4} color="rgb(168, 85, 247)" />
<Text size="sm" color="text-gray-400">Discord:</Text>
<Text size="sm" weight="semibold" color="text-white">{discordMembers.toLocaleString()}</Text>
<Text size="sm" weight="semibold" color="text-white">{discordMembersLabel}</Text>
</Stack>
)}
{monthlyActivity !== undefined && (
{monthlyActivityLabel && (
<Stack direction="row" align="center" gap={2}>
<Icon icon={Activity} size={4} color="rgb(0, 255, 255)" />
<Text size="sm" color="text-gray-400">Monthly Activity:</Text>
<Text size="sm" weight="semibold" color="text-white">{monthlyActivity}%</Text>
<Text size="sm" weight="semibold" color="text-white">{monthlyActivityLabel}</Text>
</Stack>
)}
</Stack>
@@ -206,7 +194,7 @@ export function SponsorInsightsCard({
statusColor={mainSlot.available ? 'text-performance-green' : 'text-gray-500'}
benefits={mainSlot.benefits.join(' • ')}
available={mainSlot.available}
price={`$${mainSlot.price.toLocaleString()}/season`}
price={mainSlot.priceLabel}
action={
<Button
variant="primary"
@@ -240,7 +228,7 @@ export function SponsorInsightsCard({
statusColor={availableSecondary > 0 ? 'text-purple-400' : 'text-gray-500'}
benefits={secondarySlots[0]?.benefits.join(' • ') || 'Logo placement on page'}
available={availableSecondary > 0}
price={`$${secondarySlots[0]?.price.toLocaleString()}/season`}
price={secondarySlots[0]?.priceLabel}
action={
<Button
variant="secondary"
@@ -275,7 +263,7 @@ export function SponsorInsightsCard({
<Stack key={index} direction="row" align="center" gap={2}>
<Text size="sm" color="text-gray-500">{item.label}:</Text>
<Text size="sm" weight="semibold" color="text-white">
{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}
{item.value}
</Text>
</Stack>
))}

View File

@@ -3,10 +3,10 @@ import { ComponentType } from 'react';
export interface SponsorMetric {
icon: string | ComponentType;
label: string;
value: string | number;
value: string;
color?: string;
trend?: {
value: number;
value: string;
isPositive: boolean;
};
}
@@ -14,7 +14,6 @@ export interface SponsorMetric {
export interface SponsorshipSlot {
tier: 'main' | 'secondary';
available: boolean;
price: number;
currency?: string;
priceLabel: string;
benefits: string[];
}

View File

@@ -5,11 +5,11 @@ import { LucideIcon } from 'lucide-react';
interface SponsorMetricCardProps {
label: string;
value: string | number;
value: string;
icon: LucideIcon;
color?: string;
trend?: {
value: number;
value: string;
isPositive: boolean;
};
}
@@ -35,11 +35,11 @@ export function SponsorMetricCard({
</Box>
<Box display="flex" alignItems="baseline" gap={2}>
<Text size="xl" weight="bold" color="text-white">
{typeof value === 'number' ? value.toLocaleString() : value}
{value}
</Text>
{trend && (
<Text size="xs" color={trend.isPositive ? 'text-performance-green' : 'text-red-400'}>
{trend.isPositive ? '+' : ''}{trend.value}%
{trend.value}
</Text>
)}
</Box>

View File

@@ -8,8 +8,8 @@ import { LucideIcon } from 'lucide-react';
interface SponsorshipCategoryCardProps {
icon: LucideIcon;
title: string;
count: number;
impressions: number;
countLabel: string;
impressionsLabel: string;
color: string;
href: string;
}
@@ -17,8 +17,8 @@ interface SponsorshipCategoryCardProps {
export function SponsorshipCategoryCard({
icon,
title,
count,
impressions,
countLabel,
impressionsLabel,
color,
href
}: SponsorshipCategoryCardProps) {
@@ -39,11 +39,11 @@ export function SponsorshipCategoryCard({
</Stack>
<Stack>
<Text weight="medium" color="text-white" block>{title}</Text>
<Text size="sm" color="text-gray-500">{count} active</Text>
<Text size="sm" color="text-gray-500">{countLabel}</Text>
</Stack>
</Stack>
<Stack textAlign="right">
<Text weight="semibold" color="text-white" block>{impressions.toLocaleString()}</Text>
<Text weight="semibold" color="text-white" block>{impressionsLabel}</Text>
<Text size="xs" color="text-gray-500">impressions</Text>
</Stack>
</Stack>

View File

@@ -7,9 +7,9 @@ import { AlertTriangle, Check, Clock, Download, Receipt } from 'lucide-react';
export interface Transaction {
id: string;
date: string;
formattedDate: string;
description: string;
amount: number;
formattedAmount: string;
status: 'paid' | 'pending' | 'overdue' | 'failed';
invoiceNumber: string;
type: string;
@@ -103,11 +103,11 @@ export function TransactionTable({ transactions, onDownload }: TransactionTableP
</Stack>
<Stack colSpan={{ base: 1, md: 2 } as any}>
<Text size="sm" color="text-gray-400">{new Date(tx.date).toLocaleDateString()}</Text>
<Text size="sm" color="text-gray-400">{tx.formattedDate}</Text>
</Stack>
<Stack colSpan={{ base: 1, md: 2 } as any}>
<Text weight="semibold" color="text-white">${tx.amount.toFixed(2)}</Text>
<Text weight="semibold" color="text-white">{tx.formattedAmount}</Text>
</Stack>
<Stack colSpan={{ base: 1, md: 2 } as any}>

View File

@@ -28,9 +28,9 @@ interface SkillLevelSectionProps {
description?: string;
logoUrl?: string;
memberCount: number;
rating?: number;
totalWins: number;
totalRaces: number;
ratingLabel: string;
winsLabel: string;
racesLabel: string;
performanceLevel: string;
isRecruiting: boolean;
specialization?: string;
@@ -86,9 +86,9 @@ export function SkillLevelSection({
description={team.description ?? ''}
logo={team.logoUrl}
memberCount={team.memberCount}
rating={team.rating}
totalWins={team.totalWins}
totalRaces={team.totalRaces}
ratingLabel={team.ratingLabel}
winsLabel={team.winsLabel}
racesLabel={team.racesLabel}
performanceLevel={team.performanceLevel as SkillLevel}
isRecruiting={team.isRecruiting}
specialization={specialization(team.specialization)}

View File

@@ -21,9 +21,9 @@ interface TeamCardProps {
description?: string;
logo?: string;
memberCount: number;
rating?: number | null;
totalWins?: number;
totalRaces?: number;
ratingLabel: string;
winsLabel: string;
racesLabel: string;
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
isRecruiting?: boolean;
specialization?: 'endurance' | 'sprint' | 'mixed' | undefined;
@@ -65,9 +65,9 @@ export function TeamCard({
description,
logo,
memberCount,
rating,
totalWins,
totalRaces,
ratingLabel,
winsLabel,
racesLabel,
performanceLevel,
isRecruiting,
specialization,
@@ -112,9 +112,9 @@ export function TeamCard({
)}
statsContent={
<Group gap={4} justify="center">
<TeamStatItem label="Rating" value={typeof rating === 'number' ? Math.round(rating).toLocaleString() : '—'} intent="primary" align="center" />
<TeamStatItem label="Wins" value={totalWins ?? 0} intent="success" align="center" />
<TeamStatItem label="Races" value={totalRaces ?? 0} intent="high" align="center" />
<TeamStatItem label="Rating" value={ratingLabel} intent="primary" align="center" />
<TeamStatItem label="Wins" value={winsLabel} intent="success" align="center" />
<TeamStatItem label="Races" value={racesLabel} intent="high" align="center" />
</Group>
}
/>

View File

@@ -11,10 +11,10 @@ interface TeamLeaderboardItemProps {
name: string;
logoUrl: string;
category?: string;
memberCount: number;
totalWins: number;
memberCountLabel: string;
totalWinsLabel: string;
isRecruiting: boolean;
rating?: number;
ratingLabel: string;
onClick?: () => void;
medalColor: string;
medalBg: string;
@@ -26,10 +26,10 @@ export function TeamLeaderboardItem({
name,
logoUrl,
category,
memberCount,
totalWins,
memberCountLabel,
totalWinsLabel,
isRecruiting,
rating,
ratingLabel,
onClick,
medalColor,
medalBg,
@@ -99,11 +99,11 @@ export function TeamLeaderboardItem({
)}
<Stack direction="row" align="center" gap={1}>
<Icon icon={Users} size={3} color="var(--text-gray-600)" />
<Text size="xs" color="text-gray-500">{memberCount}</Text>
<Text size="xs" color="text-gray-500">{memberCountLabel}</Text>
</Stack>
<Stack direction="row" align="center" gap={1}>
<Icon icon={Trophy} size={3} color="var(--text-gray-600)" />
<Text size="xs" color="text-gray-500">{totalWins} wins</Text>
<Text size="xs" color="text-gray-500">{totalWinsLabel}</Text>
</Stack>
{isRecruiting && (
<Stack direction="row" align="center" gap={1}>
@@ -117,7 +117,7 @@ export function TeamLeaderboardItem({
{/* Rating */}
<Stack textAlign="right">
<Text font="mono" weight="semibold" color="text-purple-400" block>
{typeof rating === 'number' ? Math.round(rating).toLocaleString() : '—'}
{ratingLabel}
</Text>
<Text size="xs" color="text-gray-500">Rating</Text>
</Stack>

View File

@@ -9,10 +9,10 @@ interface TeamLeaderboardPanelProps {
id: string;
name: string;
logoUrl?: string;
rating: number;
wins: number;
races: number;
memberCount: number;
ratingLabel: string;
winsLabel: string;
racesLabel: string;
memberCountLabel: string;
}>;
onTeamClick: (id: string) => void;
}
@@ -53,16 +53,16 @@ export function TeamLeaderboardPanel({ teams, onTeamClick }: TeamLeaderboardPane
</Stack>
</TableCell>
<TableCell className="text-center">
<Text font="mono" weight="bold" color="text-primary-blue">{team.rating}</Text>
<Text font="mono" weight="bold" color="text-primary-blue">{team.ratingLabel}</Text>
</TableCell>
<TableCell className="text-center">
<Text font="mono" color="text-gray-300">{team.wins}</Text>
<Text font="mono" color="text-gray-300">{team.winsLabel}</Text>
</TableCell>
<TableCell className="text-center">
<Text font="mono" color="text-gray-300">{team.races}</Text>
<Text font="mono" color="text-gray-300">{team.racesLabel}</Text>
</TableCell>
<TableCell className="text-center">
<Text font="mono" color="text-gray-400" size="xs">{team.memberCount}</Text>
<Text font="mono" color="text-gray-400" size="xs">{team.memberCountLabel}</Text>
</TableCell>
</TableRow>
))}

View File

@@ -11,14 +11,14 @@ import { ChevronRight, Users } from 'lucide-react';
interface TeamMembershipCardProps {
teamName: string;
role: string;
joinedAt: string;
joinedAtLabel: string;
href: string;
}
export function TeamMembershipCard({
teamName,
role,
joinedAt,
joinedAtLabel,
href,
}: TeamMembershipCardProps) {
return (
@@ -52,7 +52,7 @@ export function TeamMembershipCard({
{role}
</Badge>
<Text size="xs" color="text-gray-400">
Since {new Date(joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
Since {joinedAtLabel}
</Text>
</Stack>
</Stack>

View File

@@ -13,7 +13,7 @@ interface TeamMembership {
name: string;
};
role: string;
joinedAt: Date;
joinedAtLabel: string;
}
interface TeamMembershipGridProps {
@@ -46,7 +46,7 @@ export function TeamMembershipGrid({ memberships }: TeamMembershipGridProps) {
<Surface variant="muted" rounded="full" padding={1} style={{ paddingLeft: '0.5rem', paddingRight: '0.5rem', backgroundColor: 'rgba(147, 51, 234, 0.2)', color: '#a855f7' }}>
<Text size="xs" weight="medium" style={{ textTransform: 'capitalize' }}>{membership.role}</Text>
</Surface>
<Text size="xs" color="text-gray-500">Since {membership.joinedAt.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}</Text>
<Text size="xs" color="text-gray-400">Since {membership.joinedAtLabel}</Text>
</Stack>
</Stack>
<ChevronRight style={{ width: '1rem', height: '1rem', color: '#737373' }} />

View File

@@ -15,6 +15,10 @@ import { Select } from '@/ui/Select';
import { Text } from '@/ui/Text';
import { useMemo, useState } from 'react';
import { MemberDisplay } from '@/lib/display-objects/MemberDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
export type TeamRole = 'owner' | 'admin' | 'member';
export type TeamMemberRole = 'owner' | 'manager' | 'member';
@@ -67,9 +71,10 @@ export function TeamRoster({
return sortMembers(teamMembers as unknown as TeamMember[], sortBy);
}, [teamMembers, sortBy]);
const teamAverageRating = useMemo(() => {
if (teamMembers.length === 0) return 0;
return teamMembers.reduce((sum: number, m: { rating?: number | null }) => sum + (m.rating || 0), 0) / teamMembers.length;
const teamAverageRatingLabel = useMemo(() => {
if (teamMembers.length === 0) return '—';
const avg = teamMembers.reduce((sum: number, m: { rating?: number | null }) => sum + (m.rating || 0), 0) / teamMembers.length;
return RatingDisplay.format(avg);
}, [teamMembers]);
if (loading) {
@@ -88,8 +93,8 @@ export function TeamRoster({
<Stack>
<Heading level={3}>Team Roster</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
{memberships.length} {memberships.length === 1 ? 'member' : 'members'} Avg Rating:{' '}
<Text color="text-primary-blue" weight="medium">{teamAverageRating.toFixed(0)}</Text>
{MemberDisplay.formatCount(memberships.length)} Avg Rating:{' '}
<Text color="text-primary-blue" weight="medium">{teamAverageRatingLabel}</Text>
</Text>
</Stack>
@@ -124,9 +129,9 @@ export function TeamRoster({
driver={driver as DriverViewModel}
href={`${routes.driver.detail(driver.id)}?from=team&teamId=${teamId}`}
roleLabel={getRoleLabel(role)}
joinedAt={joinedAt}
rating={rating}
overallRank={overallRank}
joinedAtLabel={DateDisplay.formatShort(joinedAt)}
ratingLabel={RatingDisplay.format(rating)}
overallRankLabel={overallRank !== null ? `#${overallRank}` : null}
actions={canManageMembership ? (
<>
<Stack width="32">

View File

@@ -8,9 +8,9 @@ interface TeamRosterItemProps {
driver: DriverViewModel;
href: string;
roleLabel: string;
joinedAt: string | Date;
rating: number | null;
overallRank: number | null;
joinedAtLabel: string;
ratingLabel: string | null;
overallRankLabel: string | null;
actions?: ReactNode;
}
@@ -18,9 +18,9 @@ export function TeamRosterItem({
driver,
href,
roleLabel,
joinedAt,
rating,
overallRank,
joinedAtLabel,
ratingLabel,
overallRankLabel,
actions,
}: TeamRosterItemProps) {
return (
@@ -38,23 +38,23 @@ export function TeamRosterItem({
contextLabel={roleLabel}
meta={
<Text size="xs" color="text-gray-400">
{driver.country} Joined {new Date(joinedAt).toLocaleDateString()}
{driver.country} Joined {joinedAtLabel}
</Text>
}
size="md"
/>
{rating !== null && (
{ratingLabel !== null && (
<Stack direction="row" align="center" gap={6}>
<Stack display="flex" flexDirection="col" alignItems="center">
<Text size="lg" weight="bold" color="text-primary-blue" block>
{rating}
{ratingLabel}
</Text>
<Text size="xs" color="text-gray-400">Rating</Text>
</Stack>
{overallRank !== null && (
{overallRankLabel !== null && (
<Stack display="flex" flexDirection="col" alignItems="center">
<Text size="sm" color="text-gray-300" block>#{overallRank}</Text>
<Text size="sm" color="text-gray-300" block>{overallRankLabel}</Text>
<Text size="xs" color="text-gray-500">Rank</Text>
</Stack>
)}

View File

@@ -4,6 +4,7 @@ import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { LeagueScheduleViewModel, LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
@@ -14,6 +15,8 @@ function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
id: race.id,
name: race.name,
scheduledAt,
formattedDate: DateDisplay.formatShort(scheduledAt),
formattedTime: DateDisplay.formatTime(scheduledAt),
isPast,
isUpcoming: !isPast,
status: isPast ? 'completed' : 'scheduled',

View File

@@ -7,6 +7,7 @@ import { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleVie
import { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
@@ -17,6 +18,8 @@ function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
id: race.id,
name: race.name,
scheduledAt,
formattedDate: DateDisplay.formatShort(scheduledAt),
formattedTime: DateDisplay.formatTime(scheduledAt),
isPast,
isUpcoming: !isPast,
status: isPast ? 'completed' : 'scheduled',

View File

@@ -1,5 +1,10 @@
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
import { FinishDisplay } from '@/lib/display-objects/FinishDisplay';
import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
/**
* DriverProfileViewDataBuilder
@@ -17,26 +22,38 @@ export class DriverProfileViewDataBuilder {
avatarUrl: apiDto.currentDriver.avatarUrl || '',
iracingId: typeof apiDto.currentDriver.iracingId === 'string' ? parseInt(apiDto.currentDriver.iracingId, 10) : (apiDto.currentDriver.iracingId ?? null),
joinedAt: apiDto.currentDriver.joinedAt,
joinedAtLabel: DateDisplay.formatMonthYear(apiDto.currentDriver.joinedAt),
rating: apiDto.currentDriver.rating ?? null,
ratingLabel: RatingDisplay.format(apiDto.currentDriver.rating),
globalRank: apiDto.currentDriver.globalRank ?? null,
globalRankLabel: apiDto.currentDriver.globalRank != null ? `#${apiDto.currentDriver.globalRank}` : '—',
consistency: apiDto.currentDriver.consistency ?? null,
bio: apiDto.currentDriver.bio ?? null,
totalDrivers: apiDto.currentDriver.totalDrivers ?? null,
} : null,
stats: apiDto.stats ? {
totalRaces: apiDto.stats.totalRaces,
totalRacesLabel: NumberDisplay.format(apiDto.stats.totalRaces),
wins: apiDto.stats.wins,
winsLabel: NumberDisplay.format(apiDto.stats.wins),
podiums: apiDto.stats.podiums,
podiumsLabel: NumberDisplay.format(apiDto.stats.podiums),
dnfs: apiDto.stats.dnfs,
dnfsLabel: NumberDisplay.format(apiDto.stats.dnfs),
avgFinish: apiDto.stats.avgFinish ?? null,
avgFinishLabel: FinishDisplay.formatAverage(apiDto.stats.avgFinish),
bestFinish: apiDto.stats.bestFinish ?? null,
bestFinishLabel: FinishDisplay.format(apiDto.stats.bestFinish),
worstFinish: apiDto.stats.worstFinish ?? null,
worstFinishLabel: FinishDisplay.format(apiDto.stats.worstFinish),
finishRate: apiDto.stats.finishRate ?? null,
winRate: apiDto.stats.winRate ?? null,
podiumRate: apiDto.stats.podiumRate ?? null,
percentile: apiDto.stats.percentile ?? null,
rating: apiDto.stats.rating ?? null,
ratingLabel: RatingDisplay.format(apiDto.stats.rating),
consistency: apiDto.stats.consistency ?? null,
consistencyLabel: PercentDisplay.formatWhole(apiDto.stats.consistency),
overallRank: apiDto.stats.overallRank ?? null,
} : null,
finishDistribution: apiDto.finishDistribution ? {
@@ -53,6 +70,7 @@ export class DriverProfileViewDataBuilder {
teamTag: m.teamTag ?? null,
role: m.role,
joinedAt: m.joinedAt,
joinedAtLabel: DateDisplay.formatMonthYear(m.joinedAt),
isCurrent: m.isCurrent,
})),
socialSummary: {
@@ -76,7 +94,9 @@ export class DriverProfileViewDataBuilder {
description: a.description,
icon: a.icon,
rarity: a.rarity,
rarityLabel: a.rarity,
earnedAt: a.earnedAt,
earnedAtLabel: DateDisplay.formatShort(a.earnedAt),
})),
racingStyle: apiDto.extendedProfile.racingStyle,
favoriteTrack: apiDto.extendedProfile.favoriteTrack,
@@ -88,4 +108,4 @@ export class DriverProfileViewDataBuilder {
} : null,
};
}
}
}

View File

@@ -1,5 +1,7 @@
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
export class DriversViewDataBuilder {
static build(dto: DriversLeaderboardDTO): DriversViewData {
@@ -8,6 +10,7 @@ export class DriversViewDataBuilder {
id: driver.id,
name: driver.name,
rating: driver.rating,
ratingLabel: RatingDisplay.format(driver.rating),
skillLevel: driver.skillLevel,
category: driver.category,
nationality: driver.nationality,
@@ -19,8 +22,12 @@ export class DriversViewDataBuilder {
avatarUrl: driver.avatarUrl,
})),
totalRaces: dto.totalRaces,
totalRacesLabel: NumberDisplay.format(dto.totalRaces),
totalWins: dto.totalWins,
totalWinsLabel: NumberDisplay.format(dto.totalWins),
activeCount: dto.activeCount,
activeCountLabel: NumberDisplay.format(dto.activeCount),
totalDriversLabel: NumberDisplay.format(dto.drivers.length),
};
}
}

View File

@@ -1,6 +1,7 @@
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
import type { LeagueRosterAdminViewData, RosterMemberData, JoinRequestData } from '@/lib/view-data/LeagueRosterAdminViewData';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
/**
* LeagueRosterAdminViewDataBuilder
@@ -25,6 +26,7 @@ export class LeagueRosterAdminViewDataBuilder {
},
role: member.role,
joinedAt: member.joinedAt,
formattedJoinedAt: DateDisplay.formatShort(member.joinedAt),
}));
// Transform join requests
@@ -35,6 +37,7 @@ export class LeagueRosterAdminViewDataBuilder {
name: 'Unknown Driver', // driver field is unknown type
},
requestedAt: req.requestedAt,
formattedRequestedAt: DateDisplay.formatShort(req.requestedAt),
message: req.message,
}));

View File

@@ -1,5 +1,7 @@
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';
export class LeagueSponsorshipsViewDataBuilder {
static build(apiDto: LeagueSponsorshipsApiDto): LeagueSponsorshipsViewData {
@@ -9,7 +11,11 @@ export class LeagueSponsorshipsViewDataBuilder {
onTabChange: () => {},
league: apiDto.league,
sponsorshipSlots: apiDto.sponsorshipSlots,
sponsorshipRequests: apiDto.sponsorshipRequests,
sponsorshipRequests: apiDto.sponsorshipRequests.map(r => ({
...r,
formattedRequestedAt: DateDisplay.formatShort(r.requestedAt),
statusLabel: StatusDisplay.protestStatus(r.status), // Reusing protest status for now
})),
};
}
}

View File

@@ -1,13 +1,15 @@
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';
export class LeagueWalletViewDataBuilder {
static build(apiDto: LeagueWalletApiDto): LeagueWalletViewData {
const transactions: LeagueWalletTransactionViewData[] = apiDto.transactions.map(t => ({
...t,
formattedAmount: `${t.amount} ${apiDto.currency}`,
formattedAmount: CurrencyDisplay.format(t.amount, apiDto.currency),
amountColor: t.amount >= 0 ? 'green' : 'red',
formattedDate: new Date(t.createdAt).toLocaleDateString(),
formattedDate: DateDisplay.formatShort(t.createdAt),
statusColor: t.status === 'completed' ? 'green' : t.status === 'pending' ? 'yellow' : 'red',
typeColor: 'blue',
}));
@@ -15,13 +17,13 @@ export class LeagueWalletViewDataBuilder {
return {
leagueId: apiDto.leagueId,
balance: apiDto.balance,
formattedBalance: `${apiDto.balance} ${apiDto.currency}`,
formattedBalance: CurrencyDisplay.format(apiDto.balance, apiDto.currency),
totalRevenue: apiDto.balance, // Mock
formattedTotalRevenue: `${apiDto.balance} ${apiDto.currency}`,
formattedTotalRevenue: CurrencyDisplay.format(apiDto.balance, apiDto.currency),
totalFees: 0, // Mock
formattedTotalFees: `0 ${apiDto.currency}`,
formattedTotalFees: CurrencyDisplay.format(0, apiDto.currency),
pendingPayouts: 0, // Mock
formattedPendingPayouts: `0 ${apiDto.currency}`,
formattedPendingPayouts: CurrencyDisplay.format(0, apiDto.currency),
currency: apiDto.currency,
transactions,
};

View File

@@ -2,6 +2,11 @@ import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverP
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
import { mediaConfig } from '@/lib/config/mediaConfig';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { FinishDisplay } from '@/lib/display-objects/FinishDisplay';
import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
export class ProfileViewDataBuilder {
static build(apiDto: GetDriverProfileOutputDTO): ProfileViewData {
@@ -29,11 +34,6 @@ export class ProfileViewDataBuilder {
const socialSummary = apiDto.socialSummary;
const extended = apiDto.extendedProfile ?? null;
const joinedAtLabel = new Date(driver.joinedAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
});
return {
driver: {
id: driver.id,
@@ -42,22 +42,22 @@ export class ProfileViewDataBuilder {
countryFlag: CountryFlagDisplay.fromCountryCode(driver.country).toString(),
avatarUrl: driver.avatarUrl || mediaConfig.avatars.defaultFallback,
bio: driver.bio || null,
iracingId: driver.iracingId || null,
joinedAtLabel,
iracingId: driver.iracingId ? String(driver.iracingId) : null,
joinedAtLabel: DateDisplay.formatMonthYear(driver.joinedAt),
},
stats: stats
? {
ratingLabel: stats.rating != null ? String(stats.rating) : '0',
ratingLabel: RatingDisplay.format(stats.rating),
globalRankLabel: driver.globalRank != null ? `#${driver.globalRank}` : '—',
totalRacesLabel: String(stats.totalRaces),
winsLabel: String(stats.wins),
podiumsLabel: String(stats.podiums),
dnfsLabel: String(stats.dnfs),
bestFinishLabel: stats.bestFinish != null ? `P${stats.bestFinish}` : '—',
worstFinishLabel: stats.worstFinish != null ? `P${stats.worstFinish}` : '—',
avgFinishLabel: stats.avgFinish != null ? `P${stats.avgFinish.toFixed(1)}` : '—',
consistencyLabel: stats.consistency != null ? `${stats.consistency}%` : '0%',
percentileLabel: stats.percentile != null ? `${stats.percentile}%` : '—',
totalRacesLabel: NumberDisplay.format(stats.totalRaces),
winsLabel: NumberDisplay.format(stats.wins),
podiumsLabel: NumberDisplay.format(stats.podiums),
dnfsLabel: NumberDisplay.format(stats.dnfs),
bestFinishLabel: FinishDisplay.format(stats.bestFinish),
worstFinishLabel: FinishDisplay.format(stats.worstFinish),
avgFinishLabel: FinishDisplay.formatAverage(stats.avgFinish),
consistencyLabel: PercentDisplay.formatWhole(stats.consistency),
percentileLabel: PercentDisplay.format(stats.percentile),
}
: null,
teamMemberships: apiDto.teamMemberships.map((m) => ({
@@ -65,10 +65,7 @@ export class ProfileViewDataBuilder {
teamName: m.teamName,
teamTag: m.teamTag || null,
roleLabel: m.role,
joinedAtLabel: new Date(m.joinedAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
}),
joinedAtLabel: DateDisplay.formatMonthYear(m.joinedAt),
href: `/teams/${m.teamId}`,
})),
extendedProfile: extended
@@ -89,12 +86,8 @@ export class ProfileViewDataBuilder {
id: a.id,
title: a.title,
description: a.description,
earnedAtLabel: new Date(a.earnedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}),
icon: a.icon as NonNullable<ProfileViewData['extendedProfile']>['achievements'][number]['icon'],
earnedAtLabel: DateDisplay.formatShort(a.earnedAt),
icon: a.icon as any,
rarityLabel: a.rarity,
})),
friends: socialSummary.friends.slice(0, 8).map((f) => ({
@@ -104,7 +97,7 @@ export class ProfileViewDataBuilder {
avatarUrl: f.avatarUrl || mediaConfig.avatars.defaultFallback,
href: `/drivers/${f.id}`,
})),
friendsCountLabel: String(socialSummary.friendsCount),
friendsCountLabel: NumberDisplay.format(socialSummary.friendsCount),
}
: null,
};

View File

@@ -1,5 +1,7 @@
import type { SponsorDashboardDTO } from '@/lib/types/generated/SponsorDashboardDTO';
import type { SponsorDashboardViewData } from '@/lib/view-data/SponsorDashboardViewData';
import { CurrencyDisplay } from '@/lib/display-objects/CurrencyDisplay';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
/**
* Sponsor Dashboard ViewData Builder
@@ -9,26 +11,28 @@ import type { SponsorDashboardViewData } from '@/lib/view-data/SponsorDashboardV
*/
export class SponsorDashboardViewDataBuilder {
static build(apiDto: SponsorDashboardDTO): SponsorDashboardViewData {
const totalInvestmentValue = apiDto.investment.activeSponsorships * 1000;
return {
sponsorName: apiDto.sponsorName,
totalImpressions: apiDto.metrics.impressions.toString(),
totalInvestment: `$${apiDto.investment.activeSponsorships * 1000}`, // Mock calculation
totalImpressions: NumberDisplay.format(apiDto.metrics.impressions),
totalInvestment: CurrencyDisplay.format(totalInvestmentValue),
metrics: {
impressionsChange: apiDto.metrics.impressions > 1000 ? 15 : -5,
viewersChange: 8,
exposureChange: 12,
},
categoryData: {
leagues: { count: 2, impressions: 1500 },
teams: { count: 1, impressions: 800 },
drivers: { count: 3, impressions: 2200 },
races: { count: 1, impressions: 500 },
platform: { count: 0, impressions: 0 },
leagues: { count: 2, countLabel: '2 active', impressions: 1500, impressionsLabel: '1,500' },
teams: { count: 1, countLabel: '1 active', impressions: 800, impressionsLabel: '800' },
drivers: { count: 3, countLabel: '3 active', impressions: 2200, impressionsLabel: '2,200' },
races: { count: 1, countLabel: '1 active', impressions: 500, impressionsLabel: '500' },
platform: { count: 0, countLabel: '0 active', impressions: 0, impressionsLabel: '0' },
},
sponsorships: apiDto.sponsorships,
activeSponsorships: apiDto.investment.activeSponsorships,
formattedTotalInvestment: `$${apiDto.investment.activeSponsorships * 1000}`,
costPerThousandViews: '$50',
formattedTotalInvestment: CurrencyDisplay.format(totalInvestmentValue),
costPerThousandViews: CurrencyDisplay.format(50),
upcomingRenewals: [], // Mock empty for now
recentActivity: [], // Mock empty for now
};

View File

@@ -3,6 +3,7 @@ import type { TeamDetailViewData, TeamDetailData, TeamMemberData, SponsorMetric,
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { MemberDisplay } from '@/lib/display-objects/MemberDisplay';
import { LeagueDisplay } from '@/lib/display-objects/LeagueDisplay';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
/**
* TeamDetailViewDataBuilder - Transforms TeamDetailPageDto into ViewData
@@ -47,19 +48,19 @@ export class TeamDetailViewDataBuilder {
{
icon: 'users',
label: 'Members',
value: memberships.length,
value: NumberDisplay.format(memberships.length),
color: 'text-primary-blue',
},
{
icon: 'zap',
label: 'Est. Reach',
value: memberships.length * 15,
value: NumberDisplay.format(memberships.length * 15),
color: 'text-purple-400',
},
{
icon: 'calendar',
label: 'Races',
value: leagueCount,
value: NumberDisplay.format(leagueCount),
color: 'text-neon-aqua',
},
{

View File

@@ -1,6 +1,8 @@
import type { TeamsPageDto } from '@/lib/page-queries/TeamsPageQuery';
import type { TeamsViewData, TeamSummaryData } from '@/lib/view-data/TeamsViewData';
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
/**
* TeamsViewDataBuilder - Transforms TeamsPageDto into ViewData for TeamsTemplate
@@ -14,6 +16,9 @@ export class TeamsViewDataBuilder {
leagueName: team.leagues[0] || '',
memberCount: team.memberCount,
logoUrl: team.logoUrl,
ratingLabel: RatingDisplay.format(team.rating),
winsLabel: NumberDisplay.format(team.totalWins || 0),
racesLabel: NumberDisplay.format(team.totalRaces || 0),
}));
return { teams };

View File

@@ -0,0 +1,24 @@
/**
* DurationDisplay
*
* Deterministic formatting for time durations.
*/
export class DurationDisplay {
/**
* Formats milliseconds as "123.45ms".
*/
static formatMs(ms: number): string {
return `${ms.toFixed(2)}ms`;
}
/**
* Formats seconds as "M:SS.mmm".
* Example: 65.123 -> "1:05.123"
*/
static formatSeconds(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = (seconds % 60).toFixed(3);
return `${minutes}:${remainingSeconds.padStart(6, '0')}`;
}
}

View File

@@ -0,0 +1,24 @@
/**
* FinishDisplay
*
* Deterministic formatting for race finish positions.
*/
export class FinishDisplay {
/**
* Formats a finish position as "P1", "P2", etc.
*/
static format(position: number | null | undefined): string {
if (position === null || position === undefined) return '—';
return `P${position.toFixed(0)}`;
}
/**
* Formats an average finish position with one decimal place.
* Example: 5.4 -> "P5.4"
*/
static formatAverage(avg: number | null | undefined): string {
if (avg === null || avg === undefined) return '—';
return `P${avg.toFixed(1)}`;
}
}

View File

@@ -0,0 +1,21 @@
/**
* MemoryDisplay
*
* Deterministic formatting for memory usage.
*/
export class MemoryDisplay {
/**
* Formats bytes as "123.4MB".
*/
static formatMB(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
/**
* Formats bytes as "123.4KB".
*/
static formatKB(bytes: number): string {
return `${(bytes / 1024).toFixed(1)}KB`;
}
}

View File

@@ -15,4 +15,17 @@ export class NumberDisplay {
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
}
/**
* Formats a number in compact form (e.g., 1.2k, 1.5M).
*/
static formatCompact(value: number): string {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`;
}
if (value >= 1000) {
return `${(value / 1000).toFixed(1)}k`;
}
return value.toString();
}
}

View File

@@ -0,0 +1,25 @@
/**
* PercentDisplay
*
* Deterministic formatting for percentages.
*/
export class PercentDisplay {
/**
* Formats a decimal value as a percentage string.
* Example: 0.1234 -> "12.3%"
*/
static format(value: number | null | undefined): string {
if (value === null || value === undefined) return '0.0%';
return `${(value * 100).toFixed(1)}%`;
}
/**
* Formats a whole number as a percentage string.
* Example: 85 -> "85%"
*/
static formatWhole(value: number | null | undefined): string {
if (value === null || value === undefined) return '0%';
return `${Math.round(value)}%`;
}
}

View File

@@ -0,0 +1,44 @@
/**
* StatusDisplay
*
* Deterministic mapping of status codes to human-readable labels.
*/
export class StatusDisplay {
/**
* Maps transaction status to label.
*/
static transactionStatus(status: string): string {
const map: Record<string, string> = {
paid: 'Paid',
pending: 'Pending',
overdue: 'Overdue',
failed: 'Failed',
};
return map[status] || status;
}
/**
* Maps race status to label.
*/
static raceStatus(status: string): string {
const map: Record<string, string> = {
scheduled: 'Scheduled',
running: 'Live',
completed: 'Completed',
};
return map[status] || status;
}
/**
* Maps protest status to label.
*/
static protestStatus(status: string): string {
const map: Record<string, string> = {
pending: 'Pending',
under_review: 'Under Review',
resolved: 'Resolved',
};
return map[status] || status;
}
}

View File

@@ -4,4 +4,9 @@ export class WinRateDisplay {
const rate = (wins / racesCompleted) * 100;
return rate.toFixed(1);
}
static format(rate: number | null | undefined): string {
if (rate === null || rate === undefined) return '0.0%';
return `${rate.toFixed(1)}%`;
}
}

View File

@@ -18,6 +18,8 @@ import { Result } from '@/lib/contracts/Result';
import type { Service } from '@/lib/contracts/services/Service';
import type { HomeDataDTO } from '@/lib/types/dtos/HomeDataDTO';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
/**
* HomeService
*
@@ -54,7 +56,7 @@ export class HomeService implements Service {
id: r.id,
track: r.track,
car: r.car,
formattedDate: new Date(r.scheduledAt).toLocaleDateString(),
formattedDate: DateDisplay.formatShort(r.scheduledAt),
})),
topLeagues: leaguesDto.leagues.slice(0, 4).map(l => ({
id: l.id,

View File

@@ -6,26 +6,38 @@ export interface DriverProfileViewData {
avatarUrl: string;
iracingId: number | null;
joinedAt: string;
joinedAtLabel: string;
rating: number | null;
ratingLabel: string;
globalRank: number | null;
globalRankLabel: string;
consistency: number | null;
bio: string | null;
totalDrivers: number | null;
} | null;
stats: {
totalRaces: number;
totalRacesLabel: string;
wins: number;
winsLabel: string;
podiums: number;
podiumsLabel: string;
dnfs: number;
dnfsLabel: string;
avgFinish: number | null;
avgFinishLabel: string;
bestFinish: number | null;
bestFinishLabel: string;
worstFinish: number | null;
worstFinishLabel: string;
finishRate: number | null;
winRate: number | null;
podiumRate: number | null;
percentile: number | null;
rating: number | null;
ratingLabel: string;
consistency: number | null;
consistencyLabel: string;
overallRank: number | null;
} | null;
finishDistribution: {
@@ -42,6 +54,7 @@ export interface DriverProfileViewData {
teamTag: string | null;
role: string;
joinedAt: string;
joinedAtLabel: string;
isCurrent: boolean;
}[];
socialSummary: {
@@ -65,7 +78,9 @@ export interface DriverProfileViewData {
description: string;
icon: string;
rarity: string;
rarityLabel: string;
earnedAt: string;
earnedAtLabel: string;
}[];
racingStyle: string;
favoriteTrack: string;

View File

@@ -3,6 +3,7 @@ export interface DriversViewData {
id: string;
name: string;
rating: number;
ratingLabel: string;
skillLevel: string;
category?: string;
nationality: string;
@@ -14,6 +15,10 @@ export interface DriversViewData {
avatarUrl?: string;
}[];
totalRaces: number;
totalRacesLabel: string;
totalWins: number;
totalWinsLabel: string;
activeCount: number;
activeCountLabel: string;
totalDriversLabel: string;
}

View File

@@ -11,6 +11,7 @@ export interface RosterMemberData {
};
role: string;
joinedAt: string;
formattedJoinedAt: string;
}
export interface JoinRequestData {
@@ -20,6 +21,7 @@ export interface JoinRequestData {
name: string;
};
requestedAt: string;
formattedRequestedAt: string;
message?: string;
}

View File

@@ -8,11 +8,11 @@ export interface SponsorDashboardViewData {
exposureChange: number;
};
categoryData: {
leagues: { count: number; impressions: number };
teams: { count: number; impressions: number };
drivers: { count: number; impressions: number };
races: { count: number; impressions: number };
platform: { count: number; impressions: number };
leagues: { count: number; countLabel: string; impressions: number; impressionsLabel: string };
teams: { count: number; countLabel: string; impressions: number; impressionsLabel: string };
drivers: { count: number; countLabel: string; impressions: number; impressionsLabel: string };
races: { count: number; countLabel: string; impressions: number; impressionsLabel: string };
platform: { count: number; countLabel: string; impressions: number; impressionsLabel: string };
};
sponsorships: Record<string, unknown>; // From DTO
activeSponsorships: number;

View File

@@ -6,10 +6,10 @@
export interface SponsorMetric {
icon: string; // Icon name (e.g. 'users', 'zap', 'calendar')
label: string;
value: string | number;
value: string;
color?: string;
trend?: {
value: number;
value: string;
isPositive: boolean;
};
}

View File

@@ -11,6 +11,9 @@ export interface TeamSummaryData {
leagueName: string;
memberCount: number;
logoUrl?: string;
ratingLabel: string;
winsLabel: string;
racesLabel: string;
}
export interface TeamsViewData extends ViewData {

View File

@@ -26,6 +26,8 @@ export interface LeagueSponsorshipsViewData {
sponsorId: string;
sponsorName: string;
requestedAt: string;
formattedRequestedAt: string;
status: 'pending' | 'approved' | 'rejected';
statusLabel: string;
}>;
}

View File

@@ -7,6 +7,8 @@ export interface LeagueScheduleRaceViewModel {
id: string;
name: string;
scheduledAt: Date;
formattedDate: string;
formattedTime: string;
isPast: boolean;
isUpcoming: boolean;
status: string;

View File

@@ -1,5 +1,7 @@
import { ProtestDTO } from '@/lib/types/generated/ProtestDTO';
import { RaceProtestDTO } from '@/lib/types/generated/RaceProtestDTO';
import { DateDisplay } from '../display-objects/DateDisplay';
import { StatusDisplay } from '../display-objects/StatusDisplay';
/**
* Protest view model
@@ -96,11 +98,11 @@ export class ProtestViewModel {
/** UI-specific: Formatted submitted date */
get formattedSubmittedAt(): string {
return new Date(this.submittedAt).toLocaleString();
return DateDisplay.formatShort(this.submittedAt);
}
/** UI-specific: Status display */
get statusDisplay(): string {
return 'Pending';
return StatusDisplay.protestStatus(this.status);
}
}

View File

@@ -1,4 +1,5 @@
import { RaceResultDTO } from '@/lib/types/generated/RaceResultDTO';
import { FinishDisplay } from '../display-objects/FinishDisplay';
export class RaceResultViewModel {
driverId!: string;
@@ -42,7 +43,7 @@ export class RaceResultViewModel {
/** UI-specific: Badge for position */
get positionBadge(): string {
return this.position.toString();
return FinishDisplay.format(this.position);
}
/** UI-specific: Color for incidents badge */
@@ -66,6 +67,25 @@ export class RaceResultViewModel {
return this.positionChange;
}
get formattedPosition(): string {
return FinishDisplay.format(this.position);
}
get formattedStartPosition(): string {
return FinishDisplay.format(this.startPosition);
}
get formattedIncidents(): string {
return `${this.incidents}x incidents`;
}
get formattedPositionsGained(): string | undefined {
if (this.position < this.startPosition) {
return `+${this.startPosition - this.position} positions`;
}
return undefined;
}
// Note: The generated DTO doesn't have id or raceId
// These will need to be added when the OpenAPI spec is updated
id: string = '';

View File

@@ -1,3 +1,7 @@
import { CurrencyDisplay } from '../display-objects/CurrencyDisplay';
import { DateDisplay } from '../display-objects/DateDisplay';
import { NumberDisplay } from '../display-objects/NumberDisplay';
/**
* Interface for sponsorship data input
*/
@@ -69,11 +73,11 @@ export class SponsorshipViewModel {
}
get formattedImpressions(): string {
return this.impressions.toLocaleString();
return NumberDisplay.format(this.impressions);
}
get formattedPrice(): string {
return `$${this.price}`;
return CurrencyDisplay.format(this.price);
}
get daysRemaining(): number {
@@ -109,8 +113,8 @@ export class SponsorshipViewModel {
}
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' });
const start = DateDisplay.formatMonthYear(this.startDate);
const end = DateDisplay.formatMonthYear(this.endDate);
return `${start} - ${end}`;
}
}

View File

@@ -75,12 +75,12 @@ export function DriverProfileTemplate({
const { currentDriver, stats, teamMemberships, socialSummary, extendedProfile } = viewData;
const careerStats = stats ? [
{ label: 'Rating', value: stats.rating || 0, color: 'text-primary-blue' },
{ label: 'Wins', value: stats.wins, color: 'text-performance-green' },
{ label: 'Podiums', value: stats.podiums, color: 'text-warning-amber' },
{ label: 'Total Races', value: stats.totalRaces },
{ label: 'Avg Finish', value: stats.avgFinish?.toFixed(1) || '-', subValue: 'POS' },
{ label: 'Consistency', value: stats.consistency ? `${stats.consistency}%` : '-' },
{ label: 'Rating', value: stats.ratingLabel, color: 'text-primary-blue' },
{ label: 'Wins', value: stats.winsLabel, color: 'text-performance-green' },
{ label: 'Podiums', value: stats.podiumsLabel, color: 'text-warning-amber' },
{ label: 'Total Races', value: stats.totalRacesLabel },
{ label: 'Avg Finish', value: stats.avgFinishLabel, subValue: 'POS' },
{ label: 'Consistency', value: stats.consistencyLabel, color: 'text-primary-blue' },
] : [];
return (
@@ -115,7 +115,9 @@ export function DriverProfileTemplate({
avatarUrl={currentDriver.avatarUrl}
nationality={currentDriver.country}
rating={stats?.rating || 0}
globalRank={currentDriver.globalRank ?? undefined}
ratingLabel={currentDriver.ratingLabel}
safetyRatingLabel="SR 92"
globalRankLabel={currentDriver.globalRankLabel}
bio={currentDriver.bio}
friendRequestSent={friendRequestSent}
onAddFriend={onAddFriend}
@@ -132,7 +134,7 @@ export function DriverProfileTemplate({
memberships={teamMemberships.map((m) => ({
team: { id: m.teamId, name: m.teamName },
role: m.role,
joinedAt: new Date(m.joinedAt)
joinedAtLabel: m.joinedAtLabel
}))}
/>
)}
@@ -172,7 +174,8 @@ export function DriverProfileTemplate({
<AchievementGrid
achievements={extendedProfile.achievements.map((a) => ({
...a,
earnedAt: new Date(a.earnedAt)
rarity: a.rarityLabel,
earnedAtLabel: a.earnedAtLabel
}))}
/>
)}

View File

@@ -36,10 +36,10 @@ export function DriversTemplate({
<Container size="lg" py={8}>
<Stack gap={10}>
<DriversDirectoryHeader
totalDrivers={drivers.length}
activeDrivers={activeCount}
totalWins={totalWins}
totalRaces={totalRaces}
totalDriversLabel={viewData?.totalDriversLabel || '0'}
activeDriversLabel={viewData?.activeCountLabel || '0'}
totalWinsLabel={viewData?.totalWinsLabel || '0'}
totalRacesLabel={viewData?.totalRacesLabel || '0'}
onViewLeaderboard={onViewLeaderboard}
/>
@@ -54,7 +54,8 @@ export function DriversTemplate({
avatarUrl={driver.avatarUrl}
nationality={driver.nationality}
rating={driver.rating}
wins={driver.wins}
ratingLabel={driver.ratingLabel}
winsLabel={String(driver.wins)}
onClick={() => onDriverClick(driver.id)}
/>
))}

View File

@@ -121,7 +121,7 @@ export function LeagueSponsorshipsTemplate({ viewData }: LeagueSponsorshipsTempl
key={request.id}
request={{
...request,
slotName: slot?.name || 'Unknown slot'
slotName: slot?.name || 'Unknown slot',
}}
/>
);

View File

@@ -40,9 +40,9 @@ export function LeagueWalletTemplate({ viewData, onExport, transactions }: Leagu
</SharedBox>
<WalletSummaryPanel
balance={viewData.balance}
formattedBalance={viewData.formattedBalance}
currency="USD"
transactions={transactions}
transactions={viewData.transactions}
onDeposit={() => {}} // Not implemented for leagues yet
onWithdraw={() => {}} // Not implemented for leagues yet
/>

View File

@@ -70,10 +70,9 @@ export function ProfileTemplate({
...viewData.driver,
country: viewData.driver.countryCode,
iracingId: Number(viewData.driver.iracingId) || 0,
joinedAt: new Date().toISOString(), // Placeholder
}}
stats={viewData.stats ? { rating: Number(viewData.stats.ratingLabel) || 0 } : null}
globalRank={Number(viewData.stats?.globalRankLabel) || 0}
stats={viewData.stats ? { ratingLabel: viewData.stats.ratingLabel } : null}
globalRankLabel={viewData.stats?.globalRankLabel || '—'}
onAddFriend={onFriendRequestSend}
friendRequestSent={friendRequestSent}
isOwnProfile={true}
@@ -99,7 +98,7 @@ export function ProfileTemplate({
memberships={viewData.teamMemberships.map(m => ({
team: { id: m.teamId, name: m.teamName },
role: m.roleLabel,
joinedAt: new Date() // Placeholder
joinedAtLabel: m.joinedAtLabel
}))}
/>
</Stack>
@@ -117,7 +116,7 @@ export function ProfileTemplate({
achievements={viewData.extendedProfile.achievements.map(a => ({
...a,
rarity: a.rarityLabel,
earnedAt: new Date() // Placeholder
earnedAtLabel: a.earnedAtLabel
}))}
/>
</Stack>

View File

@@ -62,7 +62,7 @@ export function RosterAdminTemplate({
<Stack direction={{ base: 'col', md: 'row' }} align="center" justify="between" gap={4}>
<Stack gap={1}>
<Text weight="bold" color="text-white">{req.driver.name}</Text>
<Text size="xs" color="text-gray-500">{new Date(req.requestedAt).toLocaleString()}</Text>
<Text size="xs" color="text-gray-500">{req.formattedRequestedAt}</Text>
{req.message && (
<Text size="sm" color="text-gray-400" mt={1}>&quot;{req.message}&quot;</Text>
)}
@@ -117,7 +117,7 @@ export function RosterAdminTemplate({
<Text weight="bold" color="text-white">{member.driver.name}</Text>
</TableCell>
<TableCell>
<Text size="sm" color="text-gray-400">{new Date(member.joinedAt).toLocaleDateString()}</Text>
<Text size="sm" color="text-gray-400">{member.formattedJoinedAt}</Text>
</TableCell>
<TableCell>
<Box

View File

@@ -27,10 +27,10 @@ export interface SponsorCampaignsViewData {
leagueName: string;
seasonName: string;
tier: string;
pricing: { amount: number; currency: string };
metrics: { impressions: number };
seasonStartDate?: Date;
seasonEndDate?: Date;
formattedInvestment: string;
formattedImpressions: string;
formattedStartDate?: string;
formattedEndDate?: string;
}>;
stats: {
total: number;
@@ -38,8 +38,8 @@ export interface SponsorCampaignsViewData {
pending: number;
approved: number;
rejected: number;
totalInvestment: number;
totalImpressions: number;
formattedTotalInvestment: string;
formattedTotalImpressions: string;
};
}
@@ -80,13 +80,13 @@ export function SponsorCampaignsTemplate({
},
{
label: 'Total Investment',
value: `$${viewData.stats.totalInvestment.toLocaleString()}`,
value: viewData.stats.formattedTotalInvestment,
icon: BarChart3,
variant: 'info',
},
{
label: 'Total Impressions',
value: `${(viewData.stats.totalImpressions / 1000).toFixed(0)}k`,
value: viewData.stats.formattedTotalImpressions,
icon: Eye,
variant: 'default',
},
@@ -153,10 +153,10 @@ export function SponsorCampaignsTemplate({
title={s.leagueName}
subtitle={s.seasonName}
tier={s.tier}
investment={`$${s.pricing.amount.toLocaleString()}`}
impressions={s.metrics.impressions.toLocaleString()}
startDate={s.seasonStartDate ? new Date(s.seasonStartDate).toLocaleDateString() : undefined}
endDate={s.seasonEndDate ? new Date(s.seasonEndDate).toLocaleDateString() : undefined}
investment={s.formattedInvestment}
impressions={s.formattedImpressions}
startDate={s.formattedStartDate}
endDate={s.formattedEndDate}
/>
);
})}

View File

@@ -148,40 +148,40 @@ export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateP
<SponsorshipCategoryCard
icon={Trophy}
title="Leagues"
count={categoryData.leagues.count}
impressions={categoryData.leagues.impressions}
countLabel={categoryData.leagues.countLabel}
impressionsLabel={categoryData.leagues.impressionsLabel}
color="#3b82f6"
href="/sponsor/campaigns?type=leagues"
/>
<SponsorshipCategoryCard
icon={Users}
title="Teams"
count={categoryData.teams.count}
impressions={categoryData.teams.impressions}
countLabel={categoryData.teams.countLabel}
impressionsLabel={categoryData.teams.impressionsLabel}
color="#a855f7"
href="/sponsor/campaigns?type=teams"
/>
<SponsorshipCategoryCard
icon={Car}
title="Drivers"
count={categoryData.drivers.count}
impressions={categoryData.drivers.impressions}
countLabel={categoryData.drivers.countLabel}
impressionsLabel={categoryData.drivers.impressionsLabel}
color="#10b981"
href="/sponsor/campaigns?type=drivers"
/>
<SponsorshipCategoryCard
icon={Flag}
title="Races"
count={categoryData.races.count}
impressions={categoryData.races.impressions}
countLabel={categoryData.races.countLabel}
impressionsLabel={categoryData.races.impressionsLabel}
color="#f59e0b"
href="/sponsor/campaigns?type=races"
/>
<SponsorshipCategoryCard
icon={Megaphone}
title="Platform Ads"
count={categoryData.platform.count}
impressions={categoryData.platform.impressions}
countLabel={categoryData.platform.countLabel}
impressionsLabel={categoryData.platform.impressionsLabel}
color="#ef4444"
href="/sponsor/campaigns?type=platform"
/>

View File

@@ -45,11 +45,16 @@ export interface SponsorLeagueDetailViewData extends ViewData {
description: string;
tier: 'premium' | 'standard' | 'starter';
rating: number;
formattedRating: string;
drivers: number;
formattedDrivers: string;
races: number;
formattedRaces: string;
completedRaces: number;
racesLeft: number;
formattedRacesLeft: string;
engagement: number;
formattedEngagement: string;
totalImpressions: number;
formattedTotalImpressions: string;
projectedTotal: number;
@@ -61,17 +66,20 @@ export interface SponsorLeagueDetailViewData extends ViewData {
nextRace?: {
name: string;
date: string;
formattedDate: string;
};
sponsorSlots: {
main: {
available: boolean;
price: number;
priceLabel: string;
benefits: string[];
};
secondary: {
available: number;
total: number;
price: number;
priceLabel: string;
benefits: string[];
};
};
@@ -84,10 +92,12 @@ export interface SponsorLeagueDetailViewData extends ViewData {
drivers: Array<{
id: string;
position: number;
positionLabel: string;
name: string;
team: string;
country: string;
races: number;
formattedRaces: string;
impressions: number;
formattedImpressions: string;
}>;
@@ -98,6 +108,7 @@ export interface SponsorLeagueDetailViewData extends ViewData {
formattedDate: string;
status: 'completed' | 'upcoming';
views: number;
formattedViews: string;
}>;
}
@@ -139,13 +150,13 @@ export function SponsorLeagueDetailTemplate({
},
{
label: 'Engagement',
value: `${league.engagement}%`,
value: league.formattedEngagement,
icon: BarChart3,
variant: 'warning',
},
{
label: 'Races Left',
value: league.racesLeft,
value: league.formattedRacesLeft,
icon: Calendar,
variant: 'default',
},
@@ -156,6 +167,7 @@ export function SponsorLeagueDetailTemplate({
id: 'main',
name: 'Main Sponsor',
price: league.sponsorSlots.main.price,
priceLabel: league.sponsorSlots.main.priceLabel,
period: 'Season',
description: 'Exclusive primary branding across all league assets.',
features: league.sponsorSlots.main.benefits,
@@ -166,6 +178,7 @@ export function SponsorLeagueDetailTemplate({
id: 'secondary',
name: 'Secondary Sponsor',
price: league.sponsorSlots.secondary.price,
priceLabel: league.sponsorSlots.secondary.priceLabel,
period: 'Season',
description: 'Supporting branding on cars and broadcast overlays.',
features: league.sponsorSlots.secondary.benefits,
@@ -238,8 +251,8 @@ export function SponsorLeagueDetailTemplate({
<InfoRow label="Platform" value={league.game} />
<InfoRow label="Season" value={league.season} />
<InfoRow label="Duration" value="Oct 2025 - Feb 2026" />
<InfoRow label="Drivers" value={league.drivers} />
<InfoRow label="Races" value={league.races} last />
<InfoRow label="Drivers" value={league.formattedDrivers} />
<InfoRow label="Races" value={league.formattedRaces} last />
</SharedStack>
</SharedCard>
@@ -253,8 +266,8 @@ export function SponsorLeagueDetailTemplate({
<InfoRow label="Total Season Views" value={league.formattedTotalImpressions} />
<InfoRow label="Projected Total" value={league.formattedProjectedTotal} />
<InfoRow label="Main Sponsor CPM" value={league.formattedMainSponsorCpm} color="text-performance-green" />
<InfoRow label="Engagement Rate" value={`${league.engagement}%`} />
<InfoRow label="League Rating" value={`${league.rating}/5.0`} last />
<InfoRow label="Engagement Rate" value={league.formattedEngagement} />
<InfoRow label="League Rating" value={league.formattedRating} last />
</SharedStack>
</SharedCard>
@@ -274,7 +287,7 @@ export function SponsorLeagueDetailTemplate({
</Surface>
<SharedBox>
<SharedText size="lg" weight="semibold" color="text-white" block>{league.nextRace.name}</SharedText>
<SharedText size="sm" color="text-gray-400" block mt={1}>{league.nextRace.date}</SharedText>
<SharedText size="sm" color="text-gray-400" block mt={1}>{league.nextRace.formattedDate}</SharedText>
</SharedBox>
</SharedStack>
<SharedButton variant="secondary">
@@ -300,7 +313,7 @@ export function SponsorLeagueDetailTemplate({
<SharedStack direction="row" align="center" justify="between">
<SharedStack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="full" padding={1} style={{ width: '2.5rem', height: '2.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#262626' }}>
<SharedText weight="bold" color="text-white">{driver.position}</SharedText>
<SharedText weight="bold" color="text-white">{driver.positionLabel}</SharedText>
</Surface>
<SharedBox>
<SharedText weight="medium" color="text-white" block>{driver.name}</SharedText>
@@ -309,7 +322,7 @@ export function SponsorLeagueDetailTemplate({
</SharedStack>
<SharedStack direction="row" align="center" gap={8}>
<SharedBox textAlign="right">
<SharedText weight="medium" color="text-white" block>{driver.races}</SharedText>
<SharedText weight="medium" color="text-white" block>{driver.formattedRaces}</SharedText>
<SharedText size="xs" color="text-gray-500">races</SharedText>
</SharedBox>
<SharedBox textAlign="right">
@@ -344,7 +357,7 @@ export function SponsorLeagueDetailTemplate({
<SharedBox>
{race.status === 'completed' ? (
<SharedBox textAlign="right">
<SharedText weight="semibold" color="text-white" block>{race.views.toLocaleString()}</SharedText>
<SharedText weight="semibold" color="text-white" block>{race.formattedViews}</SharedText>
<SharedText size="xs" color="text-gray-500">views</SharedText>
</SharedBox>
) : (
@@ -384,7 +397,7 @@ export function SponsorLeagueDetailTemplate({
<SharedStack gap={3} mb={6}>
<InfoRow label="Selected Tier" value={`${selectedTier.charAt(0).toUpperCase() + selectedTier.slice(1)} Sponsor`} />
<InfoRow label="Season Price" value={`$${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}`} />
<InfoRow label="Season Price" value={selectedTier === 'main' ? league.sponsorSlots.main.priceLabel : league.sponsorSlots.secondary.priceLabel} />
<InfoRow label={`Platform Fee (${siteConfig.fees.platformFeePercent}%)`} value={`$${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}`} />
<SharedBox pt={4} borderTop borderColor="border-neutral-800">
<SharedStack direction="row" align="center" justify="between">

View File

@@ -96,9 +96,9 @@ export function TeamDetailTemplate({
entityName={team.name}
tier="standard"
metrics={viewData.teamMetrics}
slots={SlotTemplates.team(true, true, 500, 250)}
trustScore={90}
monthlyActivity={85}
slots={SlotTemplates.team(true, true, '$500', '$250')}
trustScoreLabel="90/100"
monthlyActivityLabel="85%"
onNavigate={(href) => window.location.href = href}
/>
)}

View File

@@ -33,6 +33,9 @@ export function TeamsTemplate({ viewData, onTeamClick, onViewFullLeaderboard, on
name={team.teamName}
memberCount={team.memberCount}
logo={team.logoUrl}
ratingLabel={team.ratingLabel}
winsLabel={team.winsLabel}
racesLabel={team.racesLabel}
onClick={() => onTeamClick?.(team.teamId)}
/>
))}