website refactor
This commit is contained in:
@@ -12,7 +12,7 @@ import { Heading } from '@/ui/Heading';
|
||||
import { JoinRequestList } from '@/ui/JoinRequestList';
|
||||
import { JoinRequestItem } from '@/ui/JoinRequestItem';
|
||||
import { DangerZone } from '@/ui/DangerZone';
|
||||
import { MinimalEmptyState } from '@/ui/EmptyState';
|
||||
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { useTeamJoinRequests, useUpdateTeam, useApproveJoinRequest, useRejectJoinRequest } from "@/hooks/team";
|
||||
import type { TeamJoinRequestViewModel } from '@/lib/view-models/TeamJoinRequestViewModel';
|
||||
|
||||
|
||||
79
apps/website/components/teams/TeamHero.tsx
Normal file
79
apps/website/components/teams/TeamHero.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
|
||||
|
||||
import { JoinTeamButton } from '@/components/teams/JoinTeamButton';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { TeamLogo } from '@/ui/TeamLogo';
|
||||
import { TeamTag } from '@/ui/TeamTag';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface TeamHeroProps {
|
||||
team: {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string | null;
|
||||
description?: string;
|
||||
category?: string | null;
|
||||
createdAt?: string;
|
||||
leagues: { id: string }[];
|
||||
};
|
||||
memberCount: number;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export function TeamHero({ team, memberCount, onUpdate }: TeamHeroProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Stack direction="row" align="start" justify="between" wrap gap={6}>
|
||||
<Stack direction="row" align="start" gap={6} wrap flexGrow={1}>
|
||||
<Box
|
||||
w="24"
|
||||
h="24"
|
||||
rounded="lg"
|
||||
p={1}
|
||||
overflow="hidden"
|
||||
bg="bg-deep-graphite"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<TeamLogo teamId={team.id} alt={team.name} />
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="center" gap={3} mb={2}>
|
||||
<Heading level={1}>{team.name}</Heading>
|
||||
{team.tag && <TeamTag tag={team.tag} />}
|
||||
</Stack>
|
||||
|
||||
<Text color="text-gray-300" block mb={4} maxWidth="42rem">{team.description}</Text>
|
||||
|
||||
<Stack direction="row" align="center" gap={4} wrap>
|
||||
<Text size="sm" color="text-gray-400">{memberCount} {memberCount === 1 ? 'member' : 'members'}</Text>
|
||||
{team.category && (
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Box w="2" h="2" rounded="full" bg="bg-purple-500" />
|
||||
<Text size="sm" color="text-purple-400">{team.category}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{team.createdAt && (
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Founded {new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</Text>
|
||||
)}
|
||||
{team.leagues && team.leagues.length > 0 && (
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Active in {team.leagues.length} {team.leagues.length === 1 ? 'league' : 'leagues'}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<JoinTeamButton teamId={team.id} onUpdate={onUpdate} />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
83
apps/website/components/teams/TeamHeroSection.tsx
Normal file
83
apps/website/components/teams/TeamHeroSection.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
|
||||
|
||||
import { Users } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface TeamHeroSectionProps {
|
||||
title: ReactNode;
|
||||
description: string;
|
||||
statsContent: ReactNode;
|
||||
actionsContent: ReactNode;
|
||||
sideContent: ReactNode;
|
||||
}
|
||||
|
||||
export function TeamHeroSection({
|
||||
title,
|
||||
description,
|
||||
statsContent,
|
||||
actionsContent,
|
||||
sideContent,
|
||||
}: TeamHeroSectionProps) {
|
||||
return (
|
||||
<Box position="relative" mb={10} overflow="hidden">
|
||||
{/* Main Hero Card */}
|
||||
<Box
|
||||
position="relative"
|
||||
py={12}
|
||||
px={8}
|
||||
rounded="2xl"
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom right, rgba(147, 51, 234, 0.3), rgba(38, 38, 38, 0.8), #0f1115)',
|
||||
borderColor: 'rgba(147, 51, 234, 0.2)',
|
||||
}}
|
||||
border
|
||||
>
|
||||
{/* Background decorations */}
|
||||
<Box position="absolute" top="0" right="0" w="80" h="80" bg="bg-purple-500" bgOpacity={0.1} rounded="full" blur="3xl" />
|
||||
<Box position="absolute" bottom="0" left="1/4" w="64" h="64" bg="bg-neon-aqua" bgOpacity={0.05} rounded="full" blur="3xl" />
|
||||
<Box position="absolute" top="1/2" right="1/4" w="48" h="48" bg="bg-yellow-400" bgOpacity={0.05} rounded="full" blur="2xl" />
|
||||
|
||||
<Box position="relative" zIndex={10}>
|
||||
<Stack direction={{ base: 'col', lg: 'row' }} align="start" justify="between" gap={8}>
|
||||
<Box maxWidth="xl">
|
||||
{/* Badge */}
|
||||
<Box mb={4}>
|
||||
<Badge variant="primary" icon={Users}>
|
||||
Team Racing
|
||||
</Badge>
|
||||
</Box>
|
||||
|
||||
<Heading level={1}>
|
||||
{title}
|
||||
</Heading>
|
||||
|
||||
<Text size="lg" color="text-gray-400" leading="relaxed" block mt={4} mb={6}>
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Stack direction="row" gap={4} mb={6} wrap>
|
||||
{statsContent}
|
||||
</Stack>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<Stack direction="row" gap={3} wrap>
|
||||
{actionsContent}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Side Content */}
|
||||
<Box w={{ base: 'full', lg: '72' }}>
|
||||
{sideContent}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
147
apps/website/components/teams/TeamHeroSectionWrapper.tsx
Normal file
147
apps/website/components/teams/TeamHeroSectionWrapper.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
|
||||
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { SkillLevelButton } from '@/ui/SkillLevelButton';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { TeamHeroSection as UiTeamHeroSection } from '@/components/teams/TeamHeroSection';
|
||||
import { TeamHeroStats } from '@/components/teams/TeamHeroStats';
|
||||
import { Text } from '@/ui/Text';
|
||||
import {
|
||||
Crown,
|
||||
LucideIcon,
|
||||
Plus,
|
||||
Search,
|
||||
Shield,
|
||||
Star,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
|
||||
interface SkillLevelConfig {
|
||||
id: SkillLevel;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SKILL_LEVELS: SkillLevelConfig[] = [
|
||||
{
|
||||
id: 'pro',
|
||||
label: 'Pro',
|
||||
icon: Crown,
|
||||
color: 'text-warning-amber',
|
||||
bgColor: 'bg-yellow-400/10',
|
||||
borderColor: 'border-yellow-400/30',
|
||||
description: 'Elite competition, sponsored teams',
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
label: 'Advanced',
|
||||
icon: Star,
|
||||
color: 'text-purple-400',
|
||||
bgColor: 'bg-purple-900/10',
|
||||
borderColor: 'border-purple-900/30',
|
||||
description: 'Competitive racing, high consistency',
|
||||
},
|
||||
{
|
||||
id: 'intermediate',
|
||||
label: 'Intermediate',
|
||||
icon: TrendingUp,
|
||||
color: 'text-primary-blue',
|
||||
bgColor: 'bg-blue-900/10',
|
||||
borderColor: 'border-blue-900/30',
|
||||
description: 'Growing skills, regular practice',
|
||||
},
|
||||
{
|
||||
id: 'beginner',
|
||||
label: 'Beginner',
|
||||
icon: Shield,
|
||||
color: 'text-performance-green',
|
||||
bgColor: 'bg-green-900/10',
|
||||
borderColor: 'border-green-900/30',
|
||||
description: 'Learning the basics, friendly environment',
|
||||
},
|
||||
];
|
||||
|
||||
interface TeamHeroSectionProps {
|
||||
teams: TeamSummaryViewModel[];
|
||||
teamsByLevel: Record<string, TeamSummaryViewModel[]>;
|
||||
recruitingCount: number;
|
||||
onShowCreateForm: () => void;
|
||||
onBrowseTeams: () => void;
|
||||
onSkillLevelClick: (level: SkillLevel) => void;
|
||||
}
|
||||
|
||||
export function TeamHeroSection({
|
||||
teams,
|
||||
teamsByLevel,
|
||||
recruitingCount,
|
||||
onShowCreateForm,
|
||||
onBrowseTeams,
|
||||
onSkillLevelClick,
|
||||
}: TeamHeroSectionProps) {
|
||||
return (
|
||||
<UiTeamHeroSection
|
||||
title={
|
||||
<>
|
||||
Find Your
|
||||
<Text color="text-purple-400"> Crew</Text>
|
||||
</>
|
||||
}
|
||||
description="Solo racing is great. Team racing is unforgettable. Join a team that matches your skill level and ambitions."
|
||||
statsContent={
|
||||
<TeamHeroStats teamCount={teams.length} recruitingCount={recruitingCount} />
|
||||
}
|
||||
actionsContent={
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onShowCreateForm}
|
||||
icon={<Icon icon={Plus} size={4} />}
|
||||
bg="bg-purple-600"
|
||||
>
|
||||
Create Team
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBrowseTeams}
|
||||
icon={<Icon icon={Search} size={4} />}
|
||||
>
|
||||
Browse Teams
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
sideContent={
|
||||
<>
|
||||
<Text size="xs" color="text-gray-500" weight="medium" block mb={3} uppercase letterSpacing="0.05em">
|
||||
Find Your Level
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
{SKILL_LEVELS.map((level) => {
|
||||
const count = teamsByLevel[level.id]?.length || 0;
|
||||
|
||||
return (
|
||||
<SkillLevelButton
|
||||
key={level.id}
|
||||
label={level.label}
|
||||
icon={level.icon}
|
||||
color={level.color}
|
||||
bgColor={level.bgColor}
|
||||
borderColor={level.borderColor}
|
||||
count={count}
|
||||
onClick={() => onSkillLevelClick(level.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
apps/website/components/teams/TeamHeroStats.tsx
Normal file
48
apps/website/components/teams/TeamHeroStats.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Users, UserPlus } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface TeamHeroStatsProps {
|
||||
teamCount: number;
|
||||
recruitingCount: number;
|
||||
}
|
||||
|
||||
export function TeamHeroStats({ teamCount, recruitingCount }: TeamHeroStatsProps) {
|
||||
return (
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
px={3}
|
||||
py={2}
|
||||
rounded="lg"
|
||||
bg="bg-iron-gray/50"
|
||||
border={true}
|
||||
borderColor="border-charcoal-outline"
|
||||
>
|
||||
<Icon icon={Users} size={4} color="text-purple-400" />
|
||||
<Text weight="semibold" color="text-white">{teamCount}</Text>
|
||||
<Text size="sm" color="text-gray-500">Teams</Text>
|
||||
</Box>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
px={3}
|
||||
py={2}
|
||||
rounded="lg"
|
||||
bg="bg-iron-gray/50"
|
||||
border={true}
|
||||
borderColor="border-charcoal-outline"
|
||||
>
|
||||
<Icon icon={UserPlus} size={4} color="text-performance-green" />
|
||||
<Text weight="semibold" color="text-white">{recruitingCount}</Text>
|
||||
<Text size="sm" color="text-gray-500">Recruiting</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
46
apps/website/components/teams/TeamIdentity.tsx
Normal file
46
apps/website/components/teams/TeamIdentity.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Image } from '@/ui/Image';
|
||||
|
||||
interface TeamIdentityProps {
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
performanceLevel?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export function TeamIdentity({ name, logoUrl, performanceLevel, category }: TeamIdentityProps) {
|
||||
return (
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box width="10" height="10" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<Text weight="semibold" color="text-white" block truncate>{name}</Text>
|
||||
{(performanceLevel || category) && (
|
||||
<Stack direction="row" align="center" gap={2} mt={1} wrap>
|
||||
{performanceLevel && (
|
||||
<Text size="xs" color="text-gray-500">{performanceLevel}</Text>
|
||||
)}
|
||||
{category && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Box width="1.5" height="1.5" rounded="full" bg="bg-primary-blue" opacity={0.5} />
|
||||
<Text size="xs" color="text-primary-blue">{category}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import { TeamLeaderboardItem } from '@/ui/TeamLeaderboardItem';
|
||||
import { TeamLeaderboardPreview as UiTeamLeaderboardPreview } from '@/ui/TeamLeaderboardPreview';
|
||||
|
||||
interface TeamLeaderboardPreviewProps {
|
||||
topTeams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
category?: string;
|
||||
memberCount: number;
|
||||
totalWins: number;
|
||||
isRecruiting: boolean;
|
||||
rating?: number;
|
||||
performanceLevel: string;
|
||||
}>;
|
||||
onTeamClick: (id: string) => void;
|
||||
onViewFullLeaderboard: () => void;
|
||||
}
|
||||
|
||||
export function TeamLeaderboardPreview({
|
||||
topTeams,
|
||||
onTeamClick,
|
||||
onViewFullLeaderboard
|
||||
}: TeamLeaderboardPreviewProps) {
|
||||
const getMedalColor = (position: number) => {
|
||||
switch (position) {
|
||||
case 0: return '#facc15';
|
||||
case 1: return '#d1d5db';
|
||||
case 2: return '#d97706';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
const getMedalBg = (position: number) => {
|
||||
switch (position) {
|
||||
case 0: return 'rgba(250, 204, 21, 0.1)';
|
||||
case 1: return 'rgba(209, 213, 219, 0.1)';
|
||||
case 2: return 'rgba(217, 119, 6, 0.1)';
|
||||
default: return 'rgba(38, 38, 38, 0.5)';
|
||||
}
|
||||
};
|
||||
|
||||
const getMedalBorder = (position: number) => {
|
||||
switch (position) {
|
||||
case 0: return 'rgba(250, 204, 21, 0.3)';
|
||||
case 1: return 'rgba(209, 213, 219, 0.3)';
|
||||
case 2: return 'rgba(217, 119, 6, 0.3)';
|
||||
default: return 'rgba(38, 38, 38, 1)';
|
||||
}
|
||||
};
|
||||
|
||||
if (topTeams.length === 0) return null;
|
||||
|
||||
return (
|
||||
<UiTeamLeaderboardPreview
|
||||
title="Top Teams"
|
||||
subtitle="Highest rated racing teams"
|
||||
onViewFull={onViewFullLeaderboard}
|
||||
>
|
||||
{topTeams.map((team, index) => (
|
||||
<TeamLeaderboardItem
|
||||
key={team.id}
|
||||
position={index + 1}
|
||||
name={team.name}
|
||||
logoUrl={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
category={team.category}
|
||||
memberCount={team.memberCount}
|
||||
totalWins={team.totalWins}
|
||||
isRecruiting={team.isRecruiting}
|
||||
rating={team.rating}
|
||||
onClick={() => onTeamClick(team.id)}
|
||||
medalColor={getMedalColor(index)}
|
||||
medalBg={getMedalBg(index)}
|
||||
medalBorder={getMedalBorder(index)}
|
||||
/>
|
||||
))}
|
||||
</UiTeamLeaderboardPreview>
|
||||
);
|
||||
}
|
||||
145
apps/website/components/teams/TeamPodium.tsx
Normal file
145
apps/website/components/teams/TeamPodium.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { Trophy, Crown, Users } from 'lucide-react';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Podium, PodiumItem } from '@/ui/Podium';
|
||||
|
||||
interface TeamPodiumProps {
|
||||
teams: TeamSummaryViewModel[];
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TeamPodium({ teams, onClick }: TeamPodiumProps) {
|
||||
const top3 = teams.slice(0, 3) as [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel];
|
||||
if (teams.length < 3) return null;
|
||||
|
||||
// Display order: 2nd, 1st, 3rd
|
||||
const podiumOrder: [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel] = [
|
||||
top3[1],
|
||||
top3[0],
|
||||
top3[2],
|
||||
];
|
||||
const podiumHeights = ['28', '36', '20'];
|
||||
const podiumPositions = [2, 1, 3];
|
||||
|
||||
const getPositionColor = (position: number) => {
|
||||
switch (position) {
|
||||
case 1:
|
||||
return 'text-yellow-400';
|
||||
case 2:
|
||||
return 'text-gray-300';
|
||||
case 3:
|
||||
return 'text-amber-600';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getBgColor = (position: number) => {
|
||||
switch (position) {
|
||||
case 1:
|
||||
return 'bg-gradient-to-br from-primary-blue/20 via-iron-gray/80 to-deep-graphite';
|
||||
case 2:
|
||||
return 'bg-iron-gray';
|
||||
case 3:
|
||||
return 'bg-gradient-to-br from-purple-600/20 via-iron-gray/80 to-deep-graphite';
|
||||
default:
|
||||
return 'bg-iron-gray/50';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Podium title="Top 3 Teams">
|
||||
{podiumOrder.map((team, index) => {
|
||||
const position = podiumPositions[index] ?? 0;
|
||||
|
||||
return (
|
||||
<PodiumItem
|
||||
key={team.id}
|
||||
position={position}
|
||||
height={podiumHeights[index] || '20'}
|
||||
bgColor={getBgColor(position)}
|
||||
positionColor={getPositionColor(position)}
|
||||
cardContent={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onClick(team.id)}
|
||||
h="auto"
|
||||
mb={4}
|
||||
p={0}
|
||||
className="transition-all"
|
||||
>
|
||||
<Box
|
||||
bg={getBgColor(position)}
|
||||
rounded="xl"
|
||||
border={true}
|
||||
borderColor="border-charcoal-outline"
|
||||
p={4}
|
||||
position="relative"
|
||||
>
|
||||
{/* Crown for 1st place */}
|
||||
{position === 1 && (
|
||||
<Box position="absolute" top="-4" left="1/2" translateX="-1/2">
|
||||
<Box position="relative">
|
||||
<Box animate="pulse">
|
||||
<Icon icon={Crown} size={8} color="text-warning-amber" />
|
||||
</Box>
|
||||
<Box position="absolute" inset="0" bg="bg-yellow-400" bgOpacity={0.3} blur="md" rounded="full" />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Team logo */}
|
||||
<Box h="20" w="20" display="flex" center rounded="xl" bg="bg-deep-graphite" border={true} borderColor="border-charcoal-outline" overflow="hidden" mb={3}>
|
||||
<Image
|
||||
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
alt={team.name}
|
||||
width={80}
|
||||
height={80}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Team name */}
|
||||
<Text weight="bold" size="sm" color="text-white" align="center" block truncate maxWidth="28">
|
||||
{team.name}
|
||||
</Text>
|
||||
|
||||
{/* Category */}
|
||||
{team.category && (
|
||||
<Text size="xs" color="text-primary-blue" align="center" block mt={1}>
|
||||
{team.category}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Rating placeholder */}
|
||||
<Text size="xl" weight="bold" color={getPositionColor(position)} align="center" block mt={1}>
|
||||
—
|
||||
</Text>
|
||||
|
||||
{/* Stats row */}
|
||||
<Stack direction="row" align="center" justify="center" gap={3} mt={2}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Trophy} size={3} color="text-performance-green" />
|
||||
<Text size="xs" color="text-gray-400">{team.totalWins}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Users} size={3} color="text-primary-blue" />
|
||||
<Text size="xs" color="text-gray-400">{team.memberCount}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Podium>
|
||||
);
|
||||
}
|
||||
104
apps/website/components/teams/TeamRankingsTable.tsx
Normal file
104
apps/website/components/teams/TeamRankingsTable.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
|
||||
import { RankBadge } from '@/ui/RankBadge';
|
||||
import { TeamIdentity } from '@/components/teams/TeamIdentity';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
performanceLevel: string;
|
||||
category?: string;
|
||||
region?: string;
|
||||
languages?: string[];
|
||||
isRecruiting?: boolean;
|
||||
memberCount: number;
|
||||
totalWins: number;
|
||||
totalRaces: number;
|
||||
}
|
||||
|
||||
interface TeamRankingsTableProps {
|
||||
teams: Team[];
|
||||
sortBy: string;
|
||||
onTeamClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TeamRankingsTable({ teams, sortBy, onTeamClick }: TeamRankingsTableProps) {
|
||||
return (
|
||||
<Card p={0} overflow="hidden">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>
|
||||
<Text size="xs" weight="medium" color="text-gray-500" align="center" block>Rank</Text>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<Text size="xs" weight="medium" color="text-gray-500" block>Team</Text>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<Box display={{ base: 'none', lg: 'block' }}>
|
||||
<Text size="xs" weight="medium" color="text-gray-500" align="center" block>Members</Text>
|
||||
</Box>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<Text size="xs" weight="medium" color="text-gray-500" align="center" block>Rating</Text>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<Text size="xs" weight="medium" color="text-gray-500" align="center" block>Wins</Text>
|
||||
</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{teams.map((team, index) => (
|
||||
<TableRow
|
||||
key={team.id}
|
||||
onClick={() => onTeamClick(team.id)}
|
||||
clickable
|
||||
>
|
||||
<TableCell>
|
||||
<RankBadge rank={index + 1} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TeamIdentity
|
||||
name={team.name}
|
||||
logoUrl={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
performanceLevel={team.performanceLevel}
|
||||
category={team.category}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box display={{ base: 'none', lg: 'flex' }} alignItems="center" justifyContent="center">
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={Users} size={3.5} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-400">{team.memberCount}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" center>
|
||||
<Text font="mono" weight="semibold" color={sortBy === 'rating' ? 'text-primary-blue' : 'text-white'}>
|
||||
0
|
||||
</Text>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" center>
|
||||
<Text font="mono" weight="semibold" color={sortBy === 'wins' ? 'text-primary-blue' : 'text-white'}>
|
||||
{team.totalWins}
|
||||
</Text>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import { Select } from '@/ui/Select';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { TeamRosterList } from '@/ui/TeamRosterList';
|
||||
import { TeamRosterItem } from '@/ui/TeamRosterItem';
|
||||
import { MinimalEmptyState } from '@/ui/EmptyState';
|
||||
import { TeamRosterItem } from '@/components/teams/TeamRosterItem';
|
||||
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { sortMembers } from '@/lib/utilities/roster-utils';
|
||||
|
||||
export type TeamRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
73
apps/website/components/teams/TeamRosterItem.tsx
Normal file
73
apps/website/components/teams/TeamRosterItem.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
|
||||
interface TeamRosterItemProps {
|
||||
driver: DriverViewModel;
|
||||
href: string;
|
||||
roleLabel: string;
|
||||
joinedAt: string | Date;
|
||||
rating: number | null;
|
||||
overallRank: number | null;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function TeamRosterItem({
|
||||
driver,
|
||||
href,
|
||||
roleLabel,
|
||||
joinedAt,
|
||||
rating,
|
||||
overallRank,
|
||||
actions,
|
||||
}: TeamRosterItemProps) {
|
||||
return (
|
||||
<Box
|
||||
bg="bg-iron-gray/50"
|
||||
rounded="lg"
|
||||
border={true}
|
||||
borderColor="border-charcoal-outline"
|
||||
p={4}
|
||||
>
|
||||
<Stack direction="row" align="center" justify="between" wrap gap={4}>
|
||||
<DriverIdentity
|
||||
driver={driver}
|
||||
href={href}
|
||||
contextLabel={roleLabel}
|
||||
meta={
|
||||
<Text size="xs" color="text-gray-400">
|
||||
{driver.country} • Joined {new Date(joinedAt).toLocaleDateString()}
|
||||
</Text>
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
{rating !== null && (
|
||||
<Stack direction="row" align="center" gap={6}>
|
||||
<Box display="flex" flexDirection="col" alignItems="center">
|
||||
<Text size="lg" weight="bold" color="text-primary-blue" block>
|
||||
{rating}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400">Rating</Text>
|
||||
</Box>
|
||||
{overallRank !== null && (
|
||||
<Box display="flex" flexDirection="col" alignItems="center">
|
||||
<Text size="sm" color="text-gray-300" block>#{overallRank}</Text>
|
||||
<Text size="xs" color="text-gray-500">Rank</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{actions && (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{actions}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { useTeamStandings } from "@/hooks/team/useTeamStandings";
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { StandingsList } from '@/ui/StandingsList';
|
||||
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 { Trophy } from 'lucide-react';
|
||||
|
||||
interface TeamStandingsProps {
|
||||
|
||||
145
apps/website/components/teams/TopThreePodium.tsx
Normal file
145
apps/website/components/teams/TopThreePodium.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { Trophy, Crown, Users } from 'lucide-react';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Podium, PodiumItem } from '@/ui/Podium';
|
||||
|
||||
interface TopThreePodiumProps {
|
||||
teams: TeamSummaryViewModel[];
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TopThreePodium({ teams, onClick }: TopThreePodiumProps) {
|
||||
const top3 = teams.slice(0, 3) as [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel];
|
||||
if (teams.length < 3) return null;
|
||||
|
||||
// Display order: 2nd, 1st, 3rd
|
||||
const podiumOrder: [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel] = [
|
||||
top3[1],
|
||||
top3[0],
|
||||
top3[2],
|
||||
];
|
||||
const podiumHeights = ['28', '36', '20'];
|
||||
const podiumPositions = [2, 1, 3];
|
||||
|
||||
const getPositionColor = (position: number) => {
|
||||
switch (position) {
|
||||
case 1:
|
||||
return 'text-yellow-400';
|
||||
case 2:
|
||||
return 'text-gray-300';
|
||||
case 3:
|
||||
return 'text-amber-600';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getBgColor = (position: number) => {
|
||||
switch (position) {
|
||||
case 1:
|
||||
return 'bg-gradient-to-br from-primary-blue/20 via-iron-gray/80 to-deep-graphite';
|
||||
case 2:
|
||||
return 'bg-iron-gray';
|
||||
case 3:
|
||||
return 'bg-gradient-to-br from-purple-600/20 via-iron-gray/80 to-deep-graphite';
|
||||
default:
|
||||
return 'bg-iron-gray/50';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Podium title="Top 3 Teams">
|
||||
{podiumOrder.map((team, index) => {
|
||||
const position = podiumPositions[index] ?? 0;
|
||||
|
||||
return (
|
||||
<PodiumItem
|
||||
key={team.id}
|
||||
position={position}
|
||||
height={podiumHeights[index] || '20'}
|
||||
bgColor={getBgColor(position)}
|
||||
positionColor={getPositionColor(position)}
|
||||
cardContent={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onClick(team.id)}
|
||||
h="auto"
|
||||
mb={4}
|
||||
p={0}
|
||||
transition
|
||||
>
|
||||
<Box
|
||||
bg={getBgColor(position)}
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
p={4}
|
||||
position="relative"
|
||||
>
|
||||
{/* Crown for 1st place */}
|
||||
{position === 1 && (
|
||||
<Box position="absolute" top="-4" left="1/2" translateX="-1/2">
|
||||
<Box position="relative">
|
||||
<Box animate="pulse">
|
||||
<Icon icon={Crown} size={8} color="var(--warning-amber)" />
|
||||
</Box>
|
||||
<Box position="absolute" inset="0" bg="bg-yellow-400" bgOpacity={0.3} blur="md" rounded="full" />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Team logo */}
|
||||
<Box h="20" w="20" display="flex" center rounded="xl" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" overflow="hidden" mb={3}>
|
||||
<Image
|
||||
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
alt={team.name}
|
||||
width={80}
|
||||
height={80}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Team name */}
|
||||
<Text weight="bold" size="sm" color="text-white" textAlign="center" block truncate maxWidth="28">
|
||||
{team.name}
|
||||
</Text>
|
||||
|
||||
{/* Category */}
|
||||
{team.category && (
|
||||
<Text size="xs" color="text-primary-blue" textAlign="center" block mt={1}>
|
||||
{team.category}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Rating placeholder */}
|
||||
<Text size="xl" weight="bold" color={getPositionColor(position)} textAlign="center" block mt={1}>
|
||||
—
|
||||
</Text>
|
||||
|
||||
{/* Stats row */}
|
||||
<Stack direction="row" align="center" justify="center" gap={3} mt={2}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Trophy} size={3} color="var(--performance-green)" />
|
||||
<Text size="xs" color="text-gray-400">{team.totalWins}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Users} size={3} color="var(--primary-blue)" />
|
||||
<Text size="xs" color="text-gray-400">{team.memberCount}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Podium>
|
||||
);
|
||||
}
|
||||
66
apps/website/components/teams/WhyJoinTeamSection.tsx
Normal file
66
apps/website/components/teams/WhyJoinTeamSection.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Handshake,
|
||||
MessageCircle,
|
||||
Calendar,
|
||||
Trophy,
|
||||
LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { BenefitCard } from '@/components/landing/BenefitCard';
|
||||
|
||||
interface Benefit {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function WhyJoinTeamSection() {
|
||||
const benefits: Benefit[] = [
|
||||
{
|
||||
icon: Handshake,
|
||||
title: 'Shared Strategy',
|
||||
description: 'Develop setups together, share telemetry, and coordinate pit strategies for endurance races.',
|
||||
},
|
||||
{
|
||||
icon: MessageCircle,
|
||||
title: 'Team Communication',
|
||||
description: 'Discord integration, voice chat during races, and dedicated team channels.',
|
||||
},
|
||||
{
|
||||
icon: Calendar,
|
||||
title: 'Coordinated Schedule',
|
||||
description: 'Team calendars, practice sessions, and organized race attendance.',
|
||||
},
|
||||
{
|
||||
icon: Trophy,
|
||||
title: 'Team Championships',
|
||||
description: 'Compete in team-based leagues and build your collective reputation.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box mb={12}>
|
||||
<Box textAlign="center" mb={8}>
|
||||
<Box mb={2}>
|
||||
<Heading level={2}>Why Join a Team?</Heading>
|
||||
</Box>
|
||||
<Text color="text-gray-400">Racing is better when you have teammates to share the journey</Text>
|
||||
</Box>
|
||||
|
||||
<Grid cols={4} gap={4}>
|
||||
{benefits.map((benefit) => (
|
||||
<BenefitCard
|
||||
key={benefit.title}
|
||||
icon={benefit.icon}
|
||||
title={benefit.title}
|
||||
description={benefit.description}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user