page wrapper
This commit is contained in:
@@ -1,180 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { RacesTemplate, TimeFilter, RaceStatusFilter } from '@/templates/RacesTemplate';
|
||||
import { useRacesPageData } from '@/hooks/race/useRacesPageData';
|
||||
import { useRegisterForRace } from '@/hooks/race/useRegisterForRace';
|
||||
import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace';
|
||||
import { useCancelRace } from '@/hooks/race/useCancelRace';
|
||||
import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
|
||||
export function RacesInteractive() {
|
||||
const router = useRouter();
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
// Fetch data
|
||||
const { data: pageData, isLoading } = useRacesPageData();
|
||||
|
||||
// Mutations
|
||||
const registerMutation = useRegisterForRace();
|
||||
const withdrawMutation = useWithdrawFromRace();
|
||||
const cancelMutation = useCancelRace();
|
||||
|
||||
// Filter state
|
||||
const [statusFilter, setStatusFilter] = useState<RaceStatusFilter>('all');
|
||||
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||
const [timeFilter, setTimeFilter] = useState<TimeFilter>('upcoming');
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
|
||||
// Transform data for template
|
||||
const races = pageData?.races.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: 'race', // Not in RaceListItemViewModel, using default
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField ?? undefined,
|
||||
isUpcoming: race.isUpcoming,
|
||||
isLive: race.isLive,
|
||||
isPast: race.isPast,
|
||||
})) ?? [];
|
||||
|
||||
const scheduledRaces = pageData?.scheduledRaces.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: 'race',
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField ?? undefined,
|
||||
isUpcoming: race.isUpcoming,
|
||||
isLive: race.isLive,
|
||||
isPast: race.isPast,
|
||||
})) ?? [];
|
||||
|
||||
const runningRaces = pageData?.runningRaces.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: 'race',
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField ?? undefined,
|
||||
isUpcoming: race.isUpcoming,
|
||||
isLive: race.isLive,
|
||||
isPast: race.isPast,
|
||||
})) ?? [];
|
||||
|
||||
const completedRaces = pageData?.completedRaces.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: 'race',
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField ?? undefined,
|
||||
isUpcoming: race.isUpcoming,
|
||||
isLive: race.isLive,
|
||||
isPast: race.isPast,
|
||||
})) ?? [];
|
||||
|
||||
// Actions
|
||||
const handleRaceClick = (raceId: string) => {
|
||||
router.push(`/races/${raceId}`);
|
||||
};
|
||||
|
||||
const handleLeagueClick = (leagueId: string) => {
|
||||
router.push(`/leagues/${leagueId}`);
|
||||
};
|
||||
|
||||
const handleRegister = async (raceId: string, leagueId: string) => {
|
||||
if (!currentDriverId) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Register for this race?\n\nYou'll be added to the entry list.`,
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await registerMutation.mutateAsync({ raceId, leagueId, driverId: currentDriverId });
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to register for race');
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async (raceId: string) => {
|
||||
if (!currentDriverId) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Withdraw from this race?\n\nYou can register again later if you change your mind.',
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await withdrawMutation.mutateAsync({ raceId, driverId: currentDriverId });
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (raceId: string) => {
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to cancel this race? This action cannot be undone.',
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await cancelMutation.mutateAsync(raceId);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to cancel race');
|
||||
}
|
||||
};
|
||||
|
||||
// User memberships for admin check
|
||||
// For now, we'll handle permissions in the template using LeagueMembershipUtility
|
||||
// This would need actual membership data to work properly
|
||||
const userMemberships: Array<{ leagueId: string; role: string }> = [];
|
||||
|
||||
return (
|
||||
<RacesTemplate
|
||||
races={races}
|
||||
totalCount={pageData?.totalCount ?? 0}
|
||||
scheduledRaces={scheduledRaces}
|
||||
runningRaces={runningRaces}
|
||||
completedRaces={completedRaces}
|
||||
isLoading={isLoading}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
timeFilter={timeFilter}
|
||||
setTimeFilter={setTimeFilter}
|
||||
onRaceClick={handleRaceClick}
|
||||
onLeagueClick={handleLeagueClick}
|
||||
onRegister={handleRegister}
|
||||
onWithdraw={handleWithdraw}
|
||||
onCancel={handleCancel}
|
||||
showFilterModal={showFilterModal}
|
||||
setShowFilterModal={setShowFilterModal}
|
||||
currentDriverId={currentDriverId}
|
||||
userMemberships={userMemberships}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { RacesTemplate } from '@/templates/RacesTemplate';
|
||||
import { ContainerManager } from '@/lib/di/container';
|
||||
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { RaceListItemViewModel } from '@/lib/view-models/RaceListItemViewModel';
|
||||
import type { RaceService } from '@/lib/services/races/RaceService';
|
||||
|
||||
// This is a server component that fetches data and passes it to the template
|
||||
export async function RacesStatic() {
|
||||
const container = ContainerManager.getInstance().getContainer();
|
||||
const raceService = container.get<RaceService>(RACE_SERVICE_TOKEN);
|
||||
|
||||
// Fetch race data server-side
|
||||
const pageData = await raceService.getRacesPageData();
|
||||
|
||||
// Extract races from the response
|
||||
const races = pageData.races.map((race: RaceListItemViewModel) => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: 'race', // Default since RaceListItemViewModel doesn't have sessionType
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField,
|
||||
isUpcoming: race.isUpcoming,
|
||||
isLive: race.isLive,
|
||||
isPast: race.isPast,
|
||||
}));
|
||||
|
||||
// Transform the categorized races as well
|
||||
const transformRaces = (raceList: RaceListItemViewModel[]) =>
|
||||
raceList.map((race: RaceListItemViewModel) => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: 'race',
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField,
|
||||
isUpcoming: race.isUpcoming,
|
||||
isLive: race.isLive,
|
||||
isPast: race.isPast,
|
||||
}));
|
||||
|
||||
// For the static wrapper, we'll use client-side data fetching
|
||||
// This component will be used as a server component that renders the client template
|
||||
return (
|
||||
<RacesTemplate
|
||||
races={races}
|
||||
totalCount={pageData.totalCount}
|
||||
scheduledRaces={transformRaces(pageData.scheduledRaces)}
|
||||
runningRaces={transformRaces(pageData.runningRaces)}
|
||||
completedRaces={transformRaces(pageData.completedRaces)}
|
||||
isLoading={false}
|
||||
// Filter state - will be managed by Interactive component
|
||||
statusFilter="all"
|
||||
setStatusFilter={() => {}}
|
||||
leagueFilter="all"
|
||||
setLeagueFilter={() => {}}
|
||||
timeFilter="upcoming"
|
||||
setTimeFilter={() => {}}
|
||||
// Actions
|
||||
onRaceClick={() => {}}
|
||||
onLeagueClick={() => {}}
|
||||
onRegister={() => {}}
|
||||
onWithdraw={() => {}}
|
||||
onCancel={() => {}}
|
||||
// UI State
|
||||
showFilterModal={false}
|
||||
setShowFilterModal={() => {}}
|
||||
// User state
|
||||
currentDriverId={undefined}
|
||||
userMemberships={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
|
||||
import { useRegisterForRace } from '@/hooks/race/useRegisterForRace';
|
||||
import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace';
|
||||
import { useCancelRace } from '@/hooks/race/useCancelRace';
|
||||
import { useCompleteRace } from '@/hooks/race/useCompleteRace';
|
||||
import { useReopenRace } from '@/hooks/race/useReopenRace';
|
||||
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility';
|
||||
|
||||
// Shared state components
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useRaceDetail } from '@/hooks/race/useRaceDetail';
|
||||
import { Flag } from 'lucide-react';
|
||||
|
||||
export function RaceDetailInteractive() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const raceId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
// Fetch data using DI + React-Query
|
||||
const { data: viewModel, isLoading, error, retry } = useRaceDetail(raceId, currentDriverId);
|
||||
|
||||
// Fetch membership
|
||||
const { data: membership } = useLeagueMembership(viewModel?.league?.id || '', currentDriverId);
|
||||
|
||||
// UI State
|
||||
const [showProtestModal, setShowProtestModal] = useState(false);
|
||||
const [showEndRaceModal, setShowEndRaceModal] = useState(false);
|
||||
|
||||
// Mutations
|
||||
const registerMutation = useRegisterForRace();
|
||||
const withdrawMutation = useWithdrawFromRace();
|
||||
const cancelMutation = useCancelRace();
|
||||
const completeMutation = useCompleteRace();
|
||||
const reopenMutation = useReopenRace();
|
||||
|
||||
// Determine if user is owner/admin
|
||||
const isOwnerOrAdmin = membership
|
||||
? LeagueMembershipUtility.isOwnerOrAdmin(viewModel?.league?.id || '', currentDriverId)
|
||||
: false;
|
||||
|
||||
// Actions
|
||||
const handleBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
const race = viewModel?.race;
|
||||
const league = viewModel?.league;
|
||||
if (!race || !league) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Register for ${race.track}?\n\nYou'll be added to the entry list for this race.`,
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await registerMutation.mutateAsync({ raceId: race.id, leagueId: league.id, driverId: currentDriverId });
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to register for race');
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async () => {
|
||||
const race = viewModel?.race;
|
||||
const league = viewModel?.league;
|
||||
if (!race || !league) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Withdraw from this race?\n\nYou can register again later if you change your mind.',
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await withdrawMutation.mutateAsync({ raceId: race.id, driverId: currentDriverId });
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
const race = viewModel?.race;
|
||||
if (!race || race.status !== 'scheduled') return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to cancel this race? This action cannot be undone.',
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await cancelMutation.mutateAsync(race.id);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to cancel race');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReopen = async () => {
|
||||
const race = viewModel?.race;
|
||||
if (!race || !viewModel?.canReopenRace) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Re-open this race? This will allow re-registration and re-running. Results will be archived.',
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await reopenMutation.mutateAsync(race.id);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to re-open race');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndRace = async () => {
|
||||
const race = viewModel?.race;
|
||||
if (!race) return;
|
||||
|
||||
setShowEndRaceModal(true);
|
||||
};
|
||||
|
||||
const handleFileProtest = () => {
|
||||
setShowProtestModal(true);
|
||||
};
|
||||
|
||||
const handleResultsClick = () => {
|
||||
router.push(`/races/${raceId}/results`);
|
||||
};
|
||||
|
||||
const handleStewardingClick = () => {
|
||||
router.push(`/races/${raceId}/stewarding`);
|
||||
};
|
||||
|
||||
const handleLeagueClick = (leagueId: string) => {
|
||||
router.push(`/leagues/${leagueId}`);
|
||||
};
|
||||
|
||||
const handleDriverClick = (driverId: string) => {
|
||||
router.push(`/drivers/${driverId}`);
|
||||
};
|
||||
|
||||
// Transform data for template - handle null values
|
||||
const templateViewModel = viewModel && viewModel.race ? {
|
||||
race: {
|
||||
id: viewModel.race.id,
|
||||
track: viewModel.race.track,
|
||||
car: viewModel.race.car,
|
||||
scheduledAt: viewModel.race.scheduledAt,
|
||||
status: viewModel.race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: viewModel.race.sessionType,
|
||||
},
|
||||
league: viewModel.league ? {
|
||||
id: viewModel.league.id,
|
||||
name: viewModel.league.name,
|
||||
description: viewModel.league.description || undefined,
|
||||
settings: viewModel.league.settings as { maxDrivers: number; qualifyingFormat: string },
|
||||
} : undefined,
|
||||
entryList: viewModel.entryList.map(entry => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
avatarUrl: entry.avatarUrl,
|
||||
country: entry.country,
|
||||
rating: entry.rating,
|
||||
isCurrentUser: entry.isCurrentUser,
|
||||
})),
|
||||
registration: {
|
||||
isUserRegistered: viewModel.registration.isUserRegistered,
|
||||
canRegister: viewModel.registration.canRegister,
|
||||
},
|
||||
userResult: viewModel.userResult ? {
|
||||
position: viewModel.userResult.position,
|
||||
startPosition: viewModel.userResult.startPosition,
|
||||
positionChange: viewModel.userResult.positionChange,
|
||||
incidents: viewModel.userResult.incidents,
|
||||
isClean: viewModel.userResult.isClean,
|
||||
isPodium: viewModel.userResult.isPodium,
|
||||
ratingChange: viewModel.userResult.ratingChange,
|
||||
} : undefined,
|
||||
canReopenRace: viewModel.canReopenRace,
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={viewModel}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { RaceDetailInteractive } from './RaceDetailInteractive';
|
||||
import type { RaceDetailsViewModel } from '@/lib/view-models/RaceDetailsViewModel';
|
||||
|
||||
// Mocks for Next.js navigation
|
||||
const mockPush = vi.fn();
|
||||
const mockBack = vi.fn();
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
back: mockBack,
|
||||
}),
|
||||
useParams: () => ({ id: 'race-123' }),
|
||||
}));
|
||||
|
||||
// Mock effective driver id hook
|
||||
vi.mock('@/hooks/useEffectiveDriverId', () => ({
|
||||
useEffectiveDriverId: () => 'driver-1',
|
||||
}));
|
||||
|
||||
// Mock sponsor mode hook to avoid rendering heavy sponsor card
|
||||
vi.mock('@/components/sponsors/SponsorInsightsCard', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="sponsor-insights-mock" />,
|
||||
MetricBuilders: {
|
||||
views: vi.fn(() => ({ label: 'Views', value: '100' })),
|
||||
engagement: vi.fn(() => ({ label: 'Engagement', value: '50%' })),
|
||||
reach: vi.fn(() => ({ label: 'Reach', value: '1000' })),
|
||||
},
|
||||
SlotTemplates: {
|
||||
race: vi.fn(() => []),
|
||||
},
|
||||
useSponsorMode: () => false,
|
||||
}));
|
||||
|
||||
// Mock the new DI hooks
|
||||
const mockGetRaceDetails = vi.fn();
|
||||
const mockReopenRace = vi.fn();
|
||||
const mockFetchLeagueMemberships = vi.fn();
|
||||
const mockGetMembership = vi.fn();
|
||||
|
||||
// Mock race detail hook
|
||||
vi.mock('@/hooks/race/useRaceDetail', () => ({
|
||||
useRaceDetail: (raceId: string, driverId: string) => ({
|
||||
data: mockGetRaceDetails.mock.results[0]?.value || null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: !!mockGetRaceDetails.mock.results[0]?.value,
|
||||
refetch: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock reopen race hook
|
||||
vi.mock('@/hooks/race/useReopenRace', () => ({
|
||||
useReopenRace: () => ({
|
||||
mutateAsync: mockReopenRace,
|
||||
mutate: mockReopenRace,
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock league membership service static method
|
||||
vi.mock('@/lib/services/leagues/LeagueMembershipService', () => ({
|
||||
LeagueMembershipService: {
|
||||
getMembership: mockGetMembership,
|
||||
fetchLeagueMemberships: mockFetchLeagueMemberships,
|
||||
setLeagueMemberships: vi.fn(),
|
||||
clearLeagueMemberships: vi.fn(),
|
||||
getCachedMembershipsIterator: vi.fn(() => [][Symbol.iterator]()),
|
||||
getAllMembershipsForDriver: vi.fn(() => []),
|
||||
getLeagueMembers: vi.fn(() => []),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock league membership hook (if used by component)
|
||||
vi.mock('@/hooks/league/useLeagueMemberships', () => ({
|
||||
useLeagueMemberships: (leagueId: string, currentUserId: string) => ({
|
||||
data: mockFetchLeagueMemberships.mock.results[0]?.value || null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: !!mockFetchLeagueMemberships.mock.results[0]?.value,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the useLeagueMembership hook that the component imports
|
||||
vi.mock('@/hooks/useLeagueMembershipService', () => ({
|
||||
useLeagueMembership: (leagueId: string, driverId: string) => ({
|
||||
data: mockGetMembership.mock.results[0]?.value || null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: !!mockGetMembership.mock.results[0]?.value,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// We'll use the actual hooks but they will use the mocked services
|
||||
// The hooks are already mocked above via the service mocks
|
||||
|
||||
// Mock league membership utility to control admin vs non-admin behavior
|
||||
const mockIsOwnerOrAdmin = vi.fn();
|
||||
|
||||
vi.mock('@/lib/utilities/LeagueMembershipUtility', () => ({
|
||||
LeagueMembershipUtility: {
|
||||
isOwnerOrAdmin: (...args: unknown[]) => mockIsOwnerOrAdmin(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
};
|
||||
|
||||
const createViewModel = (status: string): RaceDetailsViewModel => {
|
||||
const canReopenRace = status === 'completed' || status === 'cancelled';
|
||||
|
||||
return {
|
||||
race: {
|
||||
id: 'race-123',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2023-12-31T20:00:00Z',
|
||||
status,
|
||||
sessionType: 'race',
|
||||
},
|
||||
league: {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test league description',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'open',
|
||||
},
|
||||
},
|
||||
entryList: [],
|
||||
registration: {
|
||||
isUserRegistered: false,
|
||||
canRegister: false,
|
||||
},
|
||||
userResult: null,
|
||||
canReopenRace,
|
||||
};
|
||||
};
|
||||
|
||||
describe('RaceDetailPage - Re-open Race behavior', () => {
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
mockGetRaceDetails.mockReset();
|
||||
mockReopenRace.mockReset();
|
||||
mockFetchLeagueMemberships.mockReset();
|
||||
mockGetMembership.mockReset();
|
||||
mockIsOwnerOrAdmin.mockReset();
|
||||
|
||||
// Set up default mock implementations
|
||||
mockFetchLeagueMemberships.mockResolvedValue(undefined);
|
||||
mockGetMembership.mockReturnValue({ role: 'owner' }); // Return owner role by default
|
||||
});
|
||||
|
||||
it('shows Re-open Race button for admin when race is completed and calls reopen + reload on confirm', async () => {
|
||||
mockIsOwnerOrAdmin.mockReturnValue(true);
|
||||
const viewModel = createViewModel('completed');
|
||||
|
||||
// Mock the hooks to return the right data
|
||||
mockGetRaceDetails.mockReturnValue(viewModel);
|
||||
mockGetMembership.mockReturnValue({ role: 'owner' });
|
||||
mockReopenRace.mockResolvedValue(undefined);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
renderWithQueryClient(<RaceDetailInteractive />);
|
||||
|
||||
// Wait for the component to load and render
|
||||
await waitFor(() => {
|
||||
const tracks = screen.getAllByText('Test Track');
|
||||
expect(tracks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Check if the reopen button is present
|
||||
const reopenButton = screen.getByText('Re-open Race');
|
||||
expect(reopenButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(reopenButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReopenRace).toHaveBeenCalledWith('race-123');
|
||||
});
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not render Re-open Race button for non-admin viewer', async () => {
|
||||
mockIsOwnerOrAdmin.mockReturnValue(false);
|
||||
const viewModel = createViewModel('completed');
|
||||
|
||||
mockGetRaceDetails.mockReturnValue(viewModel);
|
||||
mockGetMembership.mockReturnValue({ role: 'member' });
|
||||
|
||||
renderWithQueryClient(<RaceDetailInteractive />);
|
||||
|
||||
await waitFor(() => {
|
||||
const tracks = screen.getAllByText('Test Track');
|
||||
expect(tracks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Re-open Race')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not render Re-open Race button when race is not completed or cancelled even for admin', async () => {
|
||||
mockIsOwnerOrAdmin.mockReturnValue(true);
|
||||
const viewModel = createViewModel('scheduled');
|
||||
|
||||
mockGetRaceDetails.mockReturnValue(viewModel);
|
||||
mockGetMembership.mockReturnValue({ role: 'owner' });
|
||||
|
||||
renderWithQueryClient(<RaceDetailInteractive />);
|
||||
|
||||
await waitFor(() => {
|
||||
const tracks = screen.getAllByText('Test Track');
|
||||
expect(tracks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Re-open Race')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,117 @@
|
||||
import { RaceDetailInteractive } from './RaceDetailInteractive';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
|
||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
||||
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { RaceService } from '@/lib/services/races/RaceService';
|
||||
import type { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
|
||||
|
||||
export default RaceDetailInteractive;
|
||||
interface RaceDetailPageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
|
||||
const raceId = params.id;
|
||||
|
||||
if (!raceId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Fetch initial race data
|
||||
const data = await PageDataFetcher.fetch<RaceService, 'getRaceDetail'>(
|
||||
RACE_SERVICE_TOKEN,
|
||||
'getRaceDetail',
|
||||
raceId,
|
||||
'' // currentDriverId - will be handled client-side for auth
|
||||
);
|
||||
|
||||
if (!data) notFound();
|
||||
|
||||
// Transform data for template
|
||||
const templateViewModel = data && data.race ? {
|
||||
race: {
|
||||
id: data.race.id,
|
||||
track: data.race.track,
|
||||
car: data.race.car,
|
||||
scheduledAt: data.race.scheduledAt,
|
||||
status: data.race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: data.race.sessionType,
|
||||
},
|
||||
league: data.league ? {
|
||||
id: data.league.id,
|
||||
name: data.league.name,
|
||||
description: data.league.description || undefined,
|
||||
settings: data.league.settings as { maxDrivers: number; qualifyingFormat: string },
|
||||
} : undefined,
|
||||
entryList: data.entryList.map((entry: any) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
avatarUrl: entry.avatarUrl,
|
||||
country: entry.country,
|
||||
rating: entry.rating,
|
||||
isCurrentUser: entry.isCurrentUser,
|
||||
})),
|
||||
registration: {
|
||||
isUserRegistered: data.registration.isUserRegistered,
|
||||
canRegister: data.registration.canRegister,
|
||||
},
|
||||
userResult: data.userResult ? {
|
||||
position: data.userResult.position,
|
||||
startPosition: data.userResult.startPosition,
|
||||
positionChange: data.userResult.positionChange,
|
||||
incidents: data.userResult.incidents,
|
||||
isClean: data.userResult.isClean,
|
||||
isPodium: data.userResult.isPodium,
|
||||
ratingChange: data.userResult.ratingChange,
|
||||
} : undefined,
|
||||
canReopenRace: data.canReopenRace,
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={data}
|
||||
Template={({ data }) => (
|
||||
<RaceDetailTemplate
|
||||
viewModel={templateViewModel}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
// These will be handled client-side in the template or a wrapper
|
||||
onBack={() => {}}
|
||||
onRegister={() => {}}
|
||||
onWithdraw={() => {}}
|
||||
onCancel={() => {}}
|
||||
onReopen={() => {}}
|
||||
onEndRace={() => {}}
|
||||
onFileProtest={() => {}}
|
||||
onResultsClick={() => {}}
|
||||
onStewardingClick={() => {}}
|
||||
onLeagueClick={() => {}}
|
||||
onDriverClick={() => {}}
|
||||
currentDriverId={''}
|
||||
isOwnerOrAdmin={false}
|
||||
showProtestModal={false}
|
||||
setShowProtestModal={() => {}}
|
||||
showEndRaceModal={false}
|
||||
setShowEndRaceModal={() => {}}
|
||||
mutationLoading={{
|
||||
register: false,
|
||||
withdraw: false,
|
||||
cancel: false,
|
||||
reopen: false,
|
||||
complete: false,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
loading={{ variant: 'skeleton', message: 'Loading race details...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
icon: require('lucide-react').Flag,
|
||||
title: 'Race not found',
|
||||
description: 'The race may have been cancelled or deleted',
|
||||
action: { label: 'Back to Races', onClick: () => {} }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
||||
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
|
||||
// Shared state components
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useRaceResultsDetail } from '@/hooks/race/useRaceResultsDetail';
|
||||
import { useRaceWithSOF } from '@/hooks/race/useRaceWithSOF';
|
||||
import { Trophy } from 'lucide-react';
|
||||
|
||||
export function RaceResultsInteractive() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const raceId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
// Fetch data using existing hooks
|
||||
const { data: raceData, isLoading, error, retry } = useRaceResultsDetail(raceId, currentDriverId);
|
||||
const { data: sofData } = useRaceWithSOF(raceId);
|
||||
|
||||
// Fetch membership
|
||||
const { data: membershipsData } = useLeagueMemberships(raceData?.league?.id || '', currentDriverId || '');
|
||||
|
||||
// UI State
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importSuccess, setImportSuccess] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [showImportForm, setShowImportForm] = useState(false);
|
||||
|
||||
const raceSOF = sofData?.strengthOfField || null;
|
||||
const currentMembership = membershipsData?.memberships.find(m => m.driverId === currentDriverId);
|
||||
const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false;
|
||||
|
||||
// Transform data for template
|
||||
const results = raceData?.results.map(result => ({
|
||||
position: result.position,
|
||||
driverId: result.driverId,
|
||||
driverName: result.driverName,
|
||||
driverAvatar: result.avatarUrl,
|
||||
country: 'US', // Default since view model doesn't have country
|
||||
car: 'Unknown', // Default since view model doesn't have car
|
||||
laps: 0, // Default since view model doesn't have laps
|
||||
time: '0:00.00', // Default since view model doesn't have time
|
||||
fastestLap: result.fastestLap.toString(), // Convert number to string
|
||||
points: 0, // Default since view model doesn't have points
|
||||
incidents: result.incidents,
|
||||
isCurrentUser: result.driverId === currentDriverId,
|
||||
})) ?? [];
|
||||
|
||||
const penalties = raceData?.penalties.map(penalty => ({
|
||||
driverId: penalty.driverId,
|
||||
driverName: raceData.results.find(r => r.driverId === penalty.driverId)?.driverName || 'Unknown',
|
||||
type: penalty.type as 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points',
|
||||
value: penalty.value || 0,
|
||||
reason: 'Penalty applied', // Default since view model doesn't have reason
|
||||
notes: undefined, // Default since view model doesn't have notes
|
||||
})) ?? [];
|
||||
|
||||
// Actions
|
||||
const handleBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleImportResults = async (importedResults: any[]) => {
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
|
||||
try {
|
||||
// TODO: Implement race results service
|
||||
// await raceResultsService.importRaceResults(raceId, {
|
||||
// resultsFileContent: JSON.stringify(importedResults),
|
||||
// });
|
||||
|
||||
setImportSuccess(true);
|
||||
// await loadData();
|
||||
} catch (err) {
|
||||
setImportError(err instanceof Error ? err.message : 'Failed to import results');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePenaltyClick = (driver: { id: string; name: string }) => {
|
||||
// This would open a penalty modal in a real implementation
|
||||
console.log('Penalty click for:', driver);
|
||||
};
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={raceData}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,125 @@
|
||||
import { RaceResultsInteractive } from './RaceResultsInteractive';
|
||||
'use client';
|
||||
|
||||
export default RaceResultsInteractive;
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
||||
import { useRaceResultsPageData } from '@/hooks/race/useRaceResultsPageData';
|
||||
import { RaceResultsDataTransformer } from '@/lib/transformers/RaceResultsDataTransformer';
|
||||
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useState } from 'react';
|
||||
import { notFound, useRouter } from 'next/navigation';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { Trophy } from 'lucide-react';
|
||||
|
||||
interface RaceResultsPageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function RaceResultsPage({ params }: RaceResultsPageProps) {
|
||||
const router = useRouter();
|
||||
const raceId = params.id;
|
||||
|
||||
if (!raceId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const currentDriverId = useEffectiveDriverId() || '';
|
||||
|
||||
// Fetch data using domain hook
|
||||
const { data: queries, isLoading, error, refetch } = useRaceResultsPageData(raceId, currentDriverId);
|
||||
|
||||
// Additional data - league memberships
|
||||
const leagueName = queries?.results?.league?.name || '';
|
||||
const { data: memberships } = useLeagueMemberships(leagueName, currentDriverId);
|
||||
|
||||
// Transform data
|
||||
const data = queries?.results && queries?.sof
|
||||
? RaceResultsDataTransformer.transform(
|
||||
queries.results,
|
||||
queries.sof,
|
||||
currentDriverId,
|
||||
memberships
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// UI State for import functionality
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importSuccess, setImportSuccess] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [showImportForm, setShowImportForm] = useState(false);
|
||||
|
||||
// Actions
|
||||
const handleBack = () => router.back();
|
||||
|
||||
const handleImportResults = async (importedResults: any[]) => {
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
|
||||
try {
|
||||
console.log('Import results:', importedResults);
|
||||
setImportSuccess(true);
|
||||
|
||||
// Refetch data after import
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
setImportError(err instanceof Error ? err.message : 'Failed to import results');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePenaltyClick = (driver: { id: string; name: string }) => {
|
||||
console.log('Penalty click for:', driver);
|
||||
};
|
||||
|
||||
// Determine admin status from memberships data
|
||||
const currentDriver = data?.results.find(r => r.isCurrentUser);
|
||||
const currentMembership = data?.memberships?.find(m => m.driverId === currentDriverId);
|
||||
const isAdmin = currentMembership
|
||||
? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<StatefulPageWrapper
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error as Error | null}
|
||||
retry={refetch}
|
||||
Template={({ data }) => (
|
||||
<RaceResultsTemplate
|
||||
raceTrack={data.raceTrack}
|
||||
raceScheduledAt={data.raceScheduledAt}
|
||||
totalDrivers={data.totalDrivers}
|
||||
leagueName={data.leagueName}
|
||||
raceSOF={data.raceSOF}
|
||||
results={data.results}
|
||||
penalties={data.penalties}
|
||||
pointsSystem={data.pointsSystem}
|
||||
fastestLapTime={data.fastestLapTime}
|
||||
currentDriverId={currentDriverId}
|
||||
isAdmin={isAdmin}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onBack={handleBack}
|
||||
onImportResults={handleImportResults}
|
||||
onPenaltyClick={handlePenaltyClick}
|
||||
importing={importing}
|
||||
importSuccess={importSuccess}
|
||||
importError={importError}
|
||||
showImportForm={showImportForm}
|
||||
setShowImportForm={setShowImportForm}
|
||||
/>
|
||||
)}
|
||||
loading={{ variant: 'skeleton', message: 'Loading race results...' }}
|
||||
errorConfig={{ 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 }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
|
||||
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
|
||||
// Shared state components
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useRaceStewardingData } from '@/hooks/race/useRaceStewardingData';
|
||||
import { Gavel } from 'lucide-react';
|
||||
|
||||
export function RaceStewardingInteractive() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const raceId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
// Fetch data using existing hooks
|
||||
const { data: stewardingData, isLoading, error, retry } = useRaceStewardingData(raceId, currentDriverId);
|
||||
|
||||
// Fetch membership
|
||||
const { data: membershipsData } = useLeagueMemberships(stewardingData?.league?.id || '', currentDriverId || '');
|
||||
|
||||
// UI State
|
||||
const [activeTab, setActiveTab] = useState<StewardingTab>('pending');
|
||||
|
||||
const currentMembership = membershipsData?.memberships.find(m => m.driverId === currentDriverId);
|
||||
const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false;
|
||||
|
||||
// Actions
|
||||
const handleBack = () => {
|
||||
router.push(`/races/${raceId}`);
|
||||
};
|
||||
|
||||
const handleReviewProtest = (protestId: string) => {
|
||||
// Navigate to protest review page
|
||||
router.push(`/leagues/${stewardingData?.league?.id}/stewarding/protests/${protestId}`);
|
||||
};
|
||||
|
||||
// Transform data for template
|
||||
const templateData = stewardingData ? {
|
||||
race: stewardingData.race,
|
||||
league: stewardingData.league,
|
||||
pendingProtests: stewardingData.pendingProtests,
|
||||
resolvedProtests: stewardingData.resolvedProtests,
|
||||
penalties: stewardingData.penalties,
|
||||
driverMap: stewardingData.driverMap,
|
||||
pendingCount: stewardingData.pendingCount,
|
||||
resolvedCount: stewardingData.resolvedCount,
|
||||
penaltiesCount: stewardingData.penaltiesCount,
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={stewardingData}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,140 @@
|
||||
import { RaceStewardingInteractive } from './RaceStewardingInteractive';
|
||||
'use client';
|
||||
|
||||
export default RaceStewardingInteractive;
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
|
||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
||||
import { RACE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { RaceStewardingService } from '@/lib/services/races/RaceStewardingService';
|
||||
import type { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
|
||||
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { Gavel } from 'lucide-react';
|
||||
|
||||
export default function RaceStewardingPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const raceId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId() || '';
|
||||
|
||||
// Data state
|
||||
const [pageData, setPageData] = useState<RaceStewardingViewModel | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// UI State
|
||||
const [activeTab, setActiveTab] = useState<StewardingTab>('pending');
|
||||
|
||||
// Fetch data on mount and when raceId/currentDriverId changes
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!raceId) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await PageDataFetcher.fetch<RaceStewardingService, 'getRaceStewardingData'>(
|
||||
RACE_STEWARDING_SERVICE_TOKEN,
|
||||
'getRaceStewardingData',
|
||||
raceId,
|
||||
currentDriverId
|
||||
);
|
||||
|
||||
if (data) {
|
||||
setPageData(data);
|
||||
} else {
|
||||
setPageData(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch stewarding data'));
|
||||
setPageData(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [raceId, currentDriverId]);
|
||||
|
||||
// Fetch membership
|
||||
const { data: membershipsData } = useLeagueMemberships(pageData?.league?.id || '', currentDriverId || '');
|
||||
const currentMembership = membershipsData?.memberships.find(m => m.driverId === currentDriverId);
|
||||
const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false;
|
||||
|
||||
// Actions
|
||||
const handleBack = () => {
|
||||
router.push(`/races/${raceId}`);
|
||||
};
|
||||
|
||||
const handleReviewProtest = (protestId: string) => {
|
||||
// Navigate to protest review page
|
||||
router.push(`/leagues/${pageData?.league?.id}/stewarding/protests/${protestId}`);
|
||||
};
|
||||
|
||||
// Transform data for template
|
||||
const templateData = pageData ? {
|
||||
race: pageData.race,
|
||||
league: pageData.league,
|
||||
pendingProtests: pageData.pendingProtests,
|
||||
resolvedProtests: pageData.resolvedProtests,
|
||||
penalties: pageData.penalties,
|
||||
driverMap: pageData.driverMap,
|
||||
pendingCount: pageData.pendingCount,
|
||||
resolvedCount: pageData.resolvedCount,
|
||||
penaltiesCount: pageData.penaltiesCount,
|
||||
} : undefined;
|
||||
|
||||
const retry = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await PageDataFetcher.fetch<RaceStewardingService, 'getRaceStewardingData'>(
|
||||
RACE_STEWARDING_SERVICE_TOKEN,
|
||||
'getRaceStewardingData',
|
||||
raceId,
|
||||
currentDriverId
|
||||
);
|
||||
|
||||
if (data) {
|
||||
setPageData(data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch stewarding data'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={pageData}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
Template={({ data }) => (
|
||||
<RaceStewardingTemplate
|
||||
stewardingData={templateData}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onBack={handleBack}
|
||||
onReviewProtest={handleReviewProtest}
|
||||
isAdmin={isAdmin}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
)}
|
||||
loading={{ variant: 'skeleton', message: 'Loading stewarding data...' }}
|
||||
errorConfig={{ 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 }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { RacesAllTemplate, StatusFilter } from '@/templates/RacesAllTemplate';
|
||||
import { useAllRacesPageData } from '@/hooks/useRaceService';
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
export function RacesAllInteractive() {
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch data
|
||||
const { data: pageData, isLoading } = useAllRacesPageData();
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
|
||||
// Transform data for template
|
||||
const races = pageData?.races.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: 'race',
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField ?? undefined,
|
||||
})) ?? [];
|
||||
|
||||
// Calculate total pages
|
||||
const filteredRaces = races.filter(race => {
|
||||
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesTrack = race.track.toLowerCase().includes(query);
|
||||
const matchesCar = race.car.toLowerCase().includes(query);
|
||||
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
|
||||
if (!matchesTrack && !matchesCar && !matchesLeague) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
|
||||
|
||||
// Actions
|
||||
const handleRaceClick = (raceId: string) => {
|
||||
router.push(`/races/${raceId}`);
|
||||
};
|
||||
|
||||
const handleLeagueClick = (leagueId: string) => {
|
||||
router.push(`/leagues/${leagueId}`);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<RacesAllTemplate
|
||||
races={races}
|
||||
isLoading={isLoading}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
onPageChange={handlePageChange}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
showFilters={showFilters}
|
||||
setShowFilters={setShowFilters}
|
||||
showFilterModal={showFilterModal}
|
||||
setShowFilterModal={setShowFilterModal}
|
||||
onRaceClick={handleRaceClick}
|
||||
onLeagueClick={handleLeagueClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,114 @@
|
||||
import { RacesAllInteractive } from './RacesAllInteractive';
|
||||
'use client';
|
||||
|
||||
export default RacesAllInteractive;
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
import { RacesAllTemplate, StatusFilter } from '@/templates/RacesAllTemplate';
|
||||
import { useAllRacesPageData } from '@/hooks/race/useAllRacesPageData';
|
||||
import { Flag } from 'lucide-react';
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
export default function RacesAllPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// Client-side state for filters and pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
|
||||
// Fetch data using domain hook
|
||||
const { data: pageData, isLoading, error, refetch } = useAllRacesPageData();
|
||||
|
||||
// Transform data for template
|
||||
const races = pageData?.races.map((race) => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: 'race',
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField ?? undefined,
|
||||
})) ?? [];
|
||||
|
||||
// Calculate total pages
|
||||
const filteredRaces = races.filter((race) => {
|
||||
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesTrack = race.track.toLowerCase().includes(query);
|
||||
const matchesCar = race.car.toLowerCase().includes(query);
|
||||
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
|
||||
if (!matchesTrack && !matchesCar && !matchesLeague) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
|
||||
|
||||
// Actions
|
||||
const handleRaceClick = (raceId: string) => {
|
||||
router.push(`/races/${raceId}`);
|
||||
};
|
||||
|
||||
const handleLeagueClick = (leagueId: string) => {
|
||||
router.push(`/leagues/${leagueId}`);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<StatefulPageWrapper
|
||||
data={pageData}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={refetch}
|
||||
Template={({ data }) => (
|
||||
<RacesAllTemplate
|
||||
races={races}
|
||||
isLoading={false}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
onPageChange={handlePageChange}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
showFilters={showFilters}
|
||||
setShowFilters={setShowFilters}
|
||||
showFilterModal={showFilterModal}
|
||||
setShowFilterModal={setShowFilterModal}
|
||||
onRaceClick={handleRaceClick}
|
||||
onLeagueClick={handleLeagueClick}
|
||||
/>
|
||||
)}
|
||||
loading={{ variant: 'skeleton', message: 'Loading races...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
icon: Flag,
|
||||
title: 'No races found',
|
||||
description: 'There are no races available at the moment',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,71 @@
|
||||
import { RacesInteractive } from './RacesInteractive';
|
||||
import { RacesTemplate } from '@/templates/RacesTemplate';
|
||||
import { RaceService } from '@/lib/services/races/RaceService';
|
||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
|
||||
export default RacesInteractive;
|
||||
export default async function Page() {
|
||||
// Create dependencies for API clients
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
|
||||
// Create API client
|
||||
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
// Create service
|
||||
const service = new RaceService(racesApiClient);
|
||||
|
||||
const data = await service.getRacesPageData();
|
||||
|
||||
// Transform data for template
|
||||
const transformRace = (race: any) => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: 'race',
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField ?? undefined,
|
||||
isUpcoming: race.isUpcoming,
|
||||
isLive: race.isLive,
|
||||
isPast: race.isPast,
|
||||
});
|
||||
|
||||
const races = data.races.map(transformRace);
|
||||
const scheduledRaces = data.scheduledRaces.map(transformRace);
|
||||
const runningRaces = data.runningRaces.map(transformRace);
|
||||
const completedRaces = data.completedRaces.map(transformRace);
|
||||
const totalCount = data.totalCount;
|
||||
|
||||
return <RacesTemplate
|
||||
races={races}
|
||||
totalCount={totalCount}
|
||||
scheduledRaces={scheduledRaces}
|
||||
runningRaces={runningRaces}
|
||||
completedRaces={completedRaces}
|
||||
isLoading={false}
|
||||
statusFilter="all"
|
||||
setStatusFilter={() => {}}
|
||||
leagueFilter="all"
|
||||
setLeagueFilter={() => {}}
|
||||
timeFilter="upcoming"
|
||||
setTimeFilter={() => {}}
|
||||
onRaceClick={() => {}}
|
||||
onLeagueClick={() => {}}
|
||||
onRegister={() => {}}
|
||||
onWithdraw={() => {}}
|
||||
onCancel={() => {}}
|
||||
showFilterModal={false}
|
||||
setShowFilterModal={() => {}}
|
||||
currentDriverId={undefined}
|
||||
userMemberships={[]}
|
||||
/>;
|
||||
}
|
||||
Reference in New Issue
Block a user