website refactor
This commit is contained in:
@@ -1,63 +1,36 @@
|
||||
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
||||
import { LeagueService } from '@/lib/services/leagues/LeagueService';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
|
||||
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
|
||||
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';
|
||||
import { LeagueRulebookPageQuery } from '@/lib/page-queries/page-queries/LeagueRulebookPageQuery';
|
||||
import { RulebookTemplate } from '@/templates/RulebookTemplate';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
|
||||
interface Props {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
// Validate params
|
||||
if (!params.id) {
|
||||
const leagueId = params.id;
|
||||
|
||||
if (!leagueId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Fetch data using PageDataFetcher.fetchManual
|
||||
const data = await PageDataFetcher.fetchManual(async () => {
|
||||
// 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',
|
||||
});
|
||||
const result = await LeagueRulebookPageQuery.execute(leagueId);
|
||||
|
||||
// Create API clients
|
||||
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
|
||||
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
|
||||
const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
|
||||
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
// Create service
|
||||
const service = new LeagueService(
|
||||
leaguesApiClient,
|
||||
driversApiClient,
|
||||
sponsorsApiClient,
|
||||
racesApiClient
|
||||
);
|
||||
|
||||
return await service.getLeagueDetailPageData(params.id);
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error.type === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
// For serverError, show the template with empty data
|
||||
return <RulebookTemplate viewData={{
|
||||
leagueId,
|
||||
scoringConfig: {
|
||||
gameName: 'Unknown',
|
||||
scoringPresetName: 'Unknown',
|
||||
championships: [],
|
||||
dropPolicySummary: 'Unknown',
|
||||
},
|
||||
}} />;
|
||||
}
|
||||
|
||||
// Create a Template wrapper that matches PageWrapper's expected interface
|
||||
const Template = ({ data }: { data: LeagueDetailPageViewModel }) => {
|
||||
return <LeagueRulebookTemplate viewModel={data} loading={false} />;
|
||||
};
|
||||
|
||||
return <PageWrapper data={data} Template={Template} />;
|
||||
return <RulebookTemplate viewData={result.unwrap()} />;
|
||||
}
|
||||
@@ -1,142 +1,43 @@
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { LeagueStandingsPageQuery } from '@/lib/page-queries/page-queries/LeagueStandingsPageQuery';
|
||||
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
|
||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
||||
import { LeagueService } from '@/lib/services/leagues/LeagueService';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
|
||||
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
|
||||
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';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||
import type { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO';
|
||||
import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
|
||||
import { LeagueStandingsPresenter } from '@/lib/presenters/LeagueStandingsPresenter';
|
||||
|
||||
interface Props {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
// Validate params
|
||||
if (!params.id) {
|
||||
const leagueId = params.id;
|
||||
|
||||
if (!leagueId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Fetch data using PageDataFetcher.fetchManual for multiple dependencies
|
||||
const data = await PageDataFetcher.fetchManual(async () => {
|
||||
// Create dependencies
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
const result = await LeagueStandingsPageQuery.execute(leagueId);
|
||||
|
||||
// Create API clients
|
||||
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
|
||||
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
|
||||
const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
|
||||
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
// Create service
|
||||
const service = new LeagueService(
|
||||
leaguesApiClient,
|
||||
driversApiClient,
|
||||
sponsorsApiClient,
|
||||
racesApiClient
|
||||
);
|
||||
|
||||
// Fetch data
|
||||
const standingsDto = await service.getLeagueStandings(params.id);
|
||||
if (!standingsDto) {
|
||||
throw new Error('League standings not found');
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error.type === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Get memberships for transformation
|
||||
const membershipsDto = await service.getLeagueMemberships(params.id);
|
||||
|
||||
// Transform standings to StandingEntryViewModel[]
|
||||
const standings: LeagueStandingDTO[] = standingsDto.standings || [];
|
||||
const leaderPoints = standings[0]?.points || 0;
|
||||
const standingViewModels = standings.map((entry, index) => {
|
||||
const nextPoints = standings[index + 1]?.points || entry.points;
|
||||
return new StandingEntryViewModel(entry, leaderPoints, nextPoints, '', undefined);
|
||||
});
|
||||
|
||||
// Extract unique drivers from standings and convert to DriverViewModel[]
|
||||
const driverMap = new Map<string, DriverViewModel>();
|
||||
standings.forEach(standing => {
|
||||
if (standing.driver && !driverMap.has(standing.driver.id)) {
|
||||
const driver = standing.driver;
|
||||
driverMap.set(driver.id, new DriverViewModel({
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
avatarUrl: null, // DriverDTO doesn't have avatarUrl
|
||||
iracingId: driver.iracingId,
|
||||
rating: undefined, // DriverDTO doesn't have rating
|
||||
country: driver.country,
|
||||
}));
|
||||
}
|
||||
});
|
||||
const drivers = Array.from(driverMap.values());
|
||||
|
||||
// Transform memberships
|
||||
const memberships: LeagueMembership[] = (membershipsDto.members || []).map((m: LeagueMemberDTO) => ({
|
||||
driverId: m.driverId,
|
||||
leagueId: params.id,
|
||||
role: (m.role as LeagueMembership['role']) ?? 'member',
|
||||
joinedAt: m.joinedAt,
|
||||
status: 'active' as const,
|
||||
}));
|
||||
|
||||
return {
|
||||
standings: standingViewModels,
|
||||
drivers,
|
||||
memberships,
|
||||
};
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
// For serverError, show the template with empty data
|
||||
return <LeagueStandingsTemplate
|
||||
viewData={{
|
||||
standings: [],
|
||||
drivers: [],
|
||||
memberships: [],
|
||||
leagueId,
|
||||
currentDriverId: null,
|
||||
isAdmin: false,
|
||||
}}
|
||||
onRemoveMember={() => {}}
|
||||
onUpdateRole={() => {}}
|
||||
/>;
|
||||
}
|
||||
|
||||
// Create a wrapper component that passes ViewData to the template
|
||||
const TemplateWrapper = () => {
|
||||
// Convert ViewModels to ViewData using Presenter
|
||||
const viewData = LeagueStandingsPresenter.createViewData(
|
||||
data.standings,
|
||||
data.drivers,
|
||||
data.memberships,
|
||||
params.id,
|
||||
null, // currentDriverId
|
||||
false // isAdmin
|
||||
);
|
||||
|
||||
return (
|
||||
<LeagueStandingsTemplate
|
||||
viewData={viewData}
|
||||
onRemoveMember={() => {}}
|
||||
onUpdateRole={() => {}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={data}
|
||||
Template={TemplateWrapper}
|
||||
loading={{ variant: 'skeleton', message: 'Loading standings...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
title: 'Standings not found',
|
||||
description: 'The standings for this league are not available.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <LeagueStandingsTemplate
|
||||
viewData={result.unwrap()}
|
||||
onRemoveMember={() => {}}
|
||||
onUpdateRole={() => {}}
|
||||
/>;
|
||||
}
|
||||
@@ -1,71 +1,35 @@
|
||||
'use client';
|
||||
import { LeagueStewardingPageQuery } from '@/lib/page-queries/page-queries/LeagueStewardingPageQuery';
|
||||
import { StewardingTemplate } from '@/templates/StewardingTemplate';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { useCurrentDriver } from "@/lib/hooks/driver/useCurrentDriver";
|
||||
import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus";
|
||||
import { useLeagueStewardingData } from "@/lib/hooks/league/useLeagueStewardingData";
|
||||
import { useLeagueStewardingMutations } from "@/lib/hooks/league/useLeagueStewardingMutations";
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
import { StewardingTemplate } from './StewardingTemplate';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
interface Props {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export default function LeagueStewardingPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const { data: currentDriver } = useCurrentDriver();
|
||||
const currentDriverId = currentDriver?.id || '';
|
||||
export default async function LeagueStewardingPage({ params }: Props) {
|
||||
const leagueId = params.id;
|
||||
|
||||
// Check admin status
|
||||
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
|
||||
|
||||
// Show loading for admin check
|
||||
if (adminLoading) {
|
||||
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
|
||||
if (!leagueId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Show access denied if not admin
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Only league admins can access stewarding functions.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
const result = await LeagueStewardingPageQuery.execute(leagueId);
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error.type === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
// For serverError, show the template with empty data
|
||||
return <StewardingTemplate viewData={{
|
||||
leagueId,
|
||||
totalPending: 0,
|
||||
totalResolved: 0,
|
||||
totalPenalties: 0,
|
||||
races: [],
|
||||
drivers: []
|
||||
}} />;
|
||||
}
|
||||
|
||||
// Load stewarding data using domain hook
|
||||
const { data, isLoading, error, refetch } = useLeagueStewardingData(leagueId);
|
||||
|
||||
return (
|
||||
<StatefulPageWrapper
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={refetch}
|
||||
Template={({ data }) => (
|
||||
<StewardingTemplate
|
||||
data={data}
|
||||
leagueId={leagueId}
|
||||
currentDriverId={currentDriverId}
|
||||
onRefetch={refetch}
|
||||
/>
|
||||
)}
|
||||
loading={{ variant: 'skeleton', message: 'Loading stewarding data...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
icon: require('lucide-react').Flag,
|
||||
title: 'No stewarding data',
|
||||
description: 'There are no protests or penalties to review.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <StewardingTemplate viewData={result.unwrap()} />;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { LeagueStandingsViewData, StandingEntryData, DriverData, LeagueMembershipData } from '@/lib/view-data/LeagueStandingsViewData';
|
||||
import type { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO';
|
||||
import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
|
||||
|
||||
interface LeagueStandingsApiDto {
|
||||
standings: LeagueStandingDTO[];
|
||||
}
|
||||
|
||||
interface LeagueMembershipsApiDto {
|
||||
members: LeagueMemberDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
* LeagueStandingsViewDataBuilder
|
||||
*
|
||||
* Transforms API DTOs into LeagueStandingsViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class LeagueStandingsViewDataBuilder {
|
||||
static build(
|
||||
standingsDto: LeagueStandingsApiDto,
|
||||
membershipsDto: LeagueMembershipsApiDto,
|
||||
leagueId: string
|
||||
): LeagueStandingsViewData {
|
||||
const standings = standingsDto.standings || [];
|
||||
const members = membershipsDto.members || [];
|
||||
|
||||
// Convert LeagueStandingDTO to StandingEntryData
|
||||
const standingData: StandingEntryData[] = standings.map(standing => ({
|
||||
driverId: standing.driverId,
|
||||
position: standing.position,
|
||||
totalPoints: standing.points,
|
||||
racesFinished: standing.races,
|
||||
racesStarted: standing.races,
|
||||
avgFinish: null, // Not in DTO
|
||||
penaltyPoints: 0, // Not in DTO
|
||||
bonusPoints: 0, // Not in DTO
|
||||
}));
|
||||
|
||||
// Extract unique drivers from standings
|
||||
const driverMap = new Map<string, DriverData>();
|
||||
standings.forEach(standing => {
|
||||
if (standing.driver && !driverMap.has(standing.driver.id)) {
|
||||
const driver = standing.driver;
|
||||
driverMap.set(driver.id, {
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
avatarUrl: null, // DTO may not have this
|
||||
iracingId: driver.iracingId,
|
||||
rating: undefined,
|
||||
country: driver.country,
|
||||
});
|
||||
}
|
||||
});
|
||||
const driverData: DriverData[] = Array.from(driverMap.values());
|
||||
|
||||
// Convert LeagueMemberDTO to LeagueMembershipData
|
||||
const membershipData: LeagueMembershipData[] = members.map(member => ({
|
||||
driverId: member.driverId,
|
||||
leagueId: leagueId,
|
||||
role: (member.role as LeagueMembershipData['role']) || 'member',
|
||||
joinedAt: member.joinedAt,
|
||||
status: 'active' as const,
|
||||
}));
|
||||
|
||||
return {
|
||||
standings: standingData,
|
||||
drivers: driverData,
|
||||
memberships: membershipData,
|
||||
leagueId,
|
||||
currentDriverId: null, // Would need to get from auth
|
||||
isAdmin: false, // Would need to check permissions
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ProtestDetailViewData } from '@/lib/view-data/leagues/ProtestDetailViewData';
|
||||
|
||||
interface ProtestDetailApiDto {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
status: string;
|
||||
submittedAt: string;
|
||||
incident: {
|
||||
lap: number;
|
||||
description: string;
|
||||
};
|
||||
protestingDriver: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
accusedDriver: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
race: {
|
||||
id: string;
|
||||
name: string;
|
||||
scheduledAt: string;
|
||||
};
|
||||
penaltyTypes: Array<{
|
||||
type: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class ProtestDetailViewDataBuilder {
|
||||
static build(apiDto: ProtestDetailApiDto): ProtestDetailViewData {
|
||||
return {
|
||||
protestId: apiDto.id,
|
||||
leagueId: apiDto.leagueId,
|
||||
status: apiDto.status,
|
||||
submittedAt: apiDto.submittedAt,
|
||||
incident: apiDto.incident,
|
||||
protestingDriver: apiDto.protestingDriver,
|
||||
accusedDriver: apiDto.accusedDriver,
|
||||
race: apiDto.race,
|
||||
penaltyTypes: apiDto.penaltyTypes,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData';
|
||||
import { RulebookApiDto } from '@/lib/types/tbd/RulebookApiDto';
|
||||
|
||||
export class RulebookViewDataBuilder {
|
||||
static build(apiDto: RulebookApiDto): RulebookViewData {
|
||||
const primaryChampionship = apiDto.scoringConfig.championships.find(c => c.type === 'driver') ?? apiDto.scoringConfig.championships[0];
|
||||
const positionPoints: { position: number; points: number }[] = primaryChampionship?.pointsPreview
|
||||
.filter((p): p is { sessionType: string; position: number; points: number } => p.sessionType === primaryChampionship.sessionTypes[0])
|
||||
.map(p => ({ position: p.position, points: p.points }))
|
||||
.sort((a, b) => a.position - b.position) || [];
|
||||
|
||||
return {
|
||||
leagueId: apiDto.leagueId,
|
||||
gameName: apiDto.scoringConfig.gameName,
|
||||
scoringPresetName: apiDto.scoringConfig.scoringPresetName,
|
||||
championshipsCount: apiDto.scoringConfig.championships.length,
|
||||
sessionTypes: primaryChampionship?.sessionTypes.join(', ') || 'Main',
|
||||
dropPolicySummary: apiDto.scoringConfig.dropPolicySummary,
|
||||
hasActiveDropPolicy: !apiDto.scoringConfig.dropPolicySummary.includes('All'),
|
||||
positionPoints,
|
||||
bonusPoints: primaryChampionship?.bonusSummary || [],
|
||||
hasBonusPoints: (primaryChampionship?.bonusSummary.length || 0) > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { StewardingApiDto } from '@/lib/types/tbd/StewardingApiDto';
|
||||
import { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
|
||||
|
||||
|
||||
export class StewardingViewDataBuilder {
|
||||
static build(apiDto: StewardingApiDto): StewardingViewData {
|
||||
return {
|
||||
leagueId: apiDto.leagueId,
|
||||
totalPending: apiDto.totalPending || 0,
|
||||
totalResolved: apiDto.totalResolved || 0,
|
||||
totalPenalties: apiDto.totalPenalties || 0,
|
||||
races: (apiDto.races || []).map((race) => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
scheduledAt: race.scheduledAt,
|
||||
pendingProtests: race.pendingProtests || [],
|
||||
resolvedProtests: race.resolvedProtests || [],
|
||||
penalties: race.penalties || [],
|
||||
})),
|
||||
drivers: (apiDto.drivers || []).map((driver) => ({
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { ProtestDetailService } from '@/lib/services/leagues/ProtestDetailService';
|
||||
import { ProtestDetailViewDataBuilder } from '@/lib/builders/view-data/ProtestDetailViewDataBuilder';
|
||||
import { ProtestDetailViewData } from '@/lib/view-data/leagues/ProtestDetailViewData';
|
||||
|
||||
interface PresentationError {
|
||||
type: 'notFound' | 'forbidden' | 'serverError';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class LeagueProtestDetailPageQuery implements PageQuery<ProtestDetailViewData, { leagueId: string; protestId: string }, PresentationError> {
|
||||
async execute(params: { leagueId: string; protestId: string }): Promise<Result<ProtestDetailViewData, PresentationError>> {
|
||||
const service = new ProtestDetailService();
|
||||
const result = await service.getProtestDetail(params.leagueId, params.protestId);
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err({ type: 'serverError', message: 'Failed to load protest details' });
|
||||
}
|
||||
|
||||
const viewData = ProtestDetailViewDataBuilder.build(result.unwrap());
|
||||
return Result.ok(viewData);
|
||||
}
|
||||
|
||||
static async execute(params: { leagueId: string; protestId: string }): Promise<Result<ProtestDetailViewData, PresentationError>> {
|
||||
const query = new LeagueProtestDetailPageQuery();
|
||||
return query.execute(params);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { LeagueRulebookService } from '@/lib/services/leagues/LeagueRulebookService';
|
||||
import { RulebookViewDataBuilder } from '@/lib/builders/view-data/RulebookViewDataBuilder';
|
||||
import { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData';
|
||||
|
||||
/**
|
||||
* LeagueRulebookPageQuery
|
||||
*
|
||||
* Fetches league rulebook data.
|
||||
* Currently returns empty data - would need API endpoint.
|
||||
*/
|
||||
export class LeagueRulebookPageQuery implements PageQuery<any, string> {
|
||||
async execute(leagueId: string): Promise<Result<any, 'notFound' | 'redirect' | 'RULEBOOK_FETCH_FAILED' | 'UNKNOWN_ERROR'>> {
|
||||
// TODO: Implement when API endpoint is available
|
||||
// For now, return empty data
|
||||
return Result.ok({ leagueId, rules: [] });
|
||||
interface PresentationError {
|
||||
type: 'notFound' | 'forbidden' | 'serverError';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class LeagueRulebookPageQuery implements PageQuery<RulebookViewData, string, PresentationError> {
|
||||
async execute(leagueId: string): Promise<Result<RulebookViewData, PresentationError>> {
|
||||
const service = new LeagueRulebookService();
|
||||
const result = await service.getRulebookData(leagueId);
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err({ type: 'serverError', message: 'Failed to load rulebook data' });
|
||||
}
|
||||
|
||||
const viewData = RulebookViewDataBuilder.build(result.unwrap());
|
||||
return Result.ok(viewData);
|
||||
}
|
||||
|
||||
static async execute(leagueId: string): Promise<Result<any, 'notFound' | 'redirect' | 'RULEBOOK_FETCH_FAILED' | 'UNKNOWN_ERROR'>> {
|
||||
static async execute(leagueId: string): Promise<Result<RulebookViewData, PresentationError>> {
|
||||
const query = new LeagueRulebookPageQuery();
|
||||
return query.execute(leagueId);
|
||||
}
|
||||
|
||||
@@ -1,64 +1,29 @@
|
||||
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData';
|
||||
import { LeagueStandingsService } from '@/lib/services/leagues/LeagueStandingsService';
|
||||
import { LeagueStandingsViewDataBuilder } from '@/lib/builders/view-data/LeagueStandingsViewDataBuilder';
|
||||
import { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData';
|
||||
|
||||
/**
|
||||
* LeagueStandingsPageQuery
|
||||
*
|
||||
* Fetches league standings data for the standings page.
|
||||
* Returns Result<LeagueStandingsViewData, PresentationError>
|
||||
*/
|
||||
export class LeagueStandingsPageQuery implements PageQuery<LeagueStandingsViewData, string> {
|
||||
async execute(leagueId: string): Promise<Result<LeagueStandingsViewData, 'notFound' | 'redirect' | 'STANDINGS_FETCH_FAILED' | 'UNKNOWN_ERROR'>> {
|
||||
// Manual wiring: create API client
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
const logger = new ConsoleLogger();
|
||||
const apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
try {
|
||||
// Fetch standings
|
||||
const standingsDto = await apiClient.getStandings(leagueId);
|
||||
|
||||
if (!standingsDto) {
|
||||
return Result.err('notFound');
|
||||
}
|
||||
|
||||
// For now, return empty data structure
|
||||
// In a real implementation, this would transform the DTO to ViewData
|
||||
const viewData: LeagueStandingsViewData = {
|
||||
standings: [],
|
||||
drivers: [],
|
||||
memberships: [],
|
||||
leagueId,
|
||||
currentDriverId: null,
|
||||
isAdmin: false,
|
||||
};
|
||||
|
||||
return Result.ok(viewData);
|
||||
} catch (error) {
|
||||
console.error('LeagueStandingsPageQuery failed:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('403') || error.message.includes('401')) {
|
||||
return Result.err('redirect');
|
||||
}
|
||||
if (error.message.includes('404')) {
|
||||
return Result.err('notFound');
|
||||
}
|
||||
if (error.message.includes('5') || error.message.includes('server')) {
|
||||
return Result.err('STANDINGS_FETCH_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
return Result.err('UNKNOWN_ERROR');
|
||||
interface PresentationError {
|
||||
type: 'notFound' | 'forbidden' | 'serverError';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class LeagueStandingsPageQuery implements PageQuery<LeagueStandingsViewData, string, PresentationError> {
|
||||
async execute(leagueId: string): Promise<Result<LeagueStandingsViewData, PresentationError>> {
|
||||
const service = new LeagueStandingsService();
|
||||
const result = await service.getStandingsData(leagueId);
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err({ type: 'serverError', message: 'Failed to load standings data' });
|
||||
}
|
||||
|
||||
const { standings, memberships } = result.unwrap();
|
||||
const viewData = LeagueStandingsViewDataBuilder.build(standings, memberships, leagueId);
|
||||
return Result.ok(viewData);
|
||||
}
|
||||
|
||||
static async execute(leagueId: string): Promise<Result<LeagueStandingsViewData, 'notFound' | 'redirect' | 'STANDINGS_FETCH_FAILED' | 'UNKNOWN_ERROR'>> {
|
||||
static async execute(leagueId: string): Promise<Result<LeagueStandingsViewData, PresentationError>> {
|
||||
const query = new LeagueStandingsPageQuery();
|
||||
return query.execute(leagueId);
|
||||
}
|
||||
|
||||
@@ -1,66 +1,30 @@
|
||||
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
|
||||
import { LeagueStewardingService } from '@/lib/services/leagues/LeagueStewardingService';
|
||||
import { StewardingViewDataBuilder } from '@/lib/builders/view-data/StewardingViewDataBuilder';
|
||||
import { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
|
||||
|
||||
/**
|
||||
* LeagueStewardingPageQuery
|
||||
*
|
||||
* Fetches league stewarding data (protests and penalties).
|
||||
*/
|
||||
export class LeagueStewardingPageQuery implements PageQuery<any, string> {
|
||||
async execute(leagueId: string): Promise<Result<any, 'notFound' | 'redirect' | 'STEWARDING_FETCH_FAILED' | 'UNKNOWN_ERROR'>> {
|
||||
// Manual wiring: create API client
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
const logger = new ConsoleLogger();
|
||||
const apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
try {
|
||||
// Get races for the league
|
||||
const racesData = await apiClient.getRaces(leagueId);
|
||||
|
||||
if (!racesData) {
|
||||
return Result.err('notFound');
|
||||
}
|
||||
|
||||
// Get memberships for driver lookup
|
||||
const memberships = await apiClient.getMemberships(leagueId);
|
||||
|
||||
// Return data structure for stewarding page
|
||||
// In real implementation, would need protest/penalty API endpoints
|
||||
return Result.ok({
|
||||
leagueId,
|
||||
races: racesData.races || [],
|
||||
memberships: memberships || { members: [] },
|
||||
totalPending: 0,
|
||||
totalResolved: 0,
|
||||
totalPenalties: 0,
|
||||
racesWithData: [],
|
||||
allDrivers: [],
|
||||
driverMap: {},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('LeagueStewardingPageQuery failed:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('403') || error.message.includes('401')) {
|
||||
return Result.err('redirect');
|
||||
}
|
||||
if (error.message.includes('404')) {
|
||||
return Result.err('notFound');
|
||||
}
|
||||
if (error.message.includes('5') || error.message.includes('server')) {
|
||||
return Result.err('STEWARDING_FETCH_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
return Result.err('UNKNOWN_ERROR');
|
||||
interface PresentationError {
|
||||
type: 'notFound' | 'forbidden' | 'notImplemented' | 'serverError';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class LeagueStewardingPageQuery implements PageQuery<StewardingViewData, string, PresentationError> {
|
||||
async execute(leagueId: string): Promise<Result<StewardingViewData, PresentationError>> {
|
||||
const service = new LeagueStewardingService();
|
||||
const result = await service.getStewardingData(leagueId);
|
||||
|
||||
if (result.isErr()) {
|
||||
// Map domain errors to presentation errors
|
||||
return Result.err({ type: 'serverError', message: 'Failed to load stewarding data' });
|
||||
}
|
||||
|
||||
const viewData = StewardingViewDataBuilder.build(result.unwrap());
|
||||
return Result.ok(viewData);
|
||||
}
|
||||
|
||||
static async execute(leagueId: string): Promise<Result<any, 'notFound' | 'redirect' | 'STEWARDING_FETCH_FAILED' | 'UNKNOWN_ERROR'>> {
|
||||
// Static method to avoid object construction in server code
|
||||
static async execute(leagueId: string): Promise<Result<StewardingViewData, PresentationError>> {
|
||||
const query = new LeagueStewardingPageQuery();
|
||||
return query.execute(leagueId);
|
||||
}
|
||||
|
||||
30
apps/website/lib/services/leagues/LeagueRulebookService.ts
Normal file
30
apps/website/lib/services/leagues/LeagueRulebookService.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { Service } from '@/lib/contracts/services/Service';
|
||||
import { RulebookApiDto } from '@/lib/types/tbd/RulebookApiDto';
|
||||
|
||||
export class LeagueRulebookService implements Service {
|
||||
async getRulebookData(leagueId: string): Promise<Result<RulebookApiDto, never>> {
|
||||
// Mock data since backend not implemented
|
||||
const mockData: RulebookApiDto = {
|
||||
leagueId,
|
||||
scoringConfig: {
|
||||
gameName: 'iRacing',
|
||||
scoringPresetName: 'Custom Rules',
|
||||
championships: [
|
||||
{
|
||||
type: 'driver',
|
||||
sessionTypes: ['Race'],
|
||||
pointsPreview: [
|
||||
{ sessionType: 'Race', position: 1, points: 25 },
|
||||
{ sessionType: 'Race', position: 2, points: 20 },
|
||||
{ sessionType: 'Race', position: 3, points: 16 },
|
||||
],
|
||||
bonusSummary: ['Pole Position: +1', 'Fastest Lap: +1'],
|
||||
}
|
||||
],
|
||||
dropPolicySummary: 'All results count',
|
||||
},
|
||||
};
|
||||
return Result.ok(mockData);
|
||||
}
|
||||
}
|
||||
89
apps/website/lib/services/leagues/LeagueStandingsService.ts
Normal file
89
apps/website/lib/services/leagues/LeagueStandingsService.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { Service } from '@/lib/contracts/services/Service';
|
||||
import { LeagueStandingsApiDto, LeagueMembershipsApiDto } from '@/lib/types/tbd/LeagueStandingsApiDto';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
|
||||
export class LeagueStandingsService implements Service {
|
||||
private apiClient: LeaguesApiClient;
|
||||
|
||||
constructor() {
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
this.apiClient = new LeaguesApiClient(
|
||||
baseUrl,
|
||||
new ConsoleErrorReporter(),
|
||||
new ConsoleLogger()
|
||||
);
|
||||
}
|
||||
|
||||
async getStandingsData(leagueId: string): Promise<Result<{ standings: LeagueStandingsApiDto; memberships: LeagueMembershipsApiDto }, never>> {
|
||||
// Mock data since backend may not be implemented
|
||||
const mockStandings: LeagueStandingsApiDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver1',
|
||||
driver: {
|
||||
id: 'driver1',
|
||||
name: 'John Doe',
|
||||
iracingId: '12345',
|
||||
country: 'US',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
points: 100,
|
||||
position: 1,
|
||||
wins: 2,
|
||||
podiums: 3,
|
||||
races: 5,
|
||||
},
|
||||
{
|
||||
driverId: 'driver2',
|
||||
driver: {
|
||||
id: 'driver2',
|
||||
name: 'Jane Smith',
|
||||
iracingId: '67890',
|
||||
country: 'UK',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
points: 80,
|
||||
position: 2,
|
||||
wins: 1,
|
||||
podiums: 2,
|
||||
races: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockMemberships: LeagueMembershipsApiDto = {
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver1',
|
||||
driver: {
|
||||
id: 'driver1',
|
||||
name: 'John Doe',
|
||||
iracingId: '12345',
|
||||
country: 'US',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
role: 'member',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
driverId: 'driver2',
|
||||
driver: {
|
||||
id: 'driver2',
|
||||
name: 'Jane Smith',
|
||||
iracingId: '67890',
|
||||
country: 'UK',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
role: 'member',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return Result.ok({ standings: mockStandings, memberships: mockMemberships });
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,18 @@
|
||||
import { RaceService } from '@/lib/services/races/RaceService';
|
||||
import { ProtestService } from '@/lib/services/protests/ProtestService';
|
||||
import { PenaltyService } from '@/lib/services/penalties/PenaltyService';
|
||||
import { DriverService } from '@/lib/services/drivers/DriverService';
|
||||
import { LeagueMembershipService } from '@/lib/services/leagues/LeagueMembershipService';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { Service } from '@/lib/contracts/services/Service';
|
||||
import { StewardingApiDto } from '@/lib/types/tbd/StewardingApiDto';
|
||||
|
||||
/**
|
||||
* League Stewarding Service - DTO Only
|
||||
*
|
||||
* Returns raw API DTOs. No ViewModels or UX logic.
|
||||
* All client-side presentation logic must be handled by hooks/components.
|
||||
*/
|
||||
export class LeagueStewardingService {
|
||||
constructor(
|
||||
private readonly raceService: RaceService,
|
||||
private readonly protestService: ProtestService,
|
||||
private readonly penaltyService: PenaltyService,
|
||||
private readonly driverService: DriverService,
|
||||
private readonly membershipService: LeagueMembershipService
|
||||
) {}
|
||||
|
||||
async getLeagueProtests(leagueId: string): Promise<any> {
|
||||
return this.protestService.getLeagueProtests(leagueId);
|
||||
}
|
||||
|
||||
async getProtestById(leagueId: string, protestId: string): Promise<any> {
|
||||
return this.protestService.getProtestById(leagueId, protestId);
|
||||
}
|
||||
|
||||
async applyPenalty(input: any): Promise<void> {
|
||||
return this.protestService.applyPenalty(input);
|
||||
}
|
||||
|
||||
async requestDefense(input: any): Promise<void> {
|
||||
return this.protestService.requestDefense(input);
|
||||
}
|
||||
|
||||
async reviewProtest(input: any): Promise<void> {
|
||||
return this.protestService.reviewProtest(input);
|
||||
export class LeagueStewardingService implements Service {
|
||||
async getStewardingData(leagueId: string): Promise<Result<StewardingApiDto, never>> {
|
||||
// Mock data since backend not implemented
|
||||
const mockData: StewardingApiDto = {
|
||||
leagueId,
|
||||
totalPending: 0,
|
||||
totalResolved: 0,
|
||||
totalPenalties: 0,
|
||||
races: [],
|
||||
drivers: []
|
||||
};
|
||||
return Result.ok(mockData);
|
||||
}
|
||||
}
|
||||
38
apps/website/lib/services/leagues/ProtestDetailService.ts
Normal file
38
apps/website/lib/services/leagues/ProtestDetailService.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { Service } from '@/lib/contracts/services/Service';
|
||||
import { ProtestDetailApiDto } from '@/lib/types/tbd/ProtestDetailApiDto';
|
||||
|
||||
export class ProtestDetailService implements Service {
|
||||
async getProtestDetail(leagueId: string, protestId: string): Promise<Result<ProtestDetailApiDto, never>> {
|
||||
// Mock data since backend not implemented
|
||||
const mockData: ProtestDetailApiDto = {
|
||||
id: protestId,
|
||||
leagueId,
|
||||
status: 'pending',
|
||||
submittedAt: new Date().toISOString(),
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact on corner 3, causing spin',
|
||||
},
|
||||
protestingDriver: {
|
||||
id: 'driver1',
|
||||
name: 'John Doe',
|
||||
},
|
||||
accusedDriver: {
|
||||
id: 'driver2',
|
||||
name: 'Jane Smith',
|
||||
},
|
||||
race: {
|
||||
id: 'race1',
|
||||
name: 'Race 1',
|
||||
scheduledAt: new Date().toISOString(),
|
||||
},
|
||||
penaltyTypes: [
|
||||
{ type: 'warning', label: 'Warning', description: 'Official warning' },
|
||||
{ type: 'time_penalty', label: 'Time Penalty', description: 'Add seconds to race time' },
|
||||
{ type: 'grid_penalty', label: 'Grid Penalty', description: 'Drop grid positions' },
|
||||
],
|
||||
};
|
||||
return Result.ok(mockData);
|
||||
}
|
||||
}
|
||||
10
apps/website/lib/types/tbd/LeagueStandingsApiDto.ts
Normal file
10
apps/website/lib/types/tbd/LeagueStandingsApiDto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO';
|
||||
import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
|
||||
|
||||
export interface LeagueStandingsApiDto {
|
||||
standings: LeagueStandingDTO[];
|
||||
}
|
||||
|
||||
export interface LeagueMembershipsApiDto {
|
||||
members: LeagueMemberDTO[];
|
||||
}
|
||||
28
apps/website/lib/types/tbd/ProtestDetailApiDto.ts
Normal file
28
apps/website/lib/types/tbd/ProtestDetailApiDto.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface ProtestDetailApiDto {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
status: string;
|
||||
submittedAt: string;
|
||||
incident: {
|
||||
lap: number;
|
||||
description: string;
|
||||
};
|
||||
protestingDriver: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
accusedDriver: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
race: {
|
||||
id: string;
|
||||
name: string;
|
||||
scheduledAt: string;
|
||||
};
|
||||
penaltyTypes: Array<{
|
||||
type: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
18
apps/website/lib/types/tbd/RulebookApiDto.ts
Normal file
18
apps/website/lib/types/tbd/RulebookApiDto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface RulebookApiDto {
|
||||
leagueId: string;
|
||||
scoringConfig: {
|
||||
gameName: string;
|
||||
scoringPresetName: string;
|
||||
championships: Array<{
|
||||
type: string;
|
||||
sessionTypes: string[];
|
||||
pointsPreview: Array<{
|
||||
sessionType: string;
|
||||
position: number;
|
||||
points: number;
|
||||
}>;
|
||||
bonusSummary: string[];
|
||||
}>;
|
||||
dropPolicySummary: string;
|
||||
};
|
||||
}
|
||||
48
apps/website/lib/types/tbd/StewardingApiDto.ts
Normal file
48
apps/website/lib/types/tbd/StewardingApiDto.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export interface StewardingApiDto {
|
||||
leagueId: string;
|
||||
totalPending: number;
|
||||
totalResolved: number;
|
||||
totalPenalties: number;
|
||||
races: Array<{
|
||||
id: string;
|
||||
track: string;
|
||||
scheduledAt: string;
|
||||
pendingProtests: Array<{
|
||||
id: string;
|
||||
protestingDriverId: string;
|
||||
accusedDriverId: string;
|
||||
incident: {
|
||||
lap: number;
|
||||
description: string;
|
||||
};
|
||||
filedAt: string;
|
||||
status: string;
|
||||
proofVideoUrl?: string;
|
||||
decisionNotes?: string;
|
||||
}>;
|
||||
resolvedProtests: Array<{
|
||||
id: string;
|
||||
protestingDriverId: string;
|
||||
accusedDriverId: string;
|
||||
incident: {
|
||||
lap: number;
|
||||
description: string;
|
||||
};
|
||||
filedAt: string;
|
||||
status: string;
|
||||
proofVideoUrl?: string;
|
||||
decisionNotes?: string;
|
||||
}>;
|
||||
penalties: Array<{
|
||||
id: string;
|
||||
driverId: string;
|
||||
type: string;
|
||||
value: number;
|
||||
reason: string;
|
||||
}>;
|
||||
}>;
|
||||
drivers: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
}
|
||||
28
apps/website/lib/view-data/leagues/ProtestDetailViewData.ts
Normal file
28
apps/website/lib/view-data/leagues/ProtestDetailViewData.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface ProtestDetailViewData {
|
||||
protestId: string;
|
||||
leagueId: string;
|
||||
status: string;
|
||||
submittedAt: string;
|
||||
incident: {
|
||||
lap: number;
|
||||
description: string;
|
||||
};
|
||||
protestingDriver: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
accusedDriver: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
race: {
|
||||
id: string;
|
||||
name: string;
|
||||
scheduledAt: string;
|
||||
};
|
||||
penaltyTypes: Array<{
|
||||
type: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
15
apps/website/lib/view-data/leagues/RulebookViewData.ts
Normal file
15
apps/website/lib/view-data/leagues/RulebookViewData.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface RulebookViewData {
|
||||
leagueId: string;
|
||||
gameName: string;
|
||||
scoringPresetName: string;
|
||||
championshipsCount: number;
|
||||
sessionTypes: string;
|
||||
dropPolicySummary: string;
|
||||
hasActiveDropPolicy: boolean;
|
||||
positionPoints: Array<{
|
||||
position: number;
|
||||
points: number;
|
||||
}>;
|
||||
bonusPoints: string[];
|
||||
hasBonusPoints: boolean;
|
||||
}
|
||||
48
apps/website/lib/view-data/leagues/StewardingViewData.ts
Normal file
48
apps/website/lib/view-data/leagues/StewardingViewData.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export interface StewardingViewData {
|
||||
leagueId: string;
|
||||
totalPending: number;
|
||||
totalResolved: number;
|
||||
totalPenalties: number;
|
||||
races: Array<{
|
||||
id: string;
|
||||
track: string;
|
||||
scheduledAt: string; // ISO string
|
||||
pendingProtests: Array<{
|
||||
id: string;
|
||||
protestingDriverId: string;
|
||||
accusedDriverId: string;
|
||||
incident: {
|
||||
lap: number;
|
||||
description: string;
|
||||
};
|
||||
filedAt: string; // ISO string
|
||||
status: string;
|
||||
proofVideoUrl?: string;
|
||||
decisionNotes?: string;
|
||||
}>;
|
||||
resolvedProtests: Array<{
|
||||
id: string;
|
||||
protestingDriverId: string;
|
||||
accusedDriverId: string;
|
||||
incident: {
|
||||
lap: number;
|
||||
description: string;
|
||||
};
|
||||
filedAt: string; // ISO string
|
||||
status: string;
|
||||
proofVideoUrl?: string;
|
||||
decisionNotes?: string;
|
||||
}>;
|
||||
penalties: Array<{
|
||||
id: string;
|
||||
driverId: string;
|
||||
type: string;
|
||||
value: number;
|
||||
reason: string;
|
||||
}>;
|
||||
}>;
|
||||
drivers: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
}
|
||||
102
apps/website/templates/RulebookTemplate.tsx
Normal file
102
apps/website/templates/RulebookTemplate.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Section } from '@/ui/Section';
|
||||
|
||||
interface RulebookTemplateProps {
|
||||
viewData: RulebookViewData;
|
||||
}
|
||||
|
||||
export function RulebookTemplate({ viewData }: RulebookTemplateProps) {
|
||||
return (
|
||||
<Section>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Rulebook</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
|
||||
<span className="text-sm font-medium text-primary-blue">{viewData.scoringPresetName || 'Custom Rules'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p>
|
||||
<p className="text-lg font-semibold text-white">{viewData.gameName}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Championships</p>
|
||||
<p className="text-lg font-semibold text-white">{viewData.championshipsCount}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Sessions Scored</p>
|
||||
<p className="text-lg font-semibold text-white capitalize">
|
||||
{viewData.sessionTypes}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Drop Policy</p>
|
||||
<p className="text-lg font-semibold text-white truncate" title={viewData.dropPolicySummary}>
|
||||
{viewData.hasActiveDropPolicy ? 'Active' : 'None'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Points Table */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Points System</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-2 font-medium text-gray-400">Position</th>
|
||||
<th className="text-left py-2 font-medium text-gray-400">Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{viewData.positionPoints.map((point) => (
|
||||
<tr key={point.position} className="border-b border-charcoal-outline/50">
|
||||
<td className="py-3 text-white">{point.position}</td>
|
||||
<td className="py-3 text-white">{point.points}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bonus Points */}
|
||||
{viewData.hasBonusPoints && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2>
|
||||
<div className="space-y-2">
|
||||
{viewData.bonusPoints.map((bonus, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-4 p-3 bg-deep-graphite rounded-lg border border-charcoal-outline"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-performance-green text-sm font-bold">+</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{bonus}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Drop Policy */}
|
||||
{viewData.hasActiveDropPolicy && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2>
|
||||
<p className="text-sm text-gray-300">{viewData.dropPolicySummary}</p>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Drop rules are applied automatically when calculating championship standings.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
156
apps/website/templates/StewardingTemplate.tsx
Normal file
156
apps/website/templates/StewardingTemplate.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/* eslint-disable gridpilot-rules/no-raw-html-in-app */
|
||||
|
||||
import { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Section } from '@/ui/Section';
|
||||
import { Flag, AlertCircle, Calendar, MapPin, Gavel } from 'lucide-react';
|
||||
|
||||
interface StewardingTemplateProps {
|
||||
viewData: StewardingViewData;
|
||||
}
|
||||
|
||||
export function StewardingTemplate({ viewData }: StewardingTemplateProps) {
|
||||
return (
|
||||
<Section>
|
||||
<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 */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline text-center">
|
||||
<div className="text-2xl font-bold text-warning-amber">{viewData.totalPending}</div>
|
||||
<div className="text-sm text-gray-400">Pending</div>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline text-center">
|
||||
<div className="text-2xl font-bold text-performance-green">{viewData.totalResolved}</div>
|
||||
<div className="text-sm text-gray-400">Resolved</div>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline text-center">
|
||||
<div className="text-2xl font-bold text-red-400">{viewData.totalPenalties}</div>
|
||||
<div className="text-sm text-gray-400">Penalties</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{viewData.races.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>
|
||||
<p className="font-semibold text-lg text-white mb-2">All Clear!</p>
|
||||
<p className="text-sm text-gray-400">No protests or penalties to review.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{viewData.races.map((race) => (
|
||||
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
|
||||
{/* Race Header */}
|
||||
<div className="px-4 py-3 bg-iron-gray/30">
|
||||
<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>{new Date(race.scheduledAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{race.pendingProtests.length} pending
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Race Content */}
|
||||
<div className="p-4 space-y-3 bg-deep-graphite/50">
|
||||
{race.pendingProtests.length === 0 && race.resolvedProtests.length === 0 && race.penalties.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
|
||||
) : (
|
||||
<>
|
||||
{race.pendingProtests.map((protest) => {
|
||||
const protester = viewData.drivers.find(d => d.id === protest.protestingDriverId);
|
||||
const accused = viewData.drivers.find(d => d.id === protest.accusedDriverId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={protest.id}
|
||||
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
|
||||
>
|
||||
<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>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</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>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 line-clamp-2">
|
||||
{protest.incident.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Review needed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{race.penalties.map((penalty) => {
|
||||
const driver = viewData.drivers.find(d => d.id === 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>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user