wip
This commit is contained in:
100
apps/website/app/drivers/[id]/page.tsx
Normal file
100
apps/website/app/drivers/[id]/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { getDriverRepository } from '@/lib/di-container';
|
||||
import DriverProfile from '@/components/alpha/DriverProfile';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
|
||||
import { EntityMappers, DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
|
||||
|
||||
export default function DriverDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const driverId = params.id as string;
|
||||
|
||||
const [driver, setDriver] = useState<DriverDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadDriver();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [driverId]);
|
||||
|
||||
const loadDriver = async () => {
|
||||
try {
|
||||
const driverRepo = getDriverRepository();
|
||||
const driverEntity = await driverRepo.findById(driverId);
|
||||
|
||||
if (!driverEntity) {
|
||||
setError('Driver not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const driverDto = EntityMappers.toDriverDTO(driverEntity);
|
||||
|
||||
if (!driverDto) {
|
||||
setError('Driver not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setDriver(driverDto);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load driver');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading driver profile...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !driver) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-warning-amber mb-4">
|
||||
{error || 'Driver not found'}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/drivers')}
|
||||
>
|
||||
Back to Drivers
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Drivers', href: '/drivers' },
|
||||
{ label: driver.name }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Driver Profile Component */}
|
||||
<DriverProfile driver={driver} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
306
apps/website/app/drivers/page.tsx
Normal file
306
apps/website/app/drivers/page.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import DriverCard from '@/components/alpha/DriverCard';
|
||||
import RankBadge from '@/components/alpha/RankBadge';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
// Mock data
|
||||
const MOCK_DRIVERS = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Max Verstappen',
|
||||
rating: 3245,
|
||||
skillLevel: 'pro' as const,
|
||||
nationality: 'Netherlands',
|
||||
racesCompleted: 156,
|
||||
wins: 45,
|
||||
podiums: 89,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Lewis Hamilton',
|
||||
rating: 3198,
|
||||
skillLevel: 'pro' as const,
|
||||
nationality: 'United Kingdom',
|
||||
racesCompleted: 234,
|
||||
wins: 78,
|
||||
podiums: 145,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Michael Schmidt',
|
||||
rating: 2912,
|
||||
skillLevel: 'advanced' as const,
|
||||
nationality: 'Germany',
|
||||
racesCompleted: 145,
|
||||
wins: 34,
|
||||
podiums: 67,
|
||||
isActive: true,
|
||||
rank: 3,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Emma Thompson',
|
||||
rating: 2789,
|
||||
skillLevel: 'advanced' as const,
|
||||
nationality: 'Australia',
|
||||
racesCompleted: 112,
|
||||
wins: 23,
|
||||
podiums: 56,
|
||||
isActive: true,
|
||||
rank: 5,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Sarah Chen',
|
||||
rating: 2456,
|
||||
skillLevel: 'advanced' as const,
|
||||
nationality: 'Singapore',
|
||||
racesCompleted: 89,
|
||||
wins: 12,
|
||||
podiums: 34,
|
||||
isActive: true,
|
||||
rank: 8,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Isabella Rossi',
|
||||
rating: 2145,
|
||||
skillLevel: 'intermediate' as const,
|
||||
nationality: 'Italy',
|
||||
racesCompleted: 67,
|
||||
wins: 8,
|
||||
podiums: 23,
|
||||
isActive: true,
|
||||
rank: 12,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: 'Carlos Rodriguez',
|
||||
rating: 1876,
|
||||
skillLevel: 'intermediate' as const,
|
||||
nationality: 'Spain',
|
||||
racesCompleted: 45,
|
||||
wins: 3,
|
||||
podiums: 12,
|
||||
isActive: false,
|
||||
rank: 18,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: 'Yuki Tanaka',
|
||||
rating: 1234,
|
||||
skillLevel: 'beginner' as const,
|
||||
nationality: 'Japan',
|
||||
racesCompleted: 12,
|
||||
wins: 0,
|
||||
podiums: 2,
|
||||
isActive: true,
|
||||
rank: 45,
|
||||
},
|
||||
];
|
||||
|
||||
export default function DriversPage() {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedSkill, setSelectedSkill] = useState('all');
|
||||
const [selectedNationality, setSelectedNationality] = useState('all');
|
||||
const [activeOnly, setActiveOnly] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<'rank' | 'rating' | 'wins' | 'podiums'>('rank');
|
||||
|
||||
const nationalities = Array.from(
|
||||
new Set(MOCK_DRIVERS.map((d) => d.nationality).filter(Boolean))
|
||||
).sort();
|
||||
|
||||
const filteredDrivers = MOCK_DRIVERS.filter((driver) => {
|
||||
const matchesSearch = driver.name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase());
|
||||
const matchesSkill =
|
||||
selectedSkill === 'all' || driver.skillLevel === selectedSkill;
|
||||
const matchesNationality =
|
||||
selectedNationality === 'all' || driver.nationality === selectedNationality;
|
||||
const matchesActive = !activeOnly || driver.isActive;
|
||||
|
||||
return matchesSearch && matchesSkill && matchesNationality && matchesActive;
|
||||
});
|
||||
|
||||
const sortedDrivers = [...filteredDrivers].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'rank':
|
||||
return a.rank - b.rank;
|
||||
case 'rating':
|
||||
return b.rating - a.rating;
|
||||
case 'wins':
|
||||
return b.wins - a.wins;
|
||||
case 'podiums':
|
||||
return b.podiums - a.podiums;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const handleDriverClick = (driverId: string) => {
|
||||
router.push(`/drivers/${driverId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Drivers</h1>
|
||||
<p className="text-gray-400">
|
||||
Browse driver profiles and stats
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Search Drivers
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Skill Level
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
|
||||
value={selectedSkill}
|
||||
onChange={(e) => setSelectedSkill(e.target.value)}
|
||||
>
|
||||
<option value="all">All Levels</option>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
<option value="pro">Pro</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Nationality
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
|
||||
value={selectedNationality}
|
||||
onChange={(e) => setSelectedNationality(e.target.value)}
|
||||
>
|
||||
<option value="all">All Countries</option>
|
||||
{nationalities.map((nat) => (
|
||||
<option key={nat} value={nat}>
|
||||
{nat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Status
|
||||
</label>
|
||||
<label className="flex items-center pt-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-primary-blue bg-iron-gray border-charcoal-outline rounded focus:ring-primary-blue focus:ring-2"
|
||||
checked={activeOnly}
|
||||
onChange={(e) => setActiveOnly(e.target.checked)}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-400">Active only</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Sort By
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
>
|
||||
<option value="rank">Overall Rank</option>
|
||||
<option value="rating">Rating</option>
|
||||
<option value="wins">Wins</option>
|
||||
<option value="podiums">Podiums</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-400">
|
||||
{sortedDrivers.length} {sortedDrivers.length === 1 ? 'driver' : 'drivers'} found
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{sortedDrivers.map((driver, index) => (
|
||||
<Card
|
||||
key={driver.id}
|
||||
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
|
||||
onClick={() => handleDriverClick(driver.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<RankBadge rank={driver.rank} size="lg" />
|
||||
|
||||
<div className="w-16 h-16 rounded-full bg-primary-blue/20 flex items-center justify-center text-2xl font-bold text-white">
|
||||
{driver.name.charAt(0)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-white mb-1">{driver.name}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
{driver.nationality} • {driver.racesCompleted} races
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-primary-blue">{driver.rating}</div>
|
||||
<div className="text-xs text-gray-400">Rating</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-400">{driver.wins}</div>
|
||||
<div className="text-xs text-gray-400">Wins</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-warning-amber">{driver.podiums}</div>
|
||||
<div className="text-xs text-gray-400">Podiums</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{((driver.wins / driver.racesCompleted) * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Win Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sortedDrivers.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-400">No drivers found matching your filters.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,20 @@ import { useRouter, useParams } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
|
||||
import JoinLeagueButton from '@/components/alpha/JoinLeagueButton';
|
||||
import MembershipStatus from '@/components/alpha/MembershipStatus';
|
||||
import LeagueMembers from '@/components/alpha/LeagueMembers';
|
||||
import LeagueSchedule from '@/components/alpha/LeagueSchedule';
|
||||
import LeagueAdmin from '@/components/alpha/LeagueAdmin';
|
||||
import StandingsTable from '@/components/alpha/StandingsTable';
|
||||
import DataWarning from '@/components/alpha/DataWarning';
|
||||
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import { Standing } from '@gridpilot/racing-domain/entities/Standing';
|
||||
import { Race } from '@gridpilot/racing-domain/entities/Race';
|
||||
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
|
||||
import { getLeagueRepository, getRaceRepository, getDriverRepository } from '@/lib/di-container';
|
||||
import { getLeagueRepository, getRaceRepository, getDriverRepository, getStandingRepository } from '@/lib/di-container';
|
||||
import { getMembership, isOwnerOrAdmin, getCurrentDriverId } from '@/lib/membership-data';
|
||||
|
||||
export default function LeagueDetailPage() {
|
||||
const router = useRouter();
|
||||
@@ -17,9 +27,16 @@ export default function LeagueDetailPage() {
|
||||
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [owner, setOwner] = useState<Driver | null>(null);
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [standings, setStandings] = useState<Standing[]>([]);
|
||||
const [drivers, setDrivers] = useState<Driver[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'standings' | 'members' | 'admin'>('overview');
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const currentDriverId = getCurrentDriverId();
|
||||
const membership = getMembership(leagueId, currentDriverId);
|
||||
const isAdmin = isOwnerOrAdmin(leagueId, currentDriverId);
|
||||
|
||||
const loadLeagueData = async () => {
|
||||
try {
|
||||
@@ -41,13 +58,15 @@ export default function LeagueDetailPage() {
|
||||
const ownerData = await driverRepo.findById(leagueData.ownerId);
|
||||
setOwner(ownerData);
|
||||
|
||||
// Load races for this league
|
||||
const allRaces = await raceRepo.findAll();
|
||||
const leagueRaces = allRaces
|
||||
.filter(race => race.leagueId === leagueId)
|
||||
.sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime());
|
||||
|
||||
setRaces(leagueRaces);
|
||||
// Load standings
|
||||
const standingRepo = getStandingRepository();
|
||||
const allStandings = await standingRepo.findAll();
|
||||
const leagueStandings = allStandings.filter(s => s.leagueId === leagueId);
|
||||
setStandings(leagueStandings);
|
||||
|
||||
// Load all drivers for standings
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
setDrivers(allDrivers);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load league');
|
||||
} finally {
|
||||
@@ -90,28 +109,30 @@ export default function LeagueDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const upcomingRaces = races.filter(race => race.status === 'scheduled');
|
||||
const handleMembershipChange = () => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
loadLeagueData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => router.push('/leagues')}
|
||||
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Leagues
|
||||
</button>
|
||||
</div>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Leagues', href: '/leagues' },
|
||||
{ label: league.name }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* League Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-white">{league.name}</h1>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">{league.name}</h1>
|
||||
<MembershipStatus leagueId={leagueId} />
|
||||
</div>
|
||||
<FeatureLimitationTooltip message="Multi-league memberships coming in production">
|
||||
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
|
||||
Alpha: Single League
|
||||
@@ -121,115 +142,195 @@ export default function LeagueDetailPage() {
|
||||
<p className="text-gray-400">{league.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
{/* League Info */}
|
||||
<Card className="lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">League Information</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<DataWarning />
|
||||
|
||||
{/* Action Card */}
|
||||
{!membership && (
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Owner</label>
|
||||
<p className="text-white">{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}</p>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Join This League</h3>
|
||||
<p className="text-gray-400 text-sm">Become a member to participate in races and track your progress</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Created</label>
|
||||
<p className="text-white">
|
||||
{new Date(league.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<h3 className="text-white font-medium mb-3">League Settings</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Points System</label>
|
||||
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Session Duration</label>
|
||||
<p className="text-white">{league.settings.sessionDuration} minutes</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Qualifying Format</label>
|
||||
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/races?leagueId=${leagueId}`)}
|
||||
{/* Tabs Navigation */}
|
||||
<div className="mb-6 border-b border-charcoal-outline">
|
||||
<div className="flex gap-4 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === 'overview'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === 'schedule'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('standings')}
|
||||
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === 'standings'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Standings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('members')}
|
||||
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === 'members'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Members
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setActiveTab('admin')}
|
||||
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
|
||||
activeTab === 'admin'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Schedule Race
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/leagues/${leagueId}/standings`)}
|
||||
>
|
||||
View Standings
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
Admin
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Races */}
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Upcoming Races</h2>
|
||||
|
||||
{upcomingRaces.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p className="mb-2">No upcoming races scheduled</p>
|
||||
<p className="text-sm text-gray-500">Click “Schedule Race” to create your first race</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingRaces.map((race) => (
|
||||
<div
|
||||
key={race.id}
|
||||
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue transition-all duration-200 cursor-pointer hover:scale-[1.02]"
|
||||
onClick={() => router.push(`/races/${race.id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* League Info */}
|
||||
<Card className="lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">League Information</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Owner</label>
|
||||
<p className="text-white">{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Created</label>
|
||||
<p className="text-white">
|
||||
{new Date(league.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<h3 className="text-white font-medium mb-3">League Settings</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{race.track}</h3>
|
||||
<p className="text-sm text-gray-400">{race.car}</p>
|
||||
<p className="text-xs text-gray-500 mt-1 uppercase">{race.sessionType}</p>
|
||||
<label className="text-sm text-gray-500">Points System</label>
|
||||
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white text-sm">
|
||||
{new Date(race.scheduledAt).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(race.scheduledAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Session Duration</label>
|
||||
<p className="text-white">{league.settings.sessionDuration} minutes</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Qualifying Format</label>
|
||||
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{membership ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
>
|
||||
View Schedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => setActiveTab('standings')}
|
||||
>
|
||||
View Standings
|
||||
</Button>
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'schedule' && (
|
||||
<Card>
|
||||
<LeagueSchedule leagueId={leagueId} key={refreshKey} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'standings' && (
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
|
||||
<StandingsTable standings={standings} drivers={drivers} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'members' && (
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">League Members</h2>
|
||||
<LeagueMembers leagueId={leagueId} key={refreshKey} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'admin' && isAdmin && (
|
||||
<LeagueAdmin
|
||||
league={league}
|
||||
onLeagueUpdate={handleMembershipChange}
|
||||
key={refreshKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import LeagueCard from '@/components/alpha/LeagueCard';
|
||||
import CreateLeagueForm from '@/components/alpha/CreateLeagueForm';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import { getLeagueRepository } from '@/lib/di-container';
|
||||
|
||||
@@ -14,6 +15,8 @@ export default function LeaguesPage() {
|
||||
const [leagues, setLeagues] = useState<League[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
|
||||
useEffect(() => {
|
||||
loadLeagues();
|
||||
@@ -35,6 +38,24 @@ export default function LeaguesPage() {
|
||||
router.push(`/leagues/${leagueId}`);
|
||||
};
|
||||
|
||||
const filteredLeagues = leagues
|
||||
.filter((league) => {
|
||||
const matchesSearch =
|
||||
league.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
league.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesSearch;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'recent':
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
@@ -49,8 +70,8 @@ export default function LeaguesPage() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Leagues</h1>
|
||||
<p className="text-gray-400">
|
||||
{leagues.length === 0
|
||||
? 'Create your first league to get started'
|
||||
{leagues.length === 0
|
||||
? 'Create your first league to get started'
|
||||
: `${leagues.length} ${leagues.length === 1 ? 'league' : 'leagues'} available`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -75,6 +96,38 @@ export default function LeaguesPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{leagues.length > 0 && (
|
||||
<Card className="mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Search Leagues
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by name or description..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Sort By
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="recent">Most Recent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{leagues.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-gray-400">
|
||||
@@ -105,16 +158,28 @@ export default function LeaguesPage() {
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{leagues.map((league) => (
|
||||
<LeagueCard
|
||||
key={league.id}
|
||||
league={league}
|
||||
onClick={() => handleLeagueClick(league.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
{filteredLeagues.length} {filteredLeagues.length === 1 ? 'league' : 'leagues'} found
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredLeagues.map((league) => (
|
||||
<LeagueCard
|
||||
key={league.id}
|
||||
league={league}
|
||||
onClick={() => handleLeagueClick(league.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{filteredLeagues.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-400">No leagues found matching your search.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
import { getAppMode } from '@/lib/mode';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import CompanionStatus from '@/components/alpha/CompanionStatus';
|
||||
import DataWarning from '@/components/alpha/DataWarning';
|
||||
import RaceCard from '@/components/alpha/RaceCard';
|
||||
import LeagueCard from '@/components/alpha/LeagueCard';
|
||||
import TeamCard from '@/components/alpha/TeamCard';
|
||||
import Hero from '@/components/landing/Hero';
|
||||
import AlternatingSection from '@/components/landing/AlternatingSection';
|
||||
import FeatureGrid from '@/components/landing/FeatureGrid';
|
||||
@@ -16,12 +21,164 @@ import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup';
|
||||
import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup';
|
||||
import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
|
||||
import MockupStack from '@/components/ui/MockupStack';
|
||||
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
||||
import { getAllTeams, getTeamMembers } from '@/lib/team-data';
|
||||
import { getLeagueMembers } from '@/lib/membership-data';
|
||||
import type { Race } from '@gridpilot/racing-domain/entities/Race';
|
||||
import type { League } from '@gridpilot/racing-domain/entities/League';
|
||||
|
||||
function AlphaDashboard() {
|
||||
const router = useRouter();
|
||||
const [upcomingRaces, setUpcomingRaces] = useState<Race[]>([]);
|
||||
const [topLeagues, setTopLeagues] = useState<League[]>([]);
|
||||
const [featuredTeams, setFeaturedTeams] = useState<any[]>([]);
|
||||
const [recentActivity, setRecentActivity] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const raceRepo = getRaceRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
|
||||
// Get upcoming races
|
||||
raceRepo.findAll().then(races => {
|
||||
const upcoming = races
|
||||
.filter(r => r.status === 'scheduled')
|
||||
.sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime())
|
||||
.slice(0, 5);
|
||||
setUpcomingRaces(upcoming);
|
||||
});
|
||||
|
||||
// Get top leagues
|
||||
leagueRepo.findAll().then(leagues => {
|
||||
const sorted = leagues
|
||||
.map(league => ({
|
||||
league,
|
||||
memberCount: getLeagueMembers(league.id).length,
|
||||
}))
|
||||
.sort((a, b) => b.memberCount - a.memberCount)
|
||||
.slice(0, 4)
|
||||
.map(item => item.league);
|
||||
setTopLeagues(sorted);
|
||||
});
|
||||
|
||||
// Get featured teams
|
||||
const teams = getAllTeams();
|
||||
const featured = teams
|
||||
.map(team => ({
|
||||
...team,
|
||||
memberCount: getTeamMembers(team.id).length,
|
||||
}))
|
||||
.sort((a, b) => b.memberCount - a.memberCount)
|
||||
.slice(0, 4);
|
||||
setFeaturedTeams(featured);
|
||||
|
||||
// Generate recent activity
|
||||
const activities = [
|
||||
{ type: 'race', text: 'Max Verstappen won at Monza GP', time: '2 hours ago' },
|
||||
{ type: 'join', text: 'Lando Norris joined European GT Championship', time: '5 hours ago' },
|
||||
{ type: 'team', text: 'Charles Leclerc joined Weekend Warriors', time: '1 day ago' },
|
||||
{ type: 'race', text: 'Upcoming: Spa-Francorchamps in 2 days', time: '2 days ago' },
|
||||
{ type: 'league', text: 'European GT Championship: 4 active members', time: '3 days ago' },
|
||||
];
|
||||
setRecentActivity(activities);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<DataWarning />
|
||||
|
||||
{/* Upcoming Races Section */}
|
||||
{upcomingRaces.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-3xl font-bold text-white">Upcoming Races</h2>
|
||||
<Button variant="secondary" onClick={() => router.push('/races')}>
|
||||
View All Races
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{upcomingRaces.slice(0, 3).map(race => (
|
||||
<RaceCard
|
||||
key={race.id}
|
||||
race={race}
|
||||
onClick={() => router.push(`/races/${race.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Leagues Section */}
|
||||
{topLeagues.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-3xl font-bold text-white">Top Leagues</h2>
|
||||
<Button variant="secondary" onClick={() => router.push('/leagues')}>
|
||||
Browse Leagues
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{topLeagues.map(league => (
|
||||
<LeagueCard
|
||||
key={league.id}
|
||||
league={league}
|
||||
onClick={() => router.push(`/leagues/${league.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Featured Teams Section */}
|
||||
{featuredTeams.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-3xl font-bold text-white">Featured Teams</h2>
|
||||
<Button variant="secondary" onClick={() => router.push('/teams')}>
|
||||
Browse Teams
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{featuredTeams.map(team => (
|
||||
<TeamCard
|
||||
key={team.id}
|
||||
id={team.id}
|
||||
name={team.name}
|
||||
logo={undefined}
|
||||
memberCount={team.memberCount}
|
||||
leagues={team.leagues}
|
||||
onClick={() => router.push(`/teams/${team.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity Section */}
|
||||
{recentActivity.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-white mb-6">Recent Activity</h2>
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
{recentActivity.map((activity, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-start gap-3 pb-4 ${
|
||||
idx < recentActivity.length - 1 ? 'border-b border-charcoal-outline' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-primary-blue mt-2 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-300">{activity.text}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{activity.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Welcome Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-4">GridPilot Alpha</h1>
|
||||
@@ -274,6 +431,7 @@ function AlphaDashboard() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getDriverRepository } from '@/lib/di-container';
|
||||
import { EntityMappers } from '@/application/mappers/EntityMappers';
|
||||
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
|
||||
import { EntityMappers, DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
|
||||
import CreateDriverForm from '@/components/alpha/CreateDriverForm';
|
||||
import DriverProfile from '@/components/alpha/DriverProfile';
|
||||
import Card from '@/components/ui/Card';
|
||||
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
|
||||
import Button from '@/components/ui/Button';
|
||||
import DataWarning from '@/components/alpha/DataWarning';
|
||||
import ProfileHeader from '@/components/alpha/ProfileHeader';
|
||||
import ProfileStats from '@/components/alpha/ProfileStats';
|
||||
import ProfileRaceHistory from '@/components/alpha/ProfileRaceHistory';
|
||||
import ProfileSettings from '@/components/alpha/ProfileSettings';
|
||||
import CareerHighlights from '@/components/alpha/CareerHighlights';
|
||||
import RatingBreakdown from '@/components/alpha/RatingBreakdown';
|
||||
import { getDriverTeam, getCurrentDriverId } from '@/lib/team-data';
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const driverRepo = getDriverRepository();
|
||||
const drivers = await driverRepo.findAll();
|
||||
const driver = EntityMappers.toDriverDTO(drivers[0] || null);
|
||||
type Tab = 'overview' | 'statistics' | 'history' | 'settings';
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Driver Profile</h1>
|
||||
<p className="text-gray-400">
|
||||
{driver ? 'Your GridPilot profile' : 'Create your GridPilot profile to get started'}
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const [driver, setDriver] = useState<DriverDTO | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadDriver = async () => {
|
||||
const driverRepo = getDriverRepository();
|
||||
const drivers = await driverRepo.findAll();
|
||||
const driverData = EntityMappers.toDriverDTO(drivers[0] || null);
|
||||
setDriver(driverData);
|
||||
setLoading(false);
|
||||
};
|
||||
loadDriver();
|
||||
}, []);
|
||||
|
||||
const handleSaveSettings = async (updates: Partial<DriverDTO>) => {
|
||||
if (!driver) return;
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const drivers = await driverRepo.findAll();
|
||||
const currentDriver = drivers[0];
|
||||
|
||||
if (currentDriver) {
|
||||
const updatedDriver: Driver = currentDriver.update({
|
||||
bio: updates.bio ?? currentDriver.bio,
|
||||
country: updates.country ?? currentDriver.country,
|
||||
});
|
||||
const persistedDriver = await driverRepo.update(updatedDriver);
|
||||
|
||||
const updatedDto = EntityMappers.toDriverDTO(persistedDriver);
|
||||
setDriver(updatedDto);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading profile...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!driver) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Driver Profile</h1>
|
||||
<p className="text-gray-400">
|
||||
Create your GridPilot profile to get started
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Create Your Profile</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Create your driver profile. Alpha data resets on reload, so test freely.
|
||||
</p>
|
||||
</div>
|
||||
<CreateDriverForm />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{driver ? (
|
||||
<>
|
||||
<FeatureLimitationTooltip message="Profile editing coming in production">
|
||||
<div className="opacity-75 pointer-events-none">
|
||||
<DriverProfile driver={driver} />
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'statistics', label: 'Statistics' },
|
||||
{ id: 'history', label: 'Race History' },
|
||||
{ id: 'settings', label: 'Settings' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<DataWarning className="mb-6" />
|
||||
|
||||
<Card className="mb-6">
|
||||
<ProfileHeader
|
||||
driver={driver}
|
||||
isOwnProfile
|
||||
onEditClick={() => setActiveTab('settings')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 border-b border-charcoal-outline">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
px-4 py-3 font-medium transition-all relative
|
||||
${activeTab === tab.id
|
||||
? 'text-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
|
||||
{driver.bio ? (
|
||||
<p className="text-gray-300 leading-relaxed">{driver.bio}</p>
|
||||
) : (
|
||||
<p className="text-gray-500 italic">No bio yet. Add one in settings!</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Quick Stats</h3>
|
||||
<div className="space-y-3">
|
||||
<StatItem label="Rating" value="1450" color="text-primary-blue" />
|
||||
<StatItem label="Safety" value="92%" color="text-green-400" />
|
||||
<StatItem label="Sportsmanship" value="4.8/5" color="text-warning-amber" />
|
||||
<StatItem label="Total Races" value="147" color="text-white" />
|
||||
</div>
|
||||
</FeatureLimitationTooltip>
|
||||
</>
|
||||
) : (
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Create Your Profile</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Create your driver profile. Alpha data resets on reload, so test freely.
|
||||
</p>
|
||||
</div>
|
||||
<CreateDriverForm />
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Preferences</h3>
|
||||
<div className="space-y-3">
|
||||
<PreferenceItem icon="🏎️" label="Favorite Car" value="Porsche 911 GT3 R" />
|
||||
<PreferenceItem icon="🏁" label="Favorite Series" value="Endurance" />
|
||||
<PreferenceItem icon="⚔️" label="Competitive Level" value="Competitive" />
|
||||
<PreferenceItem icon="🌍" label="Regions" value="EU, NA" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Team</h3>
|
||||
{(() => {
|
||||
const currentDriverId = getCurrentDriverId();
|
||||
const teamData = getDriverTeam(currentDriverId);
|
||||
|
||||
if (teamData) {
|
||||
const { team, membership } = teamData;
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-4 p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-charcoal-outline/60 transition-colors cursor-pointer"
|
||||
onClick={() => router.push(`/teams/${team.id}`)}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary-blue/20 flex items-center justify-center text-xl font-bold text-white">
|
||||
{team.tag}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-white font-medium">{team.name}</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{membership.role.charAt(0).toUpperCase() + membership.role.slice(1)} • Joined {new Date(membership.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-400 mb-4">You're not on a team yet</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => router.push('/teams')}
|
||||
>
|
||||
Browse Teams
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<CareerHighlights />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'statistics' && (
|
||||
<div className="space-y-6">
|
||||
<ProfileStats driverId={driver.id} />
|
||||
<RatingBreakdown />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<ProfileRaceHistory />
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<ProfileSettings driver={driver} onSave={handleSaveSettings} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ label, value, color }: { label: string; value: string; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400 text-sm">{label}</span>
|
||||
<span className={`font-semibold ${color}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreferenceItem({ icon, label, value }: { icon: string; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{icon}</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
<div className="text-white text-sm">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,19 @@ import Card from '@/components/ui/Card';
|
||||
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
|
||||
import { Race } from '@gridpilot/racing-domain/entities/Race';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
||||
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
|
||||
import { getRaceRepository, getLeagueRepository, getDriverRepository } from '@/lib/di-container';
|
||||
import { getMembership, getCurrentDriverId } from '@/lib/membership-data';
|
||||
import {
|
||||
isRegistered,
|
||||
registerForRace,
|
||||
withdrawFromRace,
|
||||
getRegisteredDrivers
|
||||
} from '@/lib/registration-data';
|
||||
import CompanionStatus from '@/components/alpha/CompanionStatus';
|
||||
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
|
||||
import DataWarning from '@/components/alpha/DataWarning';
|
||||
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
|
||||
|
||||
export default function RaceDetailPage() {
|
||||
const router = useRouter();
|
||||
@@ -21,6 +31,12 @@ export default function RaceDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const [entryList, setEntryList] = useState<Driver[]>([]);
|
||||
const [isUserRegistered, setIsUserRegistered] = useState(false);
|
||||
const [canRegister, setCanRegister] = useState(false);
|
||||
|
||||
const currentDriverId = getCurrentDriverId();
|
||||
|
||||
const loadRaceData = async () => {
|
||||
try {
|
||||
@@ -40,6 +56,9 @@ export default function RaceDetailPage() {
|
||||
// Load league data
|
||||
const leagueData = await leagueRepo.findById(raceData.leagueId);
|
||||
setLeague(leagueData);
|
||||
|
||||
// Load entry list
|
||||
await loadEntryList(raceData.id, raceData.leagueId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load race');
|
||||
} finally {
|
||||
@@ -47,6 +66,28 @@ export default function RaceDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadEntryList = async (raceId: string, leagueId: string) => {
|
||||
try {
|
||||
const driverRepo = getDriverRepository();
|
||||
const registeredDriverIds = getRegisteredDrivers(raceId);
|
||||
const drivers = await Promise.all(
|
||||
registeredDriverIds.map(id => driverRepo.findById(id))
|
||||
);
|
||||
setEntryList(drivers.filter((d): d is Driver => d !== null));
|
||||
|
||||
// Check user registration status
|
||||
const userIsRegistered = isRegistered(raceId, currentDriverId);
|
||||
setIsUserRegistered(userIsRegistered);
|
||||
|
||||
// Check if user can register (is league member and race is upcoming)
|
||||
const membership = getMembership(leagueId, currentDriverId);
|
||||
const isUpcoming = race?.status === 'scheduled';
|
||||
setCanRegister(!!membership && membership.status === 'active' && !!isUpcoming);
|
||||
} catch (err) {
|
||||
console.error('Failed to load entry list:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRaceData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -74,6 +115,46 @@ export default function RaceDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!race || !league) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Register for ${race.track}?\n\nYou'll be added to the entry list for this race.`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setRegistering(true);
|
||||
try {
|
||||
registerForRace(race.id, currentDriverId, league.id);
|
||||
await loadEntryList(race.id, league.id);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to register for race');
|
||||
} finally {
|
||||
setRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async () => {
|
||||
if (!race || !league) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Withdraw from this race?\n\nYou can register again later if you change your mind.'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setRegistering(true);
|
||||
try {
|
||||
withdrawFromRace(race.id, currentDriverId);
|
||||
await loadEntryList(race.id, league.id);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');
|
||||
} finally {
|
||||
setRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
@@ -240,6 +321,34 @@ export default function RaceDetailPage() {
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Registration Actions */}
|
||||
{race.status === 'scheduled' && canRegister && !isUserRegistered && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleRegister}
|
||||
disabled={registering}
|
||||
>
|
||||
{registering ? 'Registering...' : 'Register for Race'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{race.status === 'scheduled' && isUserRegistered && (
|
||||
<div className="space-y-2">
|
||||
<div className="px-3 py-2 bg-green-500/10 border border-green-500/30 rounded text-green-400 text-sm text-center">
|
||||
✓ Registered
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={handleWithdraw}
|
||||
disabled={registering}
|
||||
>
|
||||
{registering ? 'Withdrawing...' : 'Withdraw'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{race.status === 'completed' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -271,6 +380,55 @@ export default function RaceDetailPage() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Entry List */}
|
||||
{race.status === 'scheduled' && (
|
||||
<Card className="mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">Entry List</h2>
|
||||
<span className="text-sm text-gray-400">
|
||||
{entryList.length} {entryList.length === 1 ? 'driver' : 'drivers'} registered
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DataWarning className="mb-4" />
|
||||
|
||||
{entryList.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p className="mb-2">No drivers registered yet</p>
|
||||
<p className="text-sm text-gray-500">Be the first to register!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{entryList.map((driver, index) => (
|
||||
<div
|
||||
key={driver.id}
|
||||
className="flex items-center gap-4 p-3 bg-iron-gray/50 rounded-lg border border-charcoal-outline hover:border-primary-blue/50 transition-colors cursor-pointer"
|
||||
onClick={() => router.push(`/drivers/${driver.id}`)}
|
||||
>
|
||||
<div className="w-8 text-center text-gray-400 font-mono text-sm">
|
||||
#{index + 1}
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-charcoal-outline rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-lg font-bold text-gray-500">
|
||||
{driver.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium">{driver.name}</p>
|
||||
<p className="text-sm text-gray-400">{driver.country}</p>
|
||||
</div>
|
||||
{driver.id === currentDriverId && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/20 text-primary-blue rounded">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import RaceCard from '@/components/alpha/RaceCard';
|
||||
@@ -12,12 +12,12 @@ import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
||||
|
||||
export default function RacesPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [leagues, setLeagues] = useState<Map<string, League>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showScheduleForm, setShowScheduleForm] = useState(false);
|
||||
const [preselectedLeagueId, setPreselectedLeagueId] = useState<string | undefined>(undefined);
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState<RaceStatus | 'all'>('all');
|
||||
@@ -48,6 +48,14 @@ export default function RacesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadRaces();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const leagueId = params.get('leagueId') || undefined;
|
||||
setPreselectedLeagueId(leagueId || undefined);
|
||||
} catch {
|
||||
setPreselectedLeagueId(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredRaces = races.filter(race => {
|
||||
@@ -101,7 +109,7 @@ export default function RacesPage() {
|
||||
<Card>
|
||||
<h1 className="text-2xl font-bold text-white mb-6">Schedule New Race</h1>
|
||||
<ScheduleRaceForm
|
||||
preSelectedLeagueId={searchParams.get('leagueId') || undefined}
|
||||
preSelectedLeagueId={preselectedLeagueId}
|
||||
onSuccess={(race) => {
|
||||
router.push(`/races/${race.id}`);
|
||||
}}
|
||||
|
||||
170
apps/website/app/social/page.tsx
Normal file
170
apps/website/app/social/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
// Mock data for highlights
|
||||
const MOCK_HIGHLIGHTS = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'race',
|
||||
title: 'Epic finish in GT3 Championship',
|
||||
description: 'Max Verstappen wins by 0.003 seconds',
|
||||
time: '2 hours ago',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'league',
|
||||
title: 'New league created: Endurance Masters',
|
||||
description: '12 teams already registered',
|
||||
time: '5 hours ago',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'achievement',
|
||||
title: 'Sarah Chen unlocked "Century Club"',
|
||||
description: '100 races completed',
|
||||
time: '1 day ago',
|
||||
},
|
||||
];
|
||||
|
||||
const TRENDING_DRIVERS = [
|
||||
{ id: '1', name: 'Max Verstappen', metric: '+156 rating this week' },
|
||||
{ id: '2', name: 'Emma Thompson', metric: '5 wins in a row' },
|
||||
{ id: '3', name: 'Lewis Hamilton', metric: 'Most laps led' },
|
||||
];
|
||||
|
||||
const TRENDING_TEAMS = [
|
||||
{ id: '1', name: 'Apex Racing', metric: '12 new members' },
|
||||
{ id: '2', name: 'Speed Demons', metric: '3 championship wins' },
|
||||
{ id: '3', name: 'Endurance Elite', metric: '24h race victory' },
|
||||
];
|
||||
|
||||
export default function SocialPage() {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Social Hub</h1>
|
||||
<p className="text-gray-400">
|
||||
Stay updated with the racing community
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Activity Feed */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
Activity Feed
|
||||
</h2>
|
||||
<div className="bg-primary-blue/10 border border-primary-blue/20 rounded-lg p-8 text-center">
|
||||
<div className="text-4xl mb-4">🚧</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
Coming Soon
|
||||
</h3>
|
||||
<p className="text-gray-400">
|
||||
The activity feed will show real-time updates from your
|
||||
friends, leagues, and teams. This feature is currently in
|
||||
development for the alpha release.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-4">
|
||||
Recent Highlights
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{MOCK_HIGHLIGHTS.map((highlight) => (
|
||||
<div
|
||||
key={highlight.id}
|
||||
className="border-l-4 border-primary-blue pl-4 py-2"
|
||||
>
|
||||
<h4 className="font-semibold text-white">
|
||||
{highlight.title}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-400">
|
||||
{highlight.description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{highlight.time}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Trending Drivers */}
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
🔥 Trending Drivers
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{TRENDING_DRIVERS.map((driver, index) => (
|
||||
<div key={driver.id} className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-charcoal-outline rounded-full flex items-center justify-center text-sm font-bold text-gray-400">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-white">
|
||||
{driver.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{driver.metric}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Trending Teams */}
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
⭐ Trending Teams
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{TRENDING_TEAMS.map((team, index) => (
|
||||
<div key={team.id} className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-charcoal-outline rounded-lg flex items-center justify-center text-sm font-bold text-gray-400">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-white">
|
||||
{team.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{team.metric}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Friend Activity Placeholder */}
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
Friends
|
||||
</h2>
|
||||
<div className="bg-charcoal-outline rounded-lg p-4 text-center">
|
||||
<p className="text-sm text-gray-400">
|
||||
Friend features coming soon in alpha
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
apps/website/app/teams/[id]/page.tsx
Normal file
251
apps/website/app/teams/[id]/page.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import DataWarning from '@/components/alpha/DataWarning';
|
||||
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
|
||||
import TeamRoster from '@/components/alpha/TeamRoster';
|
||||
import TeamStandings from '@/components/alpha/TeamStandings';
|
||||
import TeamAdmin from '@/components/alpha/TeamAdmin';
|
||||
import JoinTeamButton from '@/components/alpha/JoinTeamButton';
|
||||
import {
|
||||
Team,
|
||||
getTeam,
|
||||
getTeamMembers,
|
||||
getCurrentDriverId,
|
||||
isTeamOwnerOrManager,
|
||||
TeamMembership,
|
||||
removeTeamMember,
|
||||
updateTeamMemberRole,
|
||||
TeamRole,
|
||||
} from '@/lib/team-data';
|
||||
|
||||
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||
|
||||
export default function TeamDetailPage() {
|
||||
const params = useParams();
|
||||
const teamId = params.id as string;
|
||||
|
||||
const [team, setTeam] = useState<Team | null>(null);
|
||||
const [memberships, setMemberships] = useState<TeamMembership[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const loadTeamData = () => {
|
||||
const teamData = getTeam(teamId);
|
||||
if (!teamData) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const teamMemberships = getTeamMembers(teamId);
|
||||
const currentDriverId = getCurrentDriverId();
|
||||
const adminStatus = isTeamOwnerOrManager(teamId, currentDriverId);
|
||||
|
||||
setTeam(teamData);
|
||||
setMemberships(teamMemberships);
|
||||
setIsAdmin(adminStatus);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTeamData();
|
||||
}, [teamId]);
|
||||
|
||||
const handleUpdate = () => {
|
||||
loadTeamData();
|
||||
};
|
||||
|
||||
const handleRemoveMember = (driverId: string) => {
|
||||
if (!confirm('Are you sure you want to remove this member?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentDriverId = getCurrentDriverId();
|
||||
removeTeamMember(teamId, driverId, currentDriverId);
|
||||
handleUpdate();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to remove member');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeRole = (driverId: string, newRole: TeamRole) => {
|
||||
try {
|
||||
const currentDriverId = getCurrentDriverId();
|
||||
updateTeamMemberRole(teamId, driverId, newRole, currentDriverId);
|
||||
handleUpdate();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to change role');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading team...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Team Not Found</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
The team you're looking for doesn't exist or has been disbanded.
|
||||
</p>
|
||||
<Button variant="primary" onClick={() => window.history.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: string; visible: boolean }[] = [
|
||||
{ id: 'overview', label: 'Overview', visible: true },
|
||||
{ id: 'roster', label: 'Roster', visible: true },
|
||||
{ id: 'standings', label: 'Standings', visible: true },
|
||||
{ id: 'admin', label: 'Admin', visible: isAdmin },
|
||||
];
|
||||
|
||||
const visibleTabs = tabs.filter(tab => tab.visible);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Teams', href: '/teams' },
|
||||
{ label: team.name }
|
||||
]}
|
||||
/>
|
||||
|
||||
<DataWarning className="mb-6" />
|
||||
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-4xl font-bold text-gray-500">
|
||||
{team.tag}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-white">{team.name}</h1>
|
||||
<span className="px-3 py-1 bg-charcoal-outline text-gray-300 rounded-full text-sm font-medium">
|
||||
{team.tag}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4 max-w-2xl">{team.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<span>{memberships.length} {memberships.length === 1 ? 'member' : 'members'}</span>
|
||||
<span>•</span>
|
||||
<span>Created {new Date(team.createdAt).toLocaleDateString()}</span>
|
||||
{team.leagues.length > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{team.leagues.length} {team.leagues.length === 1 ? 'league' : 'leagues'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JoinTeamButton teamId={teamId} onUpdate={handleUpdate} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 border-b border-charcoal-outline">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
px-4 py-3 font-medium transition-all relative
|
||||
${activeTab === tab.id
|
||||
? 'text-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2">
|
||||
<h3 className="text-xl font-semibold text-white mb-4">About</h3>
|
||||
<p className="text-gray-300 leading-relaxed">{team.description}</p>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3>
|
||||
<div className="space-y-3">
|
||||
<StatItem label="Members" value={memberships.length.toString()} color="text-primary-blue" />
|
||||
<StatItem label="Leagues" value={team.leagues.length.toString()} color="text-green-400" />
|
||||
<StatItem label="Founded" value={new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} color="text-gray-300" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-white mb-4">Recent Activity</h3>
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
No recent activity to display
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'roster' && (
|
||||
<TeamRoster
|
||||
teamId={teamId}
|
||||
memberships={memberships}
|
||||
isAdmin={isAdmin}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onChangeRole={handleChangeRole}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'standings' && (
|
||||
<TeamStandings teamId={teamId} leagues={team.leagues} />
|
||||
)}
|
||||
|
||||
{activeTab === 'admin' && isAdmin && (
|
||||
<TeamAdmin team={team} onUpdate={handleUpdate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ label, value, color }: { label: string; value: string; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400 text-sm">{label}</span>
|
||||
<span className={`font-semibold ${color}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
apps/website/app/teams/page.tsx
Normal file
185
apps/website/app/teams/page.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import TeamCard from '@/components/alpha/TeamCard';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Card from '@/components/ui/Card';
|
||||
import CreateTeamForm from '@/components/alpha/CreateTeamForm';
|
||||
import DataWarning from '@/components/alpha/DataWarning';
|
||||
import { getAllTeams, getTeamMembers, Team } from '@/lib/team-data';
|
||||
|
||||
export default function TeamsPage() {
|
||||
const router = useRouter();
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [memberFilter, setMemberFilter] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
loadTeams();
|
||||
}, []);
|
||||
|
||||
const loadTeams = () => {
|
||||
const allTeams = getAllTeams();
|
||||
setTeams(allTeams);
|
||||
};
|
||||
|
||||
const handleCreateSuccess = (teamId: string) => {
|
||||
setShowCreateForm(false);
|
||||
loadTeams();
|
||||
router.push(`/teams/${teamId}`);
|
||||
};
|
||||
|
||||
const filteredTeams = teams.filter((team) => {
|
||||
const memberCount = getTeamMembers(team.id).length;
|
||||
|
||||
const matchesSearch = team.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesMemberCount =
|
||||
memberFilter === 'all' ||
|
||||
(memberFilter === 'small' && memberCount < 5) ||
|
||||
(memberFilter === 'medium' && memberCount >= 5 && memberCount < 10) ||
|
||||
(memberFilter === 'large' && memberCount >= 10);
|
||||
|
||||
return matchesSearch && matchesMemberCount;
|
||||
});
|
||||
|
||||
const handleTeamClick = (teamId: string) => {
|
||||
router.push(`/teams/${teamId}`);
|
||||
};
|
||||
|
||||
if (showCreateForm) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<DataWarning className="mb-6" />
|
||||
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
>
|
||||
← Back to Teams
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">Create New Team</h2>
|
||||
<CreateTeamForm
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<DataWarning className="mb-6" />
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Teams</h1>
|
||||
<p className="text-gray-400">
|
||||
Browse and join racing teams
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setShowCreateForm(true)}>
|
||||
Create Team
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Search Teams
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Team Size
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
|
||||
value={memberFilter}
|
||||
onChange={(e) => setMemberFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Sizes</option>
|
||||
<option value="small">Small (<5)</option>
|
||||
<option value="medium">Medium (5-9)</option>
|
||||
<option value="large">Large (10+)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
{filteredTeams.length} {filteredTeams.length === 1 ? 'team' : 'teams'} found
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredTeams.map((team) => {
|
||||
const memberCount = getTeamMembers(team.id).length;
|
||||
return (
|
||||
<TeamCard
|
||||
key={team.id}
|
||||
id={team.id}
|
||||
name={team.name}
|
||||
memberCount={memberCount}
|
||||
leagues={team.leagues}
|
||||
onClick={() => handleTeamClick(team.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredTeams.length === 0 && (
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-gray-400">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-600 mb-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-white mb-2">
|
||||
{teams.length === 0 ? 'No teams yet' : 'No teams found'}
|
||||
</h3>
|
||||
<p className="text-sm mb-4">
|
||||
{teams.length === 0
|
||||
? 'Create your first team to start racing together.'
|
||||
: 'Try adjusting your search or filters.'}
|
||||
</p>
|
||||
{teams.length === 0 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
>
|
||||
Create Your First Team
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user