error and load state

This commit is contained in:
2026-01-06 11:05:16 +01:00
parent 4a1bfa57a3
commit 6aad7897db
29 changed files with 5172 additions and 1462 deletions

View File

@@ -27,41 +27,53 @@ import { UpcomingRaceItem } from '@/components/dashboard/UpcomingRaceItem';
import { FriendItem } from '@/components/dashboard/FriendItem';
import { FeedItemRow } from '@/components/dashboard/FeedItemRow';
import { useDashboardOverview } from '@/hooks/useDashboardService';
import { getCountryFlag } from '@/lib/utilities/country';
import { getGreeting, timeUntil } from '@/lib/utilities/time';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { EmptyState } from '@/components/shared/state/EmptyState';
import { useServices } from '@/lib/services/ServiceProvider';
export default function DashboardPage() {
const { data: dashboardData, isLoading, error } = useDashboardOverview();
const { dashboardService } = useServices();
const { data: dashboardData, isLoading, error, retry } = useDataFetching({
queryKey: ['dashboardOverview'],
queryFn: () => dashboardService.getDashboardOverview(),
});
if (isLoading) {
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<div className="text-white">Loading dashboard...</div>
</main>
);
}
return (
<StateContainer
data={dashboardData}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'full-screen', message: 'Loading dashboard...' },
error: { variant: 'full-screen' },
empty: {
icon: Activity,
title: 'No dashboard data',
description: 'Try refreshing the page',
action: { label: 'Refresh', onClick: retry }
}
}}
>
{(data) => {
const currentDriver = data.currentDriver;
const nextRace = data.nextRace;
const upcomingRaces = data.upcomingRaces;
const leagueStandingsSummaries = data.leagueStandings;
const feedSummary = { items: data.feedItems };
const friends = data.friends;
const activeLeaguesCount = data.activeLeaguesCount;
if (error || !dashboardData) {
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<div className="text-red-400">Failed to load dashboard</div>
</main>
);
}
const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver;
const currentDriver = dashboardData.currentDriver;
const nextRace = dashboardData.nextRace;
const upcomingRaces = dashboardData.upcomingRaces;
const leagueStandingsSummaries = dashboardData.leagueStandings;
const feedSummary = { items: dashboardData.feedItems };
const friends = dashboardData.friends;
const activeLeaguesCount = dashboardData.activeLeaguesCount;
const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver;
return (
<main className="min-h-screen bg-deep-graphite">
return (
<main className="min-h-screen bg-deep-graphite">
{/* Hero Section */}
<section className="relative overflow-hidden">
{/* Background Pattern */}
@@ -327,4 +339,7 @@ export default function DashboardPage() {
</section>
</main>
);
}}
</StateContainer>
);
}

View File

@@ -1,14 +1,23 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { DriversTemplate } from '@/templates/DriversTemplate';
import { useDriverLeaderboard } from '@/hooks/useDriverService';
import { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useServices } from '@/lib/services/ServiceProvider';
import { Users } from 'lucide-react';
export function DriversInteractive() {
const router = useRouter();
const { data: viewModel, isLoading: loading } = useDriverLeaderboard();
const { driverService } = useServices();
const { data: viewModel, isLoading: loading, error, retry } = useDataFetching({
queryKey: ['driverLeaderboard'],
queryFn: () => driverService.getDriverLeaderboard(),
});
const drivers = viewModel?.drivers || [];
const totalRaces = viewModel?.totalRaces || 0;
@@ -16,17 +25,35 @@ export function DriversInteractive() {
const activeCount = viewModel?.activeCount || 0;
// Transform data for template
const driverViewModels = drivers.map((driver, index) =>
const driverViewModels = drivers.map((driver, index) =>
new DriverLeaderboardItemViewModel(driver, index + 1)
);
return (
<DriversTemplate
drivers={driverViewModels}
totalRaces={totalRaces}
totalWins={totalWins}
activeCount={activeCount}
<StateContainer
data={viewModel}
isLoading={loading}
/>
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading driver leaderboard...' },
error: { variant: 'inline' },
empty: {
icon: Users,
title: 'No drivers found',
description: 'There are no drivers in the system yet',
}
}}
>
{(leaderboardData) => (
<DriversTemplate
drivers={driverViewModels}
totalRaces={totalRaces}
totalWins={totalWins}
activeCount={activeCount}
isLoading={false}
/>
)}
</StateContainer>
);
}
}

View File

@@ -1,12 +1,16 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
import { useServices } from '@/lib/services/ServiceProvider';
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { Car } from 'lucide-react';
interface Team {
id: string;
name: string;
@@ -24,34 +28,23 @@ export function DriverProfileInteractive() {
const driverId = params.id as string;
const { driverService, teamService } = useServices();
const [driverProfile, setDriverProfile] = useState<DriverProfileViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview');
const [allTeamMemberships, setAllTeamMemberships] = useState<TeamMembershipInfo[]>([]);
const [friendRequestSent, setFriendRequestSent] = useState(false);
const isSponsorMode = useSponsorMode();
useEffect(() => {
loadDriver();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [driverId]);
// Fetch driver profile
const { data: driverProfile, isLoading, error, retry } = useDataFetching({
queryKey: ['driverProfile', driverId],
queryFn: () => driverService.getDriverProfile(driverId),
});
const loadDriver = async () => {
try {
// Get driver profile
const profileViewModel = await driverService.getDriverProfile(driverId);
if (!profileViewModel.currentDriver) {
setError('Driver not found');
setLoading(false);
return;
}
setDriverProfile(profileViewModel);
// Load team memberships - get all teams and check memberships
// Fetch team memberships
const { data: allTeamMemberships } = useDataFetching({
queryKey: ['driverTeamMemberships', driverId],
queryFn: async () => {
if (!driverProfile?.currentDriver) return [];
const allTeams = await teamService.getAllTeams();
const memberships: TeamMembershipInfo[] = [];
@@ -69,13 +62,10 @@ export function DriverProfileInteractive() {
});
}
}
setAllTeamMemberships(memberships);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load driver');
} finally {
setLoading(false);
}
};
return memberships;
},
enabled: !!driverProfile?.currentDriver,
});
const handleAddFriend = () => {
setFriendRequestSent(true);
@@ -110,23 +100,38 @@ export function DriverProfileInteractive() {
/>
) : null;
if (!driverProfile) {
return null;
}
return (
<DriverProfileTemplate
driverProfile={driverProfile}
allTeamMemberships={allTeamMemberships}
isLoading={loading}
<StateContainer
data={driverProfile}
isLoading={isLoading}
error={error}
onBackClick={handleBackClick}
onAddFriend={handleAddFriend}
friendRequestSent={friendRequestSent}
activeTab={activeTab}
setActiveTab={setActiveTab}
isSponsorMode={isSponsorMode}
sponsorInsights={sponsorInsights}
/>
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading driver profile...' },
error: { variant: 'full-screen' },
empty: {
icon: Car,
title: 'Driver not found',
description: 'The driver profile may not exist or you may not have access',
action: { label: 'Back to Drivers', onClick: handleBackClick }
}
}}
>
{(profileData) => (
<DriverProfileTemplate
driverProfile={profileData}
allTeamMemberships={allTeamMemberships || []}
isLoading={false}
error={null}
onBackClick={handleBackClick}
onAddFriend={handleAddFriend}
friendRequestSent={friendRequestSent}
activeTab={activeTab}
setActiveTab={setActiveTab}
isSponsorMode={isSponsorMode}
sponsorInsights={sponsorInsights}
/>
)}
</StateContainer>
);
}
}

View File

