wip
This commit is contained in:
30
apps/website/components/teams/TeamLogo.tsx
Normal file
30
apps/website/components/teams/TeamLogo.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* TeamLogo
|
||||
*
|
||||
* Pure UI component for displaying team logos.
|
||||
* Renders an optimized image with fallback on error.
|
||||
*/
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
export interface TeamLogoProps {
|
||||
teamId: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TeamLogo({ teamId, alt, className = '' }: TeamLogoProps) {
|
||||
return (
|
||||
<Image
|
||||
src={`/media/teams/${teamId}/logo`}
|
||||
alt={alt}
|
||||
width={100}
|
||||
height={100}
|
||||
className={`object-contain ${className}`}
|
||||
onError={(e) => {
|
||||
// Fallback to default logo
|
||||
(e.target as HTMLImageElement).src = '/default-team-logo.png';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Medal, Users, Globe, Languages } from 'lucide-react';
|
||||
import { Medal, Users } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
@@ -32,66 +32,56 @@ interface TeamRankingsTableProps {
|
||||
|
||||
export function TeamRankingsTable({ teams, sortBy, onTeamClick }: TeamRankingsTableProps) {
|
||||
return (
|
||||
<Box style={{ borderRadius: '0.75rem', backgroundColor: 'rgba(38, 38, 38, 0.3)', border: '1px solid #262626', overflow: 'hidden' }}>
|
||||
<Surface variant="muted" rounded="xl" border className="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' }}>Team</Box>
|
||||
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }} className="hidden lg:block">Members</Box>
|
||||
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Rating</Box>
|
||||
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Wins</Box>
|
||||
<Box display="grid" className="grid-cols-12 gap-4 px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline text-[10px] font-medium text-gray-500 uppercase tracking-wider">
|
||||
<Box className="col-span-1 text-center">Rank</Box>
|
||||
<Box className="col-span-5">Team</Box>
|
||||
<Box className="col-span-2 text-center hidden lg:block">Members</Box>
|
||||
<Box className="col-span-2 text-center">Rating</Box>
|
||||
<Box className="col-span-2 text-center">Wins</Box>
|
||||
</Box>
|
||||
|
||||
{/* Table Body */}
|
||||
<Stack gap={0}>
|
||||
{teams.map((team, index) => {
|
||||
const winRate = team.totalRaces > 0 ? ((team.totalWins / team.totalRaces) * 100).toFixed(1) : '0.0';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={team.id}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => onTeamClick(team.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 < teams.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none'
|
||||
}}
|
||||
display="grid"
|
||||
className={`grid-cols-12 gap-4 p-4 w-full text-left bg-transparent border-0 cursor-pointer hover:bg-iron-gray/20 transition-colors ${
|
||||
index < teams.length - 1 ? 'border-b border-charcoal-outline/50' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Position */}
|
||||
<Box style={{ gridColumn: 'span 1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Surface variant="muted" rounded="full" padding={1} style={{ width: '2.25rem', height: '2.25rem', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#262626' }}>
|
||||
{index < 3 ? <Icon icon={Medal} size={4} /> : index + 1}
|
||||
</Surface>
|
||||
<Box className="col-span-1 flex items-center justify-center">
|
||||
<Box width={9} height={9} rounded="full" display="flex" center backgroundColor="charcoal-outline">
|
||||
{index < 3 ? <Icon icon={Medal} size={4} color={index === 0 ? 'text-yellow-400' : index === 1 ? 'text-gray-300' : 'text-amber-600'} /> : <Text size="xs" color="text-gray-400">{index + 1}</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Team Info */}
|
||||
<Box style={{ gridColumn: 'span 5', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<Box style={{ width: '2.5rem', height: '2.5rem', borderRadius: '0.5rem', overflow: 'hidden', border: '1px solid #262626' }}>
|
||||
<Box className="col-span-5 flex items-center gap-3">
|
||||
<Box width={10} height={10} rounded="lg" className="overflow-hidden" border borderColor="charcoal-outline">
|
||||
<Image
|
||||
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
alt={team.name}
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text weight="semibold" color="text-white" block truncate>{team.name}</Text>
|
||||
<Box className="min-w-0 flex-1">
|
||||
<Text weight="semibold" color="text-white" block className="truncate">{team.name}</Text>
|
||||
<Stack direction="row" align="center" gap={2} mt={1} wrap>
|
||||
<Text size="xs" color="text-gray-500">{team.performanceLevel}</Text>
|
||||
{team.category && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Box style={{ width: '0.375rem', height: '0.375rem', borderRadius: '9999px', backgroundColor: '#a855f7' }} />
|
||||
<Text size="xs" color="text-purple-400">{team.category}</Text>
|
||||
<Box width={1.5} height={1.5} rounded="full" backgroundColor="primary-blue" opacity={0.5} />
|
||||
<Text size="xs" color="text-primary-blue">{team.category}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -99,22 +89,22 @@ export function TeamRankingsTable({ teams, sortBy, onTeamClick }: TeamRankingsTa
|
||||
</Box>
|
||||
|
||||
{/* Members */}
|
||||
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }} className="hidden lg:flex">
|
||||
<Box className="col-span-2 flex items-center justify-center hidden lg:flex">
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={Users} size={3.5} color="#9ca3af" />
|
||||
<Icon icon={Users} size={3.5} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-400">{team.memberCount}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Rating */}
|
||||
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Box className="col-span-2 flex items-center justify-center">
|
||||
<Text font="mono" weight="semibold" color={sortBy === 'rating' ? 'text-primary-blue' : 'text-white'}>
|
||||
0
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Wins */}
|
||||
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Box className="col-span-2 flex items-center justify-center">
|
||||
<Text font="mono" weight="semibold" color={sortBy === 'wins' ? 'text-primary-blue' : 'text-white'}>
|
||||
{team.totalWins}
|
||||
</Text>
|
||||
@@ -123,6 +113,6 @@ export function TeamRankingsTable({ teams, sortBy, onTeamClick }: TeamRankingsTa
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import Card from '@/ui/Card';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
|
||||
import { useTeamRoster } from "@/lib/hooks/team";
|
||||
import { useState } from 'react';
|
||||
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Button } from '@/ui/Button';
|
||||
|
||||
type TeamRole = 'owner' | 'admin' | 'member';
|
||||
type TeamMemberRole = 'owner' | 'manager' | 'member';
|
||||
@@ -24,7 +32,7 @@ interface TeamRosterProps {
|
||||
onChangeRole?: (driverId: string, newRole: TeamRole) => void;
|
||||
}
|
||||
|
||||
export default function TeamRoster({
|
||||
export function TeamRoster({
|
||||
teamId,
|
||||
memberships,
|
||||
isAdmin,
|
||||
@@ -36,17 +44,6 @@ export default function TeamRoster({
|
||||
// Use hook for data fetching
|
||||
const { data: teamMembers = [], isLoading: loading } = useTeamRoster(memberships);
|
||||
|
||||
const getRoleBadgeColor = (role: TeamRole) => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'bg-warning-amber/20 text-warning-amber';
|
||||
case 'admin':
|
||||
return 'bg-primary-blue/20 text-primary-blue';
|
||||
default:
|
||||
return 'bg-charcoal-outline text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleLabel = (role: TeamRole | TeamMemberRole) => {
|
||||
// Convert manager to admin for display
|
||||
const displayRole = role === 'manager' ? 'admin' : role;
|
||||
@@ -85,43 +82,48 @@ export default function TeamRoster({
|
||||
});
|
||||
|
||||
const teamAverageRating = teamMembers.length > 0
|
||||
? teamMembers.reduce((sum, m) => sum + (m.rating || 0), 0) / teamMembers.length
|
||||
? teamMembers.reduce((sum: number, m: any) => sum + (m.rating || 0), 0) / teamMembers.length
|
||||
: 0;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-400">Loading roster...</div>
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="text-gray-400">Loading roster...</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-white">Team Roster</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
<Stack direction="row" align="center" justify="between" mb={6} wrap>
|
||||
<Box>
|
||||
<Heading level={3}>Team Roster</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||
{memberships.length} {memberships.length === 1 ? 'member' : 'members'} • Avg Rating:{' '}
|
||||
<span className="text-primary-blue font-medium">{teamAverageRating}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Text color="text-primary-blue" weight="medium">{teamAverageRating.toFixed(0)}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-400">Sort by:</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="rating">Rating</option>
|
||||
<option value="role">Role</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text size="sm" color="text-gray-400">Sort by:</Text>
|
||||
<Box width={32}>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||
options={[
|
||||
{ value: 'rating', label: 'Rating' },
|
||||
{ value: 'role', label: 'Role' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
]}
|
||||
className="py-1 text-sm"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Stack gap={3}>
|
||||
{sortedMembers.map((member) => {
|
||||
const { driver, role, joinedAt, rating, overallRank } = member;
|
||||
|
||||
@@ -130,68 +132,79 @@ export default function TeamRoster({
|
||||
const canManageMembership = isAdmin && role !== 'owner';
|
||||
|
||||
return (
|
||||
<div
|
||||
<Surface
|
||||
key={driver.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-charcoal-outline/60 transition-colors"
|
||||
variant="dark"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={4}
|
||||
>
|
||||
<DriverIdentity
|
||||
driver={driver as DriverViewModel}
|
||||
href={`/drivers/${driver.id}?from=team&teamId=${teamId}`}
|
||||
contextLabel={getRoleLabel(role)}
|
||||
meta={
|
||||
<span>
|
||||
{driver.country} • Joined {new Date(joinedAt).toLocaleDateString()}
|
||||
</span>
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
<Stack direction="row" align="center" justify="between" wrap gap={4}>
|
||||
<DriverIdentity
|
||||
driver={driver as DriverViewModel}
|
||||
href={`/drivers/${driver.id}?from=team&teamId=${teamId}`}
|
||||
contextLabel={getRoleLabel(role)}
|
||||
meta={
|
||||
<Text size="xs" color="text-gray-400">
|
||||
{driver.country} • Joined {new Date(joinedAt).toLocaleDateString()}
|
||||
</Text>
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
{rating !== null && (
|
||||
<div className="flex items-center gap-6 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-primary-blue">
|
||||
{rating}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Rating</div>
|
||||
</div>
|
||||
{overallRank !== null && (
|
||||
<div>
|
||||
<div className="text-sm text-gray-300">#{overallRank}</div>
|
||||
<div className="text-xs text-gray-500">Rank</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{rating !== null && (
|
||||
<Stack direction="row" align="center" gap={6}>
|
||||
<Box textAlign="center">
|
||||
<Text size="lg" weight="bold" color="text-primary-blue" block>
|
||||
{rating}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400">Rating</Text>
|
||||
</Box>
|
||||
{overallRank !== null && (
|
||||
<Box textAlign="center">
|
||||
<Text size="sm" color="text-gray-300" block>#{overallRank}</Text>
|
||||
<Text size="xs" color="text-gray-500">Rank</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{canManageMembership && (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="px-3 py-2 bg-iron-gray border-0 rounded text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
|
||||
value={displayRole}
|
||||
onChange={(e) =>
|
||||
onChangeRole?.(driver.id, e.target.value as TeamRole)
|
||||
}
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
{canManageMembership && (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Box width={32}>
|
||||
<Select
|
||||
value={displayRole}
|
||||
onChange={(e) =>
|
||||
onChangeRole?.(driver.id, e.target.value as TeamRole)
|
||||
}
|
||||
options={[
|
||||
{ value: 'member', label: 'Member' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
]}
|
||||
className="text-sm"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<button
|
||||
onClick={() => onRemoveMember?.(driver.id)}
|
||||
className="px-3 py-2 bg-danger-red/20 hover:bg-danger-red/30 text-danger-red rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onRemoveMember?.(driver.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{memberships.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400">No team members yet.</div>
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="text-gray-400">No team members yet.</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import Input from '@/ui/Input';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
interface TeamSearchBarProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
}
|
||||
|
||||
export default function TeamSearchBar({ searchQuery, onSearchChange }: TeamSearchBarProps) {
|
||||
export function TeamSearchBar({ searchQuery, onSearchChange }: TeamSearchBarProps) {
|
||||
return (
|
||||
<div id="teams-list" className="mb-6 scroll-mt-8">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<Box id="teams-list" mb={6} className="scroll-mt-8">
|
||||
<Stack direction="row" gap={4} wrap>
|
||||
<Box flex={1}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search teams by name, description, region, or language..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-11"
|
||||
icon={<Icon icon={Search} size={5} color="text-gray-500" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import Card from '@/ui/Card';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { useTeamStandings } from "@/lib/hooks/team";
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface TeamStandingsProps {
|
||||
teamId: string;
|
||||
leagues: string[];
|
||||
}
|
||||
|
||||
export default function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
|
||||
export function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
|
||||
const { data: standings = [], isLoading: loading } = useTeamStandings(teamId, leagues);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-400">Loading standings...</div>
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="text-gray-400">Loading standings...</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-white mb-6">League Standings</h3>
|
||||
<Box mb={6}>
|
||||
<Heading level={2}>
|
||||
League Standings
|
||||
</Heading>
|
||||
</Box>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Stack gap={4}>
|
||||
{standings.map((standing: any) => (
|
||||
<div
|
||||
<Surface
|
||||
key={standing.leagueId}
|
||||
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
|
||||
variant="dark"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={4}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-white font-medium">{standing.leagueName}</h4>
|
||||
<span className="px-3 py-1 bg-primary-blue/20 text-primary-blue rounded-full text-sm font-semibold">
|
||||
<Stack direction="row" align="center" justify="between" mb={3}>
|
||||
<Heading level={4}>
|
||||
{standing.leagueName}
|
||||
</Heading>
|
||||
<Badge variant="primary">
|
||||
P{standing.position}
|
||||
</span>
|
||||
</div>
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{standing.points}</div>
|
||||
<div className="text-xs text-gray-400">Points</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{standing.wins}</div>
|
||||
<div className="text-xs text-gray-400">Wins</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{standing.racesCompleted}</div>
|
||||
<div className="text-xs text-gray-400">Races</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Grid cols={3} gap={4}>
|
||||
<Box textAlign="center">
|
||||
<Text size="2xl" weight="bold" color="text-white" block>
|
||||
{standing.points}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400">Points</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Text size="2xl" weight="bold" color="text-white" block>
|
||||
{standing.wins}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400">Wins</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Text size="2xl" weight="bold" color="text-white" block>
|
||||
{standing.racesCompleted}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400">Races</Text>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{standings.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
No standings available yet.
|
||||
</div>
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="text-gray-400">
|
||||
No standings available yet.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,50 +2,20 @@ import Image from 'next/image';
|
||||
import { Trophy, Crown, Users } from 'lucide-react';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
|
||||
const SKILL_LEVELS: {
|
||||
id: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
}[] = [
|
||||
{
|
||||
id: 'pro',
|
||||
icon: () => null,
|
||||
color: 'text-yellow-400',
|
||||
bgColor: 'bg-yellow-400/10',
|
||||
borderColor: 'border-yellow-400/30',
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
icon: () => null,
|
||||
color: 'text-purple-400',
|
||||
bgColor: 'bg-purple-400/10',
|
||||
borderColor: 'border-purple-400/30',
|
||||
},
|
||||
{
|
||||
id: 'intermediate',
|
||||
icon: () => null,
|
||||
color: 'text-primary-blue',
|
||||
bgColor: 'bg-primary-blue/10',
|
||||
borderColor: 'border-primary-blue/30',
|
||||
},
|
||||
{
|
||||
id: 'beginner',
|
||||
icon: () => null,
|
||||
color: 'text-green-400',
|
||||
bgColor: 'bg-green-400/10',
|
||||
borderColor: 'border-green-400/30',
|
||||
},
|
||||
];
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Button } from '@/ui/Button';
|
||||
|
||||
interface TopThreePodiumProps {
|
||||
teams: TeamSummaryViewModel[];
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps) {
|
||||
export function TopThreePodium({ teams, onClick }: TopThreePodiumProps) {
|
||||
const top3 = teams.slice(0, 3) as [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel];
|
||||
if (teams.length < 3) return null;
|
||||
|
||||
@@ -71,118 +41,120 @@ export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps)
|
||||
}
|
||||
};
|
||||
|
||||
const getGradient = (position: number) => {
|
||||
const getVariant = (position: number): any => {
|
||||
switch (position) {
|
||||
case 1:
|
||||
return 'from-yellow-400/30 via-yellow-500/20 to-yellow-600/10';
|
||||
return 'gradient-gold';
|
||||
case 2:
|
||||
return 'from-gray-300/30 via-gray-400/20 to-gray-500/10';
|
||||
return 'default';
|
||||
case 3:
|
||||
return 'from-amber-500/30 via-amber-600/20 to-amber-700/10';
|
||||
return 'gradient-purple';
|
||||
default:
|
||||
return 'from-gray-600/30 to-gray-700/10';
|
||||
}
|
||||
};
|
||||
|
||||
const getBorderColor = (position: number) => {
|
||||
switch (position) {
|
||||
case 1:
|
||||
return 'border-yellow-400/50';
|
||||
case 2:
|
||||
return 'border-gray-300/50';
|
||||
case 3:
|
||||
return 'border-amber-600/50';
|
||||
default:
|
||||
return 'border-charcoal-outline';
|
||||
return 'muted';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-10 p-8 rounded-2xl bg-gradient-to-br from-iron-gray/60 to-iron-gray/30 border border-charcoal-outline">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Trophy className="w-6 h-6 text-yellow-400" />
|
||||
<h2 className="text-xl font-bold text-white">Top 3 Teams</h2>
|
||||
</div>
|
||||
<Surface variant="muted" rounded="2xl" border padding={8} mb={10}>
|
||||
<Box display="flex" center mb={8}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Trophy} size={6} color="text-yellow-400" />
|
||||
<Heading level={2}>Top 3 Teams</Heading>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className="flex items-end justify-center gap-4 md:gap-8">
|
||||
<Stack direction="row" align="end" justify="center" gap={8}>
|
||||
{podiumOrder.map((team, index) => {
|
||||
const position = podiumPositions[index] ?? 0;
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={team.id}
|
||||
type="button"
|
||||
onClick={() => onClick(team.id)}
|
||||
className="flex flex-col items-center group"
|
||||
>
|
||||
<Stack key={team.id} align="center">
|
||||
{/* Team card */}
|
||||
<div
|
||||
className={`relative mb-4 p-4 rounded-xl bg-gradient-to-br ${getGradient(position ?? 0)} border ${getBorderColor(position ?? 0)} transition-all group-hover:scale-105 group-hover:shadow-lg`}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onClick(team.id)}
|
||||
className="p-0 h-auto hover:scale-105 transition-transform"
|
||||
>
|
||||
{/* Crown for 1st place */}
|
||||
{position === 1 && (
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
|
||||
<div className="relative">
|
||||
<Crown className="w-8 h-8 text-yellow-400 animate-pulse" />
|
||||
<div className="absolute inset-0 w-8 h-8 bg-yellow-400/30 blur-md rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Surface
|
||||
variant={getVariant(position)}
|
||||
rounded="xl"
|
||||
border
|
||||
padding={4}
|
||||
position="relative"
|
||||
mb={4}
|
||||
>
|
||||
{/* Crown for 1st place */}
|
||||
{position === 1 && (
|
||||
<Box position="absolute" top="-4" left="50%" style={{ transform: 'translateX(-50%)' }}>
|
||||
<Box position="relative">
|
||||
<Icon icon={Crown} size={8} color="text-yellow-400" className="animate-pulse" />
|
||||
<Box position="absolute" inset="0" backgroundColor="yellow-400" opacity={0.3} className="blur-md rounded-full" />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Team logo */}
|
||||
<div className="flex h-16 w-16 md:h-20 md:w-20 items-center justify-center rounded-xl bg-charcoal-outline border border-charcoal-outline overflow-hidden mb-3">
|
||||
<Image
|
||||
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
alt={team.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/* Team logo */}
|
||||
<Box height={20} width={20} display="flex" center rounded="xl" backgroundColor="charcoal-outline" border borderColor="charcoal-outline" className="overflow-hidden" mb={3}>
|
||||
<Image
|
||||
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
alt={team.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Team name */}
|
||||
<p className="text-white font-bold text-sm md:text-base text-center max-w-[120px] truncate group-hover:text-purple-400 transition-colors">
|
||||
{team.name}
|
||||
</p>
|
||||
{/* Team name */}
|
||||
<Text weight="bold" size="sm" color="text-white" align="center" block className="max-w-[120px] truncate group-hover:text-primary-blue transition-colors">
|
||||
{team.name}
|
||||
</Text>
|
||||
|
||||
{/* Category */}
|
||||
{team.category && (
|
||||
<p className="text-xs text-purple-400 text-center mt-1">
|
||||
{team.category}
|
||||
</p>
|
||||
)}
|
||||
{/* Category */}
|
||||
{team.category && (
|
||||
<Text size="xs" color="text-primary-blue" align="center" block mt={1}>
|
||||
{team.category}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Rating */}
|
||||
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
|
||||
{'—'}
|
||||
</p>
|
||||
{/* Rating placeholder */}
|
||||
<Text size="xl" weight="bold" className={`${getPositionColor(position)}`} align="center" block mt={1}>
|
||||
—
|
||||
</Text>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center justify-center gap-3 mt-2 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Trophy className="w-3 h-3 text-performance-green" />
|
||||
{team.totalWins}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3 text-purple-400" />
|
||||
{team.memberCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Stats row */}
|
||||
<Stack direction="row" align="center" justify="center" gap={3} mt={2}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Trophy} size={3} color="text-performance-green" />
|
||||
<Text size="xs" color="text-gray-400">{team.totalWins}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Users} size={3} color="text-primary-blue" />
|
||||
<Text size="xs" color="text-gray-400">{team.memberCount}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Button>
|
||||
|
||||
{/* Podium stand */}
|
||||
<div
|
||||
className={`${podiumHeights[index]} w-20 md:w-28 rounded-t-lg bg-gradient-to-t ${getGradient(position)} border-t border-x ${getBorderColor(position)} flex items-start justify-center pt-3`}
|
||||
<Surface
|
||||
variant={getVariant(position)}
|
||||
rounded="none"
|
||||
className={`rounded-t-lg ${podiumHeights[index]}`}
|
||||
border
|
||||
width="28"
|
||||
display="flex"
|
||||
padding={3}
|
||||
>
|
||||
<span className={`text-2xl md:text-3xl font-bold ${getPositionColor(position)}`}>
|
||||
{position}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<Box display="flex" center fullWidth>
|
||||
<Text size="3xl" weight="bold" className={getPositionColor(position)}>
|
||||
{position}
|
||||
</Text>
|
||||
</Box>
|
||||
</Surface>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,23 @@ import {
|
||||
MessageCircle,
|
||||
Calendar,
|
||||
Trophy,
|
||||
LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
export default function WhyJoinTeamSection() {
|
||||
const benefits = [
|
||||
interface Benefit {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function WhyJoinTeamSection() {
|
||||
const benefits: Benefit[] = [
|
||||
{
|
||||
icon: Handshake,
|
||||
title: 'Shared Strategy',
|
||||
@@ -30,26 +43,33 @@ export default function WhyJoinTeamSection() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mb-12">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Why Join a Team?</h2>
|
||||
<p className="text-gray-400">Racing is better when you have teammates to share the journey</p>
|
||||
</div>
|
||||
<Box mb={12}>
|
||||
<Box textAlign="center" mb={8}>
|
||||
<Box mb={2}>
|
||||
<Heading level={2}>Why Join a Team?</Heading>
|
||||
</Box>
|
||||
<Text color="text-gray-400">Racing is better when you have teammates to share the journey</Text>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Grid cols={4} gap={4}>
|
||||
{benefits.map((benefit) => (
|
||||
<div
|
||||
<Surface
|
||||
key={benefit.title}
|
||||
className="p-5 rounded-xl bg-iron-gray/50 border border-charcoal-outline/50 hover:border-purple-500/30 transition-all duration-300"
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
border
|
||||
padding={5}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 border border-purple-500/20 mb-3">
|
||||
<benefit.icon className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-white font-semibold mb-1">{benefit.title}</h3>
|
||||
<p className="text-sm text-gray-500">{benefit.description}</p>
|
||||
</div>
|
||||
<Box height={10} width={10} display="flex" center rounded="lg" backgroundColor="primary-blue" opacity={0.1} border borderColor="primary-blue" mb={3}>
|
||||
<Icon icon={benefit.icon} size={5} color="text-primary-blue" />
|
||||
</Box>
|
||||
<Box mb={1}>
|
||||
<Heading level={3}>{benefit.title}</Heading>
|
||||
</Box>
|
||||
<Text size="sm" color="text-gray-500">{benefit.description}</Text>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user