fix e2e
This commit is contained in:
130
apps/website/components/DriverRankingsFilter.tsx
Normal file
130
apps/website/components/DriverRankingsFilter.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { Search, Filter, Hash, Star, Trophy, Medal, Percent } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
|
||||
|
||||
const SKILL_LEVELS: {
|
||||
id: SkillLevel;
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
}[] = [
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
|
||||
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
|
||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS: { id: SortBy; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'rank', label: 'Rank', icon: Hash },
|
||||
{ id: 'rating', label: 'Rating', icon: Star },
|
||||
{ id: 'wins', label: 'Wins', icon: Trophy },
|
||||
{ id: 'podiums', label: 'Podiums', icon: Medal },
|
||||
{ id: 'winRate', label: 'Win Rate', icon: Percent },
|
||||
];
|
||||
|
||||
interface DriverRankingsFilterProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
selectedSkill: 'all' | SkillLevel;
|
||||
onSkillChange: (skill: 'all' | SkillLevel) => void;
|
||||
sortBy: SortBy;
|
||||
onSortChange: (sort: SortBy) => void;
|
||||
showFilters: boolean;
|
||||
onToggleFilters: () => void;
|
||||
}
|
||||
|
||||
export default function DriverRankingsFilter({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
selectedSkill,
|
||||
onSkillChange,
|
||||
sortBy,
|
||||
onSortChange,
|
||||
showFilters,
|
||||
onToggleFilters,
|
||||
}: DriverRankingsFilterProps) {
|
||||
return (
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search drivers by name or nationality..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onToggleFilters}
|
||||
className="lg:hidden flex items-center gap-2"
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-wrap gap-2 ${showFilters ? 'block' : 'hidden lg:flex'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSkillChange('all')}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
selectedSkill === 'all'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'bg-iron-gray/50 text-gray-400 border border-charcoal-outline hover:text-white'
|
||||
}`}
|
||||
>
|
||||
All Levels
|
||||
</button>
|
||||
{SKILL_LEVELS.map((level) => {
|
||||
return (
|
||||
<button
|
||||
key={level.id}
|
||||
type="button"
|
||||
onClick={() => onSkillChange(level.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
selectedSkill === level.id
|
||||
? `${level.bgColor} ${level.color} border ${level.borderColor}`
|
||||
: 'bg-iron-gray/50 text-gray-400 border border-charcoal-outline hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{level.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Sort by:</span>
|
||||
<div className="flex items-center gap-1 p-1 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
{SORT_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onSortChange(option.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
sortBy === option.id
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
|
||||
}`}
|
||||
>
|
||||
<OptionIcon className="w-3.5 h-3.5" />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
apps/website/components/DriverTopThreePodium.tsx
Normal file
103
apps/website/components/DriverTopThreePodium.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Trophy, Medal, Crown } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
interface DriverTopThreePodiumProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
onDriverClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function DriverTopThreePodium({ drivers, onDriverClick }: DriverTopThreePodiumProps) {
|
||||
if (drivers.length < 3) return null;
|
||||
|
||||
const top3 = drivers.slice(0, 3) as [DriverLeaderboardItemViewModel, DriverLeaderboardItemViewModel, DriverLeaderboardItemViewModel];
|
||||
|
||||
const podiumOrder: [DriverLeaderboardItemViewModel, DriverLeaderboardItemViewModel, DriverLeaderboardItemViewModel] = [
|
||||
top3[1],
|
||||
top3[0],
|
||||
top3[2],
|
||||
]; // 2nd, 1st, 3rd
|
||||
const podiumHeights = ['h-32', 'h-40', 'h-24'];
|
||||
const podiumColors = [
|
||||
'from-gray-400/20 to-gray-500/10 border-gray-400/40',
|
||||
'from-yellow-400/20 to-amber-500/10 border-yellow-400/40',
|
||||
'from-amber-600/20 to-amber-700/10 border-amber-600/40',
|
||||
];
|
||||
const crownColors = ['text-gray-300', 'text-yellow-400', 'text-amber-600'];
|
||||
const positions = [2, 1, 3];
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<div className="flex items-end justify-center gap-4 lg:gap-8">
|
||||
{podiumOrder.map((driver, index) => {
|
||||
const position = positions[index];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={driver.id}
|
||||
type="button"
|
||||
onClick={() => onDriverClick(driver.id)}
|
||||
className="flex flex-col items-center group"
|
||||
>
|
||||
{/* Driver Avatar & Info */}
|
||||
<div className="relative mb-4">
|
||||
{/* Crown for 1st place */}
|
||||
{position === 1 && (
|
||||
<div className="absolute -top-6 left-1/2 -translate-x-1/2 animate-bounce">
|
||||
<Crown className="w-8 h-8 text-yellow-400 drop-shadow-[0_0_10px_rgba(250,204,21,0.5)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
<div className={`relative ${position === 1 ? 'w-24 h-24 lg:w-28 lg:h-28' : 'w-20 h-20 lg:w-24 lg:h-24'} rounded-full overflow-hidden border-4 ${position === 1 ? 'border-yellow-400 shadow-[0_0_30px_rgba(250,204,21,0.3)]' : position === 2 ? 'border-gray-300' : 'border-amber-600'} group-hover:scale-105 transition-transform`}>
|
||||
<Image
|
||||
src={driver.avatarUrl}
|
||||
alt={driver.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Position badge */}
|
||||
<div className={`absolute -bottom-2 left-1/2 -translate-x-1/2 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold bg-gradient-to-br ${podiumColors[index]} border-2 ${crownColors[index]}`}>
|
||||
{position}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Driver Name */}
|
||||
<p className={`text-white font-semibold ${position === 1 ? 'text-lg' : 'text-base'} group-hover:text-primary-blue transition-colors mb-1`}>
|
||||
{driver.name}
|
||||
</p>
|
||||
|
||||
{/* Rating */}
|
||||
<p className={`font-mono font-bold ${position === 1 ? 'text-xl text-yellow-400' : 'text-lg text-primary-blue'}`}>
|
||||
{driver.rating.toLocaleString()}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<Trophy className="w-3 h-3 text-performance-green" />
|
||||
{driver.wins}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Medal className="w-3 h-3 text-warning-amber" />
|
||||
{driver.podiums}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Podium Stand */}
|
||||
<div className={`mt-4 w-28 lg:w-36 ${podiumHeights[index]} rounded-t-lg bg-gradient-to-t ${podiumColors[index]} border-t border-x flex items-end justify-center pb-4`}>
|
||||
<span className={`text-4xl lg:text-5xl font-black ${crownColors[index]}`}>
|
||||
{position}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
apps/website/components/TeamRankingsFilter.tsx
Normal file
119
apps/website/components/TeamRankingsFilter.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { Search, Star, Trophy, Percent, Hash } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
||||
|
||||
const SKILL_LEVELS: {
|
||||
id: SkillLevel;
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
}[] = [
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
|
||||
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
|
||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS: { id: SortBy; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'rating', label: 'Rating', icon: Star },
|
||||
{ id: 'wins', label: 'Total Wins', icon: Trophy },
|
||||
{ id: 'winRate', label: 'Win Rate', icon: Percent },
|
||||
{ id: 'races', label: 'Races', icon: Hash },
|
||||
];
|
||||
|
||||
interface TeamRankingsFilterProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
filterLevel: SkillLevel | 'all';
|
||||
onFilterLevelChange: (level: SkillLevel | 'all') => void;
|
||||
sortBy: SortBy;
|
||||
onSortChange: (sort: SortBy) => void;
|
||||
}
|
||||
|
||||
export default function TeamRankingsFilter({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
filterLevel,
|
||||
onFilterLevelChange,
|
||||
sortBy,
|
||||
onSortChange,
|
||||
}: TeamRankingsFilterProps) {
|
||||
return (
|
||||
<div className="mb-6 space-y-4">
|
||||
{/* Search and Level Filter Row */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="flex-1 relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search teams..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Level Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFilterLevelChange('all')}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
filterLevel === 'all'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-iron-gray/50 text-gray-400 border border-charcoal-outline hover:text-white'
|
||||
}`}
|
||||
>
|
||||
All Levels
|
||||
</button>
|
||||
{SKILL_LEVELS.map((level) => {
|
||||
return (
|
||||
<button
|
||||
key={level.id}
|
||||
type="button"
|
||||
onClick={() => onFilterLevelChange(level.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
filterLevel === level.id
|
||||
? `${level.bgColor} ${level.color} border ${level.borderColor}`
|
||||
: 'bg-iron-gray/50 text-gray-400 border border-charcoal-outline hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{level.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort Options */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Sort by:</span>
|
||||
<div className="flex items-center gap-1 p-1 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
{SORT_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onSortChange(option.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
sortBy === option.id
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
|
||||
}`}
|
||||
>
|
||||
<OptionIcon className="w-3.5 h-3.5" />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -168,10 +168,10 @@ export default function AuthWorkflowMockup() {
|
||||
>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-400 mb-1">
|
||||
Step {activeStep + 1}: {WORKFLOW_STEPS[activeStep].title}
|
||||
Step {activeStep + 1}: {WORKFLOW_STEPS[activeStep]?.title || ''}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{WORKFLOW_STEPS[activeStep].description}
|
||||
{WORKFLOW_STEPS[activeStep]?.description || ''}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
69
apps/website/components/drivers/CategoryDistribution.tsx
Normal file
69
apps/website/components/drivers/CategoryDistribution.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
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 CategoryDistributionProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
}
|
||||
|
||||
export function CategoryDistribution({ drivers }: CategoryDistributionProps) {
|
||||
const distribution = CATEGORIES.map((category) => ({
|
||||
...category,
|
||||
count: drivers.filter((d) => d.category === category.id).length,
|
||||
percentage: drivers.length > 0
|
||||
? Math.round((drivers.filter((d) => d.category === category.id).length / drivers.length) * 100)
|
||||
: 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-400/10 border border-purple-400/20">
|
||||
<BarChart3 className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Category Distribution</h2>
|
||||
<p className="text-xs text-gray-500">Driver population by category</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{distribution.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className={`p-4 rounded-xl ${category.bgColor} border ${category.borderColor}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className={`text-2xl font-bold ${category.color}`}>{category.count}</span>
|
||||
</div>
|
||||
<p className="text-white font-medium mb-1">{category.label}</p>
|
||||
<div className="w-full h-2 rounded-full bg-deep-graphite/50 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
category.id === 'beginner' ? 'bg-green-400' :
|
||||
category.id === 'intermediate' ? 'bg-primary-blue' :
|
||||
category.id === 'advanced' ? 'bg-purple-400' :
|
||||
category.id === 'pro' ? 'bg-yellow-400' :
|
||||
category.id === 'endurance' ? 'bg-orange-400' :
|
||||
'bg-red-400'
|
||||
}`}
|
||||
style={{ width: `${category.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{category.percentage}% of drivers</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
apps/website/components/drivers/CircularProgress.tsx
Normal file
52
apps/website/components/drivers/CircularProgress.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
interface CircularProgressProps {
|
||||
value: number;
|
||||
max: number;
|
||||
label: string;
|
||||
color: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function CircularProgress({ value, max, label, color, size = 80 }: CircularProgressProps) {
|
||||
const percentage = Math.min((value / max) * 100, 100);
|
||||
const strokeWidth = 6;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const strokeDashoffset = circumference - (percentage / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
className="text-charcoal-outline"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className={color}
|
||||
style={{ transition: 'stroke-dashoffset 0.5s ease-in-out' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-white">{percentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 mt-2">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,12 +43,14 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
||||
// Load team data if available
|
||||
if (profile.teamMemberships && profile.teamMemberships.length > 0) {
|
||||
const currentTeam = profile.teamMemberships.find(m => m.isCurrent) || profile.teamMemberships[0];
|
||||
setTeamData({
|
||||
team: {
|
||||
name: currentTeam.teamName,
|
||||
tag: currentTeam.teamTag ?? ''
|
||||
}
|
||||
});
|
||||
if (currentTeam) {
|
||||
setTeamData({
|
||||
team: {
|
||||
name: currentTeam.teamName,
|
||||
tag: currentTeam.teamTag ?? ''
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load driver profile data:', error);
|
||||
|
||||
112
apps/website/components/drivers/FeaturedDriverCard.tsx
Normal file
112
apps/website/components/drivers/FeaturedDriverCard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { Trophy, Crown, Star, TrendingUp, Shield, Flag } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
const SKILL_LEVELS = [
|
||||
{ id: 'pro', label: 'Pro', icon: Crown, 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: DriverLeaderboardItemViewModel;
|
||||
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 hover:border-yellow-400';
|
||||
case 2: return 'border-gray-300/50 hover:border-gray-300';
|
||||
case 3: return 'border-amber-600/50 hover:border-amber-600';
|
||||
default: return 'border-charcoal-outline hover:border-primary-blue';
|
||||
}
|
||||
};
|
||||
|
||||
const getMedalColor = (pos: number) => {
|
||||
switch (pos) {
|
||||
case 1: return 'text-yellow-400';
|
||||
case 2: return 'text-gray-300';
|
||||
case 3: return 'text-amber-600';
|
||||
default: return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`p-5 rounded-xl bg-iron-gray/60 border-2 ${getBorderColor(position)} transition-all duration-200 text-left group hover:scale-[1.02]`}
|
||||
>
|
||||
{/* Header with Position */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${position <= 3 ? 'bg-gradient-to-br from-yellow-400/20 to-amber-600/10' : 'bg-iron-gray'}`}>
|
||||
{position <= 3 ? (
|
||||
<Crown className={`w-5 h-5 ${getMedalColor(position)}`} />
|
||||
) : (
|
||||
<span className="text-lg font-bold text-gray-400">#{position}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{categoryConfig && (
|
||||
<span className={`px-2 py-1 rounded-full text-[10px] font-medium ${categoryConfig.bgColor} ${categoryConfig.color} border ${categoryConfig.borderColor}`}>
|
||||
{categoryConfig.label}
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-2 py-1 rounded-full text-[10px] font-medium ${levelConfig?.bgColor} ${levelConfig?.color} border ${levelConfig?.borderColor}`}>
|
||||
{levelConfig?.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avatar & Name */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="relative w-16 h-16 rounded-full overflow-hidden border-2 border-charcoal-outline group-hover:border-primary-blue transition-colors">
|
||||
<Image src={driver.avatarUrl || mediaConfig.avatars.defaultFallback} alt={driver.name} fill className="object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-primary-blue transition-colors">
|
||||
{driver.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Flag className="w-3.5 h-3.5" />
|
||||
{driver.nationality}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
|
||||
<p className="text-lg font-bold text-primary-blue">{driver.rating.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-gray-500">Rating</p>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
|
||||
<p className="text-lg font-bold text-performance-green">{driver.wins}</p>
|
||||
<p className="text-[10px] text-gray-500">Wins</p>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
|
||||
<p className="text-lg font-bold text-warning-amber">{driver.podiums}</p>
|
||||
<p className="text-[10px] text-gray-500">Podiums</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
27
apps/website/components/drivers/HorizontalBarChart.tsx
Normal file
27
apps/website/components/drivers/HorizontalBarChart.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
interface HorizontalBarChartProps {
|
||||
data: { label: string; value: number; color: string }[];
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
export function HorizontalBarChart({ data, maxValue }: HorizontalBarChartProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{data.map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-400">{item.label}</span>
|
||||
<span className="text-white font-medium">{item.value}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-charcoal-outline rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${item.color} transition-all duration-500 ease-out`}
|
||||
style={{ width: `${Math.min((item.value / maxValue) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
apps/website/components/drivers/LeaderboardPreview.tsx
Normal file
133
apps/website/components/drivers/LeaderboardPreview.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Award, Crown, Flag, ChevronRight } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
const SKILL_LEVELS = [
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
|
||||
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
|
||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
|
||||
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
|
||||
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400' },
|
||||
{ id: 'sprint', label: 'Sprint', color: 'text-red-400' },
|
||||
];
|
||||
|
||||
interface LeaderboardPreviewProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
onDriverClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function LeaderboardPreview({ drivers, onDriverClick }: LeaderboardPreviewProps) {
|
||||
const router = useRouter();
|
||||
const top5 = drivers.slice(0, 5);
|
||||
|
||||
const getMedalColor = (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 getMedalBg = (position: number) => {
|
||||
switch (position) {
|
||||
case 1: return 'bg-yellow-400/10 border-yellow-400/30';
|
||||
case 2: return 'bg-gray-300/10 border-gray-300/30';
|
||||
case 3: return 'bg-amber-600/10 border-amber-600/30';
|
||||
default: return 'bg-iron-gray/50 border-charcoal-outline';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
|
||||
<Award className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Top Drivers</h2>
|
||||
<p className="text-xs text-gray-500">Highest rated competitors</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/leaderboards/drivers')}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
Full Rankings
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{top5.map((driver, index) => {
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
|
||||
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
|
||||
const position = index + 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={driver.id}
|
||||
type="button"
|
||||
onClick={() => onDriverClick(driver.id)}
|
||||
className="flex items-center gap-4 px-4 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
|
||||
>
|
||||
{/* Position */}
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
|
||||
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-charcoal-outline">
|
||||
<Image src={driver.avatarUrl || mediaConfig.avatars.defaultFallback} alt={driver.name} fill className="object-cover" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium truncate group-hover:text-primary-blue transition-colors">
|
||||
{driver.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Flag className="w-3 h-3" />
|
||||
{driver.nationality}
|
||||
{categoryConfig && (
|
||||
<span className={categoryConfig.color}>{categoryConfig.label}</span>
|
||||
)}
|
||||
<span className={levelConfig?.color}>{levelConfig?.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="text-center">
|
||||
<p className="text-primary-blue font-mono font-semibold">{driver.rating.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-gray-500">Rating</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-performance-green font-mono font-semibold">{driver.wins}</p>
|
||||
<p className="text-[10px] text-gray-500">Wins</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
apps/website/components/drivers/RecentActivity.tsx
Normal file
74
apps/website/components/drivers/RecentActivity.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { Activity } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
const SKILL_LEVELS = [
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
|
||||
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
|
||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
|
||||
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
|
||||
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400' },
|
||||
{ id: 'sprint', label: 'Sprint', color: 'text-red-400' },
|
||||
];
|
||||
|
||||
interface RecentActivityProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
onDriverClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RecentActivity({ drivers, onDriverClick }: RecentActivityProps) {
|
||||
const activeDrivers = drivers.filter((d) => d.isActive).slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-performance-green/10 border border-performance-green/20">
|
||||
<Activity className="w-5 h-5 text-performance-green" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Active Drivers</h2>
|
||||
<p className="text-xs text-gray-500">Currently competing in leagues</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{activeDrivers.map((driver) => {
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
|
||||
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
|
||||
return (
|
||||
<button
|
||||
key={driver.id}
|
||||
type="button"
|
||||
onClick={() => onDriverClick(driver.id)}
|
||||
className="p-3 rounded-xl bg-iron-gray/40 border border-charcoal-outline hover:border-performance-green/40 transition-all group text-center"
|
||||
>
|
||||
<div className="relative w-12 h-12 mx-auto rounded-full overflow-hidden border-2 border-charcoal-outline mb-2">
|
||||
<Image src={driver.avatarUrl || mediaConfig.avatars.defaultFallback} alt={driver.name} fill className="object-cover" />
|
||||
<div className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-performance-green border-2 border-iron-gray" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-white truncate group-hover:text-performance-green transition-colors">
|
||||
{driver.name}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-1 text-xs">
|
||||
{categoryConfig && (
|
||||
<span className={categoryConfig.color}>{categoryConfig.label}</span>
|
||||
)}
|
||||
<span className={levelConfig?.color}>{levelConfig?.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
apps/website/components/drivers/SkillDistribution.tsx
Normal file
69
apps/website/components/drivers/SkillDistribution.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
const SKILL_LEVELS = [
|
||||
{ id: 'pro', label: 'Pro', icon: BarChart3, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
|
||||
{ id: 'advanced', label: 'Advanced', icon: BarChart3, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
|
||||
{ id: 'intermediate', label: 'Intermediate', icon: BarChart3, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
|
||||
{ id: 'beginner', label: 'Beginner', icon: BarChart3, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
|
||||
];
|
||||
|
||||
interface SkillDistributionProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
}
|
||||
|
||||
export function SkillDistribution({ drivers }: SkillDistributionProps) {
|
||||
const distribution = SKILL_LEVELS.map((level) => ({
|
||||
...level,
|
||||
count: drivers.filter((d) => d.skillLevel === level.id).length,
|
||||
percentage: drivers.length > 0
|
||||
? Math.round((drivers.filter((d) => d.skillLevel === level.id).length / drivers.length) * 100)
|
||||
: 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-neon-aqua/10 border border-neon-aqua/20">
|
||||
<BarChart3 className="w-5 h-5 text-neon-aqua" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Skill Distribution</h2>
|
||||
<p className="text-xs text-gray-500">Driver population by skill level</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{distribution.map((level) => {
|
||||
const Icon = level.icon;
|
||||
return (
|
||||
<div
|
||||
key={level.id}
|
||||
className={`p-4 rounded-xl ${level.bgColor} border ${level.borderColor}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Icon className={`w-5 h-5 ${level.color}`} />
|
||||
<span className={`text-2xl font-bold ${level.color}`}>{level.count}</span>
|
||||
</div>
|
||||
<p className="text-white font-medium mb-1">{level.label}</p>
|
||||
<div className="w-full h-2 rounded-full bg-deep-graphite/50 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
level.id === 'pro' ? 'bg-yellow-400' :
|
||||
level.id === 'advanced' ? 'bg-purple-400' :
|
||||
level.id === 'intermediate' ? 'bg-primary-blue' :
|
||||
'bg-green-400'
|
||||
}`}
|
||||
style={{ width: `${level.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{level.percentage}% of drivers</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,13 @@ interface RaceCardProps {
|
||||
}
|
||||
|
||||
export function RaceCard({ race, onClick, className }: RaceCardProps) {
|
||||
const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig];
|
||||
const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig] || {
|
||||
border: 'border-charcoal-outline',
|
||||
bg: 'bg-charcoal-outline',
|
||||
color: 'text-gray-400',
|
||||
icon: () => null,
|
||||
label: 'Scheduled',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -70,7 +76,7 @@ export function RaceCard({ race, onClick, className }: RaceCardProps) {
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border`}>
|
||||
<config.icon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||
{config.icon && <config.icon className={`w-3.5 h-3.5 ${config.color}`} />}
|
||||
<span className={`text-xs font-medium ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
|
||||
151
apps/website/components/races/RaceFilterModal.tsx
Normal file
151
apps/website/components/races/RaceFilterModal.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { X, Filter, Search } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
|
||||
export type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
|
||||
|
||||
interface RaceFilterModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
statusFilter: StatusFilter;
|
||||
setStatusFilter: (filter: StatusFilter) => void;
|
||||
leagueFilter: string;
|
||||
setLeagueFilter: (filter: string) => void;
|
||||
timeFilter: TimeFilter;
|
||||
setTimeFilter: (filter: TimeFilter) => void;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
leagues: Array<{ id: string; name: string }>;
|
||||
showSearch?: boolean;
|
||||
showTimeFilter?: boolean;
|
||||
}
|
||||
|
||||
export function RaceFilterModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
leagueFilter,
|
||||
setLeagueFilter,
|
||||
timeFilter,
|
||||
setTimeFilter,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
leagues,
|
||||
showSearch = true,
|
||||
showTimeFilter = true,
|
||||
}: RaceFilterModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<Card className="!p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-5 h-5 text-primary-blue" />
|
||||
<h3 className="text-lg font-semibold text-white">Filters</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Search */}
|
||||
{showSearch && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Search</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Track, car, or league..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Filter */}
|
||||
{showTimeFilter && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Time</label>
|
||||
<div className="flex gap-2">
|
||||
{(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setTimeFilter(filter)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
timeFilter === filter
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'bg-deep-graphite text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{filter === 'live' && <span className="inline-block w-2 h-2 bg-performance-green rounded-full mr-1 animate-pulse" />}
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Status</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
||||
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="running">Live</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* League Filter */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">League</label>
|
||||
<select
|
||||
value={leagueFilter}
|
||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="all">All Leagues</option>
|
||||
{leagues.map(league => (
|
||||
<option key={league.id} value={league.id}>
|
||||
{league.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery || (showTimeFilter && timeFilter !== 'upcoming')) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setLeagueFilter('all');
|
||||
setSearchQuery('');
|
||||
if (showTimeFilter) setTimeFilter('upcoming');
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
apps/website/components/races/RaceJoinButton.tsx
Normal file
122
apps/website/components/races/RaceJoinButton.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { UserPlus, UserMinus, CheckCircle2, PlayCircle, XCircle } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
interface RaceJoinButtonProps {
|
||||
raceStatus: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
isUserRegistered: boolean;
|
||||
canRegister: boolean;
|
||||
onRegister: () => void;
|
||||
onWithdraw: () => void;
|
||||
onCancel: () => void;
|
||||
onReopen?: () => void;
|
||||
onEndRace?: () => void;
|
||||
canReopenRace?: boolean;
|
||||
isOwnerOrAdmin?: boolean;
|
||||
isLoading?: {
|
||||
register?: boolean;
|
||||
withdraw?: boolean;
|
||||
cancel?: boolean;
|
||||
reopen?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceJoinButton({
|
||||
raceStatus,
|
||||
isUserRegistered,
|
||||
canRegister,
|
||||
onRegister,
|
||||
onWithdraw,
|
||||
onCancel,
|
||||
onReopen,
|
||||
onEndRace,
|
||||
canReopenRace = false,
|
||||
isOwnerOrAdmin = false,
|
||||
isLoading = {},
|
||||
}: RaceJoinButtonProps) {
|
||||
// Show registration button for scheduled races
|
||||
if (raceStatus === 'scheduled') {
|
||||
if (canRegister && !isUserRegistered) {
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onRegister}
|
||||
disabled={isLoading.register}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{isLoading.register ? 'Registering...' : 'Register for Race'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUserRegistered) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-performance-green/10 border border-performance-green/30 rounded-lg text-performance-green">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<span className="font-medium">You're Registered</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onWithdraw}
|
||||
disabled={isLoading.withdraw}
|
||||
>
|
||||
<UserMinus className="w-4 h-4" />
|
||||
{isLoading.withdraw ? 'Withdrawing...' : 'Withdraw'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Show cancel button for owners/admins
|
||||
if (isOwnerOrAdmin) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading.cancel}
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
{isLoading.cancel ? 'Cancelling...' : 'Cancel Race'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show end race button for running races (owners/admins only)
|
||||
if (raceStatus === 'running' && isOwnerOrAdmin && onEndRace) {
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onEndRace}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
End Race & Process Results
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Show reopen button for completed/cancelled races (owners/admins only)
|
||||
if ((raceStatus === 'completed' || raceStatus === 'cancelled') && canReopenRace && isOwnerOrAdmin && onReopen) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onReopen}
|
||||
disabled={isLoading.reopen}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
{isLoading.reopen ? 'Re-opening...' : 'Re-open Race'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
84
apps/website/components/races/RacePagination.tsx
Normal file
84
apps/website/components/races/RacePagination.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface RacePaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function RacePagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
onPageChange,
|
||||
}: RacePaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const startItem = ((currentPage - 1) * itemsPerPage) + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages: number[] = [];
|
||||
|
||||
if (totalPages <= 5) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
if (currentPage <= 3) {
|
||||
return [1, 2, 3, 4, 5];
|
||||
}
|
||||
|
||||
if (currentPage >= totalPages - 2) {
|
||||
return [totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
|
||||
}
|
||||
|
||||
return [currentPage - 2, currentPage - 1, currentPage, currentPage + 1, currentPage + 2];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {startItem}–{endItem} of {totalItems}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-lg border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{getPageNumbers().map(pageNum => (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
className={`w-10 h-10 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentPage === pageNum
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 rounded-lg border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
apps/website/components/races/StewardingTabs.tsx
Normal file
44
apps/website/components/races/StewardingTabs.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export type StewardingTab = 'pending' | 'resolved' | 'penalties';
|
||||
|
||||
interface StewardingTabsProps {
|
||||
activeTab: StewardingTab;
|
||||
onTabChange: (tab: StewardingTab) => void;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
export function StewardingTabs({ activeTab, onTabChange, pendingCount }: StewardingTabsProps) {
|
||||
const tabs: Array<{ id: StewardingTab; label: string }> = [
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
{ id: 'resolved', label: 'Resolved' },
|
||||
{ id: 'penalties', label: 'Penalties' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-b border-charcoal-outline">
|
||||
<div className="flex gap-4">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`pb-3 px-1 font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.id === 'pending' && pendingCount > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
apps/website/components/teams/TeamHeroSection.tsx
Normal file
177
apps/website/components/teams/TeamHeroSection.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Users,
|
||||
Search,
|
||||
Plus,
|
||||
Crown,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
|
||||
interface SkillLevelConfig {
|
||||
id: SkillLevel;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SKILL_LEVELS: SkillLevelConfig[] = [
|
||||
{
|
||||
id: 'pro',
|
||||
label: 'Pro',
|
||||
icon: Crown,
|
||||
color: 'text-yellow-400',
|
||||
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-400/10',
|
||||
borderColor: 'border-purple-400/30',
|
||||
description: 'Competitive racing, high consistency',
|
||||
},
|
||||
{
|
||||
id: 'intermediate',
|
||||
label: 'Intermediate',
|
||||
icon: TrendingUp,
|
||||
color: 'text-primary-blue',
|
||||
bgColor: 'bg-primary-blue/10',
|
||||
borderColor: 'border-primary-blue/30',
|
||||
description: 'Growing skills, regular practice',
|
||||
},
|
||||
{
|
||||
id: 'beginner',
|
||||
label: 'Beginner',
|
||||
icon: Shield,
|
||||
color: 'text-green-400',
|
||||
bgColor: 'bg-green-400/10',
|
||||
borderColor: 'border-green-400/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 default function TeamHeroSection({
|
||||
teams,
|
||||
teamsByLevel,
|
||||
recruitingCount,
|
||||
onShowCreateForm,
|
||||
onBrowseTeams,
|
||||
onSkillLevelClick,
|
||||
}: TeamHeroSectionProps) {
|
||||
return (
|
||||
<div className="relative mb-10 overflow-hidden">
|
||||
{/* Main Hero Card */}
|
||||
<div className="relative py-12 px-8 rounded-2xl bg-gradient-to-br from-purple-900/30 via-iron-gray/80 to-deep-graphite border border-purple-500/20">
|
||||
{/* Background decorations */}
|
||||
<div className="absolute top-0 right-0 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-1/4 w-64 h-64 bg-neon-aqua/5 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-yellow-400/5 rounded-full blur-2xl" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-8">
|
||||
<div className="max-w-xl">
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-xs font-medium mb-4">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
Team Racing
|
||||
</div>
|
||||
|
||||
<Heading level={1} className="text-4xl lg:text-5xl mb-4">
|
||||
Find Your
|
||||
<span className="text-purple-400"> Crew</span>
|
||||
</Heading>
|
||||
|
||||
<p className="text-gray-400 text-lg leading-relaxed mb-6">
|
||||
Solo racing is great. Team racing is unforgettable. Join a team that matches your skill level and ambitions.
|
||||
</p>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="flex flex-wrap gap-4 mb-6">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
<Users className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-white font-semibold">{teams.length}</span>
|
||||
<span className="text-gray-500 text-sm">Teams</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
<UserPlus className="w-4 h-4 text-performance-green" />
|
||||
<span className="text-white font-semibold">{recruitingCount}</span>
|
||||
<span className="text-gray-500 text-sm">Recruiting</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onShowCreateForm}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-purple-600 hover:bg-purple-500"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Team
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBrowseTeams}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
Browse Teams
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill Level Quick Nav */}
|
||||
<div className="lg:w-72">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-3">Find Your Level</p>
|
||||
<div className="space-y-2">
|
||||
{SKILL_LEVELS.map((level) => {
|
||||
const LevelIcon = level.icon;
|
||||
const count = teamsByLevel[level.id]?.length || 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={level.id}
|
||||
type="button"
|
||||
onClick={() => onSkillLevelClick(level.id)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-lg ${level.bgColor} border ${level.borderColor} hover:scale-[1.02] transition-all duration-200`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LevelIcon className={`w-4 h-4 ${level.color}`} />
|
||||
<span className="text-white font-medium">{level.label}</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">{count} teams</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
apps/website/components/teams/TeamSearchBar.tsx
Normal file
28
apps/website/components/teams/TeamSearchBar.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import Input from '@/components/ui/Input';
|
||||
|
||||
interface TeamSearchBarProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
}
|
||||
|
||||
export default function TeamSearchBar({ searchQuery, onSearchChange }: TeamSearchBarProps) {
|
||||
return (
|
||||
<div id="teams-list" className="mb-6 scroll-mt-8">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search teams by name, description, region, or language..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user