@@ -1,33 +1,88 @@
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import LeaderboardsInteractive from './LeaderboardsInteractive';
'use client';
import { useRouter } from 'next/navigation';
import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// ============================================================================
// SERVER COMPONENT - Fetches data and passes to Interactive wrapper
// ============================================================================
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useServices } from '@/lib/services/ServiceProvider';
import { Trophy } from 'lucide-react';
export default async function LeaderboardsStatic() {
// Create services for server-side data fetching
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
const driverService = serviceFactory.createDriverService();
const teamService = serviceFactory.createTeamService();
export default function LeaderboardsStatic() {
const router = useRouter();
const { driverService, teamService } = useServices();
// Fetch data server-side
let drivers: DriverLeaderboardItemViewModel[] = [];
let teams: TeamSummaryViewModel[] = [];
const { data: driverData, isLoading: driversLoading, error: driversError, retry: driversRetry } = useDataFetching({
queryKey: ['driverLeaderboard'],
queryFn: () => driverService.getDriverLeaderboard(),
});
try {
const driversViewModel = await driverService.getDriverLeaderboard();
drivers = driversViewModel.drivers;
teams = await teamService.getAllTeams();
} catch (error) {
console.error('Failed to load leaderboard data:', error);
drivers = [];
teams = [];
}
const { data: teams, isLoading: teamsLoading, error: teamsError, retry: teamsRetry } = useDataFetching({
queryKey: ['allTeams'],
queryFn: () => teamService.getAllTeams(),
});
// Pass data to Interactive wrapper which handles client-side interactions
return <LeaderboardsInteractive drivers={drivers} teams={teams} />;
const handleDriverClick = (driverId: string) => {
router.push(`/drivers/${driverId}`);
};
const handleTeamClick = (teamId: string) => {
router.push(`/teams/${teamId}`);
};
const handleNavigateToDrivers = () => {
router.push('/leaderboards/drivers');
};
const handleNavigateToTeams = () => {
router.push('/teams/leaderboard');
};
// Combine loading states
const isLoading = driversLoading || teamsLoading;
// Combine errors (prioritize drivers error)
const error = driversError || teamsError;
// Combine retry functions
const retry = async () => {
if (driversError) await driversRetry();
if (teamsError) await teamsRetry();
};
// Prepare data for template
const drivers = driverData?.drivers || [];
const teamsData = teams || [];
const hasData = drivers.length > 0 || teamsData.length > 0;
return (
<StateContainer
data={hasData ? { drivers, teams: teamsData } : null}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading leaderboards...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'No leaderboard data',
description: 'There is no leaderboard data available at the moment.',
}
}}
isEmpty={(data) => !data || (data.drivers.length === 0 && data.teams.length === 0)}
>
{(data) => (
<LeaderboardsTemplate
drivers={data.drivers}
teams={data.teams}
onDriverClick={handleDriverClick}
onTeamClick={handleTeamClick}
onNavigateToDrivers={handleNavigateToDrivers}
onNavigateToTeams={handleNavigateToTeams}
/>
)}
</StateContainer>
);
}

View File

@@ -1,28 +1,75 @@
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import DriverRankingsInteractive from './DriverRankingsInteractive';
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
// ============================================================================
// SERVER COMPONENT - Fetches data and passes to Interactive wrapper
// ============================================================================
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useServices } from '@/lib/services/ServiceProvider';
import { Users } from 'lucide-react';
export default async function DriverRankingsStatic() {
// Create services for server-side data fetching
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
const driverService = serviceFactory.createDriverService();
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
// Fetch data server-side
let drivers: DriverLeaderboardItemViewModel[] = [];
export default function DriverRankingsStatic() {
const router = useRouter();
const { driverService } = useServices();
const [searchQuery, setSearchQuery] = useState('');
const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all');
const [sortBy, setSortBy] = useState<SortBy>('rank');
const [showFilters, setShowFilters] = useState(false);
try {
const driversViewModel = await driverService.getDriverLeaderboard();
drivers = driversViewModel.drivers;
} catch (error) {
console.error('Failed to load driver rankings:', error);
drivers = [];
}
const { data: driverData, isLoading, error, retry } = useDataFetching({
queryKey: ['driverLeaderboard'],
queryFn: () => driverService.getDriverLeaderboard(),
});
// Pass data to Interactive wrapper which handles client-side interactions
return <DriverRankingsInteractive drivers={drivers} />;
const handleDriverClick = (driverId: string) => {
if (driverId.startsWith('demo-')) return;
router.push(`/drivers/${driverId}`);
};
const handleBackToLeaderboards = () => {
router.push('/leaderboards');
};
const drivers = driverData?.drivers || [];
return (
<StateContainer
data={drivers}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading driver rankings...' },
error: { variant: 'full-screen' },
empty: {
icon: Users,
title: 'No drivers found',
description: 'There are no drivers in the system yet.',
}
}}
>
{(driversData) => (
<DriverRankingsTemplate
drivers={driversData}
searchQuery={searchQuery}
selectedSkill={selectedSkill}
sortBy={sortBy}
showFilters={showFilters}
onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onSortChange={setSortBy}
onToggleFilters={() => setShowFilters(!showFilters)}
onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>
)}
</StateContainer>
);
}

View File

@@ -1,32 +1,24 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
import { useServices } from '@/lib/services/ServiceProvider';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { Trophy } from 'lucide-react';
export default function LeaguesInteractive() {
const router = useRouter();
const [realLeagues, setRealLeagues] = useState<LeagueSummaryViewModel[]>([]);
const [loading, setLoading] = useState(true);
const { leagueService } = useServices();
const loadLeagues = useCallback(async () => {
try {
const leagues = await leagueService.getAllLeagues();
setRealLeagues(leagues);
} catch (error) {
console.error('Failed to load leagues:', error);
} finally {
setLoading(false);
}
}, [leagueService]);
useEffect(() => {
void loadLeagues();
}, [loadLeagues]);
const { data: realLeagues = [], isLoading: loading, error, retry } = useDataFetching({
queryKey: ['allLeagues'],
queryFn: () => leagueService.getAllLeagues(),
});
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
@@ -37,11 +29,30 @@ export default function LeaguesInteractive() {
};
return (
<LeaguesTemplate
leagues={realLeagues}
loading={loading}
onLeagueClick={handleLeagueClick}
onCreateLeagueClick={handleCreateLeagueClick}
/>
<StateContainer
data={realLeagues}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading leagues...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'No leagues yet',
description: 'Create your first league to start organizing races and events.',
action: { label: 'Create League', onClick: handleCreateLeagueClick }
}
}}
>
{(leaguesData) => (
<LeaguesTemplate
leagues={leaguesData}
loading={false}
onLeagueClick={handleLeagueClick}
onCreateLeagueClick={handleCreateLeagueClick}
/>
)}
</StateContainer>
);
}

View File

@@ -1,14 +1,18 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, 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';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { Trophy } from 'lucide-react';
export default function LeagueDetailInteractive() {
const router = useRouter();
const params = useParams();
@@ -16,39 +20,18 @@ export default function LeagueDetailInteractive() {
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 { data: viewModel, isLoading, error, retry } = useDataFetching({
queryKey: ['leagueDetailPage', leagueId],
queryFn: () => leagueService.getLeagueDetailPageData(leagueId),
});
const handleMembershipChange = () => {
loadLeagueData();
retry();
};
const handleEndRaceModalOpen = (raceId: string) => {
@@ -68,7 +51,7 @@ export default function LeagueDetailInteractive() {
try {
await raceService.completeRace(endRaceModalRaceId);
await loadLeagueData();
await retry();
setEndRaceModalRaceId(null);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to complete race');
@@ -79,46 +62,51 @@ export default function LeagueDetailInteractive() {
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>
</>
<StateContainer
data={viewModel}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading league...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'League not found',
description: 'The league may have been deleted or you may not have access',
action: { label: 'Back to Leagues', onClick: handleBackToLeagues }
}
}}
>
{(leagueData) => (
<>
<LeagueDetailTemplate
viewModel={leagueData}
leagueId={leagueId}
isSponsor={isSponsor}
membership={membership}
currentDriverId={currentDriverId}
onMembershipChange={handleMembershipChange}
onEndRaceModalOpen={handleEndRaceModalOpen}
onLiveRaceClick={handleLiveRaceClick}
onBackToLeagues={handleBackToLeagues}
>
{/* End Race Modal */}
{endRaceModalRaceId && leagueData && (() => {
const race = leagueData.runningRaces.find(r => r.id === endRaceModalRaceId);
return race ? (
<EndRaceModal
raceId={race.id}
raceName={race.name}
onConfirm={handleEndRaceConfirm}
onCancel={handleEndRaceCancel}
/>
) : null;
})()}
</LeagueDetailTemplate>
</>
)}
</StateContainer>
);
}
}

View File

