website refactor
This commit is contained in:
71
apps/website/components/drivers/DriverCard.tsx
Normal file
71
apps/website/components/drivers/DriverCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { RankBadge } from '@/ui/RankBadge';
|
||||
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { DriverStats } from '@/ui/DriverStats';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export interface DriverCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
skillLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||
nationality: string;
|
||||
racesCompleted: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
rank: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function DriverCard(props: DriverCardProps) {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
rating,
|
||||
nationality,
|
||||
racesCompleted,
|
||||
wins,
|
||||
podiums,
|
||||
rank,
|
||||
onClick,
|
||||
} = props;
|
||||
|
||||
// Create a proper DriverViewModel instance
|
||||
const driverViewModel = new DriverViewModel({
|
||||
id,
|
||||
name,
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
const winRate = racesCompleted > 0 ? ((wins / racesCompleted) * 100).toFixed(0) : '0';
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
transition
|
||||
>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={4} flexGrow={1}>
|
||||
<RankBadge rank={rank} size="lg" />
|
||||
|
||||
<DriverIdentity
|
||||
driver={driverViewModel}
|
||||
href={routes.driver.detail(id)}
|
||||
meta={`${nationality} • ${racesCompleted} races`}
|
||||
size="md"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<DriverStats
|
||||
rating={rating}
|
||||
wins={wins}
|
||||
podiums={podiums}
|
||||
winRate={winRate}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
118
apps/website/components/drivers/DriverEntryRow.tsx
Normal file
118
apps/website/components/drivers/DriverEntryRow.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
|
||||
|
||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface DriverEntryRowProps {
|
||||
index: number;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
country: string;
|
||||
rating?: number | null;
|
||||
isCurrentUser: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function DriverEntryRow({
|
||||
index,
|
||||
name,
|
||||
avatarUrl,
|
||||
country,
|
||||
rating,
|
||||
isCurrentUser,
|
||||
onClick,
|
||||
}: DriverEntryRowProps) {
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
p={3}
|
||||
rounded="xl"
|
||||
cursor="pointer"
|
||||
transition
|
||||
bg={isCurrentUser ? 'bg-primary-blue/10' : 'transparent'}
|
||||
border
|
||||
borderColor={isCurrentUser ? 'border-primary-blue/30' : 'transparent'}
|
||||
hoverBorderColor={isCurrentUser ? 'primary-blue/40' : 'charcoal-outline/20'}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w="8"
|
||||
h="8"
|
||||
rounded="lg"
|
||||
bg="bg-iron-gray"
|
||||
color="text-gray-500"
|
||||
style={{ fontWeight: 'bold', fontSize: '0.875rem' }}
|
||||
>
|
||||
{index + 1}
|
||||
</Box>
|
||||
|
||||
<Box position="relative" flexShrink={0}>
|
||||
<Box
|
||||
w="10"
|
||||
h="10"
|
||||
rounded="full"
|
||||
overflow="hidden"
|
||||
border={isCurrentUser}
|
||||
borderColor={isCurrentUser ? 'border-primary-blue' : ''}
|
||||
>
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
objectFit="cover"
|
||||
fill
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="-0.5"
|
||||
right="-0.5"
|
||||
w="5"
|
||||
h="5"
|
||||
rounded="full"
|
||||
bg="bg-deep-graphite"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
style={{ fontSize: '0.625rem' }}
|
||||
>
|
||||
{CountryFlagDisplay.fromCountryCode(country).toString()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text
|
||||
weight="semibold"
|
||||
size="sm"
|
||||
color={isCurrentUser ? 'text-primary-blue' : 'text-white'}
|
||||
truncate
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
{isCurrentUser && <Badge variant="primary">You</Badge>}
|
||||
</Stack>
|
||||
<Text size="xs" color="text-gray-500">{country}</Text>
|
||||
</Box>
|
||||
|
||||
{rating != null && (
|
||||
<Badge variant="warning">
|
||||
<Icon icon={Zap} size={3} />
|
||||
{rating}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
84
apps/website/components/drivers/DriverIdentity.tsx
Normal file
84
apps/website/components/drivers/DriverIdentity.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import Link from 'next/link';
|
||||
import { PlaceholderImage } from '@/ui/PlaceholderImage';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Image } from '@/ui/Image';
|
||||
|
||||
export interface DriverIdentityProps {
|
||||
driver: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
href?: string;
|
||||
contextLabel?: React.ReactNode;
|
||||
meta?: React.ReactNode;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export function DriverIdentity(props: DriverIdentityProps) {
|
||||
const { driver, href, contextLabel, meta, size = 'md' } = props;
|
||||
|
||||
const avatarSize = size === 'sm' ? 40 : 48;
|
||||
const nameSize = size === 'sm' ? 'sm' : 'base';
|
||||
|
||||
const avatarUrl = driver.avatarUrl;
|
||||
|
||||
const content = (
|
||||
<Box display="flex" alignItems="center" gap={{ base: 3, md: 4 }} flexGrow={1} minWidth="0">
|
||||
<Box
|
||||
rounded="full"
|
||||
bg="bg-primary-blue/20"
|
||||
overflow="hidden"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
flexShrink={0}
|
||||
style={{ width: avatarSize, height: avatarSize }}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={driver.name}
|
||||
width={avatarSize}
|
||||
height={avatarSize}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage size={avatarSize} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Box display="flex" alignItems="center" gap={2} minWidth="0">
|
||||
<Text size={nameSize} weight="medium" color="text-white" className="truncate">
|
||||
{driver.name}
|
||||
</Text>
|
||||
{contextLabel && (
|
||||
<Badge variant="default" className="bg-charcoal-outline/60 text-[10px] md:text-xs">
|
||||
{contextLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{meta && (
|
||||
<Text size="xs" color="text-gray-400" mt={0.5} className="truncate" block>
|
||||
{meta}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <Box display="flex" alignItems="center" gap={{ base: 3, md: 4 }} flexGrow={1} minWidth="0">{content}</Box>;
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { StatCard } from '@/ui/StatCard';
|
||||
import { ProfileHeader } from '@/ui/ProfileHeader';
|
||||
import { ProfileHeader } from '@/components/drivers/ProfileHeader';
|
||||
import { ProfileStats } from './ProfileStats';
|
||||
import { CareerHighlights } from '@/ui/CareerHighlights';
|
||||
import { DriverRankings } from '@/ui/DriverRankings';
|
||||
import { DriverRankings } from '@/components/drivers/DriverRankings';
|
||||
import { PerformanceMetrics } from '@/ui/PerformanceMetrics';
|
||||
import { useDriverProfile } from "@/hooks/driver/useDriverProfile";
|
||||
|
||||
|
||||
52
apps/website/components/drivers/DriverRankings.tsx
Normal file
52
apps/website/components/drivers/DriverRankings.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { RankingListItem } from '@/components/leaderboards/RankingListItem';
|
||||
import { RankingList } from '@/components/leaderboards/RankingList';
|
||||
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
|
||||
|
||||
export interface DriverRanking {
|
||||
type: 'overall' | 'league';
|
||||
name: string;
|
||||
rank: number;
|
||||
totalDrivers: number;
|
||||
percentile: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
interface DriverRankingsProps {
|
||||
rankings: DriverRanking[];
|
||||
}
|
||||
|
||||
export function DriverRankings({ rankings }: DriverRankingsProps) {
|
||||
if (!rankings || rankings.length === 0) {
|
||||
return (
|
||||
<Card bg="bg-iron-gray/60" borderColor="border-charcoal-outline/80" p={4}>
|
||||
<Heading level={3} mb={2}>Rankings</Heading>
|
||||
<MinimalEmptyState
|
||||
title="No ranking data available yet"
|
||||
description="Compete in leagues to earn your first results."
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bg="bg-iron-gray/60" borderColor="border-charcoal-outline/80" p={4}>
|
||||
<Heading level={3} mb={4}>Rankings</Heading>
|
||||
<RankingList>
|
||||
{rankings.map((ranking, index) => (
|
||||
<RankingListItem
|
||||
key={`${ranking.type}-${ranking.name}-${index}`}
|
||||
name={ranking.name}
|
||||
type={ranking.type}
|
||||
rank={ranking.rank}
|
||||
totalDrivers={ranking.totalDrivers}
|
||||
percentile={ranking.percentile}
|
||||
rating={ranking.rating}
|
||||
/>
|
||||
))}
|
||||
</RankingList>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
28
apps/website/components/drivers/DriverSummaryPillWrapper.tsx
Normal file
28
apps/website/components/drivers/DriverSummaryPillWrapper.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
|
||||
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { DriverRatingPill } from '@/ui/DriverRatingPill';
|
||||
import { DriverSummaryPill as UiDriverSummaryPill } from '@/ui/DriverSummaryPill';
|
||||
|
||||
export interface DriverSummaryPillProps {
|
||||
driver: DriverViewModel;
|
||||
rating: number | null;
|
||||
rank: number | null;
|
||||
avatarSrc?: string | null;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export function DriverSummaryPill(props: DriverSummaryPillProps) {
|
||||
const { driver, rating, rank, avatarSrc, onClick, href } = props;
|
||||
|
||||
return (
|
||||
<UiDriverSummaryPill
|
||||
name={driver.name}
|
||||
avatarSrc={avatarSrc}
|
||||
onClick={onClick}
|
||||
href={href}
|
||||
ratingComponent={<DriverRatingPill rating={rating} rank={rank} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
161
apps/website/components/drivers/FeaturedDriverCard.tsx
Normal file
161
apps/website/components/drivers/FeaturedDriverCard.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
|
||||
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { MedalBadge } from '@/ui/MedalBadge';
|
||||
import { MiniStat } from '@/ui/MiniStat';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Flag, Shield, Star, TrendingUp } from 'lucide-react';
|
||||
|
||||
const SKILL_LEVELS = [
|
||||
{ id: 'pro', label: 'Pro', icon: Star, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30', description: 'Elite competition level' },
|
||||
{ id: 'advanced', label: 'Advanced', icon: Star, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30', description: 'Highly competitive' },
|
||||
{ id: 'intermediate', label: 'Intermediate', icon: TrendingUp, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30', description: 'Developing skills' },
|
||||
{ id: 'beginner', label: 'Beginner', icon: Shield, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30', description: 'Learning the ropes' },
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
|
||||
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
|
||||
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400', bgColor: 'bg-orange-400/10', borderColor: 'border-orange-400/30' },
|
||||
{ id: 'sprint', label: 'Sprint', color: 'text-red-400', bgColor: 'bg-red-400/10', borderColor: 'border-red-400/30' },
|
||||
];
|
||||
|
||||
interface FeaturedDriverCardProps {
|
||||
driver: {
|
||||
id: string;
|
||||
name: string;
|
||||
nationality: string;
|
||||
avatarUrl?: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
skillLevel?: string;
|
||||
category?: string;
|
||||
};
|
||||
position: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardProps) {
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
|
||||
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
|
||||
|
||||
const getBorderColor = (pos: number) => {
|
||||
switch (pos) {
|
||||
case 1: return 'border-yellow-400/50';
|
||||
case 2: return 'border-gray-300/50';
|
||||
case 3: return 'border-amber-600/50';
|
||||
default: return 'border-charcoal-outline';
|
||||
}
|
||||
};
|
||||
|
||||
const getHoverBorderColor = (pos: number) => {
|
||||
switch (pos) {
|
||||
case 1: return 'yellow-400';
|
||||
case 2: return 'gray-300';
|
||||
case 3: return 'amber-600';
|
||||
default: return 'primary-blue';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
p={5}
|
||||
rounded="xl"
|
||||
bg="bg-iron-gray/60"
|
||||
border
|
||||
borderColor={getBorderColor(position)}
|
||||
hoverBorderColor={getHoverBorderColor(position)}
|
||||
transition
|
||||
textAlign="left"
|
||||
cursor="pointer"
|
||||
hoverScale
|
||||
group
|
||||
>
|
||||
{/* Header with Position */}
|
||||
<Box display="flex" alignItems="start" justifyContent="between" mb={4}>
|
||||
<MedalBadge position={position} />
|
||||
<Box display="flex" gap={2}>
|
||||
{categoryConfig && (
|
||||
<Badge
|
||||
bg={categoryConfig.bgColor}
|
||||
color={categoryConfig.color}
|
||||
borderColor={categoryConfig.borderColor}
|
||||
>
|
||||
{categoryConfig.label}
|
||||
</Badge>
|
||||
)}
|
||||
{levelConfig && (
|
||||
<Badge
|
||||
bg={levelConfig.bgColor}
|
||||
color={levelConfig.color}
|
||||
borderColor={levelConfig.borderColor}
|
||||
>
|
||||
{levelConfig.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Avatar & Name */}
|
||||
<Box display="flex" alignItems="center" gap={4} mb={4}>
|
||||
<Box
|
||||
position="relative"
|
||||
w="16"
|
||||
h="16"
|
||||
rounded="full"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
groupHoverBorderColor="primary-blue"
|
||||
transition
|
||||
>
|
||||
<Image
|
||||
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
|
||||
alt={driver.name}
|
||||
objectFit="cover"
|
||||
fill
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={3} groupHoverColor="primary-blue" transition>
|
||||
{driver.name}
|
||||
</Heading>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Icon icon={Flag} size={3.5} color="rgb(107, 114, 128)" />
|
||||
<Text size="sm" color="text-gray-500">{driver.nationality}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Stats */}
|
||||
<Box display="grid" gridCols={3} gap={3}>
|
||||
<MiniStat
|
||||
label="Rating"
|
||||
value={driver.rating.toLocaleString()}
|
||||
color="text-primary-blue"
|
||||
/>
|
||||
<MiniStat
|
||||
label="Wins"
|
||||
value={driver.wins}
|
||||
color="text-performance-green"
|
||||
/>
|
||||
<MiniStat
|
||||
label="Podiums"
|
||||
value={driver.podiums}
|
||||
color="text-warning-amber"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
98
apps/website/components/drivers/ProfileHeader.tsx
Normal file
98
apps/website/components/drivers/ProfileHeader.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
|
||||
|
||||
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { CountryFlag } from '@/ui/CountryFlag';
|
||||
import { DriverRatingPill } from '@/ui/DriverRatingPill';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { PlaceholderImage } from '@/ui/PlaceholderImage';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
driver: DriverViewModel;
|
||||
rating?: number | null;
|
||||
rank?: number | null;
|
||||
isOwnProfile?: boolean;
|
||||
onEditClick?: () => void;
|
||||
teamName?: string | null;
|
||||
teamTag?: string | null;
|
||||
}
|
||||
|
||||
export function ProfileHeader({
|
||||
driver,
|
||||
rating,
|
||||
rank,
|
||||
isOwnProfile = false,
|
||||
onEditClick,
|
||||
teamName,
|
||||
teamTag,
|
||||
}: ProfileHeaderProps) {
|
||||
return (
|
||||
<Box display="flex" alignItems="start" justifyContent="between">
|
||||
<Box display="flex" alignItems="start" gap={4}>
|
||||
<Box
|
||||
w="20"
|
||||
h="20"
|
||||
rounded="full"
|
||||
bg="bg-gradient-to-br from-primary-blue to-purple-600"
|
||||
overflow="hidden"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{driver.avatarUrl ? (
|
||||
<Image
|
||||
src={driver.avatarUrl}
|
||||
alt={driver.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage size={80} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box display="flex" alignItems="center" gap={3} mb={2}>
|
||||
<Heading level={1}>{driver.name}</Heading>
|
||||
{driver.country && <CountryFlag countryCode={driver.country} size="lg" />}
|
||||
{teamTag && (
|
||||
<Badge variant="primary">
|
||||
{teamTag}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Text size="sm" color="text-gray-400">iRacing ID: {driver.iracingId}</Text>
|
||||
{teamName && (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text size="sm" color="text-gray-400">•</Text>
|
||||
<Text size="sm" color="text-primary-blue">
|
||||
{teamTag ? `[${teamTag}] ${teamName}` : teamName}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(typeof rating === 'number' || typeof rank === 'number') && (
|
||||
<Box mt={2}>
|
||||
<DriverRatingPill rating={rating ?? null} rank={rank ?? null} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isOwnProfile && (
|
||||
<Button variant="secondary" onClick={onEditClick}>
|
||||
Edit Profile
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
171
apps/website/components/drivers/ProfileHero.tsx
Normal file
171
apps/website/components/drivers/ProfileHero.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
|
||||
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Calendar, Clock, ExternalLink, Globe, Star, Trophy, UserPlus } from 'lucide-react';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { LoadingWrapper } from '@/ui/LoadingWrapper';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import { EmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { Pagination } from '@/ui/Pagination';
|
||||
import { Trophy } from 'lucide-react';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user