website refactor

This commit is contained in:
2026-01-14 23:46:04 +01:00
parent c1a86348d7
commit 4a2d7d15a5
294 changed files with 5637 additions and 3418 deletions

View File

@@ -0,0 +1,6 @@
export { useCurrentSession } from './useCurrentSession';
export { useLogin } from './useLogin';
export { useLogout } from './useLogout';
export { useSignup } from './useSignup';
export { useForgotPassword } from './useForgotPassword';
export { useResetPassword } from './useResetPassword';

View File

@@ -0,0 +1,21 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { SESSION_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
export function useCurrentSession(
options?: Omit<UseQueryOptions<SessionViewModel | null, ApiError>, 'queryKey' | 'queryFn'> & { initialData?: SessionViewModel | null }
) {
const sessionService = useInject(SESSION_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['currentSession'],
queryFn: () => sessionService.getSession(),
initialData: options?.initialData,
...options,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,16 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { AUTH_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import type { ForgotPasswordDTO } from '@/lib/types/generated/ForgotPasswordDTO';
export function useForgotPassword(
options?: Omit<UseMutationOptions<{ message: string; magicLink?: string }, ApiError, ForgotPasswordDTO>, 'mutationFn'>
) {
const authService = useInject(AUTH_SERVICE_TOKEN);
return useMutation<{ message: string; magicLink?: string }, ApiError, ForgotPasswordDTO>({
mutationFn: (params) => authService.forgotPassword(params),
...options,
});
}

View File

@@ -0,0 +1,17 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { AUTH_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import type { LoginParamsDTO } from '@/lib/types/generated/LoginParamsDTO';
export function useLogin(
options?: Omit<UseMutationOptions<SessionViewModel, ApiError, LoginParamsDTO>, 'mutationFn'>
) {
const authService = useInject(AUTH_SERVICE_TOKEN);
return useMutation<SessionViewModel, ApiError, LoginParamsDTO>({
mutationFn: (params) => authService.login(params),
...options,
});
}

View File

@@ -0,0 +1,15 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { AUTH_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
export function useLogout(
options?: Omit<UseMutationOptions<void, ApiError, void>, 'mutationFn'>
) {
const authService = useInject(AUTH_SERVICE_TOKEN);
return useMutation<void, ApiError, void>({
mutationFn: () => authService.logout(),
...options,
});
}

View File

@@ -0,0 +1,16 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { AUTH_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import type { ResetPasswordDTO } from '@/lib/types/generated/ResetPasswordDTO';
export function useResetPassword(
options?: Omit<UseMutationOptions<{ message: string }, ApiError, ResetPasswordDTO>, 'mutationFn'>
) {
const authService = useInject(AUTH_SERVICE_TOKEN);
return useMutation<{ message: string }, ApiError, ResetPasswordDTO>({
mutationFn: (params) => authService.resetPassword(params),
...options,
});
}

View File

@@ -0,0 +1,17 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { AUTH_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import type { SignupParamsDTO } from '@/lib/types/generated/SignupParamsDTO';
export function useSignup(
options?: Omit<UseMutationOptions<SessionViewModel, ApiError, SignupParamsDTO>, 'mutationFn'>
) {
const authService = useInject(AUTH_SERVICE_TOKEN);
return useMutation<SessionViewModel, ApiError, SignupParamsDTO>({
mutationFn: (params) => authService.signup(params),
...options,
});
}

View File

@@ -0,0 +1,6 @@
export { useCurrentDriver } from './useCurrentDriver';
export { useDriverLeaderboard } from '@/lib/hooks/useDriverLeaderboard';
export { useDriverProfile } from './useDriverProfile';
export { useUpdateDriverProfile } from './useUpdateDriverProfile';
export { useCreateDriver } from './useCreateDriver';
export { useFindDriverById } from './useFindDriverById';

View File

@@ -0,0 +1,22 @@
'use client';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import type { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
import { CompleteOnboardingViewModel } from '@/lib/view-models/CompleteOnboardingViewModel';
export function useCreateDriver(
options?: Omit<UseMutationOptions<CompleteOnboardingViewModel, ApiError, CompleteOnboardingInputDTO>, 'mutationFn'>
) {
const driverService = useInject(DRIVER_SERVICE_TOKEN);
return useMutation<CompleteOnboardingViewModel, ApiError, CompleteOnboardingInputDTO>({
mutationFn: async (input) => {
const dto = await driverService.completeDriverOnboarding(input);
return new CompleteOnboardingViewModel(dto);
},
...options,
});
}

View File

@@ -0,0 +1,21 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
type DriverData = any; // Replace with actual type
export function useCurrentDriver(
options?: Omit<UseQueryOptions<DriverData, ApiError>, 'queryKey' | 'queryFn'>
) {
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['currentDriver'],
queryFn: () => driverService.getCurrentDriver(),
...options,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,22 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
export function useDriverProfile(
driverId: string,
options?: Omit<UseQueryOptions<DriverProfileViewModel, ApiError>, 'queryKey' | 'queryFn'>
) {
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['driverProfile', driverId],
queryFn: () => driverService.getDriverProfile(driverId),
enabled: !!driverId,
...options,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,47 @@
import { usePageDataMultiple } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN, TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
export function useDriverProfilePageData(driverId: string) {
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const teamService = useInject(TEAM_SERVICE_TOKEN);
return usePageDataMultiple({
driverProfile: {
queryKey: ['driverProfile', driverId],
queryFn: () => driverService.getDriverProfile(driverId),
enabled: !!driverId,
},
teamMemberships: {
queryKey: ['teamMemberships', driverId],
queryFn: async () => {
if (!driverId) return [];
const allTeams = await teamService.getAllTeams();
let teamMemberships: Array<{
team: { id: string; name: string };
role: string;
joinedAt: Date;
}> = [];
for (const team of allTeams) {
const teamMembers = await teamService.getTeamMembers(team.id, driverId, '');
const membership = teamMembers?.find(member => member.driverId === driverId);
if (membership) {
teamMemberships.push({
team: {
id: team.id,
name: team.name,
},
role: membership.role,
joinedAt: new Date(membership.joinedAt),
});
}
}
return teamMemberships;
},
enabled: !!driverId,
},
});
}

View File

@@ -0,0 +1,22 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
export function useFindDriverById(
driverId: string,
options?: Omit<UseQueryOptions<GetDriverOutputDTO | null, ApiError>, 'queryKey' | 'queryFn'>
) {
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['driver', driverId],
queryFn: () => driverService.findById(driverId),
enabled: !!driverId,
...options,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,31 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
import { DriverProfileViewModelBuilder } from '@/lib/builders/view-models/DriverProfileViewModelBuilder';
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
export function useUpdateDriverProfile(
options?: Omit<UseMutationOptions<DriverProfileViewModel, ApiError, { bio?: string; country?: string }>, 'mutationFn'>
) {
const driverService = useInject(DRIVER_SERVICE_TOKEN);
return useMutation<DriverProfileViewModel, ApiError, { bio?: string; country?: string }>({
mutationFn: async (updates) => {
await driverService.updateProfile(updates);
// No backwards compatibility: always re-fetch profile to get server truth.
const driverId = updates ? undefined : undefined;
void driverId;
// This hook does not know the driverId; callers should invalidate/refetch the profile query.
// Return a minimal ViewModel to satisfy types.
return DriverProfileViewModelBuilder.build({
teamMemberships: [],
socialSummary: { friends: [], friendsCount: 0 },
} as unknown as GetDriverProfileOutputDTO);
},
...options,
});
}

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useAllLeagues() {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['allLeagues'],
queryFn: () => leagueService.getAllLeagues(),
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,9 @@
import { useCreateLeagueWithBlockers } from './useCreateLeagueWithBlockers';
/**
* @deprecated Use useCreateLeagueWithBlockers instead
* This wrapper maintains backward compatibility while using the new blocker-aware hook
*/
export function useCreateLeague() {
return useCreateLeagueWithBlockers();
}

View File

@@ -0,0 +1,50 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
import { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
interface CreateLeagueInput {
name: string;
description: string;
maxDrivers: number;
scoringPresetId: string;
}
interface CreateLeagueResult {
success: boolean;
leagueId: string;
error?: string;
}
export function useCreateLeagueWithBlockers(
options?: Omit<UseMutationOptions<CreateLeagueResult, ApiError, CreateLeagueInput>, 'mutationFn'>
) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return useMutation<CreateLeagueResult, ApiError, CreateLeagueInput>({
mutationFn: async (input) => {
try {
// Transform input to DTO - note: maxDrivers and scoringPresetId are not in the DTO
// This hook may need to be updated based on actual API requirements
const inputDto: CreateLeagueInputDTO = {
name: input.name,
description: input.description,
visibility: 'public', // Default value
ownerId: '', // Will be set by the service
};
const result: CreateLeagueOutputDTO = await leagueService.createLeague(inputDto);
return { success: result.success, leagueId: result.leagueId };
} catch (error) {
// Check if it's a rate limit error
if (error instanceof ApiError && error.type === 'RATE_LIMIT_ERROR') {
return { success: false, leagueId: '', error: 'Rate limited' };
}
throw error;
}
},
...options,
});
}

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useLeagueAdminStatus(leagueId: string, currentDriverId: string) {
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['leagueMembership', leagueId, currentDriverId],
queryFn: async () => {
await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
},
enabled: !!leagueId && !!currentDriverId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,54 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import type { LeagueWithCapacityAndScoringDTO } from '@/lib/types/generated/LeagueWithCapacityAndScoringDTO';
import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO';
import type { AllLeaguesWithCapacityAndScoringDTO } from '@/lib/types/AllLeaguesWithCapacityAndScoringDTO';
interface UseLeagueDetailOptions {
leagueId: string;
queryOptions?: UseQueryOptions<LeagueWithCapacityAndScoringDTO, ApiError>;
}
interface UseLeagueMembershipsOptions {
leagueId: string;
queryOptions?: UseQueryOptions<LeagueMembershipsDTO, ApiError>;
}
export function useLeagueDetail({ leagueId, queryOptions }: UseLeagueDetailOptions) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return useQuery<LeagueWithCapacityAndScoringDTO, ApiError>({
queryKey: ['league-detail', leagueId],
queryFn: async () => {
const result = await leagueService.getAllLeagues() as AllLeaguesWithCapacityAndScoringDTO;
// Filter for the specific league
const leagues = Array.isArray(result?.leagues) ? result.leagues : [];
const league = leagues.find(l => l.id === leagueId);
if (!league) {
throw new ApiError('League not found', 'NOT_FOUND', {
endpoint: 'getAllLeagues',
statusCode: 404,
timestamp: new Date().toISOString()
});
}
return league;
},
...queryOptions,
});
}
export function useLeagueMemberships({ leagueId, queryOptions }: UseLeagueMembershipsOptions) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return useQuery<LeagueMembershipsDTO, ApiError>({
queryKey: ['league-memberships', leagueId],
queryFn: async () => {
const result = await leagueService.getLeagueMemberships(leagueId);
// The DTO already has the correct structure with members property
return result;
},
...queryOptions,
});
}

View File

@@ -0,0 +1,31 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
export function useLeagueMembershipMutation() {
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const queryClient = useQueryClient();
const joinLeagueMutation = useMutation({
mutationFn: ({ leagueId, driverId }: { leagueId: string; driverId: string }) =>
leagueMembershipService.joinLeague(leagueId, driverId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leagueMemberships'] });
queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
},
});
const leaveLeagueMutation = useMutation({
mutationFn: ({ leagueId, driverId }: { leagueId: string; driverId: string }) =>
leagueMembershipService.leaveLeague(leagueId, driverId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leagueMemberships'] });
queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
},
});
return {
joinLeague: joinLeagueMutation,
leaveLeague: leaveLeagueMutation,
};
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useLeagueMemberships(leagueId: string, currentUserId: string) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['leagueMemberships', leagueId, currentUserId],
queryFn: () => leagueService.getLeagueMemberships(leagueId),
enabled: !!leagueId && !!currentUserId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useLeagueRaces(leagueId: string) {
const raceService = useInject(RACE_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['leagueRaces', leagueId],
queryFn: () => raceService.findByLeagueId(leagueId),
enabled: !!leagueId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,94 @@
import { useMutation, useQuery, UseMutationOptions, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
interface UpdateMemberRoleInput {
leagueId: string;
driverId: string;
newRole: 'owner' | 'admin' | 'steward' | 'member';
}
interface RemoveMemberInput {
leagueId: string;
driverId: string;
}
interface JoinRequestActionInput {
leagueId: string;
requestId: string;
}
export function useLeagueRosterAdmin(leagueId: string, options?: UseQueryOptions<LeagueRosterMemberDTO[], ApiError>) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return useQuery<LeagueRosterMemberDTO[], ApiError>({
queryKey: ['league-roster-admin', leagueId],
queryFn: () => leagueService.getAdminRosterMembers(leagueId),
...options,
});
}
export function useLeagueJoinRequests(leagueId: string, options?: UseQueryOptions<LeagueRosterJoinRequestDTO[], ApiError>) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return useQuery<LeagueRosterJoinRequestDTO[], ApiError>({
queryKey: ['league-join-requests', leagueId],
queryFn: () => leagueService.getAdminRosterJoinRequests(leagueId),
...options,
});
}
export function useUpdateMemberRole(
options?: Omit<UseMutationOptions<{ success: boolean }, ApiError, UpdateMemberRoleInput>, 'mutationFn'>
) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return useMutation<{ success: boolean }, ApiError, UpdateMemberRoleInput>({
mutationFn: async (input) => {
return leagueService.updateMemberRole(input.leagueId, input.driverId, input.newRole);
},
...options,
});
}
export function useRemoveMember(
options?: Omit<UseMutationOptions<{ success: boolean }, ApiError, RemoveMemberInput>, 'mutationFn'>
) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return useMutation<{ success: boolean }, ApiError, RemoveMemberInput>({
mutationFn: async (input) => {
return leagueService.removeMember(input.leagueId, input.driverId);
},
...options,
});
}
export function useApproveJoinRequest(
options?: Omit<UseMutationOptions<{ success: boolean }, ApiError, JoinRequestActionInput>, 'mutationFn'>
) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return useMutation<{ success: boolean }, ApiError, JoinRequestActionInput>({
mutationFn: async (input) => {
return leagueService.approveJoinRequest(input.leagueId, input.requestId);
},
...options,
});
}
export function useRejectJoinRequest(
options?: Omit<UseMutationOptions<{ success: boolean }, ApiError, JoinRequestActionInput>, 'mutationFn'>
) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return useMutation<{ success: boolean }, ApiError, JoinRequestActionInput>({
mutationFn: async (input) => {
return leagueService.rejectJoinRequest(input.leagueId, input.requestId);
},
...options,
});
}

View File

@@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { LeagueScheduleViewModel, LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO';
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
const now = new Date();
const isPast = scheduledAt.getTime() < now.getTime();
const isUpcoming = !isPast;
return {
id: race.id,
name: race.name,
scheduledAt,
isPast,
isUpcoming,
status: isPast ? 'completed' : 'scheduled',
track: undefined,
car: undefined,
sessionType: undefined,
isRegistered: undefined,
};
}
export function useLeagueSchedule(leagueId: string) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['leagueSchedule', leagueId],
queryFn: async (): Promise<LeagueScheduleViewModel> => {
const dto = await leagueService.getLeagueSchedule(leagueId);
const races = dto.races.map(mapRaceDtoToViewModel);
return new LeagueScheduleViewModel(races);
},
enabled: !!leagueId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,75 @@
import { usePageData } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
import { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
import { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO';
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO';
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
const now = new Date();
const isPast = scheduledAt.getTime() < now.getTime();
const isUpcoming = !isPast;
return {
id: race.id,
name: race.name,
scheduledAt,
isPast,
isUpcoming,
status: isPast ? 'completed' : 'scheduled',
track: undefined,
car: undefined,
sessionType: undefined,
isRegistered: undefined,
};
}
export function useLeagueAdminStatus(leagueId: string, currentDriverId: string) {
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
return usePageData({
queryKey: ['admin-check', leagueId, currentDriverId],
queryFn: async () => {
await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
},
enabled: !!leagueId && !!currentDriverId,
});
}
export function useLeagueSeasons(leagueId: string, isAdmin: boolean) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return usePageData({
queryKey: ['leagueSeasons', leagueId],
queryFn: async (): Promise<LeagueSeasonSummaryViewModel[]> => {
const dtos = await leagueService.getLeagueSeasonSummaries(leagueId);
return dtos.map((dto: LeagueSeasonSummaryDTO) => new LeagueSeasonSummaryViewModel(dto));
},
enabled: !!leagueId && !!isAdmin,
});
}
export function useLeagueAdminSchedule(leagueId: string, selectedSeasonId: string, isAdmin: boolean) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return usePageData({
queryKey: ['adminSchedule', leagueId, selectedSeasonId],
queryFn: async (): Promise<LeagueAdminScheduleViewModel> => {
const dto = await leagueService.getAdminSchedule(leagueId, selectedSeasonId);
const races = dto.races.map(mapRaceDtoToViewModel);
return new LeagueAdminScheduleViewModel({
seasonId: dto.seasonId,
published: dto.published,
races,
});
},
enabled: !!leagueId && !!selectedSeasonId && !!isAdmin,
});
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useLeagueSeasons(leagueId: string) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['leagueSeasons', leagueId],
queryFn: () => leagueService.getLeagueSeasons(leagueId),
enabled: !!leagueId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,22 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SETTINGS_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
import type { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
export function useLeagueSettings(
leagueId: string,
options?: Omit<UseQueryOptions<LeagueSettingsViewModel | null, ApiError>, 'queryKey' | 'queryFn'>
) {
const leagueSettingsService = useInject(LEAGUE_SETTINGS_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['leagueSettings', leagueId],
queryFn: () => leagueSettingsService.getLeagueSettings(leagueId),
enabled: !!leagueId,
...options,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,21 @@
import { usePageDataMultiple } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
export function useLeagueSponsorshipsPageData(leagueId: string, currentDriverId: string) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
return usePageDataMultiple({
league: {
queryKey: ['leagueDetail', leagueId, currentDriverId],
queryFn: () => leagueService.getLeagueDetail(leagueId),
},
membership: {
queryKey: ['leagueMembership', leagueId, currentDriverId],
queryFn: () => leagueMembershipService.fetchLeagueMemberships(leagueId).then(() => {
return leagueMembershipService.getMembership(leagueId, currentDriverId);
}),
},
});
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useLeagueStewardingData(leagueId: string) {
const leagueStewardingService = useInject(LEAGUE_STEWARDING_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['leagueStewardingData', leagueId],
queryFn: () => leagueStewardingService.getLeagueStewardingData(leagueId),
enabled: !!leagueId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,46 @@
import { usePageMutation } from '@/lib/page/usePageData';
export function useLeagueStewardingMutations(onRefetch: () => void) {
const acceptProtestMutation = usePageMutation(
async (variables: { protestId: string; penaltyType: string; penaltyValue: number; stewardNotes: string; raceId: string; accusedDriverId: string; reason: string }) => {
// TODO: Implement protest review and penalty application
// await leagueStewardingService.reviewProtest({
// protestId: variables.protestId,
// stewardId: currentDriverId,
// decision: 'uphold',
// decisionNotes: variables.stewardNotes,
// });
// await leagueStewardingService.applyPenalty({
// raceId: variables.raceId,
// driverId: variables.accusedDriverId,
// stewardId: currentDriverId,
// type: variables.penaltyType,
// value: variables.penaltyValue,
// reason: variables.reason,
// protestId: variables.protestId,
// notes: variables.stewardNotes,
// });
},
{
onSuccess: () => onRefetch(),
}
);
const rejectProtestMutation = usePageMutation(
async (variables: { protestId: string; stewardNotes: string }) => {
// TODO: Implement protest rejection
// await leagueStewardingService.reviewProtest({
// protestId: variables.protestId,
// stewardId: currentDriverId,
// decision: 'dismiss',
// decisionNotes: variables.stewardNotes,
// });
},
{
onSuccess: () => onRefetch(),
}
);
return { acceptProtestMutation, rejectProtestMutation };
}

View File

@@ -0,0 +1,53 @@
'use client';
import { usePageData } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
import { WalletTransactionViewModel } from '@/lib/view-models/WalletTransactionViewModel';
import { useLeagueWalletWithdrawalWithBlockers } from './useLeagueWalletWithdrawalWithBlockers';
export function useLeagueWalletPageData(leagueId: string) {
const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
const queryResult = usePageData({
queryKey: ['leagueWallet', leagueId],
queryFn: async () => {
const dto = await leagueWalletService.getWalletForLeague(leagueId);
// Transform DTO to ViewModel at client boundary
const transactions = dto.transactions.map(t => new WalletTransactionViewModel({
id: t.id,
type: t.type,
description: t.description,
amount: t.amount,
fee: t.fee,
netAmount: t.netAmount,
date: new Date(t.date),
status: t.status,
reference: t.reference,
}));
return new LeagueWalletViewModel({
balance: dto.balance,
currency: dto.currency,
totalRevenue: dto.totalRevenue,
totalFees: dto.totalFees,
totalWithdrawals: dto.totalWithdrawals,
pendingPayouts: dto.pendingPayouts,
transactions,
canWithdraw: dto.canWithdraw,
withdrawalBlockReason: dto.withdrawalBlockReason,
});
},
enabled: !!leagueId,
});
return queryResult;
}
/**
* @deprecated Use useLeagueWalletWithdrawalWithBlockers instead
* This wrapper maintains backward compatibility while using the new blocker-aware hook
*/
export function useLeagueWalletWithdrawal(leagueId: string, data: any, refetch: () => void) {
return useLeagueWalletWithdrawalWithBlockers(leagueId, data, refetch);
}

View File

@@ -0,0 +1,58 @@
'use client';
import { usePageMutation } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens';
import { SubmitBlocker, ThrottleBlocker } from '@/lib/blockers';
/**
* Hook for wallet withdrawals with client-side blockers
* Handles UX prevention mechanisms (rate limiting, duplicate submission prevention)
*/
export function useLeagueWalletWithdrawalWithBlockers(leagueId: string, data: any, refetch: () => void) {
const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
// Client-side blockers for UX improvement
const submitBlocker = new SubmitBlocker();
const throttle = new ThrottleBlocker(500);
const withdrawMutation = usePageMutation(
async ({ amount }: { amount: number }) => {
if (!data) throw new Error('Wallet data not available');
// Client-side blockers (UX only, not security)
if (!submitBlocker.canExecute() || !throttle.canExecute()) {
throw new Error('Request blocked due to rate limiting');
}
submitBlocker.block();
throttle.block();
try {
const result = await leagueWalletService.withdraw(
leagueId,
amount,
data.currency,
'season-2', // Current active season
'bank-account-***1234'
);
if (!result.success) {
throw new Error(result.message || 'Withdrawal failed');
}
return result;
} finally {
submitBlocker.release();
}
},
{
onSuccess: () => {
// Refetch wallet data after successful withdrawal
refetch();
},
}
);
return withdrawMutation;
}

View File

@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { PENALTY_SERVICE_TOKEN } from '@/lib/di/tokens';
export function usePenaltyMutation() {
const penaltyService = useInject(PENALTY_SERVICE_TOKEN);
const queryClient = useQueryClient();
const applyPenaltyMutation = useMutation({
mutationFn: (command: any) => penaltyService.applyPenalty(command),
onSuccess: () => {
// Invalidate relevant queries to refresh data
queryClient.invalidateQueries({ queryKey: ['leagueStewardingData'] });
queryClient.invalidateQueries({ queryKey: ['penalties'] });
},
});
return applyPenaltyMutation;
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useProtestDetail(leagueId: string, protestId: string, enabled: boolean = true) {
const leagueStewardingService = useInject(LEAGUE_STEWARDING_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['protestDetail', leagueId, protestId],
queryFn: () => leagueStewardingService.getProtestDetailViewModel(leagueId, protestId),
enabled: enabled && !!leagueId && !!protestId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useSponsorshipRequests(entityType: string, entityId: string) {
const sponsorshipService = useInject(SPONSOR_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['sponsorshipRequests', entityType, entityId],
queryFn: async () => {
const result = await sponsorshipService.getPendingSponsorshipRequests({
entityType,
entityId,
});
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
enabled: !!entityType && !!entityId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
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'>
) {
return useMutation<Result<CompleteOnboardingOutputDTO, DomainError>, Error, CompleteOnboardingInputDTO>({
mutationFn: async (input) => {
const service = new OnboardingService();
return await service.completeOnboarding(input);
},
...options,
});
}

View File

@@ -0,0 +1,32 @@
'use client';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
import { Result } from '@/lib/contracts/Result';
import { DomainError } from '@/lib/contracts/services/Service';
interface GenerateAvatarsParams {
userId: string;
facePhotoData: string;
suitColor: string;
}
interface GenerateAvatarsResult {
success: boolean;
avatarUrls?: string[];
errorMessage?: string;
}
export function useGenerateAvatars(
options?: Omit<UseMutationOptions<Result<GenerateAvatarsResult, DomainError>, Error, GenerateAvatarsParams>, 'mutationFn'>
) {
return useMutation<Result<GenerateAvatarsResult, DomainError>, Error, GenerateAvatarsParams>({
mutationFn: async (params) => {
const service = new OnboardingService();
// 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' });
},
...options,
});
}

View File

@@ -0,0 +1,12 @@
import { usePageData } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
export function useAllRacesPageData() {
const raceService = useInject(RACE_SERVICE_TOKEN);
return usePageData({
queryKey: ['races', 'all'],
queryFn: () => raceService.getAllRacesPageData(),
});
}

View File

@@ -0,0 +1,16 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO';
export function useFileProtest(
options?: Omit<UseMutationOptions<void, ApiError, FileProtestCommandDTO>, 'mutationFn'>
) {
const raceService = useInject(RACE_SERVICE_TOKEN);
return useMutation<void, ApiError, FileProtestCommandDTO>({
mutationFn: (command) => raceService.fileProtest(command),
...options,
});
}

View File

@@ -0,0 +1,20 @@
import { usePageDataMultiple } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
export function useRaceResultsPageData(raceId: string, currentDriverId: string) {
const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
return usePageDataMultiple({
results: {
queryKey: ['raceResultsDetail', raceId, currentDriverId],
queryFn: () => raceResultsService.getResultsDetail(raceId, currentDriverId),
enabled: !!raceId,
},
sof: {
queryKey: ['raceWithSOF', raceId],
queryFn: () => raceResultsService.getWithSOF(raceId),
enabled: !!raceId,
},
});
}

View File

@@ -0,0 +1,21 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
interface RegisterForRaceParams {
raceId: string;
leagueId: string;
driverId: string;
}
export function useRegisterForRace(
options?: Omit<UseMutationOptions<void, ApiError, RegisterForRaceParams>, 'mutationFn'>
) {
const raceService = useInject(RACE_SERVICE_TOKEN);
return useMutation<void, ApiError, RegisterForRaceParams>({
mutationFn: (params) => raceService.registerForRace(params.raceId, params.leagueId, params.driverId),
...options,
});
}

View File

@@ -0,0 +1,20 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
interface WithdrawFromRaceParams {
raceId: string;
driverId: string;
}
export function useWithdrawFromRace(
options?: Omit<UseMutationOptions<void, ApiError, WithdrawFromRaceParams>, 'mutationFn'>
) {
const raceService = useInject(RACE_SERVICE_TOKEN);
return useMutation<void, ApiError, WithdrawFromRaceParams>({
mutationFn: (params) => raceService.withdrawFromRace(params.raceId, params.driverId),
...options,
});
}

View File

@@ -0,0 +1,5 @@
export { useAvailableLeagues } from './useAvailableLeagues';
export { useSponsorDashboard } from './useSponsorDashboard';
export { useSponsorSponsorships } from './useSponsorSponsorships';
export { useSponsorBilling } from './useSponsorBilling';
export { useSponsorLeagueDetail } from './useSponsorLeagueDetail';

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useAvailableLeagues() {
const sponsorService = useInject(SPONSOR_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['availableLeagues'],
queryFn: async () => {
const result = await sponsorService.getAvailableLeagues();
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useSponsorBilling(sponsorId: string) {
const sponsorService = useInject(SPONSOR_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['sponsorBilling', sponsorId],
queryFn: async () => {
const result = await sponsorService.getBilling(sponsorId);
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
enabled: !!sponsorId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useSponsorDashboard(sponsorId: string) {
const sponsorService = useInject(SPONSOR_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['sponsorDashboard', sponsorId],
queryFn: async () => {
const result = await sponsorService.getSponsorDashboard(sponsorId);
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
enabled: !!sponsorId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useSponsorLeagueDetail(leagueId: string) {
const sponsorService = useInject(SPONSOR_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['sponsorLeagueDetail', leagueId],
queryFn: async () => {
const result = await sponsorService.getLeagueDetail(leagueId);
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
enabled: !!leagueId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,29 @@
import { useAuth } from '@/lib/auth/AuthContext';
import React from 'react';
export function useSponsorMode(): boolean {
const { session } = useAuth();
const [isSponsor, setIsSponsor] = React.useState(false);
React.useEffect(() => {
if (!session) {
setIsSponsor(false);
return;
}
// Check session.role for sponsor
const role = session.role;
if (role === 'sponsor') {
setIsSponsor(true);
return;
}
// Fallback: check email patterns
const email = session.email?.toLowerCase() || '';
const displayName = session.displayName?.toLowerCase() || '';
setIsSponsor(email.includes('sponsor') || displayName.includes('sponsor'));
}, [session]);
return isSponsor;
}

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useSponsorSponsorships(sponsorId: string) {
const sponsorService = useInject(SPONSOR_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['sponsorSponsorships', sponsorId],
queryFn: async () => {
const result = await sponsorService.getSponsorSponsorships(sponsorId);
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
enabled: !!sponsorId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,13 @@
export { useAllTeams } from './useAllTeams';
export { useTeamDetails } from './useTeamDetails';
export { useTeamMembers } from './useTeamMembers';
export { useTeamJoinRequests } from './useTeamJoinRequests';
export { useCreateTeam } from './useCreateTeam';
export { useUpdateTeam } from './useUpdateTeam';
export { useTeamMembership } from './useTeamMembership';
export { useApproveJoinRequest } from './useApproveJoinRequest';
export { useRejectJoinRequest } from './useRejectJoinRequest';
export { useTeamStandings } from './useTeamStandings';
export { useJoinTeam } from './useJoinTeam';
export { useLeaveTeam } from './useLeaveTeam';
export { useTeamRoster } from './useTeamRoster';

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useAllTeams() {
const teamService = useInject(TEAM_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['allTeams'],
queryFn: async () => {
const result = await teamService.getAllTeams();
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,13 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_JOIN_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
export function useApproveJoinRequest(options?: Omit<UseMutationOptions<void, ApiError, void>, 'mutationFn'>) {
const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN);
return useMutation<void, ApiError, void>({
mutationFn: () => teamJoinService.approveJoinRequest(),
...options,
});
}

View File

@@ -0,0 +1,21 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import type { CreateTeamInputDTO } from '@/lib/types/generated/CreateTeamInputDTO';
import type { CreateTeamOutputDTO } from '@/lib/types/generated/CreateTeamOutputDTO';
export function useCreateTeam(options?: UseMutationOptions<CreateTeamOutputDTO, ApiError, CreateTeamInputDTO>) {
const teamService = useInject(TEAM_SERVICE_TOKEN);
return useMutation<CreateTeamOutputDTO, ApiError, CreateTeamInputDTO>({
mutationFn: async (input) => {
const result = await teamService.createTeam(input);
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
...options,
});
}

View File

@@ -0,0 +1,28 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
interface JoinTeamParams {
teamId: string;
driverId: string;
requiresApproval?: boolean;
}
export function useJoinTeam(options?: Omit<UseMutationOptions<void, ApiError, JoinTeamParams>, 'mutationFn'>) {
const teamService = useInject(TEAM_SERVICE_TOKEN);
return useMutation<void, ApiError, JoinTeamParams>({
mutationFn: async (params) => {
// Note: Team join functionality would need to be added to teamService
// For now, we'll use a placeholder
console.log('Joining team:', params);
if (params.requiresApproval) {
alert('Join request sent! Wait for team approval.');
} else {
alert('Successfully joined team!');
}
},
...options,
});
}

View File

@@ -0,0 +1,23 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
interface LeaveTeamParams {
teamId: string;
driverId: string;
}
export function useLeaveTeam(options?: Omit<UseMutationOptions<void, ApiError, LeaveTeamParams>, 'mutationFn'>) {
const teamService = useInject(TEAM_SERVICE_TOKEN);
return useMutation<void, ApiError, LeaveTeamParams>({
mutationFn: async (params) => {
// Note: Leave team functionality would need to be added to teamService
// For now, we'll use a placeholder
console.log('Leaving team:', params);
alert('Successfully left team');
},
...options,
});
}

View File

@@ -0,0 +1,13 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_JOIN_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
export function useRejectJoinRequest(options?: Omit<UseMutationOptions<void, ApiError, void>, 'mutationFn'>) {
const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN);
return useMutation<void, ApiError, void>({
mutationFn: () => teamJoinService.rejectJoinRequest(),
...options,
});
}

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useTeamDetails(teamId: string, currentUserId: string) {
const teamService = useInject(TEAM_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['teamDetails', teamId, currentUserId],
queryFn: async () => {
const result = await teamService.getTeamDetails(teamId, currentUserId);
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
enabled: !!teamId && !!currentUserId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_JOIN_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useTeamJoinRequests(teamId: string, currentUserId: string, isOwner: boolean) {
const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['teamJoinRequests', teamId, currentUserId, isOwner],
queryFn: () => teamJoinService.getJoinRequests(teamId, currentUserId, isOwner),
enabled: !!teamId && !!currentUserId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useTeamMembers(teamId: string, currentUserId: string, teamOwnerId: string) {
const teamService = useInject(TEAM_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['teamMembers', teamId, currentUserId, teamOwnerId],
queryFn: async () => {
const result = await teamService.getTeamMembers(teamId, currentUserId, teamOwnerId);
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
enabled: !!teamId && !!currentUserId && !!teamOwnerId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import type { GetTeamMembershipOutputDTO } from '@/lib/types/generated/GetTeamMembershipOutputDTO';
export function useTeamMembership(teamId: string, driverId: string) {
const teamService = useInject(TEAM_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['teamMembership', teamId, driverId],
queryFn: async () => {
const result = await teamService.getMembership(teamId, driverId);
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
enabled: !!teamId && !!driverId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
type TeamMemberRole = 'owner' | 'manager' | 'member';
interface TeamRosterMember {
driver: any;
role: TeamMemberRole;
joinedAt: string;
rating: number | null;
overallRank: number | null;
}
export function useTeamRoster(memberships: Array<{
driverId: string;
role: string;
joinedAt: string;
}>) {
const teamService = useInject(TEAM_SERVICE_TOKEN);
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const queryResult = useQuery<TeamRosterMember[]>({
queryKey: ['teamRoster', memberships],
queryFn: async () => {
// Get driver details for each membership
const membersWithDetails = await Promise.all(
memberships.map(async (m) => {
const driver = await driverService.findById(m.driverId);
// Convert role to TeamMemberRole
const role: TeamMemberRole = m.role === 'owner' ? 'owner' :
m.role === 'manager' ? 'manager' : 'member';
return {
driver: driver || { id: m.driverId, name: 'Unknown Driver', country: 'Unknown', position: 'N/A', races: '0', impressions: '0', team: 'None' },
role,
joinedAt: m.joinedAt,
rating: null, // DriverDTO doesn't include rating
overallRank: null, // DriverDTO doesn't include overallRank
};
})
);
return membersWithDetails;
},
enabled: memberships.length > 0,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useTeamStandings(teamId: string, leagues: string[]) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['teamStandings', teamId, leagues],
queryFn: async () => {
// For demo purposes, create mock standings
return leagues.map(leagueId => ({
leagueId,
leagueName: `League ${leagueId}`,
position: Math.floor(Math.random() * 10) + 1,
points: Math.floor(Math.random() * 100),
wins: Math.floor(Math.random() * 5),
racesCompleted: Math.floor(Math.random() * 10),
}));
},
enabled: leagues.length > 0,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,21 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import type { UpdateTeamInputDTO } from '@/lib/types/generated/UpdateTeamInputDTO';
import type { UpdateTeamOutputDTO } from '@/lib/types/generated/UpdateTeamOutputDTO';
export function useUpdateTeam(options?: UseMutationOptions<UpdateTeamOutputDTO, ApiError, { teamId: string; input: UpdateTeamInputDTO }>) {
const teamService = useInject(TEAM_SERVICE_TOKEN);
return useMutation<UpdateTeamOutputDTO, ApiError, { teamId: string; input: UpdateTeamInputDTO }>({
mutationFn: async ({ teamId, input }) => {
const result = await teamService.updateTeam(teamId, input);
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
...options,
});
}

View File

@@ -0,0 +1,35 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { POLICY_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
import { PolicySnapshotDto } from '@/lib/api/policy/PolicyApiClient';
export function useCapability(
capabilityKey: string,
options?: Omit<UseQueryOptions<PolicySnapshotDto, ApiError>, 'queryKey' | 'queryFn'>
) {
const policyService = useInject(POLICY_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['policySnapshot', capabilityKey],
queryFn: () => policyService.getSnapshot(),
staleTime: 60_000,
gcTime: 5 * 60_000,
...options,
});
const enhancedResult = enhanceQueryResult(queryResult);
// Add helper to get capability state
const capabilityState = enhancedResult.data
? policyService.getCapabilityState(enhancedResult.data, capabilityKey)
: null;
return {
...enhancedResult,
capabilityState,
isCapabilityEnabled: capabilityState === 'enabled',
isCapabilityComingSoon: capabilityState === 'coming_soon',
};
}

View File

@@ -0,0 +1,28 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
/**
* Hook for driver leaderboard data
*/
export function useDriverLeaderboard() {
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const query = useQuery({
queryKey: ['driverLeaderboard'],
queryFn: async () => {
return await driverService.getDriverLeaderboard();
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
});
return {
data: query.data,
isLoading: query.isLoading,
error: query.error,
retry: query.refetch,
};
}

View File

@@ -0,0 +1,30 @@
import { useState, useMemo } from 'react';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
/**
* useDriverSearch
*
* Client-side hook for UX-only search filtering.
* This is view-only transformation, not business logic.
*/
export function useDriverSearch(drivers: DriverLeaderboardItemViewModel[]) {
const [searchQuery, setSearchQuery] = useState('');
const filteredDrivers = useMemo(() => {
if (!searchQuery) return drivers;
const query = searchQuery.toLowerCase();
return drivers.filter(driver => {
return (
driver.name.toLowerCase().includes(query) ||
driver.nationality.toLowerCase().includes(query)
);
});
}, [drivers, searchQuery]);
return {
searchQuery,
setSearchQuery,
filteredDrivers,
};
}

View File

@@ -0,0 +1,10 @@
import { useCurrentDriver } from './driver/useCurrentDriver';
/**
* Hook to get the current driver ID from the user's session.
* Returns the driver ID string or undefined if not available.
*/
export function useEffectiveDriverId(): string | undefined {
const { data: currentDriver } = useCurrentDriver();
return currentDriver?.id;
}

View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import { useEnhancedForm } from './useEnhancedForm';
describe('useEnhancedForm', () => {
it('should be defined', () => {
expect(useEnhancedForm).toBeDefined();
});
});

View File

@@ -0,0 +1,349 @@
/**
* Enhanced Form Hook with Advanced Error Handling
*
* Provides comprehensive form state management, validation, and error handling
* with both user-friendly and developer-friendly error messages.
*/
import { useState, useCallback, useEffect, FormEvent, ChangeEvent, Dispatch, SetStateAction } from 'react';
import { parseApiError, formatValidationErrorsForForm, logErrorWithContext, createErrorContext } from '@/lib/utils/errorUtils';
import { ApiError } from '@/lib/api/base/ApiError';
export interface FormField<T> {
value: T;
error?: string;
touched: boolean;
validating: boolean;
}
export interface FormState<T extends Record<string, any>> {
fields: { [K in keyof T]: FormField<T[K]> };
isValid: boolean;
isSubmitting: boolean;
submitError?: string;
submitCount: number;
}
export interface FormOptions<T extends Record<string, any>> {
initialValues: T;
validate?: (values: T) => Record<string, string> | Promise<Record<string, string>>;
onSubmit: (values: T) => Promise<void>;
onError?: (error: unknown, values: T) => void;
onSuccess?: (values: T) => void;
component?: string;
}
export interface UseEnhancedFormReturn<T extends Record<string, any>> {
formState: FormState<T>;
setFormState: Dispatch<SetStateAction<FormState<T>>>;
handleChange: (e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void;
setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void;
setFieldError: <K extends keyof T>(field: K, error: string) => void;
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>;
reset: () => void;
setFormError: (error: string) => void;
clearFieldError: <K extends keyof T>(field: K) => void;
validateField: <K extends keyof T>(field: K) => Promise<void>;
}
/**
* Enhanced form hook with comprehensive error handling
*/
export function useEnhancedForm<T extends Record<string, any>>(
options: FormOptions<T>
): UseEnhancedFormReturn<T> {
const [formState, setFormState] = useState<FormState<T>>(() => ({
fields: Object.keys(options.initialValues).reduce((acc, key) => ({
...acc,
[key]: {
value: options.initialValues[key as keyof T],
error: undefined,
touched: false,
validating: false,
}
}), {} as { [K in keyof T]: FormField<T[K]> }),
isValid: true,
isSubmitting: false,
submitError: undefined,
submitCount: 0,
}));
// Validate form on change
useEffect(() => {
if (options.validate && formState.submitCount > 0) {
const validateAsync = async () => {
try {
const errors = await options.validate!(getValues());
setFormState(prev => ({
...prev,
isValid: Object.keys(errors).length === 0,
fields: Object.keys(prev.fields).reduce((acc, key) => ({
...acc,
[key]: {
...prev.fields[key as keyof T],
error: errors[key],
}
}), {} as { [K in keyof T]: FormField<T[K]> }),
}));
} catch (error) {
console.error('Validation error:', error);
}
};
validateAsync();
}
}, [formState.fields, formState.submitCount, options.validate]);
const getValues = useCallback((): T => {
return Object.keys(formState.fields).reduce((acc, key) => ({
...acc,
[key]: formState.fields[key as keyof T].value,
}), {} as T);
}, [formState.fields]);
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
const checked = 'checked' in e.target ? e.target.checked : false;
const fieldValue = type === 'checkbox' ? checked : value;
setFormState(prev => ({
...prev,
fields: {
...prev.fields,
[name]: {
...prev.fields[name as keyof T],
value: fieldValue as T[keyof T],
touched: true,
error: undefined, // Clear error on change
},
},
}));
}, []);
const setFieldValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
setFormState(prev => ({
...prev,
fields: {
...prev.fields,
[field]: {
...prev.fields[field],
value,
touched: true,
error: undefined,
},
},
}));
}, []);
const setFieldError = useCallback(<K extends keyof T>(field: K, error: string) => {
setFormState(prev => ({
...prev,
fields: {
...prev.fields,
[field]: {
...prev.fields[field],
error,
touched: true,
},
},
isValid: false,
}));
}, []);
const clearFieldError = useCallback(<K extends keyof T>(field: K) => {
setFormState(prev => ({
...prev,
fields: {
...prev.fields,
[field]: {
...prev.fields[field],
error: undefined,
},
},
}));
}, []);
const setFormError = useCallback((error: string) => {
setFormState(prev => ({
...prev,
submitError: error,
}));
}, []);
const validateField = useCallback(async <K extends keyof T>(field: K) => {
if (!options.validate) return;
setFormState(prev => ({
...prev,
fields: {
...prev.fields,
[field]: {
...prev.fields[field],
validating: true,
},
},
}));
try {
const values = getValues();
const errors = await options.validate(values);
setFormState(prev => ({
...prev,
fields: {
...prev.fields,
[field]: {
...prev.fields[field],
error: errors[field as string],
validating: false,
},
},
}));
} catch (error) {
setFormState(prev => ({
...prev,
fields: {
...prev.fields,
[field]: {
...prev.fields[field],
validating: false,
},
},
}));
}
}, [options.validate, getValues]);
const reset = useCallback(() => {
setFormState({
fields: Object.keys(options.initialValues).reduce((acc, key) => ({
...acc,
[key]: {
value: options.initialValues[key as keyof T],
error: undefined,
touched: false,
validating: false,
}
}), {} as { [K in keyof T]: FormField<T[K]> }),
isValid: true,
isSubmitting: false,
submitError: undefined,
submitCount: 0,
});
}, [options.initialValues]);
const handleSubmit = useCallback(async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const values = getValues();
// Increment submit count to trigger validation
setFormState(prev => ({
...prev,
submitCount: prev.submitCount + 1,
isSubmitting: true,
submitError: undefined,
}));
// Run validation if provided
if (options.validate) {
try {
const errors = await options.validate(values);
const hasErrors = Object.keys(errors).length > 0;
if (hasErrors) {
setFormState(prev => ({
...prev,
isSubmitting: false,
isValid: false,
fields: Object.keys(prev.fields).reduce((acc, key) => ({
...acc,
[key]: {
...prev.fields[key as keyof T],
error: errors[key],
touched: true,
}
}), {} as { [K in keyof T]: FormField<T[K]> }),
}));
return;
}
} catch (validationError) {
logErrorWithContext(validationError, {
timestamp: new Date().toISOString(),
component: options.component || 'useEnhancedForm',
action: 'validate',
formData: values,
});
setFormState(prev => ({
...prev,
isSubmitting: false,
submitError: 'Validation failed. Please check your input.',
}));
return;
}
}
// Submit the form
try {
await options.onSubmit(values);
setFormState(prev => ({
...prev,
isSubmitting: false,
submitError: undefined,
}));
options.onSuccess?.(values);
} catch (error) {
const parsed = parseApiError(error);
// Log for developers
logErrorWithContext(error, {
timestamp: new Date().toISOString(),
component: options.component || 'useEnhancedForm',
action: 'submit',
formData: values,
});
// Handle validation errors from API
if (parsed.isValidationError && parsed.validationErrors.length > 0) {
const fieldErrors = formatValidationErrorsForForm(parsed.validationErrors);
setFormState(prev => ({
...prev,
isSubmitting: false,
isValid: false,
fields: Object.keys(prev.fields).reduce((acc, key) => ({
...acc,
[key]: {
...prev.fields[key as keyof T],
error: fieldErrors[key],
touched: true,
}
}), {} as { [K in keyof T]: FormField<T[K]> }),
}));
} else {
// General submit error
setFormState(prev => ({
...prev,
isSubmitting: false,
submitError: parsed.userMessage,
}));
}
options.onError?.(error, values);
}
}, [getValues, options]);
return {
formState,
setFormState,
handleChange,
setFieldValue,
setFieldError,
handleSubmit,
reset,
setFormError,
clearFieldError,
validateField,
};
}

View File

@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
export function useLeagueScoringPresets() {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['leagueScoringPresets'],
queryFn: async () => {
const result = await leagueService.getScoringPresets();
return result as LeagueScoringPresetDTO[];
},
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,83 @@
import { useMutation } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
import { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
export interface LeagueWizardFormModel {
leagueId?: string;
basics?: {
name?: string;
description?: string;
visibility?: string;
gameId?: string;
};
structure?: {
mode?: string;
maxDrivers?: number;
maxTeams?: number;
driversPerTeam?: number;
multiClassEnabled?: boolean;
};
championships?: {
enableDriverChampionship?: boolean;
enableTeamChampionship?: boolean;
enableNationsChampionship?: boolean;
enableTrophyChampionship?: boolean;
};
scoring?: {
patternId?: string;
customScoringEnabled?: boolean;
};
dropPolicy?: {
strategy?: string;
n?: number;
};
timings?: {
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
sessionCount?: number;
roundsPlanned?: number;
raceDayOfWeek?: number;
raceTimeUtc?: string;
weekdays?: string[];
recurrenceStrategy?: string;
timezoneId?: string;
seasonStartDate?: string;
};
stewarding?: {
decisionMode?: string;
requiredVotes?: number;
requireDefense?: boolean;
defenseTimeLimit?: number;
voteTimeLimit?: number;
protestDeadlineHours?: number;
stewardingClosesHours?: number;
notifyAccusedOnProtest?: boolean;
notifyOnVoteRequired?: boolean;
};
seasonName?: string;
}
export function useCreateLeagueWizard() {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return useMutation({
mutationFn: async (params: { form: LeagueWizardFormModel; ownerId: string }): Promise<CreateLeagueOutputDTO> => {
// Convert form to CreateLeagueInputDTO
const input: CreateLeagueInputDTO = {
name: params.form.basics?.name?.trim() ?? '',
description: params.form.basics?.description?.trim() ?? '',
visibility: (params.form.basics?.visibility as 'public' | 'private') ?? 'public',
ownerId: params.ownerId,
};
// Use the league service to create the league
const result = await leagueService.createLeague(input);
return result;
},
});
}

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { PENALTY_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function usePenaltyTypesReference() {
const penaltyService = useInject(PENALTY_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['penaltyTypesReference'],
queryFn: () => penaltyService.getPenaltyTypesReference(),
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,125 @@
'use client';
import { useEffect, useState, RefObject } from 'react';
/**
* Calculate scroll progress (0-1) based on element's position in viewport
* @param ref - Reference to the element to track
* @param offset - Offset from viewport edges (0-1, default 0.1)
* @returns progress value between 0 and 1
*/
export function useScrollProgress(ref: RefObject<HTMLElement | null>, offset: number = 0.1): number {
const [progress, setProgress] = useState(0);
useEffect(() => {
if (!ref.current) return;
let rafId: number;
const calculateProgress = () => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const scrollY = window.scrollY;
const documentHeight = document.documentElement.scrollHeight;
// Element enters viewport from bottom
const enterPoint = viewportHeight * (1 - offset);
// Element reaches top of viewport
const exitPoint = viewportHeight * offset;
// Calculate progress: 0 when entering, 1 at 30% viewport (accelerated)
const elementCenter = rect.top + rect.height / 2;
const totalDistance = enterPoint - exitPoint;
const currentDistance = enterPoint - elementCenter;
// Accelerate progress to reach 1.0 at 30% viewport height
// Scale factor: 1.67 makes progress reach 1.0 at ~30% instead of 50%
const rawProgress = (currentDistance / totalDistance) * 1.67;
let clampedProgress = Math.max(0, Math.min(1, rawProgress));
// At bottom of page - ensure elements near bottom can reach 100%
// Only apply if we're at the very bottom AND this element is below the fold
if (scrollY + viewportHeight >= documentHeight - 50 && rect.top < viewportHeight) {
clampedProgress = Math.max(clampedProgress, 1);
}
setProgress(clampedProgress);
};
const handleScroll = () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(calculateProgress);
};
// Initial calculation
calculateProgress();
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
if (rafId) {
cancelAnimationFrame(rafId);
}
};
}, [ref, offset]);
return progress;
}
/**
* Calculate parallax offset based on scroll position
* @param ref - Reference to the element to track
* @param speed - Parallax speed multiplier (default 0.5)
* @returns offset in pixels
*/
export function useParallax(ref: RefObject<HTMLElement | null>, speed: number = 0.5): number {
const [offset, setOffset] = useState(0);
useEffect(() => {
if (!ref.current) return;
let rafId: number;
const calculateOffset = () => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Calculate offset based on element position relative to viewport
const scrolled = viewportHeight - rect.top;
const parallaxOffset = scrolled * speed;
setOffset(parallaxOffset);
};
const handleScroll = () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(calculateOffset);
};
calculateOffset();
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
if (rafId) {
cancelAnimationFrame(rafId);
}
};
}, [ref, speed]);
return offset;
}