@@ -9,47 +9,34 @@ import { useServices } from '@/lib/services/ServiceProvider';
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
import { AlertTriangle, Settings } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
export default function LeagueSettingsPage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const { leagueMembershipService, leagueSettingsService } = useServices();
const [settings, setSettings] = useState<LeagueSettingsViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const router = useRouter();
useEffect(() => {
async function checkAdmin() {
// Check admin status
const { data: isAdmin, isLoading: adminLoading } = useDataFetching({
queryKey: ['leagueMembership', leagueId, currentDriverId],
queryFn: async () => {
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
}
checkAdmin();
}, [leagueId, currentDriverId, leagueMembershipService]);
useEffect(() => {
async function loadSettings() {
setLoading(true);
try {
const settingsData = await leagueSettingsService.getLeagueSettings(leagueId);
if (settingsData) {
setSettings(settingsData);
}
} catch (err) {
console.error('Failed to load league settings:', err);
} finally {
setLoading(false);
}
}
if (isAdmin) {
loadSettings();
}
}, [leagueId, isAdmin, leagueSettingsService]);
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
},
});
// Load settings (only if admin)
const { data: settings, isLoading: settingsLoading, error, retry } = useDataFetching({
queryKey: ['leagueSettings', leagueId],
queryFn: () => leagueSettingsService.getLeagueSettings(leagueId),
enabled: !!isAdmin,
});
const handleTransferOwnership = async (newOwnerId: string) => {
try {
@@ -60,6 +47,12 @@ export default function LeagueSettingsPage() {
}
};
// Show loading for admin check
if (adminLoading) {
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
}
// Show access denied if not admin
if (!isAdmin) {
return (
<Card>
@@ -76,49 +69,47 @@ export default function LeagueSettingsPage() {
);
}
if (loading) {
return (
<Card>
<div className="py-6 text-sm text-gray-400">Loading configuration</div>
</Card>
);
}
if (!settings) {
return (
<Card>
<div className="py-6 text-sm text-gray-500">
Unable to load league configuration for this demo league.
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<Settings className="w-6 h-6 text-primary-blue" />
<StateContainer
data={settings}
isLoading={settingsLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading settings...' },
error: { variant: 'full-screen' },
empty: {
icon: Settings,
title: 'No settings available',
description: 'Unable to load league configuration.',
}
}}
>
{(settingsData) => (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<Settings className="w-6 h-6 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">League Settings</h1>
<p className="text-sm text-gray-400">Manage your league configuration</p>
</div>
</div>
{/* READONLY INFORMATION SECTION - Compact */}
<div className="space-y-4">
<ReadonlyLeagueInfo league={settingsData.league} configForm={settingsData.config} />
<LeagueOwnershipTransfer
settings={settingsData}
currentDriverId={currentDriverId}
onTransferOwnership={handleTransferOwnership}
/>
</div>
</div>
<div>
<h1 className="text-2xl font-bold text-white">League Settings</h1>
<p className="text-sm text-gray-400">Manage your league configuration</p>
</div>
</div>
{/* READONLY INFORMATION SECTION - Compact */}
<div className="space-y-4">
<ReadonlyLeagueInfo league={settings.league} configForm={settings.config} />
<LeagueOwnershipTransfer
settings={settings}
currentDriverId={currentDriverId}
onTransferOwnership={handleTransferOwnership}
/>
</div>
</div>
)}
</StateContainer>
);
}

View File

@@ -22,7 +22,12 @@ import {
} from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
export default function LeagueStewardingPage() {
const params = useParams();
@@ -30,46 +35,34 @@ export default function LeagueStewardingPage() {
const currentDriverId = useEffectiveDriverId();
const { leagueStewardingService, leagueMembershipService } = useServices();
const [stewardingData, setStewardingData] = useState<LeagueStewardingViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
useEffect(() => {
async function checkAdmin() {
// Check admin status
const { data: isAdmin, isLoading: adminLoading } = useDataFetching({
queryKey: ['leagueMembership', leagueId, currentDriverId],
queryFn: async () => {
const membership = await leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
}
checkAdmin();
}, [leagueId, currentDriverId, leagueMembershipService]);
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
},
});
useEffect(() => {
async function loadData() {
setLoading(true);
try {
const data = await leagueStewardingService.getLeagueStewardingData(leagueId);
setStewardingData(data);
// Auto-expand races with pending protests
const racesWithPending = new Set<string>();
data.pendingRaces.forEach(race => {
racesWithPending.add(race.race.id);
});
setExpandedRaces(racesWithPending);
} catch (err) {
console.error('Failed to load data:', err);
} finally {
setLoading(false);
}
}
if (isAdmin) {
loadData();
}
}, [leagueId, isAdmin, leagueStewardingService]);
// Load stewarding data (only if admin)
const { data: stewardingData, isLoading: dataLoading, error, retry } = useDataFetching({
queryKey: ['leagueStewarding', leagueId],
queryFn: () => leagueStewardingService.getLeagueStewardingData(leagueId),
enabled: !!isAdmin,
onSuccess: (data) => {
// Auto-expand races with pending protests
const racesWithPending = new Set<string>();
data.pendingRaces.forEach(race => {
racesWithPending.add(race.race.id);
});
setExpandedRaces(racesWithPending);
},
});
// Filter races based on active tab
const filteredRaces = useMemo(() => {
@@ -109,6 +102,9 @@ export default function LeagueStewardingPage() {
notes: stewardNotes,
});
}
// Retry to refresh data
await retry();
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
@@ -118,9 +114,11 @@ export default function LeagueStewardingPage() {
decision: 'dismiss',
decisionNotes: stewardNotes,
});
// Retry to refresh data
await retry();
};
const toggleRaceExpanded = (raceId: string) => {
setExpandedRaces(prev => {
const next = new Set(prev);
@@ -149,6 +147,12 @@ export default function LeagueStewardingPage() {
}
};
// Show loading for admin check
if (adminLoading) {
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
}
// Show access denied if not admin
if (!isAdmin) {
return (
<Card>
@@ -166,248 +170,260 @@ export default function LeagueStewardingPage() {
}
return (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
Quick overview of protests and penalties across all races
</p>
</div>
</div>
{/* Stats summary */}
{!loading && stewardingData && (
<StewardingStats
totalPending={stewardingData.totalPending}
totalResolved={stewardingData.totalResolved}
totalPenalties={stewardingData.totalPenalties}
/>
)}
{/* Tab navigation */}
<div className="border-b border-charcoal-outline mb-6">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'pending'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Protests
{stewardingData && stewardingData.totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{stewardingData.totalPending}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'history'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
History
</button>
</div>
</div>
{/* Content */}
{loading ? (
<div className="text-center py-12 text-gray-400">
<div className="animate-pulse">Loading stewarding data...</div>
</div>
) : filteredRaces.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="w-8 h-8 text-performance-green" />
<StateContainer
data={stewardingData}
isLoading={dataLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading stewarding data...' },
error: { variant: 'full-screen' },
empty: {
icon: Flag,
title: 'No stewarding data',
description: 'There are no protests or penalties to review.',
}
}}
>
{(data) => (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
Quick overview of protests and penalties across all races
</p>
</div>
</div>
<p className="font-semibold text-lg text-white mb-2">
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
</p>
<p className="text-sm text-gray-400">
{activeTab === 'pending'
? 'No pending protests to review'
: 'No resolved protests or penalties'}
</p>
</div>
) : (
<div className="space-y-4">
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
const isExpanded = expandedRaces.has(race.id);
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
return (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
{/* Race Header */}
<button
onClick={() => toggleRaceExpanded(race.id)}
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>{race.scheduledAt.toLocaleDateString()}</span>
</div>
{activeTab === 'pending' && pendingProtests.length > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
{pendingProtests.length} pending
</span>
)}
{activeTab === 'history' && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
{resolvedProtests.length} protests, {penalties.length} penalties
</span>
)}
</div>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 space-y-3 bg-deep-graphite/50">
{displayProtests.length === 0 && penalties.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
) : (
<>
{displayProtests.map((protest) => {
const protester = stewardingData!.driverMap[protest.protestingDriverId];
const accused = stewardingData!.driverMap[protest.accusedDriverId];
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
return (
<div
key={protest.id}
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
<span className="font-medium text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span>
{getStatusBadge(protest.status)}
{isUrgent && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
{protest.proofVideoUrl && (
<>
<span></span>
<span className="flex items-center gap-1 text-primary-blue">
<Video className="w-3 h-3" />
Video
</span>
</>
)}
</div>
<p className="text-sm text-gray-300 line-clamp-2">
{protest.incident.description}
</p>
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
{(protest.status === 'pending' || protest.status === 'under_review') && (
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button variant="primary">
Review
</Button>
</Link>
)}
</div>
</div>
);
})}
{/* Stats summary */}
<StewardingStats
totalPending={data.totalPending}
totalResolved={data.totalResolved}
totalPenalties={data.totalPenalties}
/>
{activeTab === 'history' && penalties.map((penalty) => {
const driver = stewardingData!.driverMap[penalty.driverId];
return (
<div
key={penalty.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<Gavel className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-red-400">
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
{penalty.type === 'disqualification' && 'DSQ'}
{penalty.type === 'warning' && 'Warning'}
{penalty.type === 'license_points' && `${penalty.value} LP`}
</span>
</div>
</div>
</div>
);
})}
</>
)}
</div>
{/* Tab navigation */}
<div className="border-b border-charcoal-outline mb-6">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'pending'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Protests
{data.totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{data.totalPending}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'history'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
History
</button>
</div>
</div>
{/* Content */}
{filteredRaces.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="w-8 h-8 text-performance-green" />
</div>
);
})}
</div>
)}
</Card>
<p className="font-semibold text-lg text-white mb-2">
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
</p>
<p className="text-sm text-gray-400">
{activeTab === 'pending'
? 'No pending protests to review'
: 'No resolved protests or penalties'}
</p>
</div>
) : (
<div className="space-y-4">
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
const isExpanded = expandedRaces.has(race.id);
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
return (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
{/* Race Header */}
<button
onClick={() => toggleRaceExpanded(race.id)}
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>{race.scheduledAt.toLocaleDateString()}</span>
</div>
{activeTab === 'pending' && pendingProtests.length > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
{pendingProtests.length} pending
</span>
)}
{activeTab === 'history' && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
{resolvedProtests.length} protests, {penalties.length} penalties
</span>
)}
</div>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 space-y-3 bg-deep-graphite/50">
{displayProtests.length === 0 && penalties.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
) : (
<>
{displayProtests.map((protest) => {
const protester = data.driverMap[protest.protestingDriverId];
const accused = data.driverMap[protest.accusedDriverId];
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
return (
<div
key={protest.id}
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
<span className="font-medium text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span>
{getStatusBadge(protest.status)}
{isUrgent && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
{protest.proofVideoUrl && (
<>
<span></span>
<span className="flex items-center gap-1 text-primary-blue">
<Video className="w-3 h-3" />
Video
</span>
</>
)}
</div>
<p className="text-sm text-gray-300 line-clamp-2">
{protest.incident.description}
</p>
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
{(protest.status === 'pending' || protest.status === 'under_review') && (
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button variant="primary">
Review
</Button>
</Link>
)}
</div>
</div>
);
})}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{activeTab === 'history' && penalties.map((penalty) => {
const driver = data.driverMap[penalty.driverId];
return (
<div
key={penalty.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<Gavel className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-red-400">
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
{penalty.type === 'disqualification' && 'DSQ'}
{penalty.type === 'warning' && 'Warning'}
{penalty.type === 'license_points' && `${penalty.value} LP`}
</span>
</div>
</div>
</div>
);
})}
</>
)}
</div>
)}
</div>
);
})}
</div>
)}
</Card>
{showQuickPenaltyModal && stewardingData && (
<QuickPenaltyModal
drivers={stewardingData.allDrivers}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId}
races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
/>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{showQuickPenaltyModal && stewardingData && (
<QuickPenaltyModal
drivers={stewardingData.allDrivers}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId}
races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
/>
)}
</div>
)}
</div>
</StateContainer>
);
}

