wip
This commit is contained in:
127
apps/website/components/drivers/CareerHighlights.tsx
Normal file
127
apps/website/components/drivers/CareerHighlights.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
|
||||
interface Achievement {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
unlockedAt: string;
|
||||
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||
}
|
||||
|
||||
const mockAchievements: Achievement[] = [
|
||||
{ id: '1', title: 'First Victory', description: 'Won your first race', icon: '🏆', unlockedAt: '2024-03-15', rarity: 'common' },
|
||||
{ id: '2', title: '10 Podiums', description: 'Achieved 10 podium finishes', icon: '🥈', unlockedAt: '2024-05-22', rarity: 'rare' },
|
||||
{ id: '3', title: 'Clean Racer', description: 'Completed 25 races with 0 incidents', icon: '✨', unlockedAt: '2024-08-10', rarity: 'epic' },
|
||||
{ id: '4', title: 'Comeback King', description: 'Won a race after starting P10 or lower', icon: '⚡', unlockedAt: '2024-09-03', rarity: 'rare' },
|
||||
{ id: '5', title: 'Perfect Weekend', description: 'Pole, fastest lap, and win in same race', icon: '💎', unlockedAt: '2024-10-17', rarity: 'legendary' },
|
||||
{ id: '6', title: 'Century Club', description: 'Completed 100 races', icon: '💯', unlockedAt: '2024-11-01', rarity: 'epic' },
|
||||
];
|
||||
|
||||
const rarityColors = {
|
||||
common: 'border-gray-500 bg-gray-500/10',
|
||||
rare: 'border-blue-400 bg-blue-400/10',
|
||||
epic: 'border-purple-400 bg-purple-400/10',
|
||||
legendary: 'border-warning-amber bg-warning-amber/10'
|
||||
};
|
||||
|
||||
export default function CareerHighlights() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Key Milestones</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<MilestoneItem
|
||||
label="First Race"
|
||||
value="March 15, 2024"
|
||||
icon="🏁"
|
||||
/>
|
||||
<MilestoneItem
|
||||
label="First Win"
|
||||
value="March 15, 2024 (Imola)"
|
||||
icon="🏆"
|
||||
/>
|
||||
<MilestoneItem
|
||||
label="Highest Rating"
|
||||
value="1487 (Nov 2024)"
|
||||
icon="📈"
|
||||
/>
|
||||
<MilestoneItem
|
||||
label="Longest Win Streak"
|
||||
value="4 races (Oct 2024)"
|
||||
icon="🔥"
|
||||
/>
|
||||
<MilestoneItem
|
||||
label="Most Wins (Track)"
|
||||
value="Spa-Francorchamps (7)"
|
||||
icon="🗺️"
|
||||
/>
|
||||
<MilestoneItem
|
||||
label="Favorite Car"
|
||||
value="Porsche 911 GT3 R (45 races)"
|
||||
icon="🏎️"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Achievements</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{mockAchievements.map((achievement) => (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className={`p-4 rounded-lg border ${rarityColors[achievement.rarity]}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-3xl">{achievement.icon}</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-white font-medium mb-1">{achievement.title}</div>
|
||||
<div className="text-xs text-gray-400 mb-2">{achievement.description}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(achievement.unlockedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-charcoal-200/50 border-primary-blue/30">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="text-2xl">🎯</div>
|
||||
<h3 className="text-lg font-semibold text-white">Next Goals</h3>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-gray-400">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Win 25 races</span>
|
||||
<span className="text-primary-blue">23/25</span>
|
||||
</div>
|
||||
<div className="w-full bg-deep-graphite rounded-full h-2">
|
||||
<div className="bg-primary-blue rounded-full h-2" style={{ width: '92%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MilestoneItem({ label, value, icon }: { label: string; value: string; icon: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{icon}</span>
|
||||
<span className="text-gray-400 text-sm">{label}</span>
|
||||
</div>
|
||||
<span className="text-white text-sm font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
apps/website/components/drivers/CreateDriverForm.tsx
Normal file
185
apps/website/components/drivers/CreateDriverForm.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Input from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||
import { getDriverRepository } from '../../lib/di-container';
|
||||
|
||||
interface FormErrors {
|
||||
name?: string;
|
||||
iracingId?: string;
|
||||
country?: string;
|
||||
bio?: string;
|
||||
submit?: string;
|
||||
}
|
||||
|
||||
export default function CreateDriverForm() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
iracingId: '',
|
||||
country: '',
|
||||
bio: ''
|
||||
});
|
||||
|
||||
const validateForm = async (): Promise<boolean> => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Name is required';
|
||||
}
|
||||
|
||||
if (!formData.iracingId.trim()) {
|
||||
newErrors.iracingId = 'iRacing ID is required';
|
||||
} else {
|
||||
const driverRepo = getDriverRepository();
|
||||
const exists = await driverRepo.existsByIRacingId(formData.iracingId);
|
||||
if (exists) {
|
||||
newErrors.iracingId = 'This iRacing ID is already registered';
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.country.trim()) {
|
||||
newErrors.country = 'Country is required';
|
||||
} else if (!/^[A-Z]{2,3}$/i.test(formData.country)) {
|
||||
newErrors.country = 'Invalid country code (use 2-3 letter ISO code)';
|
||||
}
|
||||
|
||||
if (formData.bio && formData.bio.length > 500) {
|
||||
newErrors.bio = 'Bio must be 500 characters or less';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (loading) return;
|
||||
|
||||
const isValid = await validateForm();
|
||||
if (!isValid) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
const driver = Driver.create({
|
||||
id: crypto.randomUUID(),
|
||||
iracingId: formData.iracingId.trim(),
|
||||
name: formData.name.trim(),
|
||||
country: formData.country.trim().toUpperCase(),
|
||||
bio: formData.bio.trim() || undefined,
|
||||
});
|
||||
|
||||
await driverRepo.create(driver);
|
||||
router.push('/profile');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setErrors({
|
||||
submit: error instanceof Error ? error.message : 'Failed to create profile'
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Driver Name *
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
error={!!errors.name}
|
||||
errorMessage={errors.name}
|
||||
placeholder="Alex Vermeer"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="iracingId" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
iRacing ID *
|
||||
</label>
|
||||
<Input
|
||||
id="iracingId"
|
||||
type="text"
|
||||
value={formData.iracingId}
|
||||
onChange={(e) => setFormData({ ...formData, iracingId: e.target.value })}
|
||||
error={!!errors.iracingId}
|
||||
errorMessage={errors.iracingId}
|
||||
placeholder="123456"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="country" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Country Code *
|
||||
</label>
|
||||
<Input
|
||||
id="country"
|
||||
type="text"
|
||||
value={formData.country}
|
||||
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
|
||||
error={!!errors.country}
|
||||
errorMessage={errors.country}
|
||||
placeholder="NL"
|
||||
maxLength={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Use ISO 3166-1 alpha-2 or alpha-3 code</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="bio" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Bio (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="bio"
|
||||
value={formData.bio}
|
||||
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
|
||||
placeholder="Tell us about yourself..."
|
||||
maxLength={500}
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 text-right">
|
||||
{formData.bio.length}/500
|
||||
</p>
|
||||
{errors.bio && (
|
||||
<p className="mt-2 text-sm text-warning-amber">{errors.bio}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errors.submit && (
|
||||
<div className="rounded-md bg-warning-amber/10 p-4 border border-warning-amber/20">
|
||||
<p className="text-sm text-warning-amber">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? 'Creating Profile...' : 'Create Profile'}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
73
apps/website/components/drivers/DriverCard.tsx
Normal file
73
apps/website/components/drivers/DriverCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import RankBadge from '@/components/drivers/RankBadge';
|
||||
|
||||
export interface DriverCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
skillLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||
nationality: string;
|
||||
racesCompleted: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
rank: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function DriverCard(props: DriverCardProps) {
|
||||
const {
|
||||
name,
|
||||
rating,
|
||||
nationality,
|
||||
racesCompleted,
|
||||
wins,
|
||||
podiums,
|
||||
rank,
|
||||
onClick,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<RankBadge rank={rank} size="lg" />
|
||||
|
||||
<div className="w-16 h-16 rounded-full bg-primary-blue/20 flex items-center justify-center text-2xl font-bold text-white">
|
||||
{name.charAt(0)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-white mb-1">{name}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
{nationality} • {racesCompleted} races
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-primary-blue">{rating}</div>
|
||||
<div className="text-xs text-gray-400">Rating</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-400">{wins}</div>
|
||||
<div className="text-xs text-gray-400">Wins</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-warning-amber">{podiums}</div>
|
||||
<div className="text-xs text-gray-400">Podiums</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{racesCompleted > 0 ? ((wins / racesCompleted) * 100).toFixed(0) : '0'}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Win Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
171
apps/website/components/drivers/DriverProfile.tsx
Normal file
171
apps/website/components/drivers/DriverProfile.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { DriverDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import Card from '../ui/Card';
|
||||
import ProfileHeader from '../profile/ProfileHeader';
|
||||
import ProfileStats from './ProfileStats';
|
||||
import CareerHighlights from './CareerHighlights';
|
||||
import DriverRankings from './DriverRankings';
|
||||
import PerformanceMetrics from './PerformanceMetrics';
|
||||
import { getDriverTeam } from '@gridpilot/racing/application';
|
||||
import { getDriverStats, getLeagueRankings } from '@/lib/di-container';
|
||||
|
||||
interface DriverProfileProps {
|
||||
driver: DriverDTO;
|
||||
}
|
||||
|
||||
export default function DriverProfile({ driver }: DriverProfileProps) {
|
||||
const driverStats = getDriverStats(driver.id);
|
||||
const leagueRank = getLeagueRankings(driver.id, 'league-1');
|
||||
|
||||
const performanceStats = driverStats ? {
|
||||
winRate: (driverStats.wins / driverStats.totalRaces) * 100,
|
||||
podiumRate: (driverStats.podiums / driverStats.totalRaces) * 100,
|
||||
dnfRate: (driverStats.dnfs / driverStats.totalRaces) * 100,
|
||||
avgFinish: driverStats.avgFinish,
|
||||
consistency: driverStats.consistency,
|
||||
bestFinish: driverStats.bestFinish,
|
||||
worstFinish: driverStats.worstFinish,
|
||||
} : null;
|
||||
|
||||
const rankings = driverStats ? [
|
||||
{
|
||||
type: 'overall' as const,
|
||||
name: 'Overall Ranking',
|
||||
rank: driverStats.overallRank,
|
||||
totalDrivers: 850,
|
||||
percentile: driverStats.percentile,
|
||||
rating: driverStats.rating,
|
||||
},
|
||||
{
|
||||
type: 'league' as const,
|
||||
name: 'European GT Championship',
|
||||
rank: leagueRank.rank,
|
||||
totalDrivers: leagueRank.totalDrivers,
|
||||
percentile: leagueRank.percentile,
|
||||
rating: driverStats.rating,
|
||||
},
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<ProfileHeader driver={driver} isOwnProfile={false} />
|
||||
</Card>
|
||||
|
||||
{driver.bio && (
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
|
||||
<p className="text-gray-300 leading-relaxed">{driver.bio}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{driverStats && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatCard label="Rating" value={driverStats.rating.toString()} color="text-primary-blue" />
|
||||
<StatCard label="Total Races" value={driverStats.totalRaces.toString()} color="text-white" />
|
||||
<StatCard label="Wins" value={driverStats.wins.toString()} color="text-green-400" />
|
||||
<StatCard label="Podiums" value={driverStats.podiums.toString()} color="text-warning-amber" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{performanceStats && <PerformanceMetrics stats={performanceStats} />}
|
||||
</div>
|
||||
|
||||
<DriverRankings rankings={rankings} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!driverStats && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatCard label="Rating" value="1450" color="text-primary-blue" />
|
||||
<StatCard label="Total Races" value="147" color="text-white" />
|
||||
<StatCard label="Wins" value="23" color="text-green-400" />
|
||||
<StatCard label="Podiums" value="56" color="text-warning-amber" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Team</h3>
|
||||
{(() => {
|
||||
const teamData = getDriverTeam(driver.id);
|
||||
|
||||
if (teamData) {
|
||||
const { team, membership } = teamData;
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary-blue/20 flex items-center justify-center text-xl font-bold text-white">
|
||||
{team.tag}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-medium">{team.name}</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{membership.role.charAt(0).toUpperCase() + membership.role.slice(1)} • Joined {new Date(membership.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-400 text-sm">
|
||||
Not on a team
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Performance by Class</h3>
|
||||
<ProfileStats stats={driverStats ? {
|
||||
totalRaces: driverStats.totalRaces,
|
||||
wins: driverStats.wins,
|
||||
podiums: driverStats.podiums,
|
||||
dnfs: driverStats.dnfs,
|
||||
avgFinish: driverStats.avgFinish,
|
||||
completionRate: ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
|
||||
} : undefined} />
|
||||
</Card>
|
||||
|
||||
<CareerHighlights />
|
||||
|
||||
<Card className="bg-charcoal-200/50 border-primary-blue/30">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="text-2xl">🔒</div>
|
||||
<h3 className="text-lg font-semibold text-white">Private Information</h3>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Detailed race history, settings, and preferences are only visible to the driver.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-charcoal-200/50 border-primary-blue/30">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="text-2xl">📊</div>
|
||||
<h3 className="text-lg font-semibold text-white">Coming Soon</h3>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Per-car statistics, per-track performance, and head-to-head comparisons will be available in production.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
|
||||
return (
|
||||
<div className="text-center p-4 rounded bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="text-sm text-gray-400 mb-1">{label}</div>
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
apps/website/components/drivers/DriverRankings.tsx
Normal file
81
apps/website/components/drivers/DriverRankings.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
export interface DriverRanking {
|
||||
type: 'overall' | 'league';
|
||||
name: string;
|
||||
rank: number;
|
||||
totalDrivers: number;
|
||||
percentile: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
interface DriverRankingsProps {
|
||||
rankings: DriverRanking[];
|
||||
}
|
||||
|
||||
export default function DriverRankings({ rankings }: DriverRankingsProps) {
|
||||
if (!rankings || rankings.length === 0) {
|
||||
return (
|
||||
<Card className="bg-iron-gray/60 border-charcoal-outline/80">
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Rankings</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
No ranking data available yet. Compete in leagues to earn your first results.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-iron-gray/60 border-charcoal-outline/80">
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Rankings</h3>
|
||||
<div className="space-y-3">
|
||||
{rankings.map((ranking, index) => (
|
||||
<div
|
||||
key={`${ranking.type}-${ranking.name}-${index}`}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg bg-deep-graphite/60"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{ranking.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{ranking.type === 'overall' ? 'Overall' : 'League'} ranking
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-right text-xs">
|
||||
<div>
|
||||
<div className="text-primary-blue text-base font-semibold">
|
||||
#{ranking.rank}
|
||||
</div>
|
||||
<div className="text-gray-500">Position</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white text-sm font-semibold">
|
||||
{ranking.totalDrivers}
|
||||
</div>
|
||||
<div className="text-gray-500">Drivers</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-green-400 text-sm font-semibold">
|
||||
{ranking.percentile.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-gray-500">Percentile</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-warning-amber text-sm font-semibold">
|
||||
{ranking.rating}
|
||||
</div>
|
||||
<div className="text-gray-500">Rating</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
82
apps/website/components/drivers/PerformanceMetrics.tsx
Normal file
82
apps/website/components/drivers/PerformanceMetrics.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
|
||||
interface PerformanceMetricsProps {
|
||||
stats: {
|
||||
winRate: number;
|
||||
podiumRate: number;
|
||||
dnfRate: number;
|
||||
avgFinish: number;
|
||||
consistency: number;
|
||||
bestFinish: number;
|
||||
worstFinish: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function PerformanceMetrics({ stats }: PerformanceMetricsProps) {
|
||||
const getPerformanceColor = (value: number, type: 'rate' | 'finish' | 'consistency') => {
|
||||
if (type === 'rate') {
|
||||
if (value >= 30) return 'text-green-400';
|
||||
if (value >= 15) return 'text-warning-amber';
|
||||
return 'text-gray-300';
|
||||
}
|
||||
if (type === 'consistency') {
|
||||
if (value >= 80) return 'text-green-400';
|
||||
if (value >= 60) return 'text-warning-amber';
|
||||
return 'text-gray-300';
|
||||
}
|
||||
return 'text-white';
|
||||
};
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
label: 'Win Rate',
|
||||
value: `${stats.winRate.toFixed(1)}%`,
|
||||
color: getPerformanceColor(stats.winRate, 'rate'),
|
||||
icon: '🏆'
|
||||
},
|
||||
{
|
||||
label: 'Podium Rate',
|
||||
value: `${stats.podiumRate.toFixed(1)}%`,
|
||||
color: getPerformanceColor(stats.podiumRate, 'rate'),
|
||||
icon: '🥇'
|
||||
},
|
||||
{
|
||||
label: 'DNF Rate',
|
||||
value: `${stats.dnfRate.toFixed(1)}%`,
|
||||
color: stats.dnfRate < 10 ? 'text-green-400' : 'text-danger-red',
|
||||
icon: '❌'
|
||||
},
|
||||
{
|
||||
label: 'Avg Finish',
|
||||
value: stats.avgFinish.toFixed(1),
|
||||
color: 'text-white',
|
||||
icon: '📊'
|
||||
},
|
||||
{
|
||||
label: 'Consistency',
|
||||
value: `${stats.consistency.toFixed(0)}%`,
|
||||
color: getPerformanceColor(stats.consistency, 'consistency'),
|
||||
icon: '🎯'
|
||||
},
|
||||
{
|
||||
label: 'Best / Worst',
|
||||
value: `${stats.bestFinish} / ${stats.worstFinish}`,
|
||||
color: 'text-gray-300',
|
||||
icon: '📈'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{metrics.map((metric, index) => (
|
||||
<Card key={index} className="text-center">
|
||||
<div className="text-2xl mb-2">{metric.icon}</div>
|
||||
<div className="text-sm text-gray-400 mb-1">{metric.label}</div>
|
||||
<div className={`text-xl font-bold ${metric.color}`}>{metric.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
apps/website/components/drivers/ProfileRaceHistory.tsx
Normal file
153
apps/website/components/drivers/ProfileRaceHistory.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface RaceResult {
|
||||
id: string;
|
||||
date: string;
|
||||
track: string;
|
||||
car: string;
|
||||
position: number;
|
||||
startPosition: number;
|
||||
incidents: number;
|
||||
league: string;
|
||||
}
|
||||
|
||||
const mockRaceHistory: RaceResult[] = [
|
||||
{ id: '1', date: '2024-11-28', track: 'Spa-Francorchamps', car: 'Porsche 911 GT3 R', position: 1, startPosition: 3, incidents: 0, league: 'GridPilot Championship' },
|
||||
{ id: '2', date: '2024-11-21', track: 'Nürburgring GP', car: 'Porsche 911 GT3 R', position: 4, startPosition: 5, incidents: 2, league: 'GridPilot Championship' },
|
||||
{ id: '3', date: '2024-11-14', track: 'Monza', car: 'Ferrari 488 GT3', position: 2, startPosition: 1, incidents: 1, league: 'GT3 Sprint Series' },
|
||||
{ id: '4', date: '2024-11-07', track: 'Silverstone', car: 'Audi R8 LMS GT3', position: 7, startPosition: 12, incidents: 0, league: 'GridPilot Championship' },
|
||||
{ id: '5', date: '2024-10-31', track: 'Interlagos', car: 'Mercedes-AMG GT3', position: 3, startPosition: 4, incidents: 1, league: 'GT3 Sprint Series' },
|
||||
{ id: '6', date: '2024-10-24', track: 'Road Atlanta', car: 'Porsche 911 GT3 R', position: 5, startPosition: 8, incidents: 2, league: 'GridPilot Championship' },
|
||||
{ id: '7', date: '2024-10-17', track: 'Watkins Glen', car: 'BMW M4 GT3', position: 1, startPosition: 2, incidents: 0, league: 'GT3 Sprint Series' },
|
||||
{ id: '8', date: '2024-10-10', track: 'Brands Hatch', car: 'Porsche 911 GT3 R', position: 6, startPosition: 7, incidents: 3, league: 'GridPilot Championship' },
|
||||
{ id: '9', date: '2024-10-03', track: 'Suzuka', car: 'McLaren 720S GT3', position: 2, startPosition: 6, incidents: 1, league: 'GT3 Sprint Series' },
|
||||
{ id: '10', date: '2024-09-26', track: 'Bathurst', car: 'Porsche 911 GT3 R', position: 8, startPosition: 10, incidents: 0, league: 'GridPilot Championship' },
|
||||
{ id: '11', date: '2024-09-19', track: 'Laguna Seca', car: 'Ferrari 488 GT3', position: 3, startPosition: 5, incidents: 2, league: 'GT3 Sprint Series' },
|
||||
{ id: '12', date: '2024-09-12', track: 'Imola', car: 'Audi R8 LMS GT3', position: 1, startPosition: 1, incidents: 0, league: 'GridPilot Championship' },
|
||||
];
|
||||
|
||||
export default function ProfileRaceHistory() {
|
||||
const [filter, setFilter] = useState<'all' | 'wins' | 'podiums'>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const resultsPerPage = 10;
|
||||
|
||||
const filteredResults = mockRaceHistory.filter(result => {
|
||||
if (filter === 'wins') return result.position === 1;
|
||||
if (filter === 'podiums') return result.position <= 3;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filteredResults.length / resultsPerPage);
|
||||
const paginatedResults = filteredResults.slice(
|
||||
(page - 1) * resultsPerPage,
|
||||
page * resultsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={filter === 'all' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('all'); setPage(1); }}
|
||||
className="text-sm"
|
||||
>
|
||||
All Races
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'wins' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('wins'); setPage(1); }}
|
||||
className="text-sm"
|
||||
>
|
||||
Wins Only
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'podiums' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('podiums'); setPage(1); }}
|
||||
className="text-sm"
|
||||
>
|
||||
Podiums
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-2">
|
||||
{paginatedResults.map((result) => (
|
||||
<div
|
||||
key={result.id}
|
||||
className="p-4 rounded bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`
|
||||
w-8 h-8 rounded flex items-center justify-center font-bold text-sm
|
||||
${result.position === 1 ? 'bg-green-400/20 text-green-400' :
|
||||
result.position === 2 ? 'bg-gray-400/20 text-gray-400' :
|
||||
result.position === 3 ? 'bg-warning-amber/20 text-warning-amber' :
|
||||
'bg-charcoal-outline text-gray-400'}
|
||||
`}>
|
||||
P{result.position}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white font-medium">{result.track}</div>
|
||||
<div className="text-sm text-gray-400">{result.car}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-400">
|
||||
{new Date(result.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{result.league}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>Started P{result.startPosition}</span>
|
||||
<span>•</span>
|
||||
<span className={result.incidents === 0 ? 'text-green-400' : result.incidents > 2 ? 'text-red-400' : ''}>
|
||||
{result.incidents}x incidents
|
||||
</span>
|
||||
{result.position < result.startPosition && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-green-400">+{result.startPosition - result.position} positions</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4 pt-4 border-t border-charcoal-outline">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="text-sm"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-gray-400 text-sm">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="text-sm"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
apps/website/components/drivers/ProfileSettings.tsx
Normal file
173
apps/website/components/drivers/ProfileSettings.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { DriverDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
|
||||
interface ProfileSettingsProps {
|
||||
driver: DriverDTO;
|
||||
onSave?: (updates: Partial<DriverDTO>) => void;
|
||||
}
|
||||
|
||||
export default function ProfileSettings({ driver, onSave }: ProfileSettingsProps) {
|
||||
const [bio, setBio] = useState(driver.bio || '');
|
||||
const [nationality, setNationality] = useState(driver.country);
|
||||
const [favoriteCarClass, setFavoriteCarClass] = useState('GT3');
|
||||
const [favoriteSeries, setFavoriteSeries] = useState('Endurance');
|
||||
const [competitiveLevel, setCompetitiveLevel] = useState('competitive');
|
||||
const [preferredRegions, setPreferredRegions] = useState<string[]>(['EU']);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.({
|
||||
bio,
|
||||
country: nationality
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Profile Information</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Bio</label>
|
||||
<textarea
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
className="w-full px-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 focus:border-transparent resize-none"
|
||||
rows={4}
|
||||
placeholder="Tell us about yourself..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Nationality</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={nationality}
|
||||
onChange={(e) => setNationality(e.target.value)}
|
||||
placeholder="e.g., US, GB, DE"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Racing Preferences</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Favorite Car Class</label>
|
||||
<select
|
||||
value={favoriteCarClass}
|
||||
onChange={(e) => setFavoriteCarClass(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 focus:border-transparent"
|
||||
>
|
||||
<option value="GT3">GT3</option>
|
||||
<option value="GT4">GT4</option>
|
||||
<option value="Formula">Formula</option>
|
||||
<option value="LMP2">LMP2</option>
|
||||
<option value="Touring">Touring Cars</option>
|
||||
<option value="NASCAR">NASCAR</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Favorite Series Type</label>
|
||||
<select
|
||||
value={favoriteSeries}
|
||||
onChange={(e) => setFavoriteSeries(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 focus:border-transparent"
|
||||
>
|
||||
<option value="Sprint">Sprint</option>
|
||||
<option value="Endurance">Endurance</option>
|
||||
<option value="Mixed">Mixed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Competitive Level</label>
|
||||
<select
|
||||
value={competitiveLevel}
|
||||
onChange={(e) => setCompetitiveLevel(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 focus:border-transparent"
|
||||
>
|
||||
<option value="casual">Casual - Just for fun</option>
|
||||
<option value="competitive">Competitive - Aiming to win</option>
|
||||
<option value="professional">Professional - Esports focused</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Preferred Regions</label>
|
||||
<div className="space-y-2">
|
||||
{['NA', 'EU', 'ASIA', 'OCE'].map(region => (
|
||||
<label key={region} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferredRegions.includes(region)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setPreferredRegions([...preferredRegions, region]);
|
||||
} else {
|
||||
setPreferredRegions(preferredRegions.filter(r => r !== region));
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
|
||||
/>
|
||||
<span className="text-white text-sm">{region}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Privacy Settings</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-white text-sm">Show profile to other drivers</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked
|
||||
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-white text-sm">Show race history</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked
|
||||
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-white text-sm">Allow friend requests</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked
|
||||
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="primary" onClick={handleSave} className="flex-1">
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button variant="secondary" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
apps/website/components/drivers/ProfileStats.tsx
Normal file
206
apps/website/components/drivers/ProfileStats.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import RankBadge from './RankBadge';
|
||||
import { getDriverStats, getAllDriverRankings, getLeagueRankings } from '@/lib/di-container';
|
||||
|
||||
interface ProfileStatsProps {
|
||||
driverId?: string;
|
||||
stats?: {
|
||||
totalRaces: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
dnfs: number;
|
||||
avgFinish: number;
|
||||
completionRate: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||
const driverStats = driverId ? getDriverStats(driverId) : null;
|
||||
const allRankings = getAllDriverRankings();
|
||||
const leagueRank = driverId ? getLeagueRankings(driverId, 'league-1') : null;
|
||||
|
||||
const defaultStats = stats || (driverStats ? {
|
||||
totalRaces: driverStats.totalRaces,
|
||||
wins: driverStats.wins,
|
||||
podiums: driverStats.podiums,
|
||||
dnfs: driverStats.dnfs,
|
||||
avgFinish: driverStats.avgFinish,
|
||||
completionRate: ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
|
||||
} : {
|
||||
totalRaces: 147,
|
||||
wins: 23,
|
||||
podiums: 56,
|
||||
dnfs: 12,
|
||||
avgFinish: 5.8,
|
||||
completionRate: 91.8
|
||||
});
|
||||
|
||||
const winRate = ((defaultStats.wins / defaultStats.totalRaces) * 100).toFixed(1);
|
||||
const podiumRate = ((defaultStats.podiums / defaultStats.totalRaces) * 100).toFixed(1);
|
||||
|
||||
const getTrendIndicator = (value: number) => {
|
||||
if (value > 0) return '↑';
|
||||
if (value < 0) return '↓';
|
||||
return '→';
|
||||
};
|
||||
|
||||
const getPercentileLabel = (percentile: number) => {
|
||||
if (percentile >= 90) return 'Top 10%';
|
||||
if (percentile >= 75) return 'Top 25%';
|
||||
if (percentile >= 50) return 'Top 50%';
|
||||
return `${(100 - percentile).toFixed(0)}th percentile`;
|
||||
};
|
||||
|
||||
const getPercentileColor = (percentile: number) => {
|
||||
if (percentile >= 90) return 'text-green-400';
|
||||
if (percentile >= 75) return 'text-primary-blue';
|
||||
if (percentile >= 50) return 'text-warning-amber';
|
||||
return 'text-gray-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{driverStats && (
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-white mb-6">Rankings Dashboard</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<RankBadge rank={driverStats.overallRank} size="lg" />
|
||||
<div>
|
||||
<div className="text-white font-medium text-lg">Overall Ranking</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{driverStats.overallRank} of {allRankings.length} drivers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-sm font-medium ${getPercentileColor(driverStats.percentile)}`}>
|
||||
{getPercentileLabel(driverStats.percentile)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Global Percentile</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-charcoal-outline">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary-blue">{driverStats.rating}</div>
|
||||
<div className="text-xs text-gray-400">Rating</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-green-400">
|
||||
{getTrendIndicator(5)} {winRate}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Win Rate</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-warning-amber">
|
||||
{getTrendIndicator(2)} {podiumRate}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Podium Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{leagueRank && leagueRank.totalDrivers > 0 && (
|
||||
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<RankBadge rank={leagueRank.rank} size="md" />
|
||||
<div>
|
||||
<div className="text-white font-medium">European GT Championship</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{leagueRank.rank} of {leagueRank.totalDrivers} drivers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-sm font-medium ${getPercentileColor(leagueRank.percentile)}`}>
|
||||
{getPercentileLabel(leagueRank.percentile)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">League Percentile</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Total Races', value: defaultStats.totalRaces, color: 'text-primary-blue' },
|
||||
{ label: 'Wins', value: defaultStats.wins, color: 'text-green-400' },
|
||||
{ label: 'Podiums', value: defaultStats.podiums, color: 'text-warning-amber' },
|
||||
{ label: 'DNFs', value: defaultStats.dnfs, color: 'text-red-400' },
|
||||
{ label: 'Avg Finish', value: defaultStats.avgFinish.toFixed(1), color: 'text-white' },
|
||||
{ label: 'Completion', value: `${defaultStats.completionRate.toFixed(1)}%`, color: 'text-green-400' },
|
||||
{ label: 'Win Rate', value: `${winRate}%`, color: 'text-primary-blue' },
|
||||
{ label: 'Podium Rate', value: `${podiumRate}%`, color: 'text-warning-amber' }
|
||||
].map((stat, index) => (
|
||||
<Card key={index} className="text-center">
|
||||
<div className="text-sm text-gray-400 mb-1">{stat.label}</div>
|
||||
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Performance by Car Class</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<PerformanceRow label="GT3" races={45} wins={12} podiums={23} avgFinish={4.2} />
|
||||
<PerformanceRow label="Formula" races={38} wins={7} podiums={15} avgFinish={6.1} />
|
||||
<PerformanceRow label="LMP2" races={32} wins={4} podiums={11} avgFinish={7.3} />
|
||||
<PerformanceRow label="Other" races={32} wins={0} podiums={7} avgFinish={8.5} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-charcoal-200/50 border-primary-blue/30">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="text-2xl">📊</div>
|
||||
<h3 className="text-lg font-semibold text-white">Coming Soon</h3>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Performance trends, track-specific stats, head-to-head comparisons vs friends, and league member comparisons will be available in production.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PerformanceRow({ label, races, wins, podiums, avgFinish }: {
|
||||
label: string;
|
||||
races: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
avgFinish: number;
|
||||
}) {
|
||||
const winRate = ((wins / races) * 100).toFixed(0);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex-1">
|
||||
<div className="text-white font-medium">{label}</div>
|
||||
<div className="text-gray-500 text-xs">{races} races</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Wins</div>
|
||||
<div className="text-green-400 font-medium">{wins} ({winRate}%)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Podiums</div>
|
||||
<div className="text-warning-amber font-medium">{podiums}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Avg</div>
|
||||
<div className="text-white font-medium">{avgFinish.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
apps/website/components/drivers/RankBadge.tsx
Normal file
41
apps/website/components/drivers/RankBadge.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
interface RankBadgeProps {
|
||||
rank: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export default function RankBadge({ rank, size = 'md', showLabel = true }: RankBadgeProps) {
|
||||
const getMedalEmoji = (rank: number) => {
|
||||
switch (rank) {
|
||||
case 1: return '🥇';
|
||||
case 2: return '🥈';
|
||||
case 3: return '🥉';
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const medal = getMedalEmoji(rank);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-sm px-2 py-1',
|
||||
md: 'text-base px-3 py-1.5',
|
||||
lg: 'text-lg px-4 py-2'
|
||||
};
|
||||
|
||||
const getRankColor = (rank: number) => {
|
||||
if (rank <= 3) return 'bg-warning-amber/20 text-warning-amber border-warning-amber/30';
|
||||
if (rank <= 10) return 'bg-primary-blue/20 text-primary-blue border-primary-blue/30';
|
||||
if (rank <= 50) return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
|
||||
return 'bg-charcoal-outline/20 text-gray-300 border-charcoal-outline';
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded font-medium border ${getRankColor(rank)} ${sizeClasses[size]}`}>
|
||||
{medal && <span>{medal}</span>}
|
||||
{showLabel && <span>#{rank}</span>}
|
||||
{!showLabel && !medal && <span>#{rank}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
203
apps/website/components/drivers/RatingBreakdown.tsx
Normal file
203
apps/website/components/drivers/RatingBreakdown.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
|
||||
interface RatingBreakdownProps {
|
||||
skillRating?: number;
|
||||
safetyRating?: number;
|
||||
sportsmanshipRating?: number;
|
||||
}
|
||||
|
||||
export default function RatingBreakdown({
|
||||
skillRating = 1450,
|
||||
safetyRating = 92,
|
||||
sportsmanshipRating = 4.8
|
||||
}: RatingBreakdownProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-6">Rating Components</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<RatingComponent
|
||||
label="Skill Rating"
|
||||
value={skillRating}
|
||||
maxValue={2000}
|
||||
color="primary-blue"
|
||||
description="Based on race results, competition strength, and consistency"
|
||||
breakdown={[
|
||||
{ label: 'Race Results', percentage: 60 },
|
||||
{ label: 'Competition Quality', percentage: 25 },
|
||||
{ label: 'Consistency', percentage: 15 }
|
||||
]}
|
||||
/>
|
||||
|
||||
<RatingComponent
|
||||
label="Safety Rating"
|
||||
value={safetyRating}
|
||||
maxValue={100}
|
||||
color="green-400"
|
||||
suffix="%"
|
||||
description="Reflects incident-free racing and clean overtakes"
|
||||
breakdown={[
|
||||
{ label: 'Incident Rate', percentage: 70 },
|
||||
{ label: 'Clean Overtakes', percentage: 20 },
|
||||
{ label: 'Position Awareness', percentage: 10 }
|
||||
]}
|
||||
/>
|
||||
|
||||
<RatingComponent
|
||||
label="Sportsmanship"
|
||||
value={sportsmanshipRating}
|
||||
maxValue={5}
|
||||
color="warning-amber"
|
||||
suffix="/5"
|
||||
description="Community feedback on racing behavior and fair play"
|
||||
breakdown={[
|
||||
{ label: 'Peer Reviews', percentage: 50 },
|
||||
{ label: 'Fair Racing', percentage: 30 },
|
||||
{ label: 'Team Play', percentage: 20 }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Rating History</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<HistoryItem
|
||||
date="November 2024"
|
||||
skillChange={+15}
|
||||
safetyChange={+2}
|
||||
sportsmanshipChange={0}
|
||||
/>
|
||||
<HistoryItem
|
||||
date="October 2024"
|
||||
skillChange={+28}
|
||||
safetyChange={-1}
|
||||
sportsmanshipChange={+0.1}
|
||||
/>
|
||||
<HistoryItem
|
||||
date="September 2024"
|
||||
skillChange={-12}
|
||||
safetyChange={+3}
|
||||
sportsmanshipChange={0}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-charcoal-200/50 border-primary-blue/30">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="text-2xl">📈</div>
|
||||
<h3 className="text-lg font-semibold text-white">Rating Insights</h3>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-gray-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-400 mt-0.5">✓</span>
|
||||
<span>Strong safety rating - keep up the clean racing!</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-warning-amber mt-0.5">→</span>
|
||||
<span>Skill rating improving - competitive against higher-rated drivers</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary-blue mt-0.5">i</span>
|
||||
<span>Complete more races to stabilize your ratings</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RatingComponent({
|
||||
label,
|
||||
value,
|
||||
maxValue,
|
||||
color,
|
||||
suffix = '',
|
||||
description,
|
||||
breakdown
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
maxValue: number;
|
||||
color: string;
|
||||
suffix?: string;
|
||||
description: string;
|
||||
breakdown: { label: string; percentage: number }[];
|
||||
}) {
|
||||
const percentage = (value / maxValue) * 100;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">{label}</span>
|
||||
<span className={`text-2xl font-bold text-${color}`}>
|
||||
{value}{suffix}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-deep-graphite rounded-full h-2 mb-3">
|
||||
<div
|
||||
className={`bg-${color} rounded-full h-2 transition-all duration-500`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 mb-3">{description}</p>
|
||||
|
||||
<div className="space-y-1">
|
||||
{breakdown.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">{item.label}</span>
|
||||
<span className="text-gray-400">{item.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryItem({
|
||||
date,
|
||||
skillChange,
|
||||
safetyChange,
|
||||
sportsmanshipChange
|
||||
}: {
|
||||
date: string;
|
||||
skillChange: number;
|
||||
safetyChange: number;
|
||||
sportsmanshipChange: number;
|
||||
}) {
|
||||
const formatChange = (value: number) => {
|
||||
if (value === 0) return '—';
|
||||
return value > 0 ? `+${value}` : `${value}`;
|
||||
};
|
||||
|
||||
const getChangeColor = (value: number) => {
|
||||
if (value === 0) return 'text-gray-500';
|
||||
return value > 0 ? 'text-green-400' : 'text-red-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded bg-deep-graphite border border-charcoal-outline">
|
||||
<span className="text-white text-sm">{date}</span>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="text-center">
|
||||
<div className="text-gray-500 mb-1">Skill</div>
|
||||
<div className={getChangeColor(skillChange)}>{formatChange(skillChange)}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-gray-500 mb-1">Safety</div>
|
||||
<div className={getChangeColor(safetyChange)}>{formatChange(safetyChange)}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-gray-500 mb-1">Sports</div>
|
||||
<div className={getChangeColor(sportsmanshipChange)}>{formatChange(sportsmanshipChange)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user