website refactor

This commit is contained in:
2026-01-14 23:46:04 +01:00
parent c1a86348d7
commit 4a2d7d15a5
294 changed files with 5637 additions and 3418 deletions

View File

@@ -0,0 +1,83 @@
'use client';
import React from 'react';
import { Award, Trophy, Medal, Star, Crown, Target, Zap } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Grid } from '@/ui/Grid';
interface Achievement {
id: string;
title: string;
description: string;
icon: string;
rarity: string;
earnedAt: Date;
}
interface AchievementGridProps {
achievements: Achievement[];
}
function getAchievementIcon(icon: string) {
switch (icon) {
case 'trophy': return Trophy;
case 'medal': return Medal;
case 'star': return Star;
case 'crown': return Crown;
case 'target': return Target;
case 'zap': return Zap;
default: return Award;
}
}
export function AchievementGrid({ achievements }: AchievementGridProps) {
return (
<Card>
<Box mb={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2} icon={<Icon icon={Award} size={5} color="#facc15" />}>
Achievements
</Heading>
<Text size="sm" color="text-gray-500" weight="normal">{achievements.length} earned</Text>
</Stack>
</Box>
<Grid cols={1} gap={4}>
{achievements.map((achievement) => {
const AchievementIcon = getAchievementIcon(achievement.icon);
return (
<Surface
key={achievement.id}
variant="muted"
rounded="xl"
border
padding={4}
>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={3}>
<Icon icon={AchievementIcon} size={5} color="#facc15" />
</Surface>
<Box>
<Text weight="semibold" size="sm" color="text-white" block>{achievement.title}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{achievement.description}</Text>
<Text size="xs" color="text-gray-500" block mt={2}>
{achievement.earnedAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</Text>
</Box>
</Stack>
</Surface>
);
})}
</Grid>
</Card>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import React from 'react';
import { TrendingUp } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Grid } from '@/ui/Grid';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface CareerStatsProps {
stats: {
totalRaces: number;
wins: number;
podiums: number;
consistency: number | null;
};
}
export function CareerStats({ stats }: CareerStatsProps) {
return (
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={TrendingUp} size={5} color="#10b981" />}>
Career Statistics
</Heading>
</Box>
<Grid cols={2} gap={4}>
<StatItem label="Races" value={stats.totalRaces} />
<StatItem label="Wins" value={stats.wins} color="text-performance-green" />
<StatItem label="Podiums" value={stats.podiums} color="text-warning-amber" />
<StatItem label="Consistency" value={`${stats.consistency}%`} color="text-primary-blue" />
</Grid>
</Card>
);
}
function StatItem({ label, value, color = 'text-white' }: { label: string, value: string | number, color?: string }) {
return (
<Box p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626', textAlign: 'center' }}>
<Text size="3xl" weight="bold" color={color as any} block mb={1}>{value}</Text>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>{label}</Text>
</Box>
);
}

View File

@@ -5,7 +5,7 @@ import Image from 'next/image';
import Link from 'next/link';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import DriverRating from '@/components/profile/DriverRatingPill';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import PlaceholderImage from '@/ui/PlaceholderImage';
export interface DriverSummaryPillProps {
driver: DriverViewModel;

View File

@@ -0,0 +1,70 @@
'use client';
import React from 'react';
import { Users } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Image } from '@/ui/Image';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { mediaConfig } from '@/lib/config/mediaConfig';
interface Friend {
id: string;
name: string;
avatarUrl?: string;
country: string;
}
interface FriendsPreviewProps {
friends: Friend[];
}
export function FriendsPreview({ friends }: FriendsPreviewProps) {
return (
<Card>
<Box mb={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2} icon={<Icon icon={Users} size={5} color="#a855f7" />}>
Friends
</Heading>
<Text size="sm" color="text-gray-500" weight="normal">({friends.length})</Text>
</Stack>
</Box>
<Stack direction="row" gap={3} wrap>
{friends.slice(0, 8).map((friend) => (
<Box key={friend.id}>
<Link
href={`/drivers/${friend.id}`}
variant="ghost"
>
<Surface variant="muted" rounded="xl" border padding={2} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', backgroundColor: 'rgba(38, 38, 38, 0.5)', borderColor: '#262626' }}>
<Box style={{ width: '2rem', height: '2rem', borderRadius: '9999px', overflow: 'hidden', background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)' }}>
<Image
src={friend.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={friend.name}
width={32}
height={32}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Text size="sm" color="text-white">{friend.name}</Text>
<Text size="lg">{CountryFlagDisplay.fromCountryCode(friend.country).toString()}</Text>
</Surface>
</Link>
</Box>
))}
{friends.length > 8 && (
<Box p={2}>
<Text size="sm" color="text-gray-500">+{friends.length - 8} more</Text>
</Box>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Button } from '@/ui/Button';
import { Surface } from '@/ui/Surface';
interface League {
leagueId: string;
name: string;
description: string;
membershipRole?: string;
}
interface LeagueListItemProps {
league: League;
isAdmin?: boolean;
}
export function LeagueListItem({ league, isAdmin }: LeagueListItemProps) {
return (
<Surface
variant="dark"
rounded="lg"
border
padding={4}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderColor: '#262626' }}
>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text weight="medium" color="text-white" block>{league.name}</Text>
<Text size="xs" color="text-gray-400" block mt={1} style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{league.description}
</Text>
{league.membershipRole && (
<Text size="xs" color="text-gray-500" block mt={1}>
Your role:{' '}
<Text color="text-gray-400" style={{ textTransform: 'capitalize' }}>{league.membershipRole}</Text>
</Text>
)}
</Box>
<Stack direction="row" align="center" gap={2} style={{ marginLeft: '1rem' }}>
<Link
href={`/leagues/${league.leagueId}`}
variant="ghost"
>
<Text size="sm" color="text-gray-300">View</Text>
</Link>
{isAdmin && (
<Link href={`/leagues/${league.leagueId}?tab=admin`} variant="ghost">
<Button variant="primary" size="sm">
Manage
</Button>
</Link>
)}
</Stack>
</Surface>
);
}

View File

@@ -1,5 +1,5 @@
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Card from '@/ui/Card';
import Button from '@/ui/Button';
import { Car, Download, Trash2, Edit } from 'lucide-react';
interface DriverLiveryItem {

View File

@@ -0,0 +1,113 @@
'use client';
import React from 'react';
import { Activity, TrendingUp, Target, BarChart3 } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Icon } from '@/ui/Icon';
import { CircularProgress } from '@/components/drivers/CircularProgress';
import { HorizontalBarChart } from '@/components/drivers/HorizontalBarChart';
interface PerformanceOverviewProps {
stats: {
wins: number;
podiums: number;
totalRaces: number;
consistency: number | null;
dnfs: number;
bestFinish: number;
avgFinish: number | null;
};
}
export function PerformanceOverview({ stats }: PerformanceOverviewProps) {
return (
<Card>
<Box mb={6}>
<Heading level={2} icon={<Icon icon={Activity} size={5} color="#00f2ff" />}>
Performance Overview
</Heading>
</Box>
<Grid cols={12} gap={8}>
<GridItem colSpan={12} lgSpan={6}>
<Stack align="center" gap={4}>
<Stack direction="row" gap={6}>
<CircularProgress
value={stats.wins}
max={stats.totalRaces}
label="Win Rate"
color="#10b981"
/>
<CircularProgress
value={stats.podiums}
max={stats.totalRaces}
label="Podium Rate"
color="#f59e0b"
/>
</Stack>
<Stack direction="row" gap={6}>
<CircularProgress
value={stats.consistency ?? 0}
max={100}
label="Consistency"
color="#3b82f6"
/>
<CircularProgress
value={stats.totalRaces - stats.dnfs}
max={stats.totalRaces}
label="Finish Rate"
color="#00f2ff"
/>
</Stack>
</Stack>
</GridItem>
<GridItem colSpan={12} lgSpan={6}>
<Box mb={4}>
<Heading level={3} icon={<Icon icon={BarChart3} size={4} color="#9ca3af" />}>
Results Breakdown
</Heading>
</Box>
<HorizontalBarChart
data={[
{ label: 'Wins', value: stats.wins, color: 'bg-performance-green' },
{ label: 'Podiums (2nd-3rd)', value: stats.podiums - stats.wins, color: 'bg-warning-amber' },
{ label: 'DNFs', value: stats.dnfs, color: 'bg-red-500' },
]}
maxValue={stats.totalRaces}
/>
<Box mt={6}>
<Grid cols={2} gap={4}>
<Box p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626' }}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={TrendingUp} size={4} color="#10b981" />
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase' }}>Best Finish</Text>
</Stack>
<Text size="2xl" weight="bold" color="text-performance-green">P{stats.bestFinish}</Text>
</Stack>
</Box>
<Box p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626' }}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Target} size={4} color="#3b82f6" />
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase' }}>Avg Finish</Text>
</Stack>
<Text size="2xl" weight="bold" color="text-primary-blue">
P{(stats.avgFinish ?? 0).toFixed(1)}
</Text>
</Stack>
</Box>
</Grid>
</Box>
</GridItem>
</Grid>
</Card>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import React from 'react';
import { User } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Box } from '@/ui/Box';
interface ProfileBioProps {
bio: string;
}
export function ProfileBio({ bio }: ProfileBioProps) {
return (
<Card>
<Box mb={3}>
<Heading level={2} icon={<Icon icon={User} size={5} color="#3b82f6" />}>
About
</Heading>
</Box>
<Text color="text-gray-300">{bio}</Text>
</Card>
);
}

View File

@@ -4,8 +4,8 @@ import Image from 'next/image';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import Button from '../ui/Button';
import DriverRatingPill from '@/components/profile/DriverRatingPill';
import CountryFlag from '@/components/ui/CountryFlag';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import CountryFlag from '@/ui/CountryFlag';
import PlaceholderImage from '@/ui/PlaceholderImage';
interface ProfileHeaderProps {
driver: DriverViewModel;

View File

@@ -0,0 +1,172 @@
'use client';
import React from 'react';
import { Star, Trophy, Globe, Calendar, Clock, UserPlus, ExternalLink, LucideIcon } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Image } from '@/ui/Image';
import { Button } from '@/ui/Button';
import { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
import { mediaConfig } from '@/lib/config/mediaConfig';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
interface ProfileHeroProps {
driver: {
name: string;
avatarUrl?: string;
country: string;
iracingId: number;
joinedAt: string | Date;
};
stats: {
rating: number;
} | null;
globalRank: number;
timezone: string;
socialHandles: {
platform: string;
handle: string;
url: string;
}[];
onAddFriend: () => void;
friendRequestSent: boolean;
}
function getSocialIcon(platform: string) {
const { Twitter, Youtube, Twitch, MessageCircle } = require('lucide-react');
switch (platform) {
case 'twitter': return Twitter;
case 'youtube': return Youtube;
case 'twitch': return Twitch;
case 'discord': return MessageCircle;
default: return Globe;
}
}
export function ProfileHero({
driver,
stats,
globalRank,
timezone,
socialHandles,
onAddFriend,
friendRequestSent,
}: ProfileHeroProps) {
return (
<Surface variant="muted" rounded="2xl" border padding={6} style={{ background: 'linear-gradient(to bottom right, rgba(38, 38, 38, 0.8), rgba(38, 38, 38, 0.6), #0f1115)', borderColor: '#262626' }}>
<Stack direction="row" align="start" gap={6} wrap>
{/* Avatar */}
<Box style={{ position: 'relative' }}>
<Box style={{ width: '7rem', height: '7rem', borderRadius: '1rem', background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)', padding: '0.25rem', boxShadow: '0 20px 25px -5px rgba(59, 130, 246, 0.2)' }}>
<Box style={{ width: '100%', height: '100%', borderRadius: '0.75rem', overflow: 'hidden', backgroundColor: '#262626' }}>
<Image
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={driver.name}
width={144}
height={144}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
</Box>
</Box>
{/* Driver Info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" align="center" gap={3} wrap mb={2}>
<Heading level={1}>{driver.name}</Heading>
<Text size="4xl" aria-label={`Country: ${driver.country}`}>
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
</Text>
</Stack>
{/* Rating and Rank */}
<Stack direction="row" align="center" gap={4} wrap mb={4}>
{stats && (
<>
<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 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 size="xs" color="text-gray-400">Global</Text>
</Stack>
</Surface>
</>
)}
</Stack>
{/* Meta info */}
<Stack direction="row" align="center" gap={4} wrap style={{ fontSize: '0.875rem', color: '#9ca3af' }}>
<Stack direction="row" align="center" gap={1.5}>
<Globe style={{ width: '1rem', height: '1rem' }} />
<Text size="sm">iRacing: {driver.iracingId}</Text>
</Stack>
<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',
})}
</Text>
</Stack>
<Stack direction="row" align="center" gap={1.5}>
<Clock style={{ width: '1rem', height: '1rem' }} />
<Text size="sm">{timezone}</Text>
</Stack>
</Stack>
</Box>
{/* Action Buttons */}
<Box>
<Button
variant="primary"
onClick={onAddFriend}
disabled={friendRequestSent}
icon={<UserPlus style={{ width: '1rem', height: '1rem' }} />}
>
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
</Button>
</Box>
</Stack>
{/* Social Handles */}
{socialHandles.length > 0 && (
<Box mt={6} pt={6} style={{ borderTop: '1px solid rgba(38, 38, 38, 0.5)' }}>
<Stack direction="row" align="center" gap={2} wrap>
<Text size="sm" color="text-gray-500" style={{ marginRight: '0.5rem' }}>Connect:</Text>
{socialHandles.map((social) => {
const Icon = getSocialIcon(social.platform);
return (
<Box key={social.platform}>
<Link
href={social.url}
target="_blank"
rel="noopener noreferrer"
variant="ghost"
>
<Surface variant="muted" rounded="lg" padding={1} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', paddingLeft: '0.75rem', paddingRight: '0.75rem', backgroundColor: 'rgba(38, 38, 38, 0.5)', border: '1px solid #262626', color: '#9ca3af' }}>
<Icon style={{ width: '1rem', height: '1rem' }} />
<Text size="sm">{social.handle}</Text>
<ExternalLink style={{ width: '0.75rem', height: '0.75rem', opacity: 0.5 }} />
</Surface>
</Link>
</Box>
);
})}
</Stack>
</Box>
)}
</Surface>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import React from 'react';
import { Grid } from '@/ui/Grid';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface Stat {
label: string;
value: string | number;
color?: string;
}
interface ProfileStatGridProps {
stats: Stat[];
}
export function ProfileStatGrid({ stats }: ProfileStatGridProps) {
return (
<Grid cols={2} gap={4}>
{stats.map((stat, idx) => (
<Box key={idx} p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626', textAlign: 'center' }}>
<Text size="3xl" weight="bold" color={stat.color as any} block mb={1}>{stat.value}</Text>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>{stat.label}</Text>
</Box>
))}
</Grid>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import React from 'react';
import { User, BarChart3 } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
type ProfileTab = 'overview' | 'stats';
interface ProfileTabsProps {
activeTab: ProfileTab;
onTabChange: (tab: ProfileTab) => void;
}
export function ProfileTabs({ activeTab, onTabChange }: ProfileTabsProps) {
return (
<Surface variant="muted" rounded="xl" padding={1} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', border: '1px solid #262626', width: 'fit-content' }}>
<Box style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Button
variant={activeTab === 'overview' ? 'primary' : 'ghost'}
onClick={() => onTabChange('overview')}
size="sm"
icon={<Icon icon={User} size={4} />}
>
Overview
</Button>
<Button
variant={activeTab === 'stats' ? 'primary' : 'ghost'}
onClick={() => onTabChange('stats')}
size="sm"
icon={<Icon icon={BarChart3} size={4} />}
>
Detailed Stats
</Button>
</Box>
</Surface>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import React from 'react';
import { Flag, Users, UserPlus } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
interface RacingProfileProps {
racingStyle: string;
favoriteTrack: string;
favoriteCar: string;
availableHours: string;
lookingForTeam: boolean;
openToRequests: boolean;
}
export function RacingProfile({
racingStyle,
favoriteTrack,
favoriteCar,
availableHours,
lookingForTeam,
openToRequests,
}: RacingProfileProps) {
return (
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={Flag} size={5} color="#00f2ff" />}>
Racing Profile
</Heading>
</Box>
<Stack gap={4}>
<Box>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block>Racing Style</Text>
<Text color="text-white" weight="medium">{racingStyle}</Text>
</Box>
<Box>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block>Favorite Track</Text>
<Text color="text-white" weight="medium">{favoriteTrack}</Text>
</Box>
<Box>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block>Favorite Car</Text>
<Text color="text-white" weight="medium">{favoriteCar}</Text>
</Box>
<Box>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block>Available</Text>
<Text color="text-white" weight="medium">{availableHours}</Text>
</Box>
{/* Status badges */}
<Box mt={4} pt={4} style={{ borderTop: '1px solid rgba(38, 38, 38, 0.5)' }}>
<Stack gap={2}>
{lookingForTeam && (
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Users} size={4} color="#10b981" />
<Text size="sm" color="text-performance-green" weight="medium">Looking for Team</Text>
</Stack>
</Surface>
)}
{openToRequests && (
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)' }}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={UserPlus} size={4} color="#3b82f6" />
<Text size="sm" color="text-primary-blue" weight="medium">Open to Friend Requests</Text>
</Stack>
</Surface>
)}
</Stack>
</Box>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import React from 'react';
import { Shield, Users, ChevronRight } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
interface TeamMembership {
team: {
id: string;
name: string;
};
role: string;
joinedAt: Date;
}
interface TeamMembershipGridProps {
memberships: TeamMembership[];
}
export function TeamMembershipGrid({ memberships }: TeamMembershipGridProps) {
return (
<Card>
<Box mb={4}>
<Heading level={2} icon={<Shield style={{ width: '1.25rem', height: '1.25rem', color: '#a855f7' }} />}>
Team Memberships
<Text size="sm" color="text-gray-500" weight="normal" style={{ marginLeft: '0.5rem' }}>({memberships.length})</Text>
</Heading>
</Box>
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', gap: '1rem' }}>
{memberships.map((membership) => (
<Box key={membership.team.id}>
<Link
href={`/teams/${membership.team.id}`}
variant="ghost"
>
<Surface variant="muted" rounded="xl" border padding={4} style={{ display: 'flex', alignItems: 'center', gap: '1rem', backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}>
<Surface variant="muted" rounded="lg" padding={3} style={{ backgroundColor: 'rgba(147, 51, 234, 0.2)', border: '1px solid rgba(147, 51, 234, 0.3)' }}>
<Users style={{ width: '1.5rem', height: '1.5rem', color: '#a855f7' }} />
</Surface>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text weight="semibold" color="text-white" block truncate>{membership.team.name}</Text>
<Stack direction="row" align="center" gap={2} mt={1}>
<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>
</Stack>
</Box>
<ChevronRight style={{ width: '1rem', height: '1rem', color: '#737373' }} />
</Surface>
</Link>
</Box>
))}
</Box>
</Card>
);
}

View File

@@ -1,120 +0,0 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import UserPill from './UserPill';
// Mock useAuth to control session state
vi.mock('@/lib/auth/AuthContext', () => {
return {
useAuth: () => mockedAuthValue,
};
});
// Mock effective driver id hook
vi.mock('@/hooks/useEffectiveDriverId', () => {
return {
useEffectiveDriverId: () => mockedDriverId,
};
});
// Mock the new DI hooks
const mockFindById = vi.fn();
let mockDriverData: any = null;
vi.mock('@/hooks/driver/useFindDriverById', () => ({
useFindDriverById: (driverId: string) => {
return {
data: mockDriverData,
isLoading: false,
isError: false,
isSuccess: !!mockDriverData,
refetch: vi.fn(),
};
},
}));
interface MockSessionUser {
id: string;
}
interface MockSession {
user: MockSessionUser | null;
}
let mockedAuthValue: { session: MockSession | null } = { session: null };
let mockedDriverId: string | null = null;
// Provide global stats helpers used by UserPill's rating/rank computation
// They are UI-level helpers, so a minimal stub is sufficient for these tests.
(globalThis as any).getDriverStats = (driverId: string) => ({
driverId,
rating: 2000,
overallRank: 10,
wins: 5,
});
(globalThis as any).getAllDriverRankings = () => [
{ driverId: 'driver-1', rating: 2100 },
{ driverId: 'driver-2', rating: 2000 },
];
describe('UserPill', () => {
beforeEach(() => {
mockedAuthValue = { session: null };
mockedDriverId = null;
mockDriverData = null;
mockFindById.mockReset();
});
it('renders auth links when there is no session', () => {
mockedAuthValue = { session: null };
const { container } = render(<UserPill />);
expect(screen.getByText('Sign In')).toBeInTheDocument();
expect(screen.getByText('Get Started')).toBeInTheDocument();
expect(mockFindById).not.toHaveBeenCalled();
expect(container).toMatchSnapshot();
});
it('does not load driver when there is no primary driver id', async () => {
mockedAuthValue = { session: { user: { id: 'user-1' } } };
mockedDriverId = null;
const { container } = render(<UserPill />);
// Component should still render user pill with session user info
await waitFor(() => {
expect(screen.getByText('User')).toBeInTheDocument();
});
expect(mockFindById).not.toHaveBeenCalled();
});
it('loads driver via driverService and uses driver avatarUrl', async () => {
const driver = {
id: 'driver-1',
iracingId: 'ir-123',
name: 'Test Driver',
country: 'DE',
joinedAt: '2023-01-01',
avatarUrl: '/api/media/avatar/driver-1',
};
mockedAuthValue = { session: { user: { id: 'user-1' } } };
mockedDriverId = driver.id;
// Set the mock data that the hook will return
mockDriverData = driver;
render(<UserPill />);
await waitFor(() => {
expect(screen.getByText('Test Driver')).toBeInTheDocument();
});
expect(mockFindById).not.toHaveBeenCalled(); // Hook is mocked, not called directly
});
});

View File

@@ -1,22 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UserPill > renders auth links when there is no session 1`] = `
<div>
<div
class="flex items-center gap-2"
>
<a
class="inline-flex items-center gap-2 rounded-full bg-iron-gray border border-charcoal-outline px-4 py-1.5 text-xs font-medium text-gray-300 hover:text-white hover:border-gray-500 transition-all"
href="/auth/login"
>
Sign In
</a>
<a
class="inline-flex items-center gap-2 rounded-full bg-primary-blue px-4 py-1.5 text-xs font-semibold text-white shadow-[0_0_12px_rgba(25,140,255,0.5)] hover:bg-primary-blue/90 hover:shadow-[0_0_18px_rgba(25,140,255,0.8)] transition-all"
href="/auth/signup"
>
Get Started
</a>
</div>
</div>
`;