View File

@@ -32,7 +32,12 @@ import {
} from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
// Timeline event types
interface TimelineEvent {
@@ -105,10 +110,6 @@ export default function ProtestReviewPage() {
const currentDriverId = useEffectiveDriverId();
const { leagueStewardingService, protestService, leagueMembershipService } = useServices();
const [detail, setDetail] = useState<ProtestDetailViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
// Decision state
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
@@ -116,6 +117,30 @@ export default function ProtestReviewPage() {
const [penaltyValue, setPenaltyValue] = useState<number>(5);
const [stewardNotes, setStewardNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [newComment, setNewComment] = useState('');
// Check admin status
const { data: isAdmin, isLoading: adminLoading } = useDataFetching({
queryKey: ['leagueMembership', leagueId, currentDriverId],
queryFn: async () => {
await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
},
});
// Load protest detail
const { data: detail, isLoading: detailLoading, error, retry } = useDataFetching({
queryKey: ['protestDetail', leagueId, protestId],
queryFn: () => leagueStewardingService.getProtestDetailViewModel(leagueId, protestId),
enabled: !!isAdmin,
onSuccess: (protestDetail) => {
if (protestDetail.initialPenaltyType) {
setPenaltyType(protestDetail.initialPenaltyType);
setPenaltyValue(protestDetail.initialPenaltyValue);
}
},
});
const penaltyTypes = useMemo(() => {
const referenceItems = detail?.penaltyTypes ?? [];
@@ -136,45 +161,6 @@ export default function ProtestReviewPage() {
const selectedPenalty = useMemo(() => {
return penaltyTypes.find((p) => p.type === penaltyType);
}, [penaltyTypes, penaltyType]);
// Comment state
const [newComment, setNewComment] = useState('');
useEffect(() => {
async function checkAdmin() {
await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
}
checkAdmin();
}, [leagueId, currentDriverId, leagueMembershipService]);
useEffect(() => {
async function loadProtest() {
setLoading(true);
try {
const protestDetail = await leagueStewardingService.getProtestDetailViewModel(leagueId, protestId);
setDetail(protestDetail);
if (protestDetail.initialPenaltyType) {
setPenaltyType(protestDetail.initialPenaltyType);
setPenaltyValue(protestDetail.initialPenaltyValue);
}
} catch (err) {
console.error('Failed to load protest:', err);
alert('Failed to load protest details');
router.push(`/leagues/${leagueId}/stewarding`);
} finally {
setLoading(false);
}
}
if (isAdmin) {
loadProtest();
}
}, [protestId, leagueId, isAdmin, router, leagueStewardingService]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !detail) return;
@@ -295,6 +281,12 @@ export default function ProtestReviewPage() {
}
};
// Show loading for admin check
if (adminLoading) {
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
}
// Show access denied if not admin
if (!isAdmin) {
return (
<Card>
@@ -311,435 +303,440 @@ export default function ProtestReviewPage() {
);
}
if (loading || !detail) {
return (
<Card>
<div className="text-center py-12">
<div className="animate-pulse text-gray-400">Loading protest details...</div>
</div>
</Card>
);
}
const protest = detail.protest;
const race = detail.race;
const protestingDriver = detail.protestingDriver;
const accusedDriver = detail.accusedDriver;
const statusConfig = getStatusConfig(protest.status);
const StatusIcon = statusConfig.icon;
const isPending = protest.status === 'pending';
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
return (
<div className="min-h-screen">
{/* Compact Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex-1 flex items-center gap-3">
<h1 className="text-xl font-bold text-white">Protest Review</h1>
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</div>
{daysSinceFiled > 2 && isPending && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
</div>
</div>
<StateContainer
data={detail}
isLoading={detailLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading protest details...' },
error: { variant: 'full-screen' },
}}
>
{(protestDetail) => {
const protest = protestDetail.protest;
const race = protestDetail.race;
const protestingDriver = protestDetail.protestingDriver;
const accusedDriver = protestDetail.accusedDriver;
{/* Main Layout: Feed + Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Sidebar - Incident Info */}
<div className="lg:col-span-3 space-y-4">
{/* Drivers Involved */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
<div className="space-y-3">
{/* Protesting Driver */}
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-400" />
const statusConfig = getStatusConfig(protest.status);
const StatusIcon = statusConfig.icon;
const isPending = protest.status === 'pending';
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
return (
<div className="min-h-screen">
{/* Compact Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex-1 flex items-center gap-3">
<h1 className="text-xl font-bold text-white">Protest Review</h1>
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-400 font-medium">Protesting</p>
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
{daysSinceFiled > 2 && isPending && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
</Link>
{/* Accused Driver */}
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-orange-400 font-medium">Accused</p>
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
</div>
</Card>
{/* Race Info */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
<Link
href={`/races/${race.id}`}
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{race.name}</span>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.name}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.formattedDate}</span>
</div>
{protest.incident?.lap && (
<div className="flex items-center gap-2 text-sm">
<Flag className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">Lap {protest.incident.lap}</span>
</div>
)}
</div>
</Card>
{protest.proofVideoUrl && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
<a
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
<Video className="w-4 h-4" />
<span className="text-sm font-medium flex-1">Watch Video</span>
<ExternalLink className="w-3 h-3" />
</a>
</Card>
)}
{/* Quick Stats */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Filed</span>
<span className="text-gray-300">{new Date(protest.submittedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Age</span>
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
</div>
{protest.reviewedAt && (
<div className="flex justify-between">
<span className="text-gray-500">Resolved</span>
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
</div>
)}
</div>
</Card>
</div>
{/* Center - Discussion Feed */}
<div className="lg:col-span-6 space-y-4">
{/* Timeline / Feed */}
<Card className="p-0 overflow-hidden">
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
<h2 className="text-sm font-semibold text-white">Discussion</h2>
</div>
<div className="divide-y divide-charcoal-outline/50">
{/* Initial Protest Filing */}
<div className="p-4">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
<span className="text-xs text-blue-400 font-medium">filed protest</span>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.submittedAt).toLocaleString()}</span>
</div>
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
{protest.comment && (
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
<p className="text-sm text-gray-400">{protest.comment}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Defense placeholder - will be populated when defense system is implemented */}
{protest.status === 'awaiting_defense' && (
<div className="p-4 bg-purple-500/5">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
<MessageCircle className="w-5 h-5 text-purple-400" />
</div>
<div className="flex-1">
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
</div>
</div>
</div>
)}
{/* Decision (if resolved) */}
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
<div className="flex gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
}`}>
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">Steward Decision</span>
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
</span>
{protest.reviewedAt && (
<>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
</>
)}
</div>
<div className={`rounded-lg p-4 border ${
protest.status === 'upheld'
? 'bg-red-500/10 border-red-500/20'
: 'bg-gray-500/10 border-gray-500/20'
}`}>
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Add Comment (future feature) */}
{isPending && (
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-gray-500" />
</div>
<div className="flex-1">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Add a comment or request more information..."
rows={2}
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
/>
<div className="flex justify-end mt-2">
<Button variant="secondary" disabled={!newComment.trim()}>
<Send className="w-3 h-3 mr-1" />
Comment
</Button>
</div>
</div>
</div>
</div>
)}
</Card>
</div>
{/* Right Sidebar - Actions */}
<div className="lg:col-span-3 space-y-4">
{isPending && (
<>
{/* Quick Actions */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
<div className="space-y-2">
<Button
variant="secondary"
className="w-full justify-start"
onClick={handleRequestDefense}
>
<MessageCircle className="w-4 h-4 mr-2" />
Request Defense
</Button>
<Button
variant="primary"
className="w-full justify-start"
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
>
<Gavel className="w-4 h-4 mr-2" />
Make Decision
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
</Button>
</div>
</Card>
{/* Decision Panel */}
{showDecisionPanel && (
{/* Main Layout: Feed + Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Sidebar - Incident Info */}
<div className="lg:col-span-3 space-y-4">
{/* Drivers Involved */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
{/* Decision Selection */}
<div className="grid grid-cols-2 gap-2 mb-4">
<button
onClick={() => setDecision('uphold')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'uphold'
? 'border-red-500 bg-red-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
<div className="space-y-3">
{/* Protesting Driver */}
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-400 font-medium">Protesting</p>
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
{/* Accused Driver */}
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-orange-400 font-medium">Accused</p>
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
</div>
</Card>
{/* Race Info */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
<Link
href={`/races/${race.id}`}
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{race.name}</span>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.name}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.formattedDate}</span>
</div>
{protest.incident?.lap && (
<div className="flex items-center gap-2 text-sm">
<Flag className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">Lap {protest.incident.lap}</span>
</div>
)}
</div>
</Card>
{protest.proofVideoUrl && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
<a
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
</button>
<button
onClick={() => setDecision('dismiss')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'dismiss'
? 'border-gray-500 bg-gray-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
</button>
<Video className="w-4 h-4" />
<span className="text-sm font-medium flex-1">Watch Video</span>
<ExternalLink className="w-3 h-3" />
</a>
</Card>
)}
{/* Quick Stats */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Filed</span>
<span className="text-gray-300">{new Date(protest.submittedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Age</span>
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
</div>
{protest.reviewedAt && (
<div className="flex justify-between">
<span className="text-gray-500">Resolved</span>
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
</div>
)}
</div>
</Card>
</div>
{/* Center - Discussion Feed */}
<div className="lg:col-span-6 space-y-4">
{/* Timeline / Feed */}
<Card className="p-0 overflow-hidden">
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
<h2 className="text-sm font-semibold text-white">Discussion</h2>
</div>
{/* Penalty Selection (if upholding) */}
{decision === 'uphold' && (
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
{penaltyTypes.length === 0 ? (
<div className="text-xs text-gray-500">
Loading penalty types...
<div className="divide-y divide-charcoal-outline/50">
{/* Initial Protest Filing */}
<div className="p-4">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="w-5 h-5 text-blue-400" />
</div>
) : (
<>
<div className="grid grid-cols-2 gap-1.5">
{penaltyTypes.map((penalty) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<button
key={penalty.type}
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
className={`p-2 rounded-lg border transition-all text-left ${
isSelected
? `${penalty.color} border`
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
}`}
title={penalty.description}
>
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
{penalty.label}
</p>
</button>
);
})}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
<span className="text-xs text-blue-400 font-medium">filed protest</span>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.submittedAt).toLocaleString()}</span>
</div>
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
{selectedPenalty?.requiresValue && (
<div className="mt-3">
<label className="text-xs font-medium text-gray-400 mb-1 block">
Value ({selectedPenalty.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
/>
{protest.comment && (
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
<p className="text-sm text-gray-400">{protest.comment}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Defense placeholder - will be populated when defense system is implemented */}
{protest.status === 'awaiting_defense' && (
<div className="p-4 bg-purple-500/5">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
<MessageCircle className="w-5 h-5 text-purple-400" />
</div>
<div className="flex-1">
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
</div>
</div>
</div>
)}
{/* Decision (if resolved) */}
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
<div className="flex gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
}`}>
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">Steward Decision</span>
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
</span>
{protest.reviewedAt && (
<>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
</>
)}
</div>
)}
</>
)}
<div className={`rounded-lg p-4 border ${
protest.status === 'upheld'
? 'bg-red-500/10 border-red-500/20'
: 'bg-gray-500/10 border-gray-500/20'
}`}>
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Add Comment (future feature) */}
{isPending && (
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-gray-500" />
</div>
<div className="flex-1">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Add a comment or request more information..."
rows={2}
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
/>
<div className="flex justify-end mt-2">
<Button variant="secondary" disabled={!newComment.trim()}>
<Send className="w-3 h-3 mr-1" />
Comment
</Button>
</div>
</div>
</div>
</div>
)}
{/* Steward Notes */}
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
<textarea
value={stewardNotes}
onChange={(e) => setStewardNotes(e.target.value)}
placeholder="Explain your decision..."
rows={4}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
/>
</div>
{/* Submit */}
<Button
variant="primary"
className="w-full"
onClick={handleSubmitDecision}
disabled={!decision || !stewardNotes.trim() || submitting}
>
{submitting ? 'Submitting...' : 'Submit Decision'}
</Button>
</Card>
)}
</>
)}
{/* Already Resolved Info */}
{!isPending && (
<Card className="p-4">
<div className={`text-center py-4 ${
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
}`}>
<Gavel className="w-8 h-8 mx-auto mb-2" />
<p className="font-semibold">Case Closed</p>
<p className="text-xs text-gray-500 mt-1">
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
</p>
</div>
</Card>
)}
</div>
</div>
</div>
{/* Right Sidebar - Actions */}
<div className="lg:col-span-3 space-y-4">
{isPending && (
<>
{/* Quick Actions */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
<div className="space-y-2">
<Button
variant="secondary"
className="w-full justify-start"
onClick={handleRequestDefense}
>
<MessageCircle className="w-4 h-4 mr-2" />
Request Defense
</Button>
<Button
variant="primary"
className="w-full justify-start"
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
>
<Gavel className="w-4 h-4 mr-2" />
Make Decision
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
</Button>
</div>
</Card>
{/* Decision Panel */}
{showDecisionPanel && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
{/* Decision Selection */}
<div className="grid grid-cols-2 gap-2 mb-4">
<button
onClick={() => setDecision('uphold')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'uphold'
? 'border-red-500 bg-red-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
</button>
<button
onClick={() => setDecision('dismiss')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'dismiss'
? 'border-gray-500 bg-gray-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
</button>
</div>
{/* Penalty Selection (if upholding) */}
{decision === 'uphold' && (
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
{penaltyTypes.length === 0 ? (
<div className="text-xs text-gray-500">
Loading penalty types...
</div>
) : (
<>
<div className="grid grid-cols-2 gap-1.5">
{penaltyTypes.map((penalty) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<button
key={penalty.type}
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
className={`p-2 rounded-lg border transition-all text-left ${
isSelected
? `${penalty.color} border`
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
}`}
title={penalty.description}
>
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
{penalty.label}
</p>
</button>
);
})}
</div>
{selectedPenalty?.requiresValue && (
<div className="mt-3">
<label className="text-xs font-medium text-gray-400 mb-1 block">
Value ({selectedPenalty.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
/>
</div>
)}
</>
)}
</div>
)}
{/* Steward Notes */}
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
<textarea
value={stewardNotes}
onChange={(e) => setStewardNotes(e.target.value)}
placeholder="Explain your decision..."
rows={4}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
/>
</div>
{/* Submit */}
<Button
variant="primary"
className="w-full"
onClick={handleSubmitDecision}
disabled={!decision || !stewardNotes.trim() || submitting}
>
{submitting ? 'Submitting...' : 'Submit Decision'}
</Button>
</Card>
)}
</>
)}
{/* Already Resolved Info */}
{!isPending && (
<Card className="p-4">
<div className={`text-center py-4 ${
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
}`}>
<Gavel className="w-8 h-8 mx-auto mb-2" />
<p className="font-semibold">Case Closed</p>
<p className="text-xs text-gray-500 mt-1">
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
</p>
</div>
</Card>
)}
</div>
</div>
</div>
);
}}
</StateContainer>
);
}

