fix e2e
This commit is contained in:
124
apps/website/app/leagues/[id]/LeagueDetailInteractive.tsx
Normal file
124
apps/website/app/leagues/[id]/LeagueDetailInteractive.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
|
||||
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
import EndRaceModal from '@/components/leagues/EndRaceModal';
|
||||
|
||||
export default function LeagueDetailInteractive() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const isSponsor = useSponsorMode();
|
||||
const { leagueService, leagueMembershipService, raceService } = useServices();
|
||||
|
||||
const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [endRaceModalRaceId, setEndRaceModalRaceId] = useState<string | null>(null);
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||
|
||||
const loadLeagueData = async () => {
|
||||
try {
|
||||
const viewModelData = await leagueService.getLeagueDetailPageData(leagueId);
|
||||
|
||||
if (!viewModelData) {
|
||||
setError('League not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setViewModel(viewModelData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load league');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLeagueData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leagueId]);
|
||||
|
||||
const handleMembershipChange = () => {
|
||||
loadLeagueData();
|
||||
};
|
||||
|
||||
const handleEndRaceModalOpen = (raceId: string) => {
|
||||
setEndRaceModalRaceId(raceId);
|
||||
};
|
||||
|
||||
const handleLiveRaceClick = (raceId: string) => {
|
||||
router.push(`/races/${raceId}`);
|
||||
};
|
||||
|
||||
const handleBackToLeagues = () => {
|
||||
router.push('/leagues');
|
||||
};
|
||||
|
||||
const handleEndRaceConfirm = async () => {
|
||||
if (!endRaceModalRaceId) return;
|
||||
|
||||
try {
|
||||
await raceService.completeRace(endRaceModalRaceId);
|
||||
await loadLeagueData();
|
||||
setEndRaceModalRaceId(null);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to complete race');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndRaceCancel = () => {
|
||||
setEndRaceModalRaceId(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400">Loading league...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !viewModel) {
|
||||
return (
|
||||
<div className="text-center text-warning-amber">
|
||||
{error || 'League not found'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LeagueDetailTemplate
|
||||
viewModel={viewModel}
|
||||
leagueId={leagueId}
|
||||
isSponsor={isSponsor}
|
||||
membership={membership}
|
||||
currentDriverId={currentDriverId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
onEndRaceModalOpen={handleEndRaceModalOpen}
|
||||
onLiveRaceClick={handleLiveRaceClick}
|
||||
onBackToLeagues={handleBackToLeagues}
|
||||
>
|
||||
{/* End Race Modal */}
|
||||
{endRaceModalRaceId && viewModel && (() => {
|
||||
const race = viewModel.runningRaces.find(r => r.id === endRaceModalRaceId);
|
||||
return race ? (
|
||||
<EndRaceModal
|
||||
raceId={race.id}
|
||||
raceName={race.name}
|
||||
onConfirm={handleEndRaceConfirm}
|
||||
onCancel={handleEndRaceCancel}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</LeagueDetailTemplate>
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
apps/website/app/leagues/[id]/LeagueDetailStatic.tsx
Normal file
60
apps/website/app/leagues/[id]/LeagueDetailStatic.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||
import { ServiceFactory } from '@/lib/services/ServiceFactory';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
|
||||
interface LeagueDetailStaticProps {
|
||||
leagueId: string;
|
||||
}
|
||||
|
||||
export default async function LeagueDetailStatic({ leagueId }: LeagueDetailStaticProps) {
|
||||
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
|
||||
const leagueService = serviceFactory.createLeagueService();
|
||||
|
||||
let viewModel: LeagueDetailPageViewModel | null = null;
|
||||
let loading = false;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
viewModel = await leagueService.getLeagueDetailPageData(leagueId);
|
||||
|
||||
if (!viewModel) {
|
||||
error = 'League not found';
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load league';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400">Loading league...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !viewModel) {
|
||||
return (
|
||||
<div className="text-center text-warning-amber">
|
||||
{error || 'League not found'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Server components can't have event handlers, so we provide empty functions
|
||||
// The Interactive wrapper will add the actual handlers
|
||||
return (
|
||||
<LeagueDetailTemplate
|
||||
viewModel={viewModel}
|
||||
leagueId={leagueId}
|
||||
isSponsor={false}
|
||||
membership={null}
|
||||
currentDriverId={null}
|
||||
onMembershipChange={() => {}}
|
||||
onEndRaceModalOpen={() => {}}
|
||||
onLiveRaceClick={() => {}}
|
||||
onBackToLeagues={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,496 +1,3 @@
|
||||
'use client';
|
||||
import LeagueDetailInteractive from './LeagueDetailInteractive';
|
||||
|
||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||
import EndRaceModal from '@/components/leagues/EndRaceModal';
|
||||
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
|
||||
import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed';
|
||||
import SponsorInsightsCard, {
|
||||
MetricBuilders,
|
||||
SlotTemplates,
|
||||
useSponsorMode,
|
||||
type SponsorMetric,
|
||||
} from '@/components/sponsors/SponsorInsightsCard';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
import { Calendar, ExternalLink, Star, Trophy, Users } from 'lucide-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export default function LeagueDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const isSponsor = useSponsorMode();
|
||||
const { leagueService, leagueMembershipService, raceService } = useServices();
|
||||
|
||||
const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [endRaceModalRaceId, setEndRaceModalRaceId] = useState<string | null>(null);
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||
|
||||
// Build metrics for SponsorInsightsCard
|
||||
const leagueMetrics: SponsorMetric[] = useMemo(() => {
|
||||
if (!viewModel) return [];
|
||||
return [
|
||||
MetricBuilders.views(viewModel.sponsorInsights.avgViewsPerRace, 'Avg Views/Race'),
|
||||
MetricBuilders.engagement(viewModel.sponsorInsights.engagementRate),
|
||||
MetricBuilders.reach(viewModel.sponsorInsights.estimatedReach),
|
||||
MetricBuilders.sof(viewModel.averageSOF ?? '—'),
|
||||
];
|
||||
}, [viewModel]);
|
||||
|
||||
const loadLeagueData = async () => {
|
||||
try {
|
||||
const viewModelData = await leagueService.getLeagueDetailPageData(leagueId);
|
||||
|
||||
if (!viewModelData) {
|
||||
setError('League not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setViewModel(viewModelData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load league');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLeagueData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leagueId]);
|
||||
|
||||
const handleMembershipChange = () => {
|
||||
loadLeagueData();
|
||||
};
|
||||
|
||||
// Note: driver summaries are now handled by the ViewModel
|
||||
|
||||
return loading ? (
|
||||
<div className="text-center text-gray-400">Loading league...</div>
|
||||
) : error || !viewModel ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-warning-amber mb-4">
|
||||
{error || 'League not found'}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/leagues')}
|
||||
>
|
||||
Back to Leagues
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Sponsor Insights Card - Only shown to sponsors, at top of page */}
|
||||
{isSponsor && viewModel && (
|
||||
<SponsorInsightsCard
|
||||
entityType="league"
|
||||
entityId={leagueId}
|
||||
entityName={viewModel.name}
|
||||
tier={viewModel.sponsorInsights.tier}
|
||||
metrics={leagueMetrics}
|
||||
slots={SlotTemplates.league(
|
||||
viewModel.sponsorInsights.mainSponsorAvailable,
|
||||
viewModel.sponsorInsights.secondarySlotsAvailable,
|
||||
viewModel.sponsorInsights.mainSponsorPrice,
|
||||
viewModel.sponsorInsights.secondaryPrice
|
||||
)}
|
||||
trustScore={viewModel.sponsorInsights.trustScore}
|
||||
discordMembers={viewModel.sponsorInsights.discordMembers}
|
||||
monthlyActivity={viewModel.sponsorInsights.monthlyActivity}
|
||||
additionalStats={{
|
||||
label: 'League Stats',
|
||||
items: [
|
||||
{ label: 'Total Races', value: viewModel.completedRacesCount },
|
||||
{ label: 'Active Members', value: viewModel.memberships.length },
|
||||
{ label: 'Total Impressions', value: viewModel.sponsorInsights.totalImpressions },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Live Race Card - Prominently show running races */}
|
||||
{viewModel && viewModel.runningRaces.length > 0 && (
|
||||
<Card className="border-2 border-performance-green/50 bg-gradient-to-r from-performance-green/10 to-performance-green/5 mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-3 h-3 bg-performance-green rounded-full animate-pulse"></div>
|
||||
<h2 className="text-xl font-bold text-white">🏁 Live Race in Progress</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{viewModel.runningRaces.map((race) => (
|
||||
<div
|
||||
key={race.id}
|
||||
className="p-4 rounded-lg bg-deep-graphite border border-performance-green/30"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="px-3 py-1 bg-performance-green/20 border border-performance-green/40 rounded-full">
|
||||
<span className="text-sm font-semibold text-performance-green">LIVE</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{race.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => router.push(`/races/${race.id}`)}
|
||||
className="bg-performance-green hover:bg-performance-green/80 text-white"
|
||||
>
|
||||
View Live Race
|
||||
</Button>
|
||||
{membership?.role === 'admin' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setEndRaceModalRaceId(race.id)}
|
||||
className="border-performance-green/50 text-performance-green hover:bg-performance-green/10"
|
||||
>
|
||||
End Race & Process Results
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>Started {new Date(race.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{race.registeredCount && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{race.registeredCount} drivers registered</span>
|
||||
</div>
|
||||
)}
|
||||
{race.strengthOfField && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
<span>SOF: {race.strengthOfField}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Action Card */}
|
||||
{!membership && !isSponsor && (
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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 className="w-48">
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* League Overview - Activity Center with Info Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Center - Activity Feed */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Recent Activity</h2>
|
||||
<LeagueActivityFeed leagueId={leagueId} limit={20} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - League Info */}
|
||||
<div className="space-y-6">
|
||||
{/* League Info - Combined */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
|
||||
<div className="text-xl font-bold text-white">{viewModel.memberships.length}</div>
|
||||
<div className="text-xs text-gray-500">Members</div>
|
||||
</div>
|
||||
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
|
||||
<div className="text-xl font-bold text-white">{viewModel.completedRacesCount}</div>
|
||||
<div className="text-xs text-gray-500">Races</div>
|
||||
</div>
|
||||
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
|
||||
<div className="text-xl font-bold text-warning-amber">{viewModel.averageSOF ?? '—'}</div>
|
||||
<div className="text-xs text-gray-500">Avg SOF</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-500">Structure</span>
|
||||
<span className="text-white">Solo • {viewModel.settings.maxDrivers ?? 32} max</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-500">Scoring</span>
|
||||
<span className="text-white">{viewModel.scoringConfig?.scoringPresetName ?? 'Standard'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-gray-500">Created</span>
|
||||
<span className="text-white">
|
||||
{new Date(viewModel.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewModel.socialLinks && (
|
||||
<div className="mt-4 pt-4 border-t border-charcoal-outline">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{viewModel.socialLinks.discordUrl && (
|
||||
<a
|
||||
href={viewModel.socialLinks.discordUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-2 py-1 text-xs text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
)}
|
||||
{viewModel.socialLinks.youtubeUrl && (
|
||||
<a
|
||||
href={viewModel.socialLinks.youtubeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-2 py-1 text-xs text-red-400 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
YouTube
|
||||
</a>
|
||||
)}
|
||||
{viewModel.socialLinks.websiteUrl && (
|
||||
<a
|
||||
href={viewModel.socialLinks.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-2 py-1 text-xs text-gray-100 hover:bg-iron-gray transition-colors"
|
||||
>
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Sponsors Section - Show sponsor logos */}
|
||||
{viewModel.sponsors.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
{viewModel.sponsors.find(s => s.tier === 'main') ? 'Presented by' : 'Sponsors'}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{/* Main Sponsor - Featured prominently */}
|
||||
{viewModel.sponsors.filter(s => s.tier === 'main').map(sponsor => (
|
||||
<div
|
||||
key={sponsor.id}
|
||||
className="p-3 rounded-lg bg-gradient-to-r from-yellow-500/10 to-transparent border border-yellow-500/30"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{sponsor.logoUrl ? (
|
||||
<div className="w-12 h-12 rounded-lg bg-white flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={sponsor.logoUrl}
|
||||
alt={sponsor.name}
|
||||
className="w-10 h-10 object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-yellow-500/20 flex items-center justify-center">
|
||||
<Trophy className="w-6 h-6 text-yellow-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-white truncate">{sponsor.name}</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
|
||||
Main
|
||||
</span>
|
||||
</div>
|
||||
{sponsor.tagline && (
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5">{sponsor.tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
{sponsor.websiteUrl && (
|
||||
<a
|
||||
href={sponsor.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-1.5 rounded-lg bg-iron-gray hover:bg-charcoal-outline transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 text-gray-400" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Secondary Sponsors - Smaller display */}
|
||||
{viewModel.sponsors.filter(s => s.tier === 'secondary').length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{viewModel.sponsors.filter(s => s.tier === 'secondary').map(sponsor => (
|
||||
<div
|
||||
key={sponsor.id}
|
||||
className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{sponsor.logoUrl ? (
|
||||
<div className="w-8 h-8 rounded bg-white flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={sponsor.logoUrl}
|
||||
alt={sponsor.name}
|
||||
className="w-6 h-6 object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded bg-purple-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Star className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-white truncate block">{sponsor.name}</span>
|
||||
</div>
|
||||
{sponsor.websiteUrl && (
|
||||
<a
|
||||
href={sponsor.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-1 rounded hover:bg-charcoal-outline transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Management */}
|
||||
{viewModel && (viewModel.ownerSummary || viewModel.adminSummaries.length > 0 || viewModel.stewardSummaries.length > 0) && (
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Management</h3>
|
||||
<div className="space-y-2">
|
||||
{viewModel.ownerSummary && (() => {
|
||||
const summary = viewModel.ownerSummary;
|
||||
const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('owner');
|
||||
const meta = summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DriverIdentity
|
||||
driver={summary.driver}
|
||||
href={`/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{viewModel.adminSummaries.map((summary) => {
|
||||
const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('admin');
|
||||
const meta = summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={summary.driver.id} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DriverIdentity
|
||||
driver={summary.driver}
|
||||
href={`/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{viewModel.stewardSummaries.map((summary) => {
|
||||
const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('steward');
|
||||
const meta = summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={summary.driver.id} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DriverIdentity
|
||||
driver={summary.driver}
|
||||
href={`/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* End Race Modal */}
|
||||
{endRaceModalRaceId && viewModel && (() => {
|
||||
const race = viewModel.runningRaces.find(r => r.id === endRaceModalRaceId);
|
||||
return race ? (
|
||||
<EndRaceModal
|
||||
raceId={race.id}
|
||||
raceName={race.name}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await raceService.completeRace(race.id);
|
||||
await loadLeagueData();
|
||||
setEndRaceModalRaceId(null);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to complete race');
|
||||
}
|
||||
}}
|
||||
onCancel={() => setEndRaceModalRaceId(null)}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default LeagueDetailInteractive;
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
|
||||
export default function LeagueRulebookInteractive() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
const { leagueService } = useServices();
|
||||
|
||||
const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const data = await leagueService.getLeagueDetailPageData(leagueId);
|
||||
if (!data) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setViewModel(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load scoring config:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
}, [leagueId, leagueService]);
|
||||
|
||||
if (!viewModel && !loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400 py-12">
|
||||
Unable to load rulebook
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <LeagueRulebookTemplate viewModel={viewModel!} loading={loading} />;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
|
||||
import { ServiceFactory } from '@/lib/services/ServiceFactory';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
|
||||
interface LeagueRulebookStaticProps {
|
||||
leagueId: string;
|
||||
}
|
||||
|
||||
export default async function LeagueRulebookStatic({ leagueId }: LeagueRulebookStaticProps) {
|
||||
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
|
||||
const leagueService = serviceFactory.createLeagueService();
|
||||
|
||||
let viewModel: LeagueDetailPageViewModel | null = null;
|
||||
let loading = false;
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const data = await leagueService.getLeagueDetailPageData(leagueId);
|
||||
if (data) {
|
||||
viewModel = data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load scoring config:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
if (!viewModel && !loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400 py-12">
|
||||
Unable to load rulebook
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <LeagueRulebookTemplate viewModel={viewModel!} loading={loading} />;
|
||||
}
|
||||
@@ -1,264 +1,3 @@
|
||||
'use client';
|
||||
import LeagueRulebookInteractive from './LeagueRulebookInteractive';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import PointsTable from '@/components/leagues/PointsTable';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
|
||||
type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties';
|
||||
|
||||
export default function LeagueRulebookPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
const { leagueService } = useServices();
|
||||
|
||||
const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const data = await leagueService.getLeagueDetailPageData(leagueId);
|
||||
if (!data) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setViewModel(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load scoring config:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
}, [leagueId, leagueService]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12 text-gray-400">Loading rulebook...</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!viewModel || !viewModel.scoringConfig) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12 text-gray-400">Unable to load rulebook</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryChampionship = viewModel.scoringConfig.championships.find(c => c.type === 'driver') ?? viewModel.scoringConfig.championships[0];
|
||||
const positionPoints = primaryChampionship?.pointsPreview
|
||||
.filter(p => (p as any).sessionType === primaryChampionship.sessionTypes[0])
|
||||
.map(p => ({ position: Number((p as any).position), points: Number((p as any).points) }))
|
||||
.sort((a, b) => a.position - b.position) || [];
|
||||
|
||||
const sections: { id: RulebookSection; label: string }[] = [
|
||||
{ id: 'scoring', label: 'Scoring' },
|
||||
{ id: 'conduct', label: 'Conduct' },
|
||||
{ id: 'protests', label: 'Protests' },
|
||||
{ id: 'penalties', label: 'Penalties' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Rulebook</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
|
||||
<span className="text-sm font-medium text-primary-blue">{viewModel.scoringConfig.scoringPresetName || 'Custom Rules'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-1 p-1 bg-deep-graphite rounded-lg border border-charcoal-outline">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeSection === section.id
|
||||
? 'bg-iron-gray text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray/50'
|
||||
}`}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Sections */}
|
||||
{activeSection === 'scoring' && (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p>
|
||||
<p className="text-lg font-semibold text-white">{viewModel.scoringConfig.gameName}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Championships</p>
|
||||
<p className="text-lg font-semibold text-white">{viewModel.scoringConfig.championships.length}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Sessions Scored</p>
|
||||
<p className="text-lg font-semibold text-white capitalize">
|
||||
{primaryChampionship?.sessionTypes.join(', ') || 'Main'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Drop Policy</p>
|
||||
<p className="text-lg font-semibold text-white truncate" title={viewModel.scoringConfig.dropPolicySummary}>
|
||||
{viewModel.scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Points Table */}
|
||||
<PointsTable points={positionPoints} />
|
||||
|
||||
{/* Bonus Points */}
|
||||
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2>
|
||||
<div className="space-y-2">
|
||||
{primaryChampionship.bonusSummary.map((bonus, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-4 p-3 bg-deep-graphite rounded-lg border border-charcoal-outline"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-performance-green text-sm font-bold">+</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{bonus}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Drop Policy */}
|
||||
{!viewModel.scoringConfig.dropPolicySummary.includes('All results count') && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2>
|
||||
<p className="text-sm text-gray-300">{viewModel.scoringConfig.dropPolicySummary}</p>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Drop rules are applied automatically when calculating championship standings.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'conduct' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Driver Conduct</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">1. Respect</h3>
|
||||
<p>All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">2. Clean Racing</h3>
|
||||
<p>Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">3. Track Limits</h3>
|
||||
<p>Drivers must stay within track limits. Gaining a lasting advantage by exceeding track limits may result in penalties.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">4. Blue Flags</h3>
|
||||
<p>Lapped cars must yield to faster traffic within a reasonable time. Failure to do so may result in penalties.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">5. Communication</h3>
|
||||
<p>Drivers are expected to communicate respectfully in voice and text chat during sessions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === 'protests' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Protest Process</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Filing a Protest</h3>
|
||||
<p>Protests can be filed within 48 hours of the race conclusion. Include the lap number, drivers involved, and a clear description of the incident.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Evidence</h3>
|
||||
<p>Video evidence is highly recommended but not required. Stewards will review available replay data.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Review Process</h3>
|
||||
<p>League stewards will review protests and make decisions within 72 hours. Decisions are final unless new evidence is presented.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Outcomes</h3>
|
||||
<p>Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === 'penalties' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Penalty Guidelines</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-2 font-medium text-gray-400">Infraction</th>
|
||||
<th className="text-left py-2 font-medium text-gray-400">Typical Penalty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-charcoal-outline/50">
|
||||
<tr>
|
||||
<td className="py-3">Causing avoidable contact</td>
|
||||
<td className="py-3 text-warning-amber">5-10 second time penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Unsafe rejoin</td>
|
||||
<td className="py-3 text-warning-amber">5 second time penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Blocking</td>
|
||||
<td className="py-3 text-warning-amber">Warning or 3 second penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Repeated track limit violations</td>
|
||||
<td className="py-3 text-warning-amber">5 second penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Intentional wrecking</td>
|
||||
<td className="py-3 text-red-400">Disqualification</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Unsportsmanlike conduct</td>
|
||||
<td className="py-3 text-red-400">Points deduction or ban</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Penalties are applied at steward discretion based on incident severity and driver history.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default LeagueRulebookInteractive;
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate';
|
||||
|
||||
export default function LeagueScheduleInteractive() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
return <LeagueScheduleTemplate leagueId={leagueId} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate';
|
||||
|
||||
interface LeagueScheduleStaticProps {
|
||||
leagueId: string;
|
||||
}
|
||||
|
||||
export default async function LeagueScheduleStatic({ leagueId }: LeagueScheduleStaticProps) {
|
||||
// The LeagueScheduleTemplate doesn't need data fetching - it delegates to LeagueSchedule component
|
||||
return <LeagueScheduleTemplate leagueId={leagueId} />;
|
||||
}
|
||||
@@ -1,22 +1,3 @@
|
||||
'use client';
|
||||
import LeagueScheduleInteractive from './LeagueScheduleInteractive';
|
||||
|
||||
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
export default function LeagueSchedulePage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Schedule</h2>
|
||||
<LeagueSchedule leagueId={leagueId} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default LeagueScheduleInteractive;
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
|
||||
|
||||
export default function LeagueStandingsInteractive() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueService } = useServices();
|
||||
|
||||
const [standings, setStandings] = useState<StandingEntryViewModel[]>([]);
|
||||
const [drivers, setDrivers] = useState<DriverViewModel[]>([]);
|
||||
const [memberships, setMemberships] = useState<LeagueMembership[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId);
|
||||
setStandings(vm.standings);
|
||||
setDrivers(vm.drivers.map((d) => new DriverViewModel({ ...d, avatarUrl: (d as any).avatarUrl ?? null })));
|
||||
setMemberships(vm.memberships);
|
||||
|
||||
// Check if current user is admin
|
||||
const membership = vm.memberships.find(m => m.driverId === currentDriverId);
|
||||
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load standings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [leagueId, currentDriverId, leagueService]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleRemoveMember = async (driverId: string) => {
|
||||
if (!confirm('Are you sure you want to remove this member?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await leagueService.removeMember(leagueId, currentDriverId, driverId);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove member');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (driverId: string, newRole: string) => {
|
||||
try {
|
||||
await leagueService.updateMemberRole(leagueId, currentDriverId, driverId, newRole);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update role');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400">
|
||||
Loading standings...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center text-warning-amber">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LeagueStandingsTemplate
|
||||
standings={standings}
|
||||
drivers={drivers}
|
||||
memberships={memberships}
|
||||
leagueId={leagueId}
|
||||
currentDriverId={currentDriverId}
|
||||
isAdmin={isAdmin}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onUpdateRole={handleUpdateRole}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
|
||||
import { ServiceFactory } from '@/lib/services/ServiceFactory';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||
import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
|
||||
|
||||
interface LeagueStandingsStaticProps {
|
||||
leagueId: string;
|
||||
currentDriverId?: string | null;
|
||||
}
|
||||
|
||||
export default async function LeagueStandingsStatic({ leagueId, currentDriverId }: LeagueStandingsStaticProps) {
|
||||
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
|
||||
const leagueService = serviceFactory.createLeagueService();
|
||||
|
||||
let standings: StandingEntryViewModel[] = [];
|
||||
let drivers: DriverViewModel[] = [];
|
||||
let memberships: LeagueMembership[] = [];
|
||||
let loading = false;
|
||||
let error: string | null = null;
|
||||
let isAdmin = false;
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId || '');
|
||||
standings = vm.standings;
|
||||
drivers = vm.drivers.map((d) => new DriverViewModel({ ...d, avatarUrl: (d as any).avatarUrl ?? null }));
|
||||
memberships = vm.memberships;
|
||||
|
||||
// Check if current user is admin
|
||||
const membership = vm.memberships.find(m => m.driverId === currentDriverId);
|
||||
isAdmin = membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load standings';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400">
|
||||
Loading standings...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center text-warning-amber">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Server components can't have event handlers, so we provide empty functions
|
||||
// The Interactive wrapper will add the actual handlers
|
||||
return (
|
||||
<LeagueStandingsTemplate
|
||||
standings={standings}
|
||||
drivers={drivers}
|
||||
memberships={memberships}
|
||||
leagueId={leagueId}
|
||||
currentDriverId={currentDriverId ?? null}
|
||||
isAdmin={isAdmin}
|
||||
onRemoveMember={() => {}}
|
||||
onUpdateRole={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +1,3 @@
|
||||
'use client';
|
||||
import LeagueStandingsInteractive from './LeagueStandingsInteractive';
|
||||
|
||||
import StandingsTable from '@/components/leagues/StandingsTable';
|
||||
import LeagueChampionshipStats from '@/components/leagues/LeagueChampionshipStats';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { LeagueStandingsViewModel } from '@/lib/view-models/LeagueStandingsViewModel';
|
||||
import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export default function LeagueStandingsPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueService } = useServices();
|
||||
|
||||
const [standings, setStandings] = useState<StandingEntryViewModel[]>([]);
|
||||
const [drivers, setDrivers] = useState<DriverViewModel[]>([]);
|
||||
const [memberships, setMemberships] = useState<LeagueMembership[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId);
|
||||
setStandings(vm.standings);
|
||||
setDrivers(vm.drivers.map((d) => new DriverViewModel({ ...d, avatarUrl: (d as any).avatarUrl ?? null })));
|
||||
setMemberships(vm.memberships);
|
||||
|
||||
// Check if current user is admin
|
||||
const membership = vm.memberships.find(m => m.driverId === currentDriverId);
|
||||
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load standings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [leagueId, currentDriverId, leagueService]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleRemoveMember = async (driverId: string) => {
|
||||
if (!confirm('Are you sure you want to remove this member?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await leagueService.removeMember(leagueId, currentDriverId, driverId);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove member');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (driverId: string, newRole: string) => {
|
||||
try {
|
||||
await leagueService.updateMemberRole(leagueId, currentDriverId, driverId, newRole);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update role');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400">
|
||||
Loading standings...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center text-warning-amber">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Championship Stats */}
|
||||
<LeagueChampionshipStats standings={standings} drivers={drivers} />
|
||||
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
|
||||
<StandingsTable
|
||||
standings={standings.map((s) => ({
|
||||
leagueId,
|
||||
driverId: s.driverId,
|
||||
position: s.position,
|
||||
totalPoints: s.points,
|
||||
racesFinished: s.races,
|
||||
racesStarted: s.races,
|
||||
avgFinish: null,
|
||||
penaltyPoints: 0,
|
||||
bonusPoints: 0,
|
||||
}) satisfies {
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
totalPoints: number;
|
||||
racesFinished: number;
|
||||
racesStarted: number;
|
||||
avgFinish: number | null;
|
||||
penaltyPoints: number;
|
||||
bonusPoints: number;
|
||||
teamName?: string;
|
||||
})}
|
||||
drivers={drivers}
|
||||
leagueId={leagueId}
|
||||
memberships={memberships}
|
||||
currentDriverId={currentDriverId}
|
||||
isAdmin={isAdmin}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onUpdateRole={handleUpdateRole}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default LeagueStandingsInteractive;
|
||||
|
||||
Reference in New Issue
Block a user