website refactor
This commit is contained in:
@@ -1,127 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
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: {
|
||||
category?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import Input from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import { useCreateDriver } from "@/lib/hooks/driver/useCreateDriver";
|
||||
import React, { useState, FormEvent } from 'react';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { TextArea } from '@/ui/TextArea';
|
||||
import { InfoBox } from '@/ui/InfoBox';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
interface FormErrors {
|
||||
name?: string;
|
||||
@@ -15,9 +18,12 @@ interface FormErrors {
|
||||
submit?: string;
|
||||
}
|
||||
|
||||
export default function CreateDriverForm() {
|
||||
const router = useRouter();
|
||||
const createDriverMutation = useCreateDriver();
|
||||
interface CreateDriverFormProps {
|
||||
onSuccess: () => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function CreateDriverForm({ onSuccess, isPending }: CreateDriverFormProps) {
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -50,7 +56,7 @@ export default function CreateDriverForm() {
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (createDriverMutation.isPending) return;
|
||||
if (isPending) return;
|
||||
|
||||
const isValid = await validateForm();
|
||||
if (!isValid) return;
|
||||
@@ -61,118 +67,89 @@ export default function CreateDriverForm() {
|
||||
const firstName = parts[0] ?? displayName;
|
||||
const lastName = parts.slice(1).join(' ') || 'Driver';
|
||||
|
||||
createDriverMutation.mutate(
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
country: formData.country.trim().toUpperCase(),
|
||||
...(bio ? { bio } : {}),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
router.push(routes.protected.profile);
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrors({
|
||||
submit: error instanceof Error ? error.message : 'Failed to create profile'
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
// Construct data for parent to handle
|
||||
const driverData = {
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
country: formData.country.trim().toUpperCase(),
|
||||
...(bio ? { bio } : {}),
|
||||
};
|
||||
|
||||
console.log('Driver data to create:', driverData);
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
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>
|
||||
<Box as="form" onSubmit={handleSubmit}>
|
||||
<Stack gap={6}>
|
||||
<Input
|
||||
label="Driver Name *"
|
||||
id="name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
error={!!errors.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({ ...formData, name: e.target.value })}
|
||||
variant={errors.name ? 'error' : 'default'}
|
||||
errorMessage={errors.name}
|
||||
placeholder="Alex Vermeer"
|
||||
disabled={createDriverMutation.isPending}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="iracingId" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Display 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={createDriverMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<Box>
|
||||
<Input
|
||||
label="Country Code *"
|
||||
id="country"
|
||||
type="text"
|
||||
value={formData.country}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({ ...formData, country: e.target.value })}
|
||||
variant={errors.country ? 'error' : 'default'}
|
||||
errorMessage={errors.country}
|
||||
placeholder="NL"
|
||||
maxLength={3}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Text size="xs" color="text-gray-500" mt={1} block>Use ISO 3166-1 alpha-2 or alpha-3 code</Text>
|
||||
</Box>
|
||||
|
||||
<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={createDriverMutation.isPending}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Use ISO 3166-1 alpha-2 or alpha-3 code</p>
|
||||
</div>
|
||||
<Box>
|
||||
<TextArea
|
||||
label="Bio (Optional)"
|
||||
id="bio"
|
||||
value={formData.bio}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData({ ...formData, bio: e.target.value })}
|
||||
placeholder="Tell us about yourself..."
|
||||
maxLength={500}
|
||||
rows={4}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Box display="flex" justifyContent="between" mt={1}>
|
||||
{errors.bio ? (
|
||||
<Text size="sm" color="text-warning-amber">{errors.bio}</Text>
|
||||
) : <Box />}
|
||||
<Text size="xs" color="text-gray-500">
|
||||
{formData.bio.length}/500
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<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={createDriverMutation.isPending}
|
||||
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>
|
||||
{errors.submit && (
|
||||
<InfoBox
|
||||
variant="warning"
|
||||
icon={AlertCircle}
|
||||
title="Error"
|
||||
description={errors.submit}
|
||||
/>
|
||||
)}
|
||||
</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={createDriverMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{createDriverMutation.isPending ? 'Creating Profile...' : 'Create Profile'}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isPending}
|
||||
fullWidth
|
||||
>
|
||||
{isPending ? 'Creating Profile...' : 'Create Profile'}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import Card from '@/ui/Card';
|
||||
import RankBadge from '@/components/drivers/RankBadge';
|
||||
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
|
||||
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 {
|
||||
id,
|
||||
name,
|
||||
rating,
|
||||
nationality,
|
||||
racesCompleted,
|
||||
wins,
|
||||
podiums,
|
||||
rank,
|
||||
onClick,
|
||||
} = props;
|
||||
|
||||
// Create a proper DriverViewModel instance
|
||||
const driverViewModel = new DriverViewModel({
|
||||
id,
|
||||
name,
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
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" />
|
||||
|
||||
<DriverIdentity
|
||||
driver={driverViewModel}
|
||||
href={`/drivers/${id}`}
|
||||
meta={`${nationality} • ${racesCompleted} races`}
|
||||
size="md"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import PlaceholderImage from '@/ui/PlaceholderImage';
|
||||
|
||||
export interface DriverIdentityProps {
|
||||
driver: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
href?: string;
|
||||
contextLabel?: React.ReactNode;
|
||||
meta?: React.ReactNode;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export function DriverIdentity(props: DriverIdentityProps) {
|
||||
const { driver, href, contextLabel, meta, size = 'md' } = props;
|
||||
|
||||
const avatarSize = size === 'sm' ? 40 : 48;
|
||||
const nameTextClasses =
|
||||
size === 'sm'
|
||||
? 'text-sm font-medium text-white'
|
||||
: 'text-base md:text-lg font-semibold text-white';
|
||||
|
||||
const metaTextClasses = 'text-xs md:text-sm text-gray-400';
|
||||
|
||||
// Use provided avatar URL or show placeholder if null
|
||||
const avatarUrl = driver.avatarUrl;
|
||||
|
||||
const content = (
|
||||
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
|
||||
<div
|
||||
className={`rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0`}
|
||||
style={{ width: avatarSize, height: avatarSize }}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={driver.name}
|
||||
width={avatarSize}
|
||||
height={avatarSize}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage size={avatarSize} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`${nameTextClasses} truncate`}>{driver.name}</span>
|
||||
{contextLabel ? (
|
||||
<span className="inline-flex items-center rounded-full bg-charcoal-outline/60 px-2 py-0.5 text-[10px] md:text-xs font-medium text-gray-200">
|
||||
{contextLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{meta ? <div className={`${metaTextClasses} mt-0.5 truncate`}>{meta}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">{content}</div>;
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import type { DriverProfileStatsViewModel } from '@/lib/view-models/DriverProfileViewModel';
|
||||
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 { useDriverProfile } from "@/lib/hooks/driver/useDriverProfile";
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { StatCard } from '@/ui/StatCard';
|
||||
import { ProfileHeader } from '@/ui/ProfileHeader';
|
||||
import { ProfileStats } from './ProfileStats';
|
||||
import { CareerHighlights } from '@/ui/CareerHighlights';
|
||||
import { DriverRankings } from '@/ui/DriverRankings';
|
||||
import { PerformanceMetrics } from '@/ui/PerformanceMetrics';
|
||||
import { useDriverProfile } from "@/hooks/driver/useDriverProfile";
|
||||
|
||||
interface DriverProfileProps {
|
||||
driver: DriverViewModel;
|
||||
@@ -23,8 +27,8 @@ interface DriverTeamViewModel {
|
||||
};
|
||||
}
|
||||
|
||||
export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
|
||||
const { data: profileData, isLoading } = useDriverProfile(driver.id);
|
||||
export function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
|
||||
const { data: profileData } = useDriverProfile(driver.id);
|
||||
|
||||
// Extract team data from profile
|
||||
const teamData: DriverTeamViewModel | null = (() => {
|
||||
@@ -32,7 +36,7 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentTeam = profileData.teamMemberships.find(m => m.isCurrent) || profileData.teamMemberships[0];
|
||||
const currentTeam = profileData.teamMemberships.find((m: { isCurrent: boolean }) => m.isCurrent) || profileData.teamMemberships[0];
|
||||
if (!currentTeam) {
|
||||
return null;
|
||||
}
|
||||
@@ -71,7 +75,7 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Stack gap={6}>
|
||||
<Card>
|
||||
<ProfileHeader
|
||||
driver={driver}
|
||||
@@ -86,48 +90,50 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
||||
|
||||
{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>
|
||||
<Heading level={3} mb={4}>About</Heading>
|
||||
<Text color="text-gray-300" leading="relaxed" block>{driver.bio}</Text>
|
||||
</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 ?? 0).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>
|
||||
<Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={6}>
|
||||
<Box responsiveColSpan={{ lg: 2 }}>
|
||||
<Stack gap={6}>
|
||||
<Card>
|
||||
<Heading level={3} mb={4}>Career Statistics</Heading>
|
||||
<Box display="grid" gridCols={2} gap={4}>
|
||||
<StatCard
|
||||
label="Rating"
|
||||
value={driverStats.rating ?? 0}
|
||||
variant="blue"
|
||||
/>
|
||||
<StatCard label="Total Races" value={driverStats.totalRaces} variant="blue" />
|
||||
<StatCard label="Wins" value={driverStats.wins} variant="green" />
|
||||
<StatCard label="Podiums" value={driverStats.podiums} variant="orange" />
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{performanceStats && <PerformanceMetrics stats={performanceStats} />}
|
||||
</div>
|
||||
{performanceStats && <PerformanceMetrics stats={performanceStats} />}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<DriverRankings rankings={rankings} />
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!driverStats && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-3">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
<Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={6}>
|
||||
<Card responsiveColSpan={{ lg: 3 }}>
|
||||
<Heading level={3} mb={4}>Career Statistics</Heading>
|
||||
<Text color="text-gray-400" size="sm" block>
|
||||
No statistics available yet. Compete in races to start building your record.
|
||||
</p>
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Performance by Class</h3>
|
||||
<Heading level={3} mb={4}>Performance by Class</Heading>
|
||||
{driverStats && (
|
||||
<ProfileStats
|
||||
stats={{
|
||||
@@ -147,34 +153,25 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
||||
|
||||
<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">
|
||||
<Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
|
||||
<Box display="flex" alignItems="center" gap={3} mb={3}>
|
||||
<Text size="2xl">🔒</Text>
|
||||
<Heading level={3}>Private Information</Heading>
|
||||
</Box>
|
||||
<Text color="text-gray-400" size="sm" block>
|
||||
Detailed race history, settings, and preferences are only visible to the driver.
|
||||
</p>
|
||||
</Text>
|
||||
</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">
|
||||
<Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
|
||||
<Box display="flex" alignItems="center" gap={3} mb={3}>
|
||||
<Text size="2xl">📊</Text>
|
||||
<Heading level={3}>Coming Soon</Heading>
|
||||
</Box>
|
||||
<Text color="text-gray-400" size="sm" block>
|
||||
Per-car statistics, per-track performance, and head-to-head comparisons will be available in production.
|
||||
</p>
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import Card from '@/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>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Input } from '@/ui/Input';
|
||||
|
||||
interface DriversSearchProps {
|
||||
query: string;
|
||||
onChange: (query: string) => void;
|
||||
}
|
||||
|
||||
export function DriversSearch({ query, onChange }: DriversSearchProps) {
|
||||
return (
|
||||
<Box mb={8}>
|
||||
<Box style={{ position: 'relative', maxWidth: '28rem' }}>
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
|
||||
<Search style={{ width: '1.25rem', height: '1.25rem', color: '#6b7280' }} />
|
||||
</Box>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search drivers by name or nationality..."
|
||||
value={query}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ paddingLeft: '2.75rem' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Trophy, Crown, Star, TrendingUp, Shield, Flag } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
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: {
|
||||
id: string;
|
||||
name: string;
|
||||
nationality: string;
|
||||
avatarUrl?: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
skillLevel?: string;
|
||||
category?: string;
|
||||
};
|
||||
position: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardProps) {
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
|
||||
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
|
||||
|
||||
const getBorderColor = (pos: number) => {
|
||||
switch (pos) {
|
||||
case 1: return 'border-yellow-400/50 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>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Award, Crown, Flag, ChevronRight } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Button from '@/ui/Button';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
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: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
nationality: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
skillLevel?: string;
|
||||
category?: string;
|
||||
}[];
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { LoadingWrapper } from '@/ui/LoadingWrapper';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { Pagination } from '@/ui/Pagination';
|
||||
import { Trophy } from 'lucide-react';
|
||||
|
||||
interface RaceHistoryProps {
|
||||
driverId: string;
|
||||
}
|
||||
|
||||
export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
export function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
const [filter, setFilter] = useState<'all' | 'wins' | 'podiums'>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -32,94 +39,72 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
const filteredResults: Array<unknown> = [];
|
||||
|
||||
const totalPages = Math.ceil(filteredResults.length / resultsPerPage);
|
||||
const paginatedResults = filteredResults.slice(
|
||||
(page - 1) * resultsPerPage,
|
||||
page * resultsPerPage
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Stack gap={4}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-9 w-24 bg-iron-gray rounded animate-pulse" />
|
||||
<Box key={i} h="9" w="24" bg="bg-iron-gray" rounded="md" animate="pulse" />
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
<Card>
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-20 bg-deep-graphite rounded animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
<LoadingWrapper variant="skeleton" skeletonCount={3} />
|
||||
</Card>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
return (
|
||||
<Card className="text-center py-12">
|
||||
<p className="text-gray-400 mb-2">No race history yet</p>
|
||||
<p className="text-sm text-gray-500">Complete races to build your racing record</p>
|
||||
</Card>
|
||||
<EmptyState
|
||||
icon={Trophy}
|
||||
title="No race history yet"
|
||||
description="Complete races to build your racing record"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Stack gap={4}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Button
|
||||
variant={filter === 'all' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('all'); setPage(1); }}
|
||||
className="text-sm"
|
||||
size="sm"
|
||||
>
|
||||
All Races
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'wins' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('wins'); setPage(1); }}
|
||||
className="text-sm"
|
||||
size="sm"
|
||||
>
|
||||
Wins Only
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'podiums' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('podiums'); setPage(1); }}
|
||||
className="text-sm"
|
||||
size="sm"
|
||||
>
|
||||
Podiums
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-2">
|
||||
{/* No results until API provides driver results */}
|
||||
</div>
|
||||
{/* No results until API provides driver results */}
|
||||
<Box minHeight="100px" display="flex" center>
|
||||
<Text color="text-gray-500">No results found for the selected filter.</Text>
|
||||
</Box>
|
||||
|
||||
{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>
|
||||
)}
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
totalItems={filteredResults.length}
|
||||
itemsPerPage={resultsPerPage}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { DriverProfileDriverSummaryViewModel } from '@/lib/view-models/DriverProfileViewModel';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Toggle } from '@/ui/Toggle';
|
||||
import { TextArea } from '@/ui/TextArea';
|
||||
import { Checkbox } from '@/ui/Checkbox';
|
||||
|
||||
interface ProfileSettingsProps {
|
||||
driver: DriverProfileDriverSummaryViewModel;
|
||||
onSave?: (updates: { bio?: string; country?: string }) => void;
|
||||
}
|
||||
|
||||
export default function ProfileSettings({ driver, onSave }: ProfileSettingsProps) {
|
||||
export function ProfileSettings({ driver, onSave }: ProfileSettingsProps) {
|
||||
const [bio, setBio] = useState(driver.bio || '');
|
||||
const [nationality, setNationality] = useState(driver.country);
|
||||
const [favoriteCarClass, setFavoriteCarClass] = useState('GT3');
|
||||
@@ -27,147 +34,122 @@ export default function ProfileSettings({ driver, onSave }: ProfileSettingsProps
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Stack gap={6}>
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Profile Information</h3>
|
||||
<Heading level={3} mb={4}>Profile Information</Heading>
|
||||
|
||||
<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>
|
||||
<Stack gap={4}>
|
||||
<TextArea
|
||||
label="Bio"
|
||||
value={bio}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setBio(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Tell us about yourself..."
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Input
|
||||
label="Nationality"
|
||||
type="text"
|
||||
value={nationality}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNationality(e.target.value)}
|
||||
placeholder="e.g., US, GB, DE"
|
||||
maxLength={2}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Racing Preferences</h3>
|
||||
<Heading level={3} mb={4}>Racing Preferences</Heading>
|
||||
|
||||
<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>
|
||||
<Stack gap={4}>
|
||||
<Select
|
||||
label="Favorite Car Class"
|
||||
value={favoriteCarClass}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFavoriteCarClass(e.target.value)}
|
||||
options={[
|
||||
{ value: 'GT3', label: 'GT3' },
|
||||
{ value: 'GT4', label: 'GT4' },
|
||||
{ value: 'Formula', label: 'Formula' },
|
||||
{ value: 'LMP2', label: 'LMP2' },
|
||||
{ value: 'Touring', label: 'Touring Cars' },
|
||||
{ value: 'NASCAR', label: 'NASCAR' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Select
|
||||
label="Favorite Series Type"
|
||||
value={favoriteSeries}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFavoriteSeries(e.target.value)}
|
||||
options={[
|
||||
{ value: 'Sprint', label: 'Sprint' },
|
||||
{ value: 'Endurance', label: 'Endurance' },
|
||||
{ value: 'Mixed', label: 'Mixed' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Select
|
||||
label="Competitive Level"
|
||||
value={competitiveLevel}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setCompetitiveLevel(e.target.value)}
|
||||
options={[
|
||||
{ value: 'casual', label: 'Casual - Just for fun' },
|
||||
{ value: 'competitive', label: 'Competitive - Aiming to win' },
|
||||
{ value: 'professional', label: 'Professional - Esports focused' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Preferred Regions</label>
|
||||
<div className="space-y-2">
|
||||
<Stack gap={2}>
|
||||
<Heading level={4}>Preferred Regions</Heading>
|
||||
<Stack gap={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>
|
||||
<Checkbox
|
||||
key={region}
|
||||
label={region}
|
||||
checked={preferredRegions.includes(region)}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
setPreferredRegions([...preferredRegions, region]);
|
||||
} else {
|
||||
setPreferredRegions(preferredRegions.filter(r => r !== region));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Privacy Settings</h3>
|
||||
<Heading level={3} mb={4}>Privacy Settings</Heading>
|
||||
|
||||
<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>
|
||||
<Stack gap={0}>
|
||||
<Toggle
|
||||
checked={true}
|
||||
onChange={() => {}}
|
||||
label="Show profile to other drivers"
|
||||
/>
|
||||
<Toggle
|
||||
checked={true}
|
||||
onChange={() => {}}
|
||||
label="Show race history"
|
||||
/>
|
||||
<Toggle
|
||||
checked={true}
|
||||
onChange={() => {}}
|
||||
label="Allow friend requests"
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="primary" onClick={handleSave} className="flex-1">
|
||||
<Box display="flex" gap={3}>
|
||||
<Button variant="primary" onClick={handleSave} fullWidth>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button variant="secondary" className="flex-1">
|
||||
<Button variant="secondary" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useDriverProfile } from "@/lib/hooks/driver";
|
||||
import { useDriverProfile } from "@/hooks/driver/useDriverProfile";
|
||||
import { useMemo } from 'react';
|
||||
import Card from '../ui/Card';
|
||||
import RankBadge from './RankBadge';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { StatCard } from '@/ui/StatCard';
|
||||
import { RankBadge } from '@/ui/RankBadge';
|
||||
|
||||
interface ProfileStatsProps {
|
||||
driverId?: string;
|
||||
@@ -17,15 +22,12 @@ interface ProfileStatsProps {
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||
export function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||
const { data: profileData } = useDriverProfile(driverId ?? '');
|
||||
|
||||
const driverStats = profileData?.stats ?? null;
|
||||
const totalDrivers = profileData?.currentDriver?.totalDrivers ?? 0;
|
||||
|
||||
// League rank widget needs a dedicated API contract; keep it disabled until provided.
|
||||
// (Leaving UI block out avoids `never` typing issues.)
|
||||
|
||||
const defaultStats = useMemo(() => {
|
||||
if (stats) {
|
||||
return stats;
|
||||
@@ -78,132 +80,102 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Stack gap={6}>
|
||||
{driverStats && (
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-white mb-6">Rankings Dashboard</h3>
|
||||
<Heading level={2} mb={6}>Rankings Dashboard</Heading>
|
||||
|
||||
<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">
|
||||
<Stack gap={4}>
|
||||
<Box p={4} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={3}>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<RankBadge rank={driverStats.overallRank ?? 0} size="lg" />
|
||||
<div>
|
||||
<div className="text-white font-medium text-lg">Overall Ranking</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
<Box>
|
||||
<Text color="text-white" weight="medium" size="lg" block>Overall Ranking</Text>
|
||||
<Text size="sm" color="text-gray-400" block>
|
||||
{driverStats.overallRank ?? 0} of {totalDrivers} drivers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div
|
||||
className={`text-sm font-medium ${getPercentileColor(driverStats.percentile ?? 0)}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box textAlign="right">
|
||||
<Text
|
||||
size="sm"
|
||||
weight="medium"
|
||||
color={getPercentileColor(driverStats.percentile ?? 0)}
|
||||
block
|
||||
>
|
||||
{getPercentileLabel(driverStats.percentile ?? 0)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Global Percentile</div>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>Global Percentile</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<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">
|
||||
<Box display="grid" gridCols={3} gap={4} pt={3} borderTop borderColor="border-charcoal-outline">
|
||||
<Box textAlign="center">
|
||||
<Text size="2xl" weight="bold" color="text-primary-blue" block>
|
||||
{driverStats.rating ?? 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Rating</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-green-400">
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400" block>Rating</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Text size="lg" weight="bold" color="text-green-400" block>
|
||||
{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">
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400" block>Win Rate</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Text size="lg" weight="bold" color="text-warning-amber" block>
|
||||
{getTrendIndicator(2)} {podiumRate}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Podium Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary-league ranking removed until we have a dedicated API + view model for league ranks. */}
|
||||
</div>
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400" block>Podium Rate</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{defaultStats ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
<Box display="grid" responsiveGridCols={{ base: 2, md: 4 }} gap={4}>
|
||||
<StatCard label="Total Races" value={defaultStats.totalRaces} variant="blue" />
|
||||
<StatCard label="Wins" value={defaultStats.wins} variant="green" />
|
||||
<StatCard label="Podiums" value={defaultStats.podiums} variant="orange" />
|
||||
<StatCard label="DNFs" value={defaultStats.dnfs} variant="blue" />
|
||||
<StatCard label="Avg Finish" value={defaultStats.avgFinish.toFixed(1)} variant="blue" />
|
||||
<StatCard label="Completion" value={`${defaultStats.completionRate.toFixed(1)}%`} variant="green" />
|
||||
<StatCard label="Win Rate" value={`${winRate}%`} variant="blue" />
|
||||
<StatCard label="Podium Rate" value={`${podiumRate}%`} variant="orange" />
|
||||
</Box>
|
||||
) : (
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Career Statistics</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
<Heading level={3} mb={2}>Career Statistics</Heading>
|
||||
<Text size="sm" color="text-gray-400" block>
|
||||
No statistics available yet. Compete in races to start building your record.
|
||||
</p>
|
||||
</Text>
|
||||
</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">Performance by Car Class</h3>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
<Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
|
||||
<Box display="flex" alignItems="center" gap={3} mb={3}>
|
||||
<Text size="2xl">📊</Text>
|
||||
<Heading level={3}>Performance by Car Class</Heading>
|
||||
</Box>
|
||||
<Text color="text-gray-400" size="sm" block>
|
||||
Detailed per-car and per-class performance breakdowns will be available in a future
|
||||
version once more race history data is tracked.
|
||||
</p>
|
||||
</Text>
|
||||
</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">
|
||||
<Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
|
||||
<Box display="flex" alignItems="center" gap={3} mb={3}>
|
||||
<Text size="2xl">📈</Text>
|
||||
<Heading level={3}>Coming Soon</Heading>
|
||||
</Box>
|
||||
<Text color="text-gray-400" size="sm" block>
|
||||
Performance trends, track-specific stats, head-to-head comparisons vs friends, and
|
||||
league member comparisons will be available in production.
|
||||
</p>
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Crown } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface PodiumDriver {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
}
|
||||
|
||||
interface RankingsPodiumProps {
|
||||
podium: PodiumDriver[];
|
||||
onDriverClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RankingsPodium({ podium, onDriverClick }: RankingsPodiumProps) {
|
||||
return (
|
||||
<Box mb={10}>
|
||||
<Box style={{ display: 'flex', alignItems: 'end', justifyContent: 'center', gap: '1rem' }}>
|
||||
{[1, 0, 2].map((index) => {
|
||||
const driver = podium[index];
|
||||
if (!driver) return null;
|
||||
|
||||
const position = index === 1 ? 1 : index === 0 ? 2 : 3;
|
||||
const config = {
|
||||
1: { height: '10rem', color: 'rgba(250, 204, 21, 0.2)', borderColor: 'rgba(250, 204, 21, 0.4)', crown: '#facc15' },
|
||||
2: { height: '8rem', color: 'rgba(209, 213, 219, 0.2)', borderColor: 'rgba(209, 213, 219, 0.4)', crown: '#d1d5db' },
|
||||
3: { height: '6rem', color: 'rgba(217, 119, 6, 0.2)', borderColor: 'rgba(217, 119, 6, 0.4)', crown: '#d97706' },
|
||||
}[position] || { height: '6rem', color: 'rgba(217, 119, 6, 0.2)', borderColor: 'rgba(217, 119, 6, 0.4)', crown: '#d97706' };
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={driver.id}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => onDriverClick?.(driver.id)}
|
||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<Box style={{ position: 'relative', marginBottom: '1rem' }}>
|
||||
<Box style={{ position: 'relative', width: position === 1 ? '6rem' : '5rem', height: position === 1 ? '6rem' : '5rem', borderRadius: '9999px', overflow: 'hidden', border: `4px solid ${config.crown}`, boxShadow: position === 1 ? '0 0 30px rgba(250, 204, 21, 0.3)' : 'none' }}>
|
||||
<Image
|
||||
src={driver.avatarUrl}
|
||||
alt={driver.name}
|
||||
width={112}
|
||||
height={112}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ position: 'absolute', bottom: '-0.5rem', left: '50%', transform: 'translateX(-50%)', width: '2rem', height: '2rem', borderRadius: '9999px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.875rem', fontWeight: 'bold', background: `linear-gradient(to bottom right, ${config.color}, transparent)`, border: `2px solid ${config.crown}`, color: config.crown }}>
|
||||
{position}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Text weight="semibold" color="text-white" style={{ fontSize: position === 1 ? '1.125rem' : '1rem', marginBottom: '0.25rem' }}>
|
||||
{driver.name}
|
||||
</Text>
|
||||
|
||||
<Text font="mono" weight="bold" style={{ fontSize: position === 1 ? '1.25rem' : '1.125rem', color: position === 1 ? '#facc15' : '#3b82f6' }}>
|
||||
{driver.rating.toString()}
|
||||
</Text>
|
||||
|
||||
<Stack direction="row" align="center" gap={2} style={{ fontSize: '0.75rem', color: '#6b7280', marginTop: '0.25rem' }}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Text color="text-performance-green">🏆</Text>
|
||||
{driver.wins}
|
||||
</Stack>
|
||||
<Text>•</Text>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Text color="text-warning-amber">🏅</Text>
|
||||
{driver.podiums}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Box style={{ marginTop: '1rem', width: position === 1 ? '7rem' : '6rem', height: config.height, borderRadius: '0.5rem 0.5rem 0 0', background: `linear-gradient(to top, ${config.color}, transparent)`, borderTop: `1px solid ${config.borderColor}`, borderLeft: `1px solid ${config.borderColor}`, borderRight: `1px solid ${config.borderColor}`, display: 'flex', alignItems: 'end', justifyContent: 'center', paddingBottom: '1rem' }}>
|
||||
<Text weight="bold" style={{ fontSize: position === 1 ? '3rem' : '2.25rem', color: config.crown }}>
|
||||
{position}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Medal } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
interface Driver {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
rank: number;
|
||||
nationality: string;
|
||||
skillLevel: string;
|
||||
racesCompleted: number;
|
||||
rating: number;
|
||||
wins: number;
|
||||
medalBg?: string;
|
||||
medalColor?: string;
|
||||
}
|
||||
|
||||
interface RankingsTableProps {
|
||||
drivers: Driver[];
|
||||
onDriverClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RankingsTable({ drivers, onDriverClick }: RankingsTableProps) {
|
||||
return (
|
||||
<Box style={{ borderRadius: '0.75rem', backgroundColor: 'rgba(38, 38, 38, 0.3)', border: '1px solid #262626', overflow: 'hidden' }}>
|
||||
{/* Table Header */}
|
||||
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(12, minmax(0, 1fr))', gap: '1rem', padding: '0.75rem 1rem', backgroundColor: 'rgba(38, 38, 38, 0.5)', borderBottom: '1px solid #262626', fontSize: '0.75rem', fontWeight: 500, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
<Box style={{ gridColumn: 'span 1', textAlign: 'center' }}>Rank</Box>
|
||||
<Box style={{ gridColumn: 'span 5' }}>Driver</Box>
|
||||
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Races</Box>
|
||||
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Rating</Box>
|
||||
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Wins</Box>
|
||||
</Box>
|
||||
|
||||
{/* Table Body */}
|
||||
<Stack gap={0}>
|
||||
{drivers.map((driver, index) => {
|
||||
const position = driver.rank;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={driver.id}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => onDriverClick?.(driver.id)}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(12, minmax(0, 1fr))',
|
||||
gap: '1rem',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
borderBottom: index < drivers.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none'
|
||||
}}
|
||||
>
|
||||
{/* Position */}
|
||||
<Box style={{ gridColumn: 'span 1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Box style={{ display: 'flex', height: '2.25rem', width: '2.25rem', alignItems: 'center', justifyContent: 'center', borderRadius: '9999px', fontSize: '0.875rem', fontWeight: 'bold', border: '1px solid #262626', backgroundColor: driver.medalBg, color: driver.medalColor }}>
|
||||
{position <= 3 ? <Icon icon={Medal} size={4} /> : position}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Driver Info */}
|
||||
<Box style={{ gridColumn: 'span 5', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<Box style={{ position: 'relative', width: '2.5rem', height: '2.5rem', borderRadius: '9999px', overflow: 'hidden', border: '2px solid #262626' }}>
|
||||
<Image src={driver.avatarUrl} alt={driver.name} width={40} height={40} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text weight="semibold" color="text-white" block truncate>
|
||||
{driver.name}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={2} mt={1}>
|
||||
<Text size="xs" color="text-gray-500">{driver.nationality}</Text>
|
||||
<Text size="xs" color="text-gray-500">{driver.skillLevel}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Races */}
|
||||
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text color="text-gray-400">{driver.racesCompleted}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Rating */}
|
||||
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text font="mono" weight="semibold" color="text-white">
|
||||
{driver.rating.toString()}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Wins */}
|
||||
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text font="mono" weight="semibold" color="text-performance-green">
|
||||
{driver.wins}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{/* Empty State */}
|
||||
{drivers.length === 0 && (
|
||||
<Box style={{ padding: '4rem 0', textAlign: 'center' }}>
|
||||
<Text size="4xl" block mb={4}>🔍</Text>
|
||||
<Text color="text-gray-400" block mb={2}>No drivers found</Text>
|
||||
<Text size="sm" color="text-gray-500">There are no drivers in the system yet</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Activity } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
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: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
isActive: boolean;
|
||||
skillLevel?: string;
|
||||
category?: string;
|
||||
}[];
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
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: {
|
||||
skillLevel?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user