View File

@@ -1,19 +1,31 @@
'use client';
import { Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import OnboardingWizard from '@/components/onboarding/OnboardingWizard';
import { useCurrentDriver } from '@/hooks/useDriverService';
import { useAuth } from '@/lib/auth/AuthContext';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { useServices } from '@/lib/services/ServiceProvider';
export default function OnboardingPage() {
const router = useRouter();
const { session } = useAuth();
const { data: driver, isLoading } = useCurrentDriver();
const { driverService } = useServices();
// Check if user is logged in
const shouldRedirectToLogin = !session;
// Fetch current driver data
const { data: driver, isLoading } = useDataFetching({
queryKey: ['currentDriver'],
queryFn: () => driverService.getCurrentDriver(),
enabled: !!session,
});
const shouldRedirectToDashboard = !isLoading && Boolean(driver);
useEffect(() => {
@@ -32,8 +44,8 @@ export default function OnboardingPage() {
if (isLoading) {
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<Loader2 className="w-8 h-8 text-primary-blue animate-spin" />
<main className="min-h-screen bg-deep-graphite">
<LoadingWrapper variant="full-screen" message="Loading onboarding..." />
</main>
);
}
@@ -47,4 +59,4 @@ export default function OnboardingPage() {
<OnboardingWizard />
</main>
);
}
}

View File

@@ -14,6 +14,11 @@ import type {
DriverProfileViewModel
} from '@/lib/view-models/DriverProfileViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import {
Activity,
Award,
@@ -260,34 +265,19 @@ export default function ProfilePage() {
const { driverService, mediaService } = useServices();
const [profileData, setProfileData] = useState<DriverProfileViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [editMode, setEditMode] = useState(false);
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
const effectiveDriverId = useEffectiveDriverId();
const isOwnProfile = true; // This page is always your own profile
useEffect(() => {
if (!effectiveDriverId) {
return;
}
// Shared state components
const { data: profileData, isLoading: loading, error, retry } = useDataFetching({
queryKey: ['driverProfile', effectiveDriverId],
queryFn: () => driverService.getDriverProfile(effectiveDriverId),
enabled: !!effectiveDriverId,
});
const loadData = async () => {
setLoading(true);
try {
const profileViewModel = await driverService.getDriverProfile(effectiveDriverId);
setProfileData(profileViewModel);
} catch (error) {
console.error('Failed to load profile:', error);
} finally {
setLoading(false);
}
};
void loadData();
}, [effectiveDriverId, driverService]);
const [editMode, setEditMode] = useState(false);
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
// Update URL when tab changes
useEffect(() => {
@@ -315,7 +305,8 @@ export default function ProfilePage() {
try {
const updatedProfile = await driverService.updateProfile(updates);
setProfileData(updatedProfile);
// Update local state
retry();
setEditMode(false);
} catch (error) {
console.error('Failed to update profile:', error);
@@ -327,20 +318,8 @@ export default function ProfilePage() {
// In production, this would call a use case
};
if (loading) {
return (
<div className="max-w-6xl mx-auto px-4">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
<p className="text-gray-400">Loading profile...</p>
</div>
</div>
</div>
);
}
if (!profileData?.currentDriver) {
// Show create form if no profile exists
if (!loading && !profileData?.currentDriver && !error) {
return (
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-8">
@@ -366,16 +345,8 @@ export default function ProfilePage() {
);
}
// Extract data from profileData ViewModel
const currentDriver = profileData.currentDriver;
const stats = profileData.stats;
const teamMemberships = profileData.teamMemberships;
const socialSummary = profileData.socialSummary;
const extendedProfile = profileData.extendedProfile;
const globalRank = currentDriver?.globalRank || null;
// Show edit mode
if (editMode) {
if (editMode && profileData?.currentDriver) {
return (
<div className="max-w-4xl mx-auto px-4 space-y-6">
<div className="flex items-center justify-between mb-4">
@@ -390,7 +361,49 @@ export default function ProfilePage() {
}
return (
<div className="max-w-7xl mx-auto px-4 pb-12 space-y-6">
<StateContainer
data={profileData}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'full-screen', message: 'Loading profile...' },
error: { variant: 'full-screen' },
empty: {
icon: User,
title: 'No profile data',
description: 'Unable to load your profile information',
action: { label: 'Retry', onClick: retry }
}
}}
>
{(profileData) => {
// Extract data from profileData ViewModel
// At this point, we know profileData exists and currentDriver should exist
// (otherwise we would have shown the create form above)
const currentDriver = profileData.currentDriver;
// If currentDriver is null despite our checks, show empty state
if (!currentDriver) {
return (
<div className="max-w-4xl mx-auto px-4">
<Card className="text-center py-12">
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No driver profile found</p>
<p className="text-sm text-gray-500">Please create a driver profile to continue</p>
</Card>
</div>
);
}
const stats = profileData.stats;
const teamMemberships = profileData.teamMemberships;
const socialSummary = profileData.socialSummary;
const extendedProfile = profileData.extendedProfile;
const globalRank = currentDriver.globalRank || null;
return (
<div className="max-w-7xl mx-auto px-4 pb-12 space-y-6">
{/* Hero Header Section */}
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-iron-gray/80 via-iron-gray/60 to-deep-graphite border border-charcoal-outline">
{/* Background Pattern */}
@@ -1045,13 +1058,16 @@ export default function ProfilePage() {
</div>
)}
{activeTab === 'stats' && !stats && (
<Card className="text-center py-12">
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No statistics available yet</p>
<p className="text-sm text-gray-500">Join a league and complete races to see detailed stats</p>
</Card>
)}
</div>
);
{activeTab === 'stats' && !stats && (
<Card className="text-center py-12">
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No statistics available yet</p>
<p className="text-sm text-gray-500">Join a league and complete races to see detailed stats</p>
</Card>
)}
</div>
);
}}
</StateContainer>
);
}

View File

@@ -1,28 +1,39 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
import {
useRaceDetail,
useRegisterForRace,
useWithdrawFromRace,
useCancelRace,
useCompleteRace,
useReopenRace
import {
useRegisterForRace,
useWithdrawFromRace,
useCancelRace,
useCompleteRace,
useReopenRace
} from '@/hooks/useRaceService';
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useServices } from '@/lib/services/ServiceProvider';
import { Flag } from 'lucide-react';
export function RaceDetailInteractive() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const { raceService } = useServices();
// Fetch data
const { data: viewModel, isLoading, error } = useRaceDetail(raceId, currentDriverId);
// Fetch data using new hook
const { data: viewModel, isLoading, error, retry } = useDataFetching({
queryKey: ['raceDetail', raceId, currentDriverId],
queryFn: () => raceService.getRaceDetail(raceId, currentDriverId),
});
// Fetch membership
const { data: membership } = useLeagueMembership(viewModel?.league?.id || '', currentDriverId);
// UI State
@@ -37,7 +48,7 @@ export function RaceDetailInteractive() {
const reopenMutation = useReopenRace();
// Determine if user is owner/admin
const isOwnerOrAdmin = membership
const isOwnerOrAdmin = membership
? LeagueMembershipUtility.isOwnerOrAdmin(viewModel?.league?.id || '', currentDriverId)
: false;
@@ -184,34 +195,53 @@ export function RaceDetailInteractive() {
} : undefined;
return (
<RaceDetailTemplate
viewModel={templateViewModel}
<StateContainer
data={viewModel}
isLoading={isLoading}
error={error}
onBack={handleBack}
onRegister={handleRegister}
onWithdraw={handleWithdraw}
onCancel={handleCancel}
onReopen={handleReopen}
onEndRace={handleEndRace}
onFileProtest={handleFileProtest}
onResultsClick={handleResultsClick}
onStewardingClick={handleStewardingClick}
onLeagueClick={handleLeagueClick}
onDriverClick={handleDriverClick}
currentDriverId={currentDriverId}
isOwnerOrAdmin={isOwnerOrAdmin}
showProtestModal={showProtestModal}
setShowProtestModal={setShowProtestModal}
showEndRaceModal={showEndRaceModal}
setShowEndRaceModal={setShowEndRaceModal}
mutationLoading={{
register: registerMutation.isPending,
withdraw: withdrawMutation.isPending,
cancel: cancelMutation.isPending,
reopen: reopenMutation.isPending,
complete: completeMutation.isPending,
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading race details...' },
error: { variant: 'full-screen' },
empty: {
icon: Flag,
title: 'Race not found',
description: 'The race may have been cancelled or deleted',
action: { label: 'Back to Races', onClick: handleBack }
}
}}
/>
>
{(raceData) => (
<RaceDetailTemplate
viewModel={templateViewModel}
isLoading={false}
error={null}
onBack={handleBack}
onRegister={handleRegister}
onWithdraw={handleWithdraw}
onCancel={handleCancel}
onReopen={handleReopen}
onEndRace={handleEndRace}
onFileProtest={handleFileProtest}
onResultsClick={handleResultsClick}
onStewardingClick={handleStewardingClick}
onLeagueClick={handleLeagueClick}
onDriverClick={handleDriverClick}
currentDriverId={currentDriverId}
isOwnerOrAdmin={isOwnerOrAdmin}
showProtestModal={showProtestModal}
setShowProtestModal={setShowProtestModal}
showEndRaceModal={showEndRaceModal}
setShowEndRaceModal={setShowEndRaceModal}
mutationLoading={{
register: registerMutation.isPending,
withdraw: withdrawMutation.isPending,
cancel: cancelMutation.isPending,
reopen: reopenMutation.isPending,
complete: completeMutation.isPending,
}}
/>
)}
</StateContainer>
);
}
}

View File

@@ -3,20 +3,36 @@
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
import { useRaceResultsDetail, useRaceWithSOF } from '@/hooks/useRaceService';
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useServices } from '@/lib/services/ServiceProvider';
import { Trophy } from 'lucide-react';
export function RaceResultsInteractive() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const { raceResultsService, raceService } = useServices();
// Fetch data
const { data: raceData, isLoading, error } = useRaceResultsDetail(raceId, currentDriverId);
const { data: sofData } = useRaceWithSOF(raceId);
// Fetch data using new hook
const { data: raceData, isLoading, error, retry } = useDataFetching({
queryKey: ['raceResultsDetail', raceId, currentDriverId],
queryFn: () => raceResultsService.getResultsDetail(raceId, currentDriverId),
});
// Fetch SOF data
const { data: sofData } = useDataFetching({
queryKey: ['raceWithSOF', raceId],
queryFn: () => raceResultsService.getWithSOF(raceId),
});
// Fetch membership
const { data: membership } = useLeagueMembership(raceData?.league?.id || '', currentDriverId);
// UI State
@@ -83,28 +99,47 @@ export function RaceResultsInteractive() {
};
return (
<RaceResultsTemplate
raceTrack={raceData?.race?.track}
raceScheduledAt={raceData?.race?.scheduledAt}
totalDrivers={raceData?.stats.totalDrivers}
leagueName={raceData?.league?.name}
raceSOF={raceSOF}
results={results}
penalties={penalties}
pointsSystem={raceData?.pointsSystem ?? {}}
fastestLapTime={raceData?.fastestLapTime ?? 0}
currentDriverId={currentDriverId}
isAdmin={isAdmin}
<StateContainer
data={raceData}
isLoading={isLoading}
error={error}
onBack={handleBack}
onImportResults={handleImportResults}
onPenaltyClick={handlePenaltyClick}
importing={importing}
importSuccess={importSuccess}
importError={importError}
showImportForm={showImportForm}
setShowImportForm={setShowImportForm}
/>
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading race results...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'No results available',
description: 'Race results will appear here once the race is completed',
action: { label: 'Back to Race', onClick: handleBack }
}
}}
>
{(raceResultsData) => (
<RaceResultsTemplate
raceTrack={raceResultsData?.race?.track}
raceScheduledAt={raceResultsData?.race?.scheduledAt}
totalDrivers={raceResultsData?.stats.totalDrivers}
leagueName={raceResultsData?.league?.name}
raceSOF={raceSOF}
results={results}
penalties={penalties}
pointsSystem={raceResultsData?.pointsSystem ?? {}}
fastestLapTime={raceResultsData?.fastestLapTime ?? 0}
currentDriverId={currentDriverId}
isAdmin={isAdmin}
isLoading={false}
error={null}
onBack={handleBack}
onImportResults={handleImportResults}
onPenaltyClick={handlePenaltyClick}
importing={importing}
importSuccess={importSuccess}
importError={importError}
showImportForm={showImportForm}
setShowImportForm={setShowImportForm}
/>
)}
</StateContainer>
);
}
}

