website refactor
This commit is contained in:
@@ -199,7 +199,8 @@
|
||||
"gridpilot-rules/client-only-must-have-directive": "error",
|
||||
"gridpilot-rules/server-actions-must-use-mutations": "error",
|
||||
"gridpilot-rules/server-actions-return-result": "error",
|
||||
"gridpilot-rules/server-actions-interface": "error"
|
||||
"gridpilot-rules/server-actions-interface": "error",
|
||||
"gridpilot-rules/no-use-mutation-in-client": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { LeagueSummaryViewModelBuilder } from '@/lib/builders/view-models/LeagueSummaryViewModelBuilder';
|
||||
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
@@ -375,27 +375,7 @@ function LeagueSlider({
|
||||
hideScrollbar
|
||||
>
|
||||
{leagues.map((league) => {
|
||||
// TODO wtf we have builders for this
|
||||
// Convert ViewData to ViewModel for LeagueCard
|
||||
const viewModel: LeagueSummaryViewModel = {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description ?? '',
|
||||
logoUrl: league.logoUrl,
|
||||
ownerId: league.ownerId,
|
||||
createdAt: league.createdAt,
|
||||
maxDrivers: league.maxDrivers,
|
||||
usedDriverSlots: league.usedDriverSlots,
|
||||
maxTeams: league.maxTeams ?? 0,
|
||||
usedTeamSlots: league.usedTeamSlots ?? 0,
|
||||
structureSummary: league.structureSummary,
|
||||
timingSummary: league.timingSummary,
|
||||
category: league.category ?? undefined,
|
||||
scoring: league.scoring ? {
|
||||
...league.scoring,
|
||||
primaryChampionshipType: league.scoring.primaryChampionshipType as 'driver' | 'team' | 'nations' | 'trophy',
|
||||
} : undefined,
|
||||
};
|
||||
const viewModel = LeagueSummaryViewModelBuilder.build(league);
|
||||
|
||||
return (
|
||||
<Box key={league.id} flexShrink={0} w="320px" h="full">
|
||||
@@ -621,26 +601,7 @@ export function LeaguesPageClient({
|
||||
</Box>
|
||||
<Grid cols={1} mdCols={2} lgCols={3} gap={6}>
|
||||
{categoryFilteredLeagues.map((league) => {
|
||||
// Convert ViewData to ViewModel for LeagueCard
|
||||
const viewModel: LeagueSummaryViewModel = {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description ?? '',
|
||||
logoUrl: league.logoUrl,
|
||||
ownerId: league.ownerId,
|
||||
createdAt: league.createdAt,
|
||||
maxDrivers: league.maxDrivers,
|
||||
usedDriverSlots: league.usedDriverSlots,
|
||||
maxTeams: league.maxTeams ?? 0,
|
||||
usedTeamSlots: league.usedTeamSlots ?? 0,
|
||||
structureSummary: league.structureSummary,
|
||||
timingSummary: league.timingSummary,
|
||||
category: league.category ?? undefined,
|
||||
scoring: league.scoring ? {
|
||||
...league.scoring,
|
||||
primaryChampionshipType: league.scoring.primaryChampionshipType as 'driver' | 'team' | 'nations' | 'trophy',
|
||||
} : undefined,
|
||||
};
|
||||
const viewModel = LeagueSummaryViewModelBuilder.build(league);
|
||||
|
||||
return (
|
||||
<GridItem key={league.id}>
|
||||
@@ -677,4 +638,4 @@ export function LeaguesPageClient({
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
|
||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
export default async function LeagueLayout({
|
||||
@@ -42,7 +43,16 @@ export default async function LeagueLayout({
|
||||
);
|
||||
}
|
||||
|
||||
const viewData = result.unwrap();
|
||||
const data = result.unwrap();
|
||||
|
||||
const viewData = LeagueDetailViewDataBuilder.build({
|
||||
league: data.league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
// Define tab configuration
|
||||
const baseTabs = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemplate';
|
||||
import {
|
||||
@@ -10,9 +10,13 @@ import {
|
||||
useLeagueAdminSchedule
|
||||
} from "@/hooks/league/useLeagueScheduleAdminPageData";
|
||||
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import {
|
||||
publishScheduleAction,
|
||||
unpublishScheduleAction,
|
||||
createRaceAction,
|
||||
updateRaceAction,
|
||||
deleteRaceAction
|
||||
} from './actions';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
@@ -23,10 +27,8 @@ export function LeagueAdminSchedulePageClient() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId() || '';
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
|
||||
// Form state
|
||||
const [seasonId, setSeasonId] = useState<string>('');
|
||||
const [track, setTrack] = useState('');
|
||||
@@ -34,6 +36,11 @@ export function LeagueAdminSchedulePageClient() {
|
||||
const [scheduledAtIso, setScheduledAtIso] = useState('');
|
||||
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
|
||||
|
||||
// Action state
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [deletingRaceId, setDeletingRaceId] = useState<string | null>(null);
|
||||
|
||||
// Check admin status using domain hook
|
||||
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
|
||||
|
||||
@@ -48,62 +55,6 @@ export function LeagueAdminSchedulePageClient() {
|
||||
// Load schedule using domain hook
|
||||
const { data: schedule, isLoading: scheduleLoading } = useLeagueAdminSchedule(leagueId, selectedSeasonId, !!isAdmin);
|
||||
|
||||
// Mutations
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!schedule || !selectedSeasonId) return null;
|
||||
return schedule.published
|
||||
? await leagueService.unpublishAdminSchedule(leagueId, selectedSeasonId)
|
||||
: await leagueService.publishAdminSchedule(leagueId, selectedSeasonId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedSeasonId || !scheduledAtIso) return null;
|
||||
|
||||
if (!editingRaceId) {
|
||||
return await leagueService.createAdminScheduleRace(leagueId, selectedSeasonId, {
|
||||
track,
|
||||
car,
|
||||
scheduledAtIso,
|
||||
});
|
||||
} else {
|
||||
return await leagueService.updateAdminScheduleRace(leagueId, selectedSeasonId, editingRaceId, {
|
||||
...(track ? { track } : {}),
|
||||
...(car ? { car } : {}),
|
||||
...(scheduledAtIso ? { scheduledAtIso } : {}),
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
|
||||
// Reset form
|
||||
setTrack('');
|
||||
setCar('');
|
||||
setScheduledAtIso('');
|
||||
setEditingRaceId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (raceId: string) => {
|
||||
return await leagueService.deleteAdminScheduleRace(leagueId, selectedSeasonId, raceId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
|
||||
},
|
||||
});
|
||||
|
||||
// Derived states
|
||||
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
|
||||
const isPublishing = publishMutation.isPending;
|
||||
const isSaving = saveMutation.isPending;
|
||||
const isDeleting = deleteMutation.variables || null;
|
||||
|
||||
// Handlers
|
||||
const handleSeasonChange = (newSeasonId: string) => {
|
||||
setSeasonId(newSeasonId);
|
||||
@@ -113,13 +64,51 @@ export function LeagueAdminSchedulePageClient() {
|
||||
setScheduledAtIso('');
|
||||
};
|
||||
|
||||
const handlePublishToggle = () => {
|
||||
publishMutation.mutate();
|
||||
const handlePublishToggle = async () => {
|
||||
if (!schedule || !selectedSeasonId) return;
|
||||
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const result = schedule.published
|
||||
? await unpublishScheduleAction(leagueId, selectedSeasonId)
|
||||
: await publishScheduleAction(leagueId, selectedSeasonId);
|
||||
|
||||
if (result.isOk()) {
|
||||
router.refresh();
|
||||
} else {
|
||||
alert(result.getError());
|
||||
}
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddOrSave = () => {
|
||||
if (!scheduledAtIso) return;
|
||||
saveMutation.mutate();
|
||||
const handleAddOrSave = async () => {
|
||||
if (!selectedSeasonId || !scheduledAtIso) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = !editingRaceId
|
||||
? await createRaceAction(leagueId, selectedSeasonId, { track, car, scheduledAtIso })
|
||||
: await updateRaceAction(leagueId, selectedSeasonId, editingRaceId, {
|
||||
...(track ? { track } : {}),
|
||||
...(car ? { car } : {}),
|
||||
...(scheduledAtIso ? { scheduledAtIso } : {}),
|
||||
});
|
||||
|
||||
if (result.isOk()) {
|
||||
// Reset form
|
||||
setTrack('');
|
||||
setCar('');
|
||||
setScheduledAtIso('');
|
||||
setEditingRaceId(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
alert(result.getError());
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (raceId: string) => {
|
||||
@@ -128,15 +117,27 @@ export function LeagueAdminSchedulePageClient() {
|
||||
if (!race) return;
|
||||
|
||||
setEditingRaceId(raceId);
|
||||
setTrack('');
|
||||
setCar('');
|
||||
setTrack(race.track || '');
|
||||
setCar(race.car || '');
|
||||
setScheduledAtIso(race.scheduledAt.toISOString());
|
||||
};
|
||||
|
||||
const handleDelete = (raceId: string) => {
|
||||
const handleDelete = async (raceId: string) => {
|
||||
if (!selectedSeasonId) return;
|
||||
const confirmed = window.confirm('Delete this race?');
|
||||
if (!confirmed) return;
|
||||
deleteMutation.mutate(raceId);
|
||||
|
||||
setDeletingRaceId(raceId);
|
||||
try {
|
||||
const result = await deleteRaceAction(leagueId, selectedSeasonId, raceId);
|
||||
if (result.isOk()) {
|
||||
router.refresh();
|
||||
} else {
|
||||
alert(result.getError());
|
||||
}
|
||||
} finally {
|
||||
setDeletingRaceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
@@ -146,6 +147,9 @@ export function LeagueAdminSchedulePageClient() {
|
||||
setScheduledAtIso('');
|
||||
};
|
||||
|
||||
// Derived states
|
||||
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
|
||||
|
||||
// Prepare template data
|
||||
const templateData = schedule && seasonsData && selectedSeasonId
|
||||
? {
|
||||
@@ -200,7 +204,7 @@ export function LeagueAdminSchedulePageClient() {
|
||||
editingRaceId={editingRaceId}
|
||||
isPublishing={isPublishing}
|
||||
isSaving={isSaving}
|
||||
isDeleting={isDeleting}
|
||||
isDeleting={deletingRaceId}
|
||||
setTrack={setTrack}
|
||||
setCar={setCar}
|
||||
setScheduledAtIso={setScheduledAtIso}
|
||||
@@ -221,4 +225,4 @@ export function LeagueAdminSchedulePageClient() {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
70
apps/website/app/leagues/[id]/schedule/admin/actions.ts
Normal file
70
apps/website/app/leagues/[id]/schedule/admin/actions.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { ScheduleAdminMutation } from '@/lib/mutations/leagues/ScheduleAdminMutation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export async function publishScheduleAction(leagueId: string, seasonId: string): Promise<Result<void, string>> {
|
||||
const mutation = new ScheduleAdminMutation();
|
||||
const result = await mutation.publishSchedule(leagueId, seasonId);
|
||||
|
||||
if (result.isOk()) {
|
||||
revalidatePath(routes.league.schedule(leagueId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function unpublishScheduleAction(leagueId: string, seasonId: string): Promise<Result<void, string>> {
|
||||
const mutation = new ScheduleAdminMutation();
|
||||
const result = await mutation.unpublishSchedule(leagueId, seasonId);
|
||||
|
||||
if (result.isOk()) {
|
||||
revalidatePath(routes.league.schedule(leagueId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function createRaceAction(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
input: { track: string; car: string; scheduledAtIso: string }
|
||||
): Promise<Result<void, string>> {
|
||||
const mutation = new ScheduleAdminMutation();
|
||||
const result = await mutation.createRace(leagueId, seasonId, input);
|
||||
|
||||
if (result.isOk()) {
|
||||
revalidatePath(routes.league.schedule(leagueId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateRaceAction(
|
||||
leagueId: string,
|
||||
seasonId: string,
|
||||
raceId: string,
|
||||
input: Partial<{ track: string; car: string; scheduledAtIso: string }>
|
||||
): Promise<Result<void, string>> {
|
||||
const mutation = new ScheduleAdminMutation();
|
||||
const result = await mutation.updateRace(leagueId, seasonId, raceId, input);
|
||||
|
||||
if (result.isOk()) {
|
||||
revalidatePath(routes.league.schedule(leagueId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteRaceAction(leagueId: string, seasonId: string, raceId: string): Promise<Result<void, string>> {
|
||||
const mutation = new ScheduleAdminMutation();
|
||||
const result = await mutation.deleteRace(leagueId, seasonId, raceId);
|
||||
|
||||
if (result.isOk()) {
|
||||
revalidatePath(routes.league.schedule(leagueId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -169,6 +169,7 @@ module.exports = {
|
||||
|
||||
// Architecture Rules
|
||||
'no-index-files': require('./no-index-files'),
|
||||
'no-use-mutation-in-client': require('./no-use-mutation-in-client'),
|
||||
},
|
||||
|
||||
// Configurations for different use cases
|
||||
@@ -271,6 +272,7 @@ module.exports = {
|
||||
|
||||
// Route Configuration Rules
|
||||
'gridpilot-rules/no-hardcoded-routes': 'error',
|
||||
'gridpilot-rules/no-use-mutation-in-client': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
50
apps/website/eslint-rules/no-use-mutation-in-client.js
Normal file
50
apps/website/eslint-rules/no-use-mutation-in-client.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* ESLint Rule: No useMutation in Client Components
|
||||
*
|
||||
* Forbids the use of useMutation from @tanstack/react-query in client components.
|
||||
* All write operations must go through Next.js Server Actions.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Forbid useMutation usage in client components',
|
||||
category: 'Architecture',
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
noUseMutation: 'useMutation from @tanstack/react-query is forbidden. Use Next.js Server Actions for all write operations. See docs/architecture/website/FORM_SUBMISSION.md',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value === '@tanstack/react-query') {
|
||||
const useMutationSpecifier = node.specifiers.find(
|
||||
(specifier) =>
|
||||
specifier.type === 'ImportSpecifier' &&
|
||||
specifier.imported.name === 'useMutation'
|
||||
);
|
||||
|
||||
if (useMutationSpecifier) {
|
||||
context.report({
|
||||
node: useMutationSpecifier,
|
||||
messageId: 'noUseMutation',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (node.callee.type === 'Identifier' && node.callee.name === 'useMutation') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noUseMutation',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,19 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
|
||||
import { completeOnboardingAction } from '@/app/onboarding/completeOnboardingAction';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
|
||||
import { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
|
||||
|
||||
export function useCompleteOnboarding(
|
||||
options?: Omit<UseMutationOptions<Result<CompleteOnboardingOutputDTO, DomainError>, Error, CompleteOnboardingInputDTO>, 'mutationFn'>
|
||||
options?: Omit<UseMutationOptions<Result<{ success: boolean }, string>, Error, CompleteOnboardingInputDTO>, 'mutationFn'>
|
||||
) {
|
||||
return useMutation<Result<CompleteOnboardingOutputDTO, DomainError>, Error, CompleteOnboardingInputDTO>({
|
||||
return useMutation<Result<{ success: boolean }, string>, Error, CompleteOnboardingInputDTO>({
|
||||
mutationFn: async (input) => {
|
||||
const service = new OnboardingService();
|
||||
return await service.completeOnboarding(input);
|
||||
return await completeOnboardingAction(input);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { generateAvatarsAction } from '@/app/onboarding/generateAvatarsAction';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
|
||||
interface GenerateAvatarsParams {
|
||||
userId: string;
|
||||
@@ -13,17 +13,14 @@ interface GenerateAvatarsParams {
|
||||
interface GenerateAvatarsResult {
|
||||
success: boolean;
|
||||
avatarUrls?: string[];
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function useGenerateAvatars(
|
||||
options?: Omit<UseMutationOptions<Result<GenerateAvatarsResult, DomainError>, Error, GenerateAvatarsParams>, 'mutationFn'>
|
||||
options?: Omit<UseMutationOptions<Result<GenerateAvatarsResult, string>, Error, GenerateAvatarsParams>, 'mutationFn'>
|
||||
) {
|
||||
return useMutation<Result<GenerateAvatarsResult, DomainError>, Error, GenerateAvatarsParams>({
|
||||
mutationFn: async (_params) => {
|
||||
// This method doesn't exist in the service yet, but the hook is now created
|
||||
// The service will need to implement this or we need to adjust the architecture
|
||||
return Result.ok({ success: false, errorMessage: 'Not implemented' });
|
||||
return useMutation<Result<GenerateAvatarsResult, string>, Error, GenerateAvatarsParams>({
|
||||
mutationFn: async (params) => {
|
||||
return await generateAvatarsAction(params);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
import { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
|
||||
export class LeagueSummaryViewModelBuilder {
|
||||
static build(league: LeaguesViewData['leagues'][number]): LeagueSummaryViewModel {
|
||||
return {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description ?? '',
|
||||
logoUrl: league.logoUrl,
|
||||
ownerId: league.ownerId,
|
||||
createdAt: league.createdAt,
|
||||
maxDrivers: league.maxDrivers,
|
||||
usedDriverSlots: league.usedDriverSlots,
|
||||
maxTeams: league.maxTeams ?? 0,
|
||||
usedTeamSlots: league.usedTeamSlots ?? 0,
|
||||
structureSummary: league.structureSummary,
|
||||
timingSummary: league.timingSummary,
|
||||
category: league.category ?? undefined,
|
||||
scoring: league.scoring ? {
|
||||
...league.scoring,
|
||||
primaryChampionshipType: league.scoring.primaryChampionshipType as 'driver' | 'team' | 'nations' | 'trophy',
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
47
apps/website/lib/display-objects/DateDisplay.ts
Normal file
47
apps/website/lib/display-objects/DateDisplay.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* DateDisplay
|
||||
*
|
||||
* Deterministic date formatting for display.
|
||||
* Avoids Intl and toLocaleString to prevent SSR/hydration mismatches.
|
||||
*/
|
||||
|
||||
export class DateDisplay {
|
||||
private static readonly MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
private static readonly FULL_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
private static readonly DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
private static readonly FULL_DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
/**
|
||||
* Formats a date as "MMM D, YYYY" (e.g., "Jan 1, 2024")
|
||||
*/
|
||||
static formatShort(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return `${this.MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date as "YYYY-MM-DD"
|
||||
*/
|
||||
static formatIso(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = d.getDate().toString().padStart(2, '0');
|
||||
return `${d.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date as "Weekday, Month Day, Year"
|
||||
*/
|
||||
static formatFull(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return `${this.FULL_DAYS[d.getDay()]}, ${this.FULL_MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date as "MMM YYYY" (e.g., "Jan 2024")
|
||||
*/
|
||||
static formatMonthYear(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return `${this.MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
}
|
||||
18
apps/website/lib/display-objects/NumberDisplay.ts
Normal file
18
apps/website/lib/display-objects/NumberDisplay.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* NumberDisplay
|
||||
*
|
||||
* Deterministic number formatting for display.
|
||||
* Avoids Intl and toLocaleString to prevent SSR/hydration mismatches.
|
||||
*/
|
||||
|
||||
export class NumberDisplay {
|
||||
/**
|
||||
* Formats a number with thousands separators (commas).
|
||||
* Example: 1234567 -> "1,234,567"
|
||||
*/
|
||||
static format(value: number): string {
|
||||
const parts = value.toString().split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return parts.join('.');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { LeagueService } from '@/lib/services/leagues/LeagueService';
|
||||
import { LeagueService, type LeagueDetailData } from '@/lib/services/leagues/LeagueService';
|
||||
import { type PresentationError, mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError';
|
||||
|
||||
/**
|
||||
@@ -8,8 +8,8 @@ import { type PresentationError, mapToPresentationError } from '@/lib/contracts/
|
||||
* Returns the raw API DTO for the league detail page
|
||||
* No DI container usage - constructs dependencies explicitly
|
||||
*/
|
||||
export class LeagueDetailPageQuery implements PageQuery<unknown, string, PresentationError> {
|
||||
async execute(leagueId: string): Promise<Result<unknown, PresentationError>> {
|
||||
export class LeagueDetailPageQuery implements PageQuery<LeagueDetailData, string, PresentationError> {
|
||||
async execute(leagueId: string): Promise<Result<LeagueDetailData, PresentationError>> {
|
||||
const service = new LeagueService();
|
||||
const result = await service.getLeagueDetailData(leagueId);
|
||||
|
||||
@@ -21,7 +21,7 @@ export class LeagueDetailPageQuery implements PageQuery<unknown, string, Present
|
||||
}
|
||||
|
||||
// Static method to avoid object construction in server code
|
||||
static async execute(leagueId: string): Promise<Result<unknown, PresentationError>> {
|
||||
static async execute(leagueId: string): Promise<Result<LeagueDetailData, PresentationError>> {
|
||||
const query = new LeagueDetailPageQuery();
|
||||
return query.execute(leagueId);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ export function usePageDataMultiple<T extends Record<string, unknown>>(
|
||||
* Mutation hook wrapper - STANDARDIZED PATTERN
|
||||
* Use for: All mutation operations
|
||||
*
|
||||
* @deprecated Use Next.js Server Actions instead for all write operations.
|
||||
* See docs/architecture/website/FORM_SUBMISSION.md
|
||||
*
|
||||
* @example
|
||||
* const mutation = usePageMutation(
|
||||
* (variables) => service.mutateData(variables),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Container } from '@/ui/Container';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { StatusBadge } from '@/ui/StatusBadge';
|
||||
import { InfoBox } from '@/ui/InfoBox';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import {
|
||||
RefreshCw,
|
||||
Shield,
|
||||
@@ -193,7 +194,7 @@ export function AdminUsersTemplate({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'}
|
||||
{user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { RecentActivity } from '@/components/feed/RecentActivity';
|
||||
import { PageHero } from '@/ui/PageHero';
|
||||
import { DriversSearch } from '@/ui/DriversSearch';
|
||||
import { EmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
|
||||
|
||||
interface DriversTemplateProps {
|
||||
@@ -62,8 +63,8 @@ export function DriversTemplate({
|
||||
stats={[
|
||||
{ label: 'drivers', value: drivers.length, color: 'text-primary-blue' },
|
||||
{ label: 'active', value: activeCount, color: 'text-performance-green', animate: true },
|
||||
{ label: 'total wins', value: totalWins.toLocaleString(), color: 'text-warning-amber' },
|
||||
{ label: 'races', value: totalRaces.toLocaleString(), color: 'text-neon-aqua' },
|
||||
{ label: 'total wins', value: NumberDisplay.format(totalWins), color: 'text-warning-amber' },
|
||||
{ label: 'races', value: NumberDisplay.format(totalRaces), color: 'text-neon-aqua' },
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ import { GridItem } from '@/ui/GridItem';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Settings, Users, Trophy, Shield, Clock, LucideIcon } from 'lucide-react';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import type { LeagueSettingsViewData } from '@/lib/view-data/leagues/LeagueSettingsViewData';
|
||||
|
||||
interface LeagueSettingsTemplateProps {
|
||||
@@ -47,7 +48,7 @@ export function LeagueSettingsTemplate({ viewData }: LeagueSettingsTemplateProps
|
||||
<GridItem colSpan={2}>
|
||||
<InfoItem label="Description" value={viewData.league.description} />
|
||||
</GridItem>
|
||||
<InfoItem label="Created" value={new Date(viewData.league.createdAt).toLocaleDateString()} />
|
||||
<InfoItem label="Created" value={DateDisplay.formatShort(viewData.league.createdAt)} />
|
||||
<InfoItem label="Owner ID" value={viewData.league.ownerId} />
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Grid } from '@/ui/Grid';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { ArrowLeft, Trophy, Zap, type LucideIcon } from 'lucide-react';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import type { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData';
|
||||
import { RaceResultRow } from '@/components/races/RaceResultRow';
|
||||
import { RacePenaltyRow } from '@/ui/RacePenaltyRowWrapper';
|
||||
@@ -45,12 +46,7 @@ export function RaceResultsTemplate({
|
||||
importError,
|
||||
}: RaceResultsTemplateProps) {
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
return DateDisplay.formatFull(date);
|
||||
};
|
||||
|
||||
const formatTime = (ms: number) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Gavel,
|
||||
Scale,
|
||||
} from 'lucide-react';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import type { RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData';
|
||||
|
||||
export type StewardingTab = 'pending' | 'resolved' | 'penalties';
|
||||
@@ -51,11 +52,7 @@ export function RaceStewardingTemplate({
|
||||
setActiveTab,
|
||||
}: RaceStewardingTemplateProps) {
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
return DateDisplay.formatShort(date);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { SponsorTierCard } from '@/components/sponsors/SponsorTierCard';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
@@ -320,7 +321,7 @@ export function SponsorLeagueDetailTemplate({
|
||||
<Box>
|
||||
{race.status === 'completed' ? (
|
||||
<Box textAlign="right">
|
||||
<Text weight="semibold" color="text-white" block>{race.views.toLocaleString()}</Text>
|
||||
<Text weight="semibold" color="text-white" block>{NumberDisplay.format(race.views)}</Text>
|
||||
<Text size="xs" color="text-gray-500">views</Text>
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
|
||||
|
||||
export interface SponsorshipRequestsTemplateProps {
|
||||
@@ -61,7 +62,7 @@ export function SponsorshipRequestsTemplate({
|
||||
<Text size="xs" color="text-gray-400" block mt={1}>{request.message}</Text>
|
||||
)}
|
||||
<Text size="xs" color="text-gray-500" block mt={2}>
|
||||
{new Date(request.createdAtIso).toLocaleDateString()}
|
||||
{DateDisplay.formatShort(request.createdAtIso)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Stack direction="row" gap={2}>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { HorizontalStatItem } from '@/ui/HorizontalStatItem';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
|
||||
import { TeamAdmin } from '@/components/teams/TeamAdmin';
|
||||
import { TeamHero } from '@/components/teams/TeamHero';
|
||||
@@ -169,10 +170,7 @@ export function TeamDetailTemplate({
|
||||
{team.createdAt && (
|
||||
<HorizontalStatItem
|
||||
label="Founded"
|
||||
value={new Date(team.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
value={DateDisplay.formatMonthYear(team.createdAt)}
|
||||
color="text-gray-300"
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user