View File

@@ -3,19 +3,30 @@
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
import { useRaceStewardingData } from '@/hooks/useRaceStewardingService';
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useServices } from '@/lib/services/ServiceProvider';
import { Gavel } from 'lucide-react';
export function RaceStewardingInteractive() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const { raceStewardingService } = useServices();
// Fetch data
const { data: stewardingData, isLoading, error } = useRaceStewardingData(raceId, currentDriverId);
// Fetch data using new hook
const { data: stewardingData, isLoading, error, retry } = useDataFetching({
queryKey: ['raceStewardingData', raceId, currentDriverId],
queryFn: () => raceStewardingService.getRaceStewardingData(raceId, currentDriverId),
});
// Fetch membership
const { data: membership } = useLeagueMembership(stewardingData?.league?.id || '', currentDriverId);
// UI State
@@ -47,15 +58,34 @@ export function RaceStewardingInteractive() {
} : undefined;
return (
<RaceStewardingTemplate
stewardingData={templateData}
<StateContainer
data={stewardingData}
isLoading={isLoading}
error={error}
onBack={handleBack}
onReviewProtest={handleReviewProtest}
isAdmin={isAdmin}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading stewarding data...' },
error: { variant: 'full-screen' },
empty: {
icon: Gavel,
title: 'No stewarding data',
description: 'No protests or penalties for this race',
action: { label: 'Back to Race', onClick: handleBack }
}
}}
>
{(stewardingData) => (
<RaceStewardingTemplate
stewardingData={templateData}
isLoading={false}
error={null}
onBack={handleBack}
onReviewProtest={handleReviewProtest}
isAdmin={isAdmin}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
)}
</StateContainer>
);
}
}

View File

@@ -14,16 +14,26 @@ import WhyJoinTeamSection from '@/components/teams/WhyJoinTeamSection';
import SkillLevelSection from '@/components/teams/SkillLevelSection';
import FeaturedRecruiting from '@/components/teams/FeaturedRecruiting';
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
import { useAllTeams } from '@/hooks/useTeamService';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useServices } from '@/lib/services/ServiceProvider';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
const SKILL_LEVELS: SkillLevel[] = ['pro', 'advanced', 'intermediate', 'beginner'];
export default function TeamsInteractive() {
const router = useRouter();
const { data: teams = [], isLoading: loading } = useAllTeams();
const { teamService } = useServices();
const { data: teams = [], isLoading: loading, error, retry } = useDataFetching({
queryKey: ['allTeams'],
queryFn: () => teamService.getAllTeams(),
});
const [searchQuery, setSearchQuery] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
@@ -35,17 +45,20 @@ export default function TeamsInteractive() {
advanced: [],
pro: [],
};
teams.forEach((team) => {
const level = team.performanceLevel || 'intermediate';
if (byLevel[level]) {
byLevel[level].push(team);
}
});
if (teams) {
teams.forEach((team) => {
const level = team.performanceLevel || 'intermediate';
if (byLevel[level]) {
byLevel[level].push(team);
}
});
}
return byLevel;
}, [teams]);
// Select top teams by rating for the preview section
const topTeams = useMemo(() => {
if (!teams) return [];
const sortedByRating = [...teams].sort((a, b) => {
// Rating is not currently part of TeamSummaryViewModel in this build.
// Keep deterministic ordering by name until a rating field is exposed.
@@ -67,7 +80,7 @@ export default function TeamsInteractive() {
};
// Filter by search query
const filteredTeams = teams.filter((team) => {
const filteredTeams = teams ? teams.filter((team) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
@@ -76,7 +89,7 @@ export default function TeamsInteractive() {
(team.region ?? '').toLowerCase().includes(query) ||
(team.languages ?? []).some((lang) => lang.toLowerCase().includes(query))
);
});
}) : [];
// Group teams by skill level
const teamsByLevel = useMemo(() => {
@@ -97,7 +110,7 @@ export default function TeamsInteractive() {
);
}, [groupsBySkillLevel, filteredTeams]);
const recruitingCount = teams.filter((t) => t.isRecruiting).length;
const recruitingCount = teams ? teams.filter((t) => t.isRecruiting).length : 0;
const handleSkillLevelClick = (level: SkillLevel) => {
const element = document.getElementById(`level-${level}`);
@@ -126,98 +139,104 @@ export default function TeamsInteractive() {
);
}
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-purple-400 border-t-transparent rounded-full animate-spin" />
<p className="text-gray-400">Loading teams...</p>
</div>
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Hero Section */}
<TeamHeroSection
teams={teams}
teamsByLevel={teamsByLevel}
recruitingCount={recruitingCount}
onShowCreateForm={() => setShowCreateForm(true)}
onBrowseTeams={handleBrowseTeams}
onSkillLevelClick={handleSkillLevelClick}
/>
<StateContainer
data={teams}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading teams...' },
error: { variant: 'full-screen' },
empty: {
icon: Users,
title: 'No teams yet',
description: 'Be the first to create a racing team. Gather drivers and compete together in endurance events.',
action: { label: 'Create Your First Team', onClick: () => setShowCreateForm(true) }
}
}}
>
{(teamsData) => (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Hero Section */}
<TeamHeroSection
teams={teamsData}
teamsByLevel={teamsByLevel}
recruitingCount={recruitingCount}
onShowCreateForm={() => setShowCreateForm(true)}
onBrowseTeams={handleBrowseTeams}
onSkillLevelClick={handleSkillLevelClick}
/>
{/* Search Bar */}
<TeamSearchBar searchQuery={searchQuery} onSearchChange={setSearchQuery} />
{/* Search Bar */}
<TeamSearchBar searchQuery={searchQuery} onSearchChange={setSearchQuery} />
{/* Why Join Section */}
{!searchQuery && <WhyJoinTeamSection />}
{/* Why Join Section */}
{!searchQuery && <WhyJoinTeamSection />}
{/* Team Leaderboard Preview */}
{!searchQuery && <TeamLeaderboardPreview topTeams={topTeams} onTeamClick={handleTeamClick} />}
{/* Team Leaderboard Preview */}
{!searchQuery && <TeamLeaderboardPreview topTeams={topTeams} onTeamClick={handleTeamClick} />}
{/* Featured Recruiting */}
{!searchQuery && <FeaturedRecruiting teams={teams} onTeamClick={handleTeamClick} />}
{/* Featured Recruiting */}
{!searchQuery && <FeaturedRecruiting teams={teamsData} onTeamClick={handleTeamClick} />}
{/* Teams by Skill Level */}
{teams.length === 0 ? (
<Card className="text-center py-16">
<div className="max-w-md mx-auto">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-purple-500/10 border border-purple-500/20 mb-6">
<Users className="w-8 h-8 text-purple-400" />
{/* Teams by Skill Level */}
{teamsData.length === 0 ? (
<Card className="text-center py-16">
<div className="max-w-md mx-auto">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-purple-500/10 border border-purple-500/20 mb-6">
<Users className="w-8 h-8 text-purple-400" />
</div>
<Heading level={2} className="text-2xl mb-3">
No teams yet
</Heading>
<p className="text-gray-400 mb-8">
Be the first to create a racing team. Gather drivers and compete together in endurance events.
</p>
<Button
variant="primary"
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2 mx-auto bg-purple-600 hover:bg-purple-500"
>
<Sparkles className="w-4 h-4" />
Create Your First Team
</Button>
</div>
</Card>
) : filteredTeams.length === 0 ? (
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<Search className="w-10 h-10 text-gray-600" />
<p className="text-gray-400">No teams found matching "{searchQuery}"</p>
<Button variant="secondary" onClick={() => setSearchQuery('')}>
Clear search
</Button>
</div>
</Card>
) : (
<div>
{SKILL_LEVELS.map((level, index) => (
<div key={level} id={`level-${level}`} className="scroll-mt-8">
<SkillLevelSection
level={{
id: level,
label: level.charAt(0).toUpperCase() + level.slice(1),
icon: level === 'pro' ? Crown : level === 'advanced' ? Star : level === 'intermediate' ? TrendingUp : Shield,
color: level === 'pro' ? 'text-yellow-400' : level === 'advanced' ? 'text-purple-400' : level === 'intermediate' ? 'text-primary-blue' : 'text-green-400',
bgColor: level === 'pro' ? 'bg-yellow-400/10' : level === 'advanced' ? 'bg-purple-400/10' : level === 'intermediate' ? 'bg-primary-blue/10' : 'bg-green-400/10',
borderColor: level === 'pro' ? 'border-yellow-400/30' : level === 'advanced' ? 'border-purple-400/30' : level === 'intermediate' ? 'border-primary-blue/30' : 'border-green-400/30',
description: level === 'pro' ? 'Elite competition, sponsored teams' : level === 'advanced' ? 'Competitive racing, high consistency' : level === 'intermediate' ? 'Growing skills, regular practice' : 'Learning the basics, friendly environment',
}}
teams={teamsByLevel[level] ?? []}
onTeamClick={handleTeamClick}
defaultExpanded={index === 0}
/>
</div>
))}
</div>
<Heading level={2} className="text-2xl mb-3">
No teams yet
</Heading>
<p className="text-gray-400 mb-8">
Be the first to create a racing team. Gather drivers and compete together in endurance events.
</p>
<Button
variant="primary"
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2 mx-auto bg-purple-600 hover:bg-purple-500"
>
<Sparkles className="w-4 h-4" />
Create Your First Team
</Button>
</div>
</Card>
) : filteredTeams.length === 0 ? (
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<Search className="w-10 h-10 text-gray-600" />
<p className="text-gray-400">No teams found matching "{searchQuery}"</p>
<Button variant="secondary" onClick={() => setSearchQuery('')}>
Clear search
</Button>
</div>
</Card>
) : (
<div>
{SKILL_LEVELS.map((level, index) => (
<div key={level} id={`level-${level}`} className="scroll-mt-8">
<SkillLevelSection
level={{
id: level,
label: level.charAt(0).toUpperCase() + level.slice(1),
icon: level === 'pro' ? Crown : level === 'advanced' ? Star : level === 'intermediate' ? TrendingUp : Shield,
color: level === 'pro' ? 'text-yellow-400' : level === 'advanced' ? 'text-purple-400' : level === 'intermediate' ? 'text-primary-blue' : 'text-green-400',
bgColor: level === 'pro' ? 'bg-yellow-400/10' : level === 'advanced' ? 'bg-purple-400/10' : level === 'intermediate' ? 'bg-primary-blue/10' : 'bg-green-400/10',
borderColor: level === 'pro' ? 'border-yellow-400/30' : level === 'advanced' ? 'border-purple-400/30' : level === 'intermediate' ? 'border-primary-blue/30' : 'border-green-400/30',
description: level === 'pro' ? 'Elite competition, sponsored teams' : level === 'advanced' ? 'Competitive racing, high consistency' : level === 'intermediate' ? 'Growing skills, regular practice' : 'Learning the basics, friendly environment',
}}
teams={teamsByLevel[level] ?? []}
onTeamClick={handleTeamClick}
defaultExpanded={index === 0}
/>
</div>
))}
)}
</div>
)}
</div>
</StateContainer>
);
}

View File

@@ -1,8 +1,9 @@
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import TeamsTemplate from '@/templates/TeamsTemplate';
// This is a server component that fetches data server-side
// It will be used by the page.tsx when server-side rendering is needed
// This is a static component that receives data as props
// It can be used in server components or parent components that fetch data
// For client-side data fetching, use TeamsInteractive instead
interface TeamsStaticProps {
teams: TeamSummaryViewModel[];

View File

@@ -1,13 +1,16 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
import { useServices } from '@/lib/services/ServiceProvider';
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { Users } from 'lucide-react';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
export default function TeamDetailInteractive() {
@@ -17,43 +20,37 @@ export default function TeamDetailInteractive() {
const router = useRouter();
const currentDriverId = useEffectiveDriverId();
const [team, setTeam] = useState<TeamDetailsViewModel | null>(null);
const [memberships, setMemberships] = useState<TeamMemberViewModel[]>([]);
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const loadTeamData = useCallback(async () => {
setLoading(true);
try {
const teamDetails = await teamService.getTeamDetails(teamId, currentDriverId);
// Fetch team details
const { data: teamDetails, isLoading: teamLoading, error: teamError, retry: teamRetry } = useDataFetching({
queryKey: ['teamDetails', teamId, currentDriverId],
queryFn: () => teamService.getTeamDetails(teamId, currentDriverId),
});
if (!teamDetails) {
setTeam(null);
setMemberships([]);
setIsAdmin(false);
return;
}
// Fetch team members
const { data: memberships, isLoading: membersLoading, error: membersError, retry: membersRetry } = useDataFetching({
queryKey: ['teamMembers', teamId, currentDriverId],
queryFn: async () => {
if (!teamDetails?.ownerId) return [];
return teamService.getTeamMembers(teamId, currentDriverId, teamDetails.ownerId);
},
enabled: !!teamDetails?.ownerId,
});
const teamMembers = await teamService.getTeamMembers(teamId, currentDriverId, teamDetails.ownerId);
const isLoading = teamLoading || membersLoading;
const error = teamError || membersError;
const retry = async () => {
await teamRetry();
await membersRetry();
};
const adminStatus = teamDetails.isOwner ||
teamMembers.some((m) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
setTeam(teamDetails);
setMemberships(teamMembers);
setIsAdmin(adminStatus);
} finally {
setLoading(false);
}
}, [teamId, currentDriverId, teamService]);
useEffect(() => {
void loadTeamData();
}, [loadTeamData]);
// Determine admin status
const isAdmin = teamDetails?.isOwner ||
(memberships || []).some((m: any) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
const handleUpdate = () => {
loadTeamData();
retry();
};
const handleRemoveMember = async (driverId: string) => {
@@ -111,17 +108,36 @@ export default function TeamDetailInteractive() {
};
return (
<TeamDetailTemplate
team={team}
memberships={memberships}
activeTab={activeTab}
loading={loading}
isAdmin={isAdmin}
onTabChange={setActiveTab}
onUpdate={handleUpdate}
onRemoveMember={handleRemoveMember}
onChangeRole={handleChangeRole}
onGoBack={handleGoBack}
/>
<StateContainer
data={teamDetails}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading team details...' },
error: { variant: 'full-screen' },
empty: {
icon: Users,
title: 'Team not found',
description: 'The team may have been deleted or you may not have access',
action: { label: 'Back to Teams', onClick: () => router.push('/teams') }
}
}}
>
{(teamData) => (
<TeamDetailTemplate
team={teamData}
memberships={memberships || []}
activeTab={activeTab}
loading={isLoading}
isAdmin={isAdmin}
onTabChange={setActiveTab}
onUpdate={handleUpdate}
onRemoveMember={handleRemoveMember}
onChangeRole={handleChangeRole}
onGoBack={handleGoBack}
/>
)}
</StateContainer>
);
}
}