website refactor
This commit is contained in:
@@ -1,22 +1,20 @@
|
||||
import Link from 'next/link';
|
||||
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
|
||||
import { ErrorActionButtons } from '@/ui/ErrorActionButtons';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function Custom404Page() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<h1 className="text-3xl font-semibold">404</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
This page doesn't exist.
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary-blue px-4 py-2 text-sm font-medium text-white hover:bg-primary-blue/80 transition-colors"
|
||||
>
|
||||
Drive home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<ErrorPageContainer
|
||||
errorCode="404"
|
||||
description="This page doesn't exist."
|
||||
>
|
||||
<ErrorActionButtons
|
||||
onHomeClick={() => router.push(routes.public.home)}
|
||||
homeLabel="Drive home"
|
||||
/>
|
||||
</ErrorPageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,20 @@
|
||||
import Link from 'next/link';
|
||||
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
|
||||
import { ErrorActionButtons } from '@/ui/ErrorActionButtons';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function Custom500Page() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<h1 className="text-3xl font-semibold">500</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
Something went wrong.
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary-blue px-4 py-2 text-sm font-medium text-white hover:bg-primary-blue/80 transition-colors"
|
||||
>
|
||||
Drive home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<ErrorPageContainer
|
||||
errorCode="500"
|
||||
description="Something went wrong."
|
||||
>
|
||||
<ErrorActionButtons
|
||||
onHomeClick={() => router.push(routes.public.home)}
|
||||
homeLabel="Drive home"
|
||||
/>
|
||||
</ErrorPageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,26 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { AuthApiClient } from '@/lib/api/auth/AuthApiClient';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { LogoutMutation } from '@/lib/mutations/auth/LogoutMutation';
|
||||
|
||||
/**
|
||||
* Server action for logout
|
||||
*
|
||||
* Performs the logout mutation by calling the API and redirects to login.
|
||||
* Performs the logout mutation and returns a Result.
|
||||
* Follows the write boundary contract: all writes enter through server actions.
|
||||
* Returns Result type for type-safe error handling.
|
||||
*
|
||||
* Note: This action does NOT redirect. The caller should handle redirect
|
||||
* based on the Result to maintain proper error handling flow.
|
||||
*/
|
||||
export async function logoutAction(): Promise<void> {
|
||||
try {
|
||||
// Create required dependencies for API client
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: false,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
|
||||
// Get API base URL from environment
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
|
||||
// Create API client instance
|
||||
const apiClient = new AuthApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
// Call the logout API endpoint
|
||||
await apiClient.logout();
|
||||
|
||||
// Redirect to login page after successful logout
|
||||
redirect('/auth/login');
|
||||
} catch (error) {
|
||||
// Log error for debugging
|
||||
console.error('Logout action failed:', error);
|
||||
|
||||
// Still redirect even if logout fails - user should be able to leave
|
||||
redirect('/auth/login');
|
||||
export async function logoutAction(): Promise<Result<void, string>> {
|
||||
const mutation = new LogoutMutation();
|
||||
const result = await mutation.execute();
|
||||
|
||||
if (result.isErr()) {
|
||||
console.error('Logout action failed:', result.getError());
|
||||
return Result.err(result.getError());
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { UpdateUserStatusMutation } from '@/lib/mutations/admin/UpdateUserStatusMutation';
|
||||
import { DeleteUserMutation } from '@/lib/mutations/admin/DeleteUserMutation';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
/**
|
||||
* Server actions for admin operations
|
||||
@@ -10,34 +12,44 @@ import { revalidatePath } from 'next/cache';
|
||||
* All write operations must enter through server actions.
|
||||
* Actions are thin wrappers that handle framework concerns (revalidation).
|
||||
* Business logic is handled by Mutations.
|
||||
* All actions return Result types for type-safe error handling.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Update user status
|
||||
*
|
||||
* @param userId - The ID of the user to update
|
||||
* @param status - The new status to set
|
||||
* @returns Result with success indicator or error
|
||||
*/
|
||||
export async function updateUserStatus(userId: string, status: string) {
|
||||
export async function updateUserStatus(userId: string, status: string): Promise<Result<{ success: boolean }, string>> {
|
||||
const mutation = new UpdateUserStatusMutation();
|
||||
const result = await mutation.execute({ userId, status });
|
||||
|
||||
if (result.isErr()) {
|
||||
console.error('updateUserStatus failed:', result.getError());
|
||||
throw new Error('Failed to update user status');
|
||||
return Result.err(result.getError());
|
||||
}
|
||||
|
||||
revalidatePath('/admin/users');
|
||||
revalidatePath(routes.admin.users);
|
||||
return Result.ok({ success: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user
|
||||
*
|
||||
* @param userId - The ID of the user to delete
|
||||
* @returns Result with success indicator or error
|
||||
*/
|
||||
export async function deleteUser(userId: string) {
|
||||
export async function deleteUser(userId: string): Promise<Result<{ success: boolean }, string>> {
|
||||
const mutation = new DeleteUserMutation();
|
||||
const result = await mutation.execute({ userId });
|
||||
|
||||
if (result.isErr()) {
|
||||
console.error('deleteUser failed:', result.getError());
|
||||
throw new Error('Failed to delete user');
|
||||
return Result.err(result.getError());
|
||||
}
|
||||
|
||||
revalidatePath('/admin/users');
|
||||
revalidatePath(routes.admin.users);
|
||||
return Result.ok({ success: true });
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
||||
import Section from '@/components/ui/Section';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -23,8 +24,8 @@ export default async function AdminLayout({ children }: AdminLayoutProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite">
|
||||
<Section variant="default" className="min-h-screen">
|
||||
{children}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AdminDashboardPageQuery } from '@/lib/page-queries/AdminDashboardPageQuery';
|
||||
import { AdminDashboardTemplate } from '@/templates/AdminDashboardTemplate';
|
||||
import { ErrorBanner } from '@/components/ui/ErrorBanner';
|
||||
|
||||
export default async function AdminPage() {
|
||||
const result = await AdminDashboardPageQuery.execute();
|
||||
@@ -8,25 +9,25 @@ export default async function AdminPage() {
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg">
|
||||
Access denied - You must be logged in as an Owner or Admin
|
||||
</div>
|
||||
</div>
|
||||
<ErrorBanner
|
||||
title="Access Denied"
|
||||
message="You must be logged in as an Owner or Admin"
|
||||
variant="error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg">
|
||||
Failed to load dashboard: {error}
|
||||
</div>
|
||||
</div>
|
||||
<ErrorBanner
|
||||
title="Load Failed"
|
||||
message={`Failed to load dashboard: ${error}`}
|
||||
variant="error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const viewData = result.unwrap();
|
||||
const output = result.unwrap();
|
||||
|
||||
// For now, use empty callbacks. In a real app, these would be Server Actions
|
||||
// that trigger revalidation or navigation
|
||||
return <AdminDashboardTemplate adminDashboardViewData={viewData} onRefresh={() => {}} isLoading={false} />;
|
||||
return <AdminDashboardTemplate adminDashboardViewData={output} onRefresh={() => {}} isLoading={false} />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { AdminUsersTemplate } from '@/templates/AdminUsersTemplate';
|
||||
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
||||
import { updateUserStatus, deleteUser } from '../actions';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface AdminUsersWrapperProps {
|
||||
initialViewData: AdminUsersViewData;
|
||||
@@ -30,7 +31,7 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
||||
if (newSearch) params.set('search', newSearch);
|
||||
else params.delete('search');
|
||||
params.delete('page'); // Reset to page 1
|
||||
router.push(`/admin/users?${params.toString()}`);
|
||||
router.push(`${routes.admin.users}?${params.toString()}`);
|
||||
}, [router, searchParams]);
|
||||
|
||||
const handleFilterRole = useCallback((role: string) => {
|
||||
@@ -38,7 +39,7 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
||||
if (role) params.set('role', role);
|
||||
else params.delete('role');
|
||||
params.delete('page');
|
||||
router.push(`/admin/users?${params.toString()}`);
|
||||
router.push(`${routes.admin.users}?${params.toString()}`);
|
||||
}, [router, searchParams]);
|
||||
|
||||
const handleFilterStatus = useCallback((status: string) => {
|
||||
@@ -46,11 +47,11 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
||||
if (status) params.set('status', status);
|
||||
else params.delete('status');
|
||||
params.delete('page');
|
||||
router.push(`/admin/users?${params.toString()}`);
|
||||
router.push(`${routes.admin.users}?${params.toString()}`);
|
||||
}, [router, searchParams]);
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
router.push('/admin/users');
|
||||
router.push(routes.admin.users);
|
||||
}, [router]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
@@ -61,7 +62,13 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
||||
const handleUpdateStatus = useCallback(async (userId: string, newStatus: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await updateUserStatus(userId, newStatus);
|
||||
const result = await updateUserStatus(userId, newStatus);
|
||||
|
||||
if (result.isErr()) {
|
||||
setError(result.getError());
|
||||
return;
|
||||
}
|
||||
|
||||
// Revalidate data
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
@@ -78,7 +85,13 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
||||
|
||||
try {
|
||||
setDeletingUser(userId);
|
||||
await deleteUser(userId);
|
||||
const result = await deleteUser(userId);
|
||||
|
||||
if (result.isErr()) {
|
||||
setError(result.getError());
|
||||
return;
|
||||
}
|
||||
|
||||
// Revalidate data
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AdminUsersPageQuery } from '@/lib/page-queries/AdminUsersPageQuery';
|
||||
import { AdminUsersWrapper } from './AdminUsersWrapper';
|
||||
import { ErrorBanner } from '@/components/ui/ErrorBanner';
|
||||
|
||||
interface AdminUsersPageProps {
|
||||
searchParams?: {
|
||||
@@ -28,24 +29,24 @@ export default async function AdminUsersPage({ searchParams }: AdminUsersPagePro
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg">
|
||||
Access denied - You must be logged in as an Owner or Admin
|
||||
</div>
|
||||
</div>
|
||||
<ErrorBanner
|
||||
title="Access Denied"
|
||||
message="You must be logged in as an Owner or Admin"
|
||||
variant="error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg">
|
||||
Failed to load users: {error}
|
||||
</div>
|
||||
</div>
|
||||
<ErrorBanner
|
||||
title="Load Failed"
|
||||
message={`Failed to load users: ${error}`}
|
||||
variant="error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const viewData = result.unwrap();
|
||||
const output = result.unwrap();
|
||||
|
||||
// Pass to client wrapper for UI interactions
|
||||
return <AdminUsersWrapper initialViewData={viewData} />;
|
||||
return <AdminUsersWrapper initialViewData={output} />;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ForgotPasswordViewData } from '@/lib/builders/view-data/ForgotPasswordViewDataBuilder';
|
||||
import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
|
||||
import { ForgotPasswordTemplate } from '@/templates/auth/ForgotPasswordTemplate';
|
||||
import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation';
|
||||
import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder';
|
||||
@@ -73,7 +73,7 @@ export function ForgotPasswordClient({ viewData }: ForgotPasswordClientProps) {
|
||||
setShowSuccess: (show) => {
|
||||
if (!show) {
|
||||
// Reset to initial state
|
||||
setViewModel(prev => ForgotPasswordViewModelBuilder.build(viewData));
|
||||
setViewModel(() => ForgotPasswordViewModelBuilder.build(viewData));
|
||||
}
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { ForgotPasswordPageQuery } from '@/lib/page-queries/auth/ForgotPasswordPageQuery';
|
||||
import { ForgotPasswordClient } from './ForgotPasswordClient';
|
||||
import { AuthError } from '@/components/ui/AuthError';
|
||||
|
||||
export default async function ForgotPasswordPage({
|
||||
searchParams,
|
||||
@@ -19,12 +20,7 @@ export default async function ForgotPasswordPage({
|
||||
const queryResult = await ForgotPasswordPageQuery.execute(params);
|
||||
|
||||
if (queryResult.isErr()) {
|
||||
// Handle query error
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite flex items-center justify-center">
|
||||
<div className="text-red-400">Failed to load forgot password page</div>
|
||||
</div>
|
||||
);
|
||||
return <AuthError action="forgot password" />;
|
||||
}
|
||||
|
||||
const viewData = queryResult.unwrap();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
||||
import { AuthContainer } from '@/components/ui/AuthContainer';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -11,7 +12,7 @@ interface AuthLayoutProps {
|
||||
*
|
||||
* Provides authentication route protection for all auth routes.
|
||||
* Uses RouteGuard to enforce access control server-side.
|
||||
*
|
||||
*
|
||||
* Behavior:
|
||||
* - Unauthenticated users can access auth pages (login, signup, etc.)
|
||||
* - Authenticated users are redirected away from auth pages
|
||||
@@ -26,9 +27,5 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||
redirect(result.to);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite flex items-center justify-center p-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return <AuthContainer>{children}</AuthContainer>;
|
||||
}
|
||||
|
||||
@@ -11,12 +11,13 @@ import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController';
|
||||
import { LoginViewData } from '@/lib/builders/view-data/LoginViewDataBuilder';
|
||||
import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
|
||||
import { LoginTemplate } from '@/templates/auth/LoginTemplate';
|
||||
import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
|
||||
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
|
||||
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
|
||||
import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
|
||||
import { AuthLoading } from '@/components/ui/AuthLoading';
|
||||
|
||||
interface LoginClientProps {
|
||||
viewData: LoginViewData;
|
||||
@@ -179,30 +180,12 @@ export function LoginClient({ viewData }: LoginClientProps) {
|
||||
}));
|
||||
};
|
||||
|
||||
// Dismiss error details
|
||||
const dismissErrorDetails = () => {
|
||||
setViewModel(prev => {
|
||||
const newFormState = {
|
||||
...prev.formState,
|
||||
submitError: undefined,
|
||||
};
|
||||
return prev.withFormState(newFormState).withUIState({
|
||||
...prev.uiState,
|
||||
showErrorDetails: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Get current state from controller
|
||||
const state = controller.getState();
|
||||
|
||||
// If user is authenticated with permissions, show loading
|
||||
if (state === LoginState.AUTHENTICATED_WITH_PERMISSIONS) {
|
||||
return (
|
||||
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
</main>
|
||||
);
|
||||
return <AuthLoading />;
|
||||
}
|
||||
|
||||
// If user has insufficient permissions, show permission error
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { LoginPageQuery } from '@/lib/page-queries/auth/LoginPageQuery';
|
||||
import { LoginClient } from './LoginClient';
|
||||
import { AuthError } from '@/components/ui/AuthError';
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
@@ -19,12 +20,7 @@ export default async function LoginPage({
|
||||
const queryResult = await LoginPageQuery.execute(params);
|
||||
|
||||
if (queryResult.isErr()) {
|
||||
// Handle query error
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite flex items-center justify-center">
|
||||
<div className="text-red-400">Failed to load login page</div>
|
||||
</div>
|
||||
);
|
||||
return <AuthError action="login" />;
|
||||
}
|
||||
|
||||
const viewData = queryResult.unwrap();
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ResetPasswordViewData } from '@/lib/builders/view-data/ResetPasswordViewDataBuilder';
|
||||
import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
|
||||
import { ResetPasswordTemplate } from '@/templates/auth/ResetPasswordTemplate';
|
||||
import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation';
|
||||
import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetPasswordViewModelBuilder';
|
||||
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface ResetPasswordClientProps {
|
||||
viewData: ResetPasswordViewData;
|
||||
@@ -70,7 +71,7 @@ export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
|
||||
|
||||
// Redirect to login after a delay
|
||||
setTimeout(() => {
|
||||
router.push('/auth/login');
|
||||
router.push(routes.auth.login);
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to reset password';
|
||||
@@ -120,7 +121,7 @@ export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
|
||||
setShowSuccess: (show) => {
|
||||
if (!show) {
|
||||
// Reset to initial state
|
||||
setViewModel(prev => ResetPasswordViewModelBuilder.build(viewData));
|
||||
setViewModel(() => ResetPasswordViewModelBuilder.build(viewData));
|
||||
}
|
||||
},
|
||||
setShowPassword: togglePassword,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { ResetPasswordPageQuery } from '@/lib/page-queries/auth/ResetPasswordPageQuery';
|
||||
import { ResetPasswordClient } from './ResetPasswordClient';
|
||||
import { AuthError } from '@/components/ui/AuthError';
|
||||
|
||||
export default async function ResetPasswordPage({
|
||||
searchParams,
|
||||
@@ -19,12 +20,7 @@ export default async function ResetPasswordPage({
|
||||
const queryResult = await ResetPasswordPageQuery.execute(params);
|
||||
|
||||
if (queryResult.isErr()) {
|
||||
// Handle query error
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite flex items-center justify-center">
|
||||
<div className="text-red-400">Failed to load reset password page</div>
|
||||
</div>
|
||||
);
|
||||
return <AuthError action="reset password" />;
|
||||
}
|
||||
|
||||
const viewData = queryResult.unwrap();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { SignupViewData } from '@/lib/builders/view-data/SignupViewDataBuilder';
|
||||
import { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
|
||||
import { SignupTemplate } from '@/templates/auth/SignupTemplate';
|
||||
import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
|
||||
import { SignupViewModelBuilder } from '@/lib/builders/view-models/SignupViewModelBuilder';
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { SignupPageQuery } from '@/lib/page-queries/auth/SignupPageQuery';
|
||||
import { SignupClient } from './SignupClient';
|
||||
import { AuthError } from '@/components/ui/AuthError';
|
||||
|
||||
export default async function SignupPage({
|
||||
searchParams,
|
||||
@@ -19,12 +20,7 @@ export default async function SignupPage({
|
||||
const queryResult = await SignupPageQuery.execute(params);
|
||||
|
||||
if (queryResult.isErr()) {
|
||||
// Handle query error
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite flex items-center justify-center">
|
||||
<div className="text-red-400">Failed to load signup page</div>
|
||||
</div>
|
||||
);
|
||||
return <AuthError action="signup" />;
|
||||
}
|
||||
|
||||
const viewData = queryResult.unwrap();
|
||||
|
||||
@@ -1,30 +1,9 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
||||
import { DashboardLayoutWrapper } from '@/ui/DashboardLayoutWrapper';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Layout
|
||||
*
|
||||
* Provides authentication protection for all dashboard routes.
|
||||
* Uses RouteGuard to enforce access control server-side.
|
||||
*/
|
||||
export default async function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const headerStore = await headers();
|
||||
const pathname = headerStore.get('x-pathname') || '/';
|
||||
|
||||
const guard = createRouteGuard();
|
||||
const result = await guard.enforce({ pathname });
|
||||
if (result.type === 'redirect') {
|
||||
redirect(result.to);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}) {
|
||||
return <DashboardLayoutWrapper>{children}</DashboardLayoutWrapper>;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default async function DashboardPage() {
|
||||
} else if (error === 'redirect') {
|
||||
redirect('/');
|
||||
} else {
|
||||
// DASHBOARD_FETCH_FAILED or UNKNOWN_ERROR
|
||||
// serverError, networkError, unknown, validationError, unauthorized
|
||||
console.error('Dashboard error:', error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DriversTemplate } from '@/templates/DriversTemplate';
|
||||
import { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
|
||||
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
|
||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
|
||||
interface DriversPageClientProps {
|
||||
pageDto: DriversLeaderboardDTO | null;
|
||||
error?: string;
|
||||
empty?: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DriversPageClient
|
||||
*
|
||||
* Client component that:
|
||||
* 1. Handles state (search, filter, sort)
|
||||
* 2. Calls ViewModel to get computed display data
|
||||
* 3. Transforms ViewModel to Template-compatible format
|
||||
* 4. Passes data to Template
|
||||
*/
|
||||
export function DriversPageClient({ pageDto, error, empty }: DriversPageClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Client state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Event handlers
|
||||
const onSearchChange = (query: string) => setSearchQuery(query);
|
||||
const onDriverClick = (id: string) => router.push(`/drivers/${id}`);
|
||||
const onBackToLeaderboards = () => router.push('/leaderboards');
|
||||
|
||||
// Handle error/empty states
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
|
||||
<div className="text-red-400 mb-4">Error loading drivers</div>
|
||||
<p className="text-gray-400">Please try again later</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pageDto || pageDto.drivers.length === 0) {
|
||||
if (empty) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">{empty.title}</h2>
|
||||
<p className="text-gray-400">{empty.description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transform DTO to ViewModel
|
||||
const dtoForViewModel: { drivers: DriverLeaderboardItemDTO[] } = {
|
||||
drivers: pageDto.drivers.map(driver => ({
|
||||
...driver,
|
||||
avatarUrl: driver.avatarUrl || '',
|
||||
})),
|
||||
};
|
||||
const viewModel = new DriverLeaderboardViewModel(dtoForViewModel);
|
||||
|
||||
// Filter drivers based on search
|
||||
let filteredDrivers = viewModel.drivers.filter(driver => {
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesSearch =
|
||||
driver.name.toLowerCase().includes(query) ||
|
||||
driver.nationality.toLowerCase().includes(query);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Pass to template
|
||||
return <DriversTemplate data={viewModel} />;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
||||
import { DriverProfileViewModelBuilder } from '@/lib/builders/view-models/DriverProfileViewModelBuilder';
|
||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
||||
|
||||
interface DriverProfilePageClientProps {
|
||||
pageDto: GetDriverProfileOutputDTO | null;
|
||||
error?: string;
|
||||
empty?: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DriverProfilePageClient
|
||||
*
|
||||
* Client component that:
|
||||
* 1. Handles UI state (tabs, friend requests)
|
||||
* 2. Uses ViewModelBuilder to transform DTO
|
||||
* 3. Passes ViewModel to Template
|
||||
*/
|
||||
export function DriverProfilePageClient({ pageDto, error, empty }: DriverProfilePageClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// UI State
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview');
|
||||
const [friendRequestSent, setFriendRequestSent] = useState(false);
|
||||
|
||||
// Event handlers
|
||||
const handleAddFriend = () => {
|
||||
setFriendRequestSent(true);
|
||||
};
|
||||
|
||||
const handleBackClick = () => {
|
||||
router.push('/drivers');
|
||||
};
|
||||
|
||||
// Handle error/empty states
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-12 text-center">
|
||||
<div className="text-red-400 mb-4">Error loading driver profile</div>
|
||||
<p className="text-gray-400">Please try again later</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pageDto || !pageDto.currentDriver) {
|
||||
if (empty) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">{empty.title}</h2>
|
||||
<p className="text-gray-400">{empty.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transform DTO to ViewModel using Builder
|
||||
const viewModel = DriverProfileViewModelBuilder.build(pageDto);
|
||||
|
||||
// Transform teamMemberships for template
|
||||
const allTeamMemberships = pageDto.teamMemberships.map(membership => ({
|
||||
team: {
|
||||
id: membership.teamId,
|
||||
name: membership.teamName,
|
||||
},
|
||||
role: membership.role,
|
||||
joinedAt: new Date(membership.joinedAt),
|
||||
}));
|
||||
|
||||
return (
|
||||
<DriverProfileTemplate
|
||||
driverProfile={viewModel}
|
||||
allTeamMemberships={allTeamMemberships}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onBackClick={handleBackClick}
|
||||
onAddFriend={handleAddFriend}
|
||||
friendRequestSent={friendRequestSent}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { DriverProfilePageQuery } from '@/lib/page-queries/page-queries/DriverProfilePageQuery';
|
||||
import { DriverProfilePageClient } from './DriverProfilePageClient';
|
||||
import { DriverProfilePageClient } from '@/components/drivers/DriverProfilePageClient';
|
||||
|
||||
export default async function DriverProfilePage({ params }: { params: { id: string } }) {
|
||||
// Execute the page query
|
||||
@@ -9,7 +10,7 @@ export default async function DriverProfilePage({ params }: { params: { id: stri
|
||||
// Handle different result statuses
|
||||
switch (result.status) {
|
||||
case 'notFound':
|
||||
redirect('/404');
|
||||
redirect(routes.error.notFound);
|
||||
case 'redirect':
|
||||
redirect(result.to);
|
||||
case 'error':
|
||||
@@ -21,8 +22,8 @@ export default async function DriverProfilePage({ params }: { params: { id: stri
|
||||
/>
|
||||
);
|
||||
case 'ok':
|
||||
const pageDto = result.dto;
|
||||
const hasData = !!pageDto.currentDriver;
|
||||
const viewModel = result.dto;
|
||||
const hasData = !!viewModel.currentDriver;
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
@@ -38,7 +39,7 @@ export default async function DriverProfilePage({ params }: { params: { id: stri
|
||||
|
||||
return (
|
||||
<DriverProfilePageClient
|
||||
pageDto={pageDto}
|
||||
pageDto={viewModel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { DriversPageQuery } from '@/lib/page-queries/page-queries/DriversPageQuery';
|
||||
import { DriversPageClient } from './DriversPageClient';
|
||||
import { DriversPageClient } from '@/components/drivers/DriversPageClient';
|
||||
|
||||
export default async function Page() {
|
||||
// Execute the page query
|
||||
@@ -9,7 +10,7 @@ export default async function Page() {
|
||||
// Handle different result statuses
|
||||
switch (result.status) {
|
||||
case 'notFound':
|
||||
redirect('/404');
|
||||
redirect(routes.error.notFound);
|
||||
case 'redirect':
|
||||
redirect(result.to);
|
||||
case 'error':
|
||||
@@ -21,8 +22,8 @@ export default async function Page() {
|
||||
/>
|
||||
);
|
||||
case 'ok':
|
||||
const pageDto = result.dto;
|
||||
const hasData = (pageDto.drivers?.length ?? 0) > 0;
|
||||
const viewModel = result.dto;
|
||||
const hasData = (viewModel.drivers?.length ?? 0) > 0;
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
@@ -38,7 +39,7 @@ export default async function Page() {
|
||||
|
||||
return (
|
||||
<DriversPageClient
|
||||
pageDto={pageDto}
|
||||
pageDto={viewModel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
|
||||
import { ErrorActionButtons } from '@/ui/ErrorActionButtons';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
@@ -9,29 +13,23 @@ export default function ErrorPage({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<h1 className="text-3xl font-semibold">Something went wrong</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
{error?.message ? error.message : 'An unexpected error occurred.'}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary-blue px-4 py-2 text-sm font-medium text-white hover:bg-primary-blue/80 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center rounded-md bg-iron-gray px-4 py-2 text-sm font-medium text-white hover:bg-iron-gray/80 transition-colors"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<ErrorPageContainer
|
||||
errorCode="Error"
|
||||
description={error?.message || 'An unexpected error occurred.'}
|
||||
>
|
||||
{error?.digest && (
|
||||
<Text size="xs" color="text-gray-500" font="mono">
|
||||
Error ID: {error.digest}
|
||||
</Text>
|
||||
)}
|
||||
<ErrorActionButtons
|
||||
onRetry={reset}
|
||||
onHomeClick={() => router.push(routes.public.home)}
|
||||
showRetry={true}
|
||||
/>
|
||||
</ErrorPageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
|
||||
import { ErrorActionButtons } from '@/ui/ErrorActionButtons';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
@@ -9,38 +13,27 @@ export default function GlobalError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="antialiased">
|
||||
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<h1 className="text-3xl font-semibold">Something went wrong</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
{error?.message ? error.message : 'An unexpected error occurred.'}
|
||||
</p>
|
||||
{error?.digest && (
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary-blue px-4 py-2 text-sm font-medium text-white hover:bg-primary-blue/80 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center rounded-md bg-iron-gray px-4 py-2 text-sm font-medium text-white hover:bg-iron-gray/80 transition-colors"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<ErrorPageContainer
|
||||
errorCode="Error"
|
||||
description={error?.message || 'An unexpected error occurred.'}
|
||||
>
|
||||
{error?.digest && (
|
||||
<Text size="xs" color="text-gray-500" font="mono">
|
||||
Error ID: {error.digest}
|
||||
</Text>
|
||||
)}
|
||||
<ErrorActionButtons
|
||||
onRetry={reset}
|
||||
onHomeClick={() => router.push(routes.public.home)}
|
||||
showRetry={true}
|
||||
/>
|
||||
</ErrorPageContainer>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import DevToolbar from '@/components/dev/DevToolbar';
|
||||
import { EnhancedErrorBoundary } from '@/components/errors/EnhancedErrorBoundary';
|
||||
import { NotificationIntegration } from '@/components/errors/NotificationIntegration';
|
||||
import NotificationProvider from '@/components/notifications/NotificationProvider';
|
||||
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||
import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
|
||||
import { FeatureFlagProvider } from '@/lib/feature/FeatureFlagProvider';
|
||||
import { ContainerProvider } from '@/lib/di/providers/ContainerProvider';
|
||||
import { QueryClientProvider } from '@/lib/providers/QueryClientProvider';
|
||||
import { initializeGlobalErrorHandling } from '@/lib/infrastructure/GlobalErrorHandler';
|
||||
import { initializeApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import './globals.css';
|
||||
import { AppWrapper } from '@/ui/AppWrapper';
|
||||
import { Header } from '@/ui/Header';
|
||||
import { HeaderContent } from '@/ui/HeaderContent';
|
||||
import { MainContent } from '@/ui/MainContent';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -81,43 +75,14 @@ export default async function RootLayout({
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body className="antialiased overflow-x-hidden">
|
||||
<ContainerProvider>
|
||||
<QueryClientProvider>
|
||||
<AuthProvider>
|
||||
<FeatureFlagProvider flags={enabledFlags}>
|
||||
<NotificationProvider>
|
||||
<NotificationIntegration />
|
||||
<EnhancedErrorBoundary enableDevOverlay={process.env.NODE_ENV === 'development'}>
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-deep-graphite/80 backdrop-blur-sm border-b border-white/5">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Link href="/" className="inline-flex items-center">
|
||||
<Image
|
||||
src="/images/logos/wordmark-rectangle-dark.svg"
|
||||
alt="GridPilot"
|
||||
width={160}
|
||||
height={30}
|
||||
className="h-6 w-auto md:h-8"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
<p className="hidden sm:block text-sm text-gray-400 font-light">
|
||||
Making league racing less chaotic
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="pt-16">{children}</div>
|
||||
{/* Development Tools */}
|
||||
{process.env.NODE_ENV === 'development' && <DevToolbar />}
|
||||
</EnhancedErrorBoundary>
|
||||
</NotificationProvider>
|
||||
</FeatureFlagProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</ContainerProvider>
|
||||
<AppWrapper enabledFlags={enabledFlags}>
|
||||
<Header>
|
||||
<HeaderContent />
|
||||
</Header>
|
||||
<MainContent>
|
||||
{children}
|
||||
</MainContent>
|
||||
</AppWrapper>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate';
|
||||
import { LeaderboardsTemplate } from '@/templates/LeaderboardsTemplate';
|
||||
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export function LeaderboardsPageWrapper({ data }: { data: LeaderboardsViewData | null }) {
|
||||
const router = useRouter();
|
||||
@@ -12,22 +13,22 @@ export function LeaderboardsPageWrapper({ data }: { data: LeaderboardsViewData |
|
||||
}
|
||||
|
||||
const handleDriverClick = (driverId: string) => {
|
||||
router.push(`/drivers/${driverId}`);
|
||||
router.push(routes.driver.detail(driverId));
|
||||
};
|
||||
|
||||
const handleTeamClick = (teamId: string) => {
|
||||
router.push(`/teams/${teamId}`);
|
||||
router.push(routes.team.detail(teamId));
|
||||
};
|
||||
|
||||
const handleNavigateToDrivers = () => {
|
||||
router.push('/leaderboards/drivers');
|
||||
router.push(routes.leaderboards.drivers);
|
||||
};
|
||||
|
||||
const handleNavigateToTeams = () => {
|
||||
router.push('/teams/leaderboard');
|
||||
router.push(routes.team.leaderboard);
|
||||
};
|
||||
|
||||
// Transform ViewData to template props
|
||||
// Transform ViewData to template props (simple field mapping only)
|
||||
const templateData = {
|
||||
drivers: data.drivers.map(d => ({
|
||||
id: d.id,
|
||||
|
||||
@@ -1,51 +1,27 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { DriverRankingsPageQuery } from '@/lib/page-queries/page-queries/DriverRankingsPageQuery';
|
||||
import { DriverRankingsViewDataBuilder } from '@/lib/builders/view-data/DriverRankingsViewDataBuilder';
|
||||
import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate';
|
||||
|
||||
// ============================================================================
|
||||
// MAIN PAGE COMPONENT
|
||||
// ============================================================================
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export default async function DriverLeaderboardPage() {
|
||||
// Execute the page query
|
||||
const result = await DriverRankingsPageQuery.execute();
|
||||
|
||||
// Handle different result statuses
|
||||
switch (result.status) {
|
||||
case 'notFound':
|
||||
redirect('/404');
|
||||
case 'redirect':
|
||||
redirect(result.to);
|
||||
case 'error':
|
||||
// For now, show empty state. In a real app, you'd pass error to client
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
|
||||
<div className="text-red-400 mb-4">Error loading driver rankings</div>
|
||||
<p className="text-gray-400">Please try again later</p>
|
||||
</div>
|
||||
);
|
||||
case 'ok':
|
||||
const viewData = DriverRankingsViewDataBuilder.build(result.dto);
|
||||
const hasData = (viewData.drivers?.length ?? 0) > 0;
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<DriverRankingsTemplate
|
||||
viewData={{
|
||||
drivers: [],
|
||||
podium: [],
|
||||
searchQuery: '',
|
||||
selectedSkill: 'all',
|
||||
sortBy: 'rank',
|
||||
showFilters: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DriverRankingsTemplate viewData={viewData} />
|
||||
);
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
|
||||
// Handle different error types
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
} else if (error === 'redirect') {
|
||||
redirect(routes.public.home);
|
||||
} else {
|
||||
// serverError, networkError, unknown, validationError, unauthorized
|
||||
console.error('Driver rankings error:', error);
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
// Success
|
||||
const viewData = result.unwrap();
|
||||
return <DriverRankingsTemplate viewData={viewData} />;
|
||||
}
|
||||
@@ -1,61 +1,27 @@
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { LeaderboardsPageQuery } from '@/lib/page-queries/page-queries/LeaderboardsPageQuery';
|
||||
import { LeaderboardsViewDataBuilder } from '@/lib/builders/view-data/LeaderboardsViewDataBuilder';
|
||||
import { LeaderboardsPageWrapper } from './LeaderboardsPageWrapper';
|
||||
|
||||
// ============================================================================
|
||||
// MAIN PAGE COMPONENT
|
||||
// ============================================================================
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export default async function LeaderboardsPage() {
|
||||
// Execute the page query
|
||||
const result = await LeaderboardsPageQuery.execute();
|
||||
|
||||
// Handle different result statuses
|
||||
switch (result.status) {
|
||||
case 'notFound':
|
||||
redirect('/404');
|
||||
case 'redirect':
|
||||
redirect(result.to);
|
||||
case 'error':
|
||||
// Show empty state with error
|
||||
return (
|
||||
<PageWrapper
|
||||
data={null}
|
||||
isLoading={false}
|
||||
error={new Error(result.errorId)}
|
||||
retry={async () => redirect('/leaderboards')}
|
||||
Template={LeaderboardsPageWrapper}
|
||||
loading={{ variant: 'full-screen', message: 'Loading leaderboards...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
icon: Trophy,
|
||||
title: 'No leaderboard data',
|
||||
description: 'There is no leaderboard data available at the moment.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'ok':
|
||||
const viewData = LeaderboardsViewDataBuilder.build(result.dto.drivers, result.dto.teams);
|
||||
const hasData = (viewData.drivers?.length ?? 0) > 0 || (viewData.teams?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={hasData ? viewData : null}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
retry={async () => redirect('/leaderboards')}
|
||||
Template={LeaderboardsPageWrapper}
|
||||
loading={{ variant: 'full-screen', message: 'Loading leaderboards...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
icon: Trophy,
|
||||
title: 'No leaderboard data',
|
||||
description: 'There is no leaderboard data available at the moment.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
|
||||
// Handle different error types
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
} else if (error === 'redirect') {
|
||||
redirect(routes.public.home);
|
||||
} else {
|
||||
// serverError, networkError, unknown, validationError, unauthorized
|
||||
console.error('Leaderboards error:', error);
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
// Success
|
||||
const viewData = result.unwrap();
|
||||
return <LeaderboardsPageWrapper data={viewData} />;
|
||||
}
|
||||
@@ -1,45 +1,40 @@
|
||||
'use client';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery';
|
||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import LeagueHeader from '@/components/leagues/LeagueHeader';
|
||||
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||
import { useLeagueDetail } from "@/lib/hooks/league/useLeagueDetail";
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
export default function LeagueLayout({
|
||||
export default async function LeagueLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { id: string };
|
||||
}) {
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const { data: leagueDetail, isLoading: loading } = useLeagueDetail({ leagueId });
|
||||
|
||||
if (loading) {
|
||||
const leagueId = params.id;
|
||||
|
||||
// Execute PageQuery to get league data
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error === 'notFound' || error === 'redirect') {
|
||||
notFound();
|
||||
}
|
||||
// Return error state
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading league...</div>
|
||||
</div>
|
||||
</div>
|
||||
<LeagueDetailTemplate
|
||||
leagueId={leagueId}
|
||||
leagueName="Error"
|
||||
leagueDescription="Failed to load league"
|
||||
tabs={[]}
|
||||
>
|
||||
<div className="text-center text-gray-400">Failed to load league</div>
|
||||
</LeagueDetailTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
if (!leagueDetail) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">League not found</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const data = result.unwrap();
|
||||
const league = data.league;
|
||||
|
||||
// Define tab configuration
|
||||
const baseTabs = [
|
||||
{ label: 'Overview', href: `/leagues/${leagueId}`, exact: true },
|
||||
@@ -61,46 +56,13 @@ export default function LeagueLayout({
|
||||
const tabs = [...baseTabs, ...adminTabs];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Leagues', href: '/leagues' },
|
||||
{ label: leagueDetail.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<LeagueHeader
|
||||
leagueId={leagueDetail.id}
|
||||
leagueName={leagueDetail.name}
|
||||
description={leagueDetail.description}
|
||||
ownerId={leagueDetail.ownerId}
|
||||
ownerName={''}
|
||||
mainSponsor={null}
|
||||
/>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="mb-6 border-b border-charcoal-outline">
|
||||
<div className="flex gap-6 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.href}
|
||||
onClick={() => router.push(tab.href)}
|
||||
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
|
||||
(tab.exact ? pathname === tab.href : pathname.startsWith(tab.href))
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<LeagueDetailTemplate
|
||||
leagueId={leagueId}
|
||||
leagueName={league.name}
|
||||
leagueDescription={league.description}
|
||||
tabs={tabs}
|
||||
>
|
||||
{children}
|
||||
</LeagueDetailTemplate>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery';
|
||||
import { LeagueDetailPresenter } from '@/lib/presenters/LeagueDetailPresenter';
|
||||
import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
||||
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
|
||||
|
||||
interface Props {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
// Validate params
|
||||
if (!params.id) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Execute the PageQuery
|
||||
const result = await LeagueDetailPageQuery.execute(params.id);
|
||||
|
||||
@@ -31,56 +24,29 @@ export default async function Page({ params }: Props) {
|
||||
case 'LEAGUE_FETCH_FAILED':
|
||||
case 'UNKNOWN_ERROR':
|
||||
default:
|
||||
// Return error state that PageWrapper can handle
|
||||
// For error state, we need a simple template that just renders an error
|
||||
const ErrorTemplate: React.ComponentType<{ data: any }> = ({ data }) => (
|
||||
<div>Error state</div>
|
||||
);
|
||||
// Return error state
|
||||
return (
|
||||
<PageWrapper
|
||||
data={undefined}
|
||||
error={new Error('Failed to fetch league')}
|
||||
Template={ErrorTemplate}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
/>
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Failed to load league details</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const data = result.unwrap();
|
||||
|
||||
// Convert the API DTO to ViewModel using the existing presenter
|
||||
// This maintains compatibility with the existing template
|
||||
const viewModel = data.apiDto as unknown as LeagueDetailPageViewModel;
|
||||
// Build ViewData using the builder
|
||||
// Note: This would need additional data (owner, scoring config, etc.) in real implementation
|
||||
const viewData = LeagueDetailViewDataBuilder.build({
|
||||
league: data.league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
// Create a wrapper component that passes ViewData to the template
|
||||
const TemplateWrapper: React.ComponentType<{ data: typeof data }> = ({ data }) => {
|
||||
// Convert ViewModel to ViewData using Presenter
|
||||
const viewData = LeagueDetailPresenter.createViewData(viewModel, params.id, false);
|
||||
|
||||
return (
|
||||
<LeagueDetailTemplate
|
||||
viewData={viewData}
|
||||
leagueId={params.id}
|
||||
isSponsor={false}
|
||||
membership={null}
|
||||
onMembershipChange={() => {}}
|
||||
onEndRaceModalOpen={() => {}}
|
||||
onLiveRaceClick={() => {}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={data}
|
||||
Template={TemplateWrapper}
|
||||
loading={{ variant: 'skeleton', message: 'Loading league details...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
title: 'League not found',
|
||||
description: 'The league you are looking for does not exist or has been removed.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <LeagueDetailTemplate viewData={viewData} />;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import type { Mocked } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel';
|
||||
import type { LeagueAdminRosterMemberViewModel } from '@/lib/view-models/LeagueAdminRosterMemberViewModel';
|
||||
@@ -26,61 +27,68 @@ vi.mock('next/navigation', () => ({
|
||||
let mockJoinRequests: any[] = [];
|
||||
let mockMembers: any[] = [];
|
||||
|
||||
// Mock the new DI hooks
|
||||
vi.mock('@/hooks/league/useLeagueRosterAdmin', () => ({
|
||||
useLeagueRosterJoinRequests: (leagueId: string) => ({
|
||||
// Mock the hooks directly
|
||||
vi.mock('@/lib/hooks/league/useLeagueRosterAdmin', () => ({
|
||||
useLeagueJoinRequests: (leagueId: string) => ({
|
||||
data: [...mockJoinRequests],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useLeagueRosterMembers: (leagueId: string) => ({
|
||||
useLeagueRosterAdmin: (leagueId: string) => ({
|
||||
data: [...mockMembers],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useApproveJoinRequest: () => ({
|
||||
useApproveJoinRequest: (options?: any) => ({
|
||||
mutate: (params: any) => {
|
||||
// Remove from join requests
|
||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId);
|
||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.requestId);
|
||||
if (options?.onSuccess) options.onSuccess();
|
||||
},
|
||||
mutateAsync: async (params: any) => {
|
||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId);
|
||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.requestId);
|
||||
if (options?.onSuccess) options.onSuccess();
|
||||
return { success: true };
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
useRejectJoinRequest: () => ({
|
||||
useRejectJoinRequest: (options?: any) => ({
|
||||
mutate: (params: any) => {
|
||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId);
|
||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.requestId);
|
||||
if (options?.onSuccess) options.onSuccess();
|
||||
},
|
||||
mutateAsync: async (params: any) => {
|
||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId);
|
||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.requestId);
|
||||
if (options?.onSuccess) options.onSuccess();
|
||||
return { success: true };
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdateMemberRole: () => ({
|
||||
useUpdateMemberRole: (options?: any) => ({
|
||||
mutate: (params: any) => {
|
||||
const member = mockMembers.find(m => m.driverId === params.driverId);
|
||||
if (member) member.role = params.role;
|
||||
if (member) member.role = params.newRole;
|
||||
if (options?.onError) options.onError();
|
||||
},
|
||||
mutateAsync: async (params: any) => {
|
||||
const member = mockMembers.find(m => m.driverId === params.driverId);
|
||||
if (member) member.role = params.role;
|
||||
if (member) member.role = params.newRole;
|
||||
if (options?.onError) options.onError();
|
||||
return { success: true };
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
useRemoveMember: () => ({
|
||||
useRemoveMember: (options?: any) => ({
|
||||
mutate: (params: any) => {
|
||||
mockMembers = mockMembers.filter(m => m.driverId !== params.driverId);
|
||||
if (options?.onSuccess) options.onSuccess();
|
||||
},
|
||||
mutateAsync: async (params: any) => {
|
||||
mockMembers = mockMembers.filter(m => m.driverId !== params.driverId);
|
||||
if (options?.onSuccess) options.onSuccess();
|
||||
return { success: true };
|
||||
},
|
||||
isPending: false,
|
||||
@@ -91,9 +99,11 @@ function makeJoinRequest(overrides: Partial<LeagueAdminRosterJoinRequestViewMode
|
||||
return {
|
||||
id: 'jr-1',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-1',
|
||||
driverName: 'Driver One',
|
||||
requestedAtIso: '2025-01-01T00:00:00.000Z',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Driver One',
|
||||
},
|
||||
requestedAt: '2025-01-01T00:00:00.000Z',
|
||||
message: 'Please let me in',
|
||||
...overrides,
|
||||
};
|
||||
@@ -102,14 +112,19 @@ function makeJoinRequest(overrides: Partial<LeagueAdminRosterJoinRequestViewMode
|
||||
function makeMember(overrides: Partial<LeagueAdminRosterMemberViewModel> = {}): LeagueAdminRosterMemberViewModel {
|
||||
return {
|
||||
driverId: 'driver-10',
|
||||
driverName: 'Member Ten',
|
||||
driver: {
|
||||
id: 'driver-10',
|
||||
name: 'Member Ten',
|
||||
},
|
||||
role: 'member',
|
||||
joinedAtIso: '2025-01-01T00:00:00.000Z',
|
||||
joinedAt: '2025-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RosterAdminPage', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock data
|
||||
mockJoinRequests = [];
|
||||
@@ -123,24 +138,44 @@ describe('RosterAdminPage', () => {
|
||||
updateMemberRole: vi.fn(),
|
||||
removeMember: vi.fn(),
|
||||
} as any;
|
||||
|
||||
// Create a new QueryClient for each test
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const renderWithProviders = (component: React.ReactNode) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{component}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders join requests + members from service ViewModels', async () => {
|
||||
const joinRequests: LeagueAdminRosterJoinRequestViewModel[] = [
|
||||
makeJoinRequest({ id: 'jr-1', driverName: 'Driver One' }),
|
||||
makeJoinRequest({ id: 'jr-2', driverName: 'Driver Two', driverId: 'driver-2' }),
|
||||
makeJoinRequest({ id: 'jr-1', driver: { id: 'driver-1', name: 'Driver One' } }),
|
||||
makeJoinRequest({ id: 'jr-2', driver: { id: 'driver-2', name: 'Driver Two' } }),
|
||||
];
|
||||
|
||||
const members: LeagueAdminRosterMemberViewModel[] = [
|
||||
makeMember({ driverId: 'driver-10', driverName: 'Member Ten', role: 'member' }),
|
||||
makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'admin' }),
|
||||
makeMember({ driverId: 'driver-10', driver: { id: 'driver-10', name: 'Member Ten' }, role: 'member' }),
|
||||
makeMember({ driverId: 'driver-11', driver: { id: 'driver-11', name: 'Member Eleven' }, role: 'admin' }),
|
||||
];
|
||||
|
||||
// Set mock data for hooks
|
||||
mockJoinRequests = joinRequests;
|
||||
mockMembers = members;
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
renderWithProviders(<RosterAdminPage />);
|
||||
|
||||
expect(await screen.findByText('Roster Admin')).toBeInTheDocument();
|
||||
|
||||
@@ -152,10 +187,10 @@ describe('RosterAdminPage', () => {
|
||||
});
|
||||
|
||||
it('approves a join request and removes it from the pending list', async () => {
|
||||
mockJoinRequests = [makeJoinRequest({ id: 'jr-1', driverName: 'Driver One' })];
|
||||
mockMembers = [makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })];
|
||||
mockJoinRequests = [makeJoinRequest({ id: 'jr-1', driver: { id: 'driver-1', name: 'Driver One' } })];
|
||||
mockMembers = [makeMember({ driverId: 'driver-10', driver: { id: 'driver-10', name: 'Member Ten' } })];
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
renderWithProviders(<RosterAdminPage />);
|
||||
|
||||
expect(await screen.findByText('Driver One')).toBeInTheDocument();
|
||||
|
||||
@@ -167,10 +202,10 @@ describe('RosterAdminPage', () => {
|
||||
});
|
||||
|
||||
it('rejects a join request and removes it from the pending list', async () => {
|
||||
mockJoinRequests = [makeJoinRequest({ id: 'jr-2', driverName: 'Driver Two' })];
|
||||
mockMembers = [makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })];
|
||||
mockJoinRequests = [makeJoinRequest({ id: 'jr-2', driver: { id: 'driver-2', name: 'Driver Two' } })];
|
||||
mockMembers = [makeMember({ driverId: 'driver-10', driver: { id: 'driver-10', name: 'Member Ten' } })];
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
renderWithProviders(<RosterAdminPage />);
|
||||
|
||||
expect(await screen.findByText('Driver Two')).toBeInTheDocument();
|
||||
|
||||
@@ -183,9 +218,9 @@ describe('RosterAdminPage', () => {
|
||||
|
||||
it('changes a member role via service and updates the displayed role', async () => {
|
||||
mockJoinRequests = [];
|
||||
mockMembers = [makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'member' })];
|
||||
mockMembers = [makeMember({ driverId: 'driver-11', driver: { id: 'driver-11', name: 'Member Eleven' }, role: 'member' })];
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
renderWithProviders(<RosterAdminPage />);
|
||||
|
||||
expect(await screen.findByText('Member Eleven')).toBeInTheDocument();
|
||||
|
||||
@@ -201,9 +236,9 @@ describe('RosterAdminPage', () => {
|
||||
|
||||
it('removes a member via service and removes them from the list', async () => {
|
||||
mockJoinRequests = [];
|
||||
mockMembers = [makeMember({ driverId: 'driver-12', driverName: 'Member Twelve', role: 'member' })];
|
||||
mockMembers = [makeMember({ driverId: 'driver-12', driver: { id: 'driver-12', name: 'Member Twelve' }, role: 'member' })];
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
renderWithProviders(<RosterAdminPage />);
|
||||
|
||||
expect(await screen.findByText('Member Twelve')).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
useUpdateMemberRole,
|
||||
useRemoveMember,
|
||||
} from "@/lib/hooks/league/useLeagueRosterAdmin";
|
||||
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
|
||||
|
||||
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
|
||||
|
||||
@@ -72,114 +72,16 @@ export function RosterAdminPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Roster Admin</h1>
|
||||
<p className="text-sm text-gray-400">Manage join requests and member roles.</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-charcoal-outline pt-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold text-white">Pending join requests</h2>
|
||||
<p className="text-xs text-gray-500">{pendingCountLabel}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-4 text-sm text-gray-400">Loading…</div>
|
||||
) : joinRequests.length ? (
|
||||
<div className="space-y-2">
|
||||
{joinRequests.map((req) => (
|
||||
<div
|
||||
key={req.id}
|
||||
className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white font-medium truncate">{(req.driver as any)?.name || 'Unknown'}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{req.requestedAt}</p>
|
||||
{req.message ? <p className="text-xs text-gray-500 truncate">{req.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`join-request-${req.id}-approve`}
|
||||
onClick={() => handleApprove(req.id)}
|
||||
className="px-3 py-1.5 rounded bg-primary-blue text-white"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`join-request-${req.id}-reject`}
|
||||
onClick={() => handleReject(req.id)}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-sm text-gray-500">No pending join requests.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-charcoal-outline pt-4 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">Members</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-4 text-sm text-gray-400">Loading…</div>
|
||||
) : members.length ? (
|
||||
<div className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.driverId}
|
||||
className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white font-medium truncate">{member.driver.name}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{member.joinedAt}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-2">
|
||||
<label className="text-xs text-gray-400" htmlFor={`role-${member.driverId}`}>
|
||||
Role for {member.driver.name}
|
||||
</label>
|
||||
<select
|
||||
id={`role-${member.driverId}`}
|
||||
aria-label={`Role for ${member.driver.name}`}
|
||||
value={member.role}
|
||||
onChange={(e) => handleRoleChange(member.driverId, e.target.value as MembershipRole)}
|
||||
className="bg-iron-gray text-white px-3 py-2 rounded"
|
||||
>
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`member-${member.driverId}-remove`}
|
||||
onClick={() => handleRemove(member.driverId)}
|
||||
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-sm text-gray-500">No members found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<RosterAdminTemplate
|
||||
joinRequests={joinRequests}
|
||||
members={members}
|
||||
loading={loading}
|
||||
pendingCountLabel={pendingCountLabel}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
onRoleChange={handleRoleChange}
|
||||
onRemove={handleRemove}
|
||||
roleOptions={ROLE_OPTIONS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
|
||||
import { LeaguesPageQuery } from '@/lib/page-queries/page-queries/LeaguesPageQuery';
|
||||
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
|
||||
export default async function Page() {
|
||||
// Execute the PageQuery
|
||||
@@ -21,19 +19,12 @@ export default async function Page() {
|
||||
case 'LEAGUES_FETCH_FAILED':
|
||||
case 'UNKNOWN_ERROR':
|
||||
default:
|
||||
// Return error state that PageWrapper can handle
|
||||
return (
|
||||
<PageWrapper
|
||||
data={undefined}
|
||||
error={new Error('Failed to fetch leagues')}
|
||||
Template={LeaguesTemplate}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
/>
|
||||
);
|
||||
// Return error state - use LeaguesTemplate with empty data
|
||||
return <LeaguesTemplate data={{ leagues: [] }} />;
|
||||
}
|
||||
}
|
||||
|
||||
const viewData = result.unwrap();
|
||||
|
||||
return <PageWrapper data={viewData} Template={LeaguesTemplate} />;
|
||||
return <LeaguesTemplate data={viewData} />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { proxyMediaRequest, getMediaContentType, getMediaCacheControl } from '@/lib/adapters/MediaProxyAdapter';
|
||||
import { GetAvatarPageQuery } from '@/lib/page-queries/media/GetAvatarPageQuery';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -7,16 +7,22 @@ export async function GET(
|
||||
) {
|
||||
const { driverId } = params;
|
||||
|
||||
const result = await proxyMediaRequest(`/media/avatar/${driverId}`);
|
||||
const result = await GetAvatarPageQuery.execute({ driverId });
|
||||
|
||||
if (result.isErr()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
|
||||
return new NextResponse(result.unwrap(), {
|
||||
const viewData = result.unwrap();
|
||||
|
||||
return new NextResponse(viewData.buffer, {
|
||||
headers: {
|
||||
'Content-Type': getMediaContentType('/media/avatar'),
|
||||
'Cache-Control': getMediaCacheControl(),
|
||||
'Content-Type': viewData.contentType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { proxyMediaRequest, getMediaContentType, getMediaCacheControl } from '@/lib/adapters/MediaProxyAdapter';
|
||||
import { GetCategoryIconPageQuery } from '@/lib/page-queries/media/GetCategoryIconPageQuery';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -7,16 +7,22 @@ export async function GET(
|
||||
) {
|
||||
const { categoryId } = params;
|
||||
|
||||
const result = await proxyMediaRequest(`/media/categories/${categoryId}/icon`);
|
||||
const result = await GetCategoryIconPageQuery.execute({ categoryId });
|
||||
|
||||
if (result.isErr()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
|
||||
return new NextResponse(result.unwrap(), {
|
||||
const viewData = result.unwrap();
|
||||
|
||||
return new NextResponse(viewData.buffer, {
|
||||
headers: {
|
||||
'Content-Type': getMediaContentType('/media/categories'),
|
||||
'Cache-Control': getMediaCacheControl(),
|
||||
'Content-Type': viewData.contentType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { proxyMediaRequest, getMediaContentType, getMediaCacheControl } from '@/lib/adapters/MediaProxyAdapter';
|
||||
import { GetLeagueCoverPageQuery } from '@/lib/page-queries/media/GetLeagueCoverPageQuery';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -7,16 +7,22 @@ export async function GET(
|
||||
) {
|
||||
const { leagueId } = params;
|
||||
|
||||
const result = await proxyMediaRequest(`/media/leagues/${leagueId}/cover`);
|
||||
const result = await GetLeagueCoverPageQuery.execute({ leagueId });
|
||||
|
||||
if (result.isErr()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
|
||||
return new NextResponse(result.unwrap(), {
|
||||
const viewData = result.unwrap();
|
||||
|
||||
return new NextResponse(viewData.buffer, {
|
||||
headers: {
|
||||
'Content-Type': getMediaContentType('/media/leagues'),
|
||||
'Cache-Control': getMediaCacheControl(),
|
||||
'Content-Type': viewData.contentType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { proxyMediaRequest, getMediaContentType, getMediaCacheControl } from '@/lib/adapters/MediaProxyAdapter';
|
||||
import { GetLeagueLogoPageQuery } from '@/lib/page-queries/media/GetLeagueLogoPageQuery';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -7,16 +7,22 @@ export async function GET(
|
||||
) {
|
||||
const { leagueId } = params;
|
||||
|
||||
const result = await proxyMediaRequest(`/media/leagues/${leagueId}/logo`);
|
||||
const result = await GetLeagueLogoPageQuery.execute({ leagueId });
|
||||
|
||||
if (result.isErr()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
|
||||
return new NextResponse(result.unwrap(), {
|
||||
const viewData = result.unwrap();
|
||||
|
||||
return new NextResponse(viewData.buffer, {
|
||||
headers: {
|
||||
'Content-Type': getMediaContentType('/media/leagues'),
|
||||
'Cache-Control': getMediaCacheControl(),
|
||||
'Content-Type': viewData.contentType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { proxyMediaRequest, getMediaContentType, getMediaCacheControl } from '@/lib/adapters/MediaProxyAdapter';
|
||||
import { GetSponsorLogoPageQuery } from '@/lib/page-queries/media/GetSponsorLogoPageQuery';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -7,16 +7,22 @@ export async function GET(
|
||||
) {
|
||||
const { sponsorId } = params;
|
||||
|
||||
const result = await proxyMediaRequest(`/media/sponsors/${sponsorId}/logo`);
|
||||
const result = await GetSponsorLogoPageQuery.execute({ sponsorId });
|
||||
|
||||
if (result.isErr()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
|
||||
return new NextResponse(result.unwrap(), {
|
||||
const viewData = result.unwrap();
|
||||
|
||||
return new NextResponse(viewData.buffer, {
|
||||
headers: {
|
||||
'Content-Type': getMediaContentType('/media/sponsors'),
|
||||
'Cache-Control': getMediaCacheControl(),
|
||||
'Content-Type': viewData.contentType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { proxyMediaRequest, getMediaContentType, getMediaCacheControl } from '@/lib/adapters/MediaProxyAdapter';
|
||||
import { GetTeamLogoPageQuery } from '@/lib/page-queries/media/GetTeamLogoPageQuery';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -7,16 +7,22 @@ export async function GET(
|
||||
) {
|
||||
const { teamId } = params;
|
||||
|
||||
const result = await proxyMediaRequest(`/media/teams/${teamId}/logo`);
|
||||
const result = await GetTeamLogoPageQuery.execute({ teamId });
|
||||
|
||||
if (result.isErr()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
|
||||
return new NextResponse(result.unwrap(), {
|
||||
const viewData = result.unwrap();
|
||||
|
||||
return new NextResponse(viewData.buffer, {
|
||||
headers: {
|
||||
'Content-Type': getMediaContentType('/media/teams'),
|
||||
'Cache-Control': getMediaCacheControl(),
|
||||
'Content-Type': viewData.contentType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { proxyMediaRequest, getMediaContentType, getMediaCacheControl } from '@/lib/adapters/MediaProxyAdapter';
|
||||
import { GetTrackImagePageQuery } from '@/lib/page-queries/media/GetTrackImagePageQuery';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -7,16 +7,22 @@ export async function GET(
|
||||
) {
|
||||
const { trackId } = params;
|
||||
|
||||
const result = await proxyMediaRequest(`/media/tracks/${trackId}/image`);
|
||||
const result = await GetTrackImagePageQuery.execute({ trackId });
|
||||
|
||||
if (result.isErr()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
|
||||
return new NextResponse(result.unwrap(), {
|
||||
const viewData = result.unwrap();
|
||||
|
||||
return new NextResponse(viewData.buffer, {
|
||||
headers: {
|
||||
'Content-Type': getMediaContentType('/media/tracks'),
|
||||
'Cache-Control': getMediaCacheControl(),
|
||||
'Content-Type': viewData.contentType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
3
apps/website/app/onboarding/OnboardingLayoutProps.ts
Normal file
3
apps/website/app/onboarding/OnboardingLayoutProps.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface OnboardingLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { OnboardingWizard } from '@/components/onboarding/OnboardingWizard';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { completeOnboardingAction, generateAvatarsAction } from './actions';
|
||||
import { completeOnboardingAction } from './completeOnboardingAction';
|
||||
import { generateAvatarsAction } from './generateAvatarsAction';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
|
||||
export function OnboardingWizardClient() {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { CompleteOnboardingMutation } from '@/lib/mutations/onboarding/CompleteOnboardingMutation';
|
||||
import { GenerateAvatarsMutation } from '@/lib/mutations/onboarding/GenerateAvatarsMutation';
|
||||
import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
@@ -28,26 +27,4 @@ export async function completeOnboardingAction(
|
||||
|
||||
revalidatePath(routes.protected.dashboard);
|
||||
return Result.ok({ success: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate avatars - thin wrapper around mutation
|
||||
*
|
||||
* Note: This action requires userId to be passed from the client.
|
||||
* The client should get userId from session and pass it as a parameter.
|
||||
*/
|
||||
export async function generateAvatarsAction(params: {
|
||||
userId: string;
|
||||
facePhotoData: string;
|
||||
suitColor: string;
|
||||
}): Promise<Result<{ success: boolean; avatarUrls?: string[] }, string>> {
|
||||
const mutation = new GenerateAvatarsMutation();
|
||||
const result = await mutation.execute(params);
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err(result.getError());
|
||||
}
|
||||
|
||||
const data = result.unwrap();
|
||||
return Result.ok({ success: data.success, avatarUrls: data.avatarUrls });
|
||||
}
|
||||
26
apps/website/app/onboarding/generateAvatarsAction.ts
Normal file
26
apps/website/app/onboarding/generateAvatarsAction.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
'use server';
|
||||
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { GenerateAvatarsMutation } from '@/lib/mutations/onboarding/GenerateAvatarsMutation';
|
||||
|
||||
/**
|
||||
* Generate avatars - thin wrapper around mutation
|
||||
*
|
||||
* Note: This action requires userId to be passed from the client.
|
||||
* The client should get userId from session and pass it as a parameter.
|
||||
*/
|
||||
export async function generateAvatarsAction(params: {
|
||||
userId: string;
|
||||
facePhotoData: string;
|
||||
suitColor: string;
|
||||
}): Promise<Result<{ success: boolean; avatarUrls?: string[] }, string>> {
|
||||
const mutation = new GenerateAvatarsMutation();
|
||||
const result = await mutation.execute(params);
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err(result.getError());
|
||||
}
|
||||
|
||||
const data = result.unwrap();
|
||||
return Result.ok({ success: data.success, avatarUrls: data.avatarUrls });
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
interface OnboardingLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding Layout
|
||||
*
|
||||
@@ -11,6 +7,7 @@ interface OnboardingLayoutProps {
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
||||
import { OnboardingLayoutProps } from './OnboardingLayoutProps';
|
||||
|
||||
export default async function OnboardingLayout({ children }: OnboardingLayoutProps) {
|
||||
const headerStore = await headers();
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { ProfileLeaguesPageDto } from '@/lib/page-queries/page-queries/ProfileLeaguesPageQuery';
|
||||
import { ProfileLeaguesPresenter } from '@/lib/presenters/ProfileLeaguesPresenter';
|
||||
import { ProfileLeaguesTemplate } from '@/templates/ProfileLeaguesTemplate';
|
||||
|
||||
interface ProfileLeaguesPageClientProps {
|
||||
pageDto: ProfileLeaguesPageDto;
|
||||
}
|
||||
|
||||
export function ProfileLeaguesPageClient({ pageDto }: ProfileLeaguesPageClientProps) {
|
||||
// Convert Page DTO to ViewData using Presenter
|
||||
const viewData = ProfileLeaguesPresenter.toViewData(pageDto);
|
||||
|
||||
// Render Template with ViewData
|
||||
return <ProfileLeaguesTemplate viewData={viewData} />;
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ProfileLeaguesPageQuery } from '@/lib/page-queries/page-queries/ProfileLeaguesPageQuery';
|
||||
import { ProfileLeaguesPageClient } from './ProfileLeaguesPageClient';
|
||||
import { ProfileLeaguesTemplate } from '@/templates/ProfileLeaguesTemplate';
|
||||
|
||||
export default async function ProfileLeaguesPage() {
|
||||
const result = await ProfileLeaguesPageQuery.execute();
|
||||
|
||||
switch (result.status) {
|
||||
case 'notFound':
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
case 'redirect':
|
||||
// Note: In Next.js, redirect would be imported from next/navigation
|
||||
// For now, we'll handle this case by returning notFound
|
||||
// In a full implementation, you'd use: redirect(result.to);
|
||||
} else if (error === 'redirect') {
|
||||
// In a real implementation, you'd use redirect('/')
|
||||
notFound();
|
||||
case 'error':
|
||||
// For now, treat errors as notFound
|
||||
// In a full implementation, you might render an error page
|
||||
} else {
|
||||
// For other errors, show notFound for now
|
||||
notFound();
|
||||
case 'ok':
|
||||
return <ProfileLeaguesPageClient pageDto={result.dto} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const viewData = result.unwrap();
|
||||
return <ProfileLeaguesTemplate viewData={viewData} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import type { Result } from '@/lib/contracts/Result';
|
||||
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
|
||||
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
|
||||
|
||||
interface SponsorshipRequestsClientProps {
|
||||
viewData: SponsorshipRequestsViewData;
|
||||
onAccept: (requestId: string) => Promise<Result<void, string>>;
|
||||
onReject: (requestId: string, reason?: string) => Promise<Result<void, string>>;
|
||||
}
|
||||
|
||||
export function SponsorshipRequestsClient({ viewData, onAccept, onReject }: SponsorshipRequestsClientProps) {
|
||||
const handleAccept = async (requestId: string) => {
|
||||
const result = await onAccept(requestId);
|
||||
if (result.isErr()) {
|
||||
console.error('Failed to accept request:', result.getError());
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (requestId: string, reason?: string) => {
|
||||
const result = await onReject(requestId, reason);
|
||||
if (result.isErr()) {
|
||||
console.error('Failed to reject request:', result.getError());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SponsorshipRequestsTemplate
|
||||
viewData={viewData}
|
||||
onAccept={handleAccept}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ interface SponsorshipRequestsPageClientProps {
|
||||
export function SponsorshipRequestsPageClient({ viewData, onAccept, onReject }: SponsorshipRequestsPageClientProps) {
|
||||
return (
|
||||
<SponsorshipRequestsTemplate
|
||||
data={viewData.sections}
|
||||
viewData={viewData}
|
||||
onAccept={async (requestId) => {
|
||||
await onAccept(requestId);
|
||||
}}
|
||||
|
||||
@@ -1,26 +1,57 @@
|
||||
'use server';
|
||||
|
||||
import { AcceptSponsorshipRequestMutation } from '@/lib/mutations/sponsors/AcceptSponsorshipRequestMutation';
|
||||
import { RejectSponsorshipRequestMutation } from '@/lib/mutations/sponsors/RejectSponsorshipRequestMutation';
|
||||
import type { AcceptSponsorshipRequestCommand } from '@/lib/services/sponsors/SponsorshipRequestsService';
|
||||
import type { RejectSponsorshipRequestCommand } from '@/lib/services/sponsors/SponsorshipRequestsService';
|
||||
import { SessionGateway } from '@/lib/gateways/SessionGateway';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export async function acceptSponsorshipRequest(
|
||||
command: AcceptSponsorshipRequestCommand,
|
||||
): Promise<void> {
|
||||
requestId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
// Get session for actorDriverId
|
||||
const sessionGateway = new SessionGateway();
|
||||
const session = await sessionGateway.getSession();
|
||||
const actorDriverId = session?.user?.primaryDriverId;
|
||||
|
||||
if (!actorDriverId) {
|
||||
return Result.err('Not authenticated');
|
||||
}
|
||||
|
||||
const mutation = new AcceptSponsorshipRequestMutation();
|
||||
const result = await mutation.execute(command);
|
||||
const result = await mutation.execute({ requestId, actorDriverId });
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to accept sponsorship request');
|
||||
console.error('Failed to accept sponsorship request:', result.getError());
|
||||
return Result.err(result.getError());
|
||||
}
|
||||
|
||||
revalidatePath(routes.protected.profileSponsorshipRequests);
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
export async function rejectSponsorshipRequest(
|
||||
command: RejectSponsorshipRequestCommand,
|
||||
): Promise<void> {
|
||||
requestId: string,
|
||||
reason?: string,
|
||||
): Promise<Result<void, string>> {
|
||||
// Get session for actorDriverId
|
||||
const sessionGateway = new SessionGateway();
|
||||
const session = await sessionGateway.getSession();
|
||||
const actorDriverId = session?.user?.primaryDriverId;
|
||||
|
||||
if (!actorDriverId) {
|
||||
return Result.err('Not authenticated');
|
||||
}
|
||||
|
||||
const mutation = new RejectSponsorshipRequestMutation();
|
||||
const result = await mutation.execute(command);
|
||||
const result = await mutation.execute({ requestId, actorDriverId, reason: reason || null });
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to reject sponsorship request');
|
||||
console.error('Failed to reject sponsorship request:', result.getError());
|
||||
return Result.err(result.getError());
|
||||
}
|
||||
|
||||
revalidatePath(routes.protected.profileSponsorshipRequests);
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { SponsorshipRequestsPageQuery } from '@/lib/page-queries/page-queries/SponsorshipRequestsPageQuery';
|
||||
import { SponsorshipRequestsClient } from './SponsorshipRequestsClient';
|
||||
import { acceptSponsorshipRequest, rejectSponsorshipRequest } from './actions';
|
||||
|
||||
export default async function SponsorshipRequestsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string>;
|
||||
}) {
|
||||
return <SponsorshipRequestsTemplate searchParams={searchParams} />;
|
||||
export default async function SponsorshipRequestsPage() {
|
||||
// Execute PageQuery
|
||||
const queryResult = await SponsorshipRequestsPageQuery.execute();
|
||||
|
||||
if (queryResult.isErr()) {
|
||||
const error = queryResult.getError();
|
||||
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
} else if (error === 'redirect') {
|
||||
// In a real implementation, you'd use redirect('/')
|
||||
notFound();
|
||||
} else {
|
||||
// For other errors, show notFound for now
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
const viewData = queryResult.unwrap();
|
||||
|
||||
return (
|
||||
<SponsorshipRequestsClient
|
||||
viewData={viewData}
|
||||
onAccept={acceptSponsorshipRequest}
|
||||
onReject={rejectSponsorshipRequest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
|
||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { RaceDetailPageQuery } from '@/lib/page-queries/races/RaceDetailPageQuery';
|
||||
|
||||
interface RaceDetailPageProps {
|
||||
params: {
|
||||
@@ -19,72 +16,87 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Manual wiring: create dependencies
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
|
||||
// Create API client
|
||||
const apiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
// Fetch initial race data (empty driverId for now, handled client-side)
|
||||
const data = await apiClient.getDetail(raceId, '');
|
||||
// Execute PageQuery
|
||||
const result = await RaceDetailPageQuery.execute({ raceId, driverId: '' });
|
||||
|
||||
if (!data) notFound();
|
||||
|
||||
// Transform data for template
|
||||
const templateViewModel = data && data.race ? {
|
||||
race: {
|
||||
id: data.race.id,
|
||||
track: data.race.track,
|
||||
car: data.race.car,
|
||||
scheduledAt: data.race.scheduledAt,
|
||||
status: data.race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: data.race.sessionType,
|
||||
},
|
||||
league: data.league ? {
|
||||
id: data.league.id,
|
||||
name: data.league.name,
|
||||
description: data.league.description || undefined,
|
||||
settings: data.league.settings as { maxDrivers: number; qualifyingFormat: string },
|
||||
} : undefined,
|
||||
entryList: data.entryList.map((entry: any) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
avatarUrl: entry.avatarUrl,
|
||||
country: entry.country,
|
||||
rating: entry.rating,
|
||||
isCurrentUser: entry.isCurrentUser,
|
||||
})),
|
||||
registration: {
|
||||
isUserRegistered: data.registration.isUserRegistered,
|
||||
canRegister: data.registration.canRegister,
|
||||
},
|
||||
userResult: data.userResult ? {
|
||||
position: data.userResult.position,
|
||||
startPosition: data.userResult.startPosition,
|
||||
positionChange: data.userResult.positionChange,
|
||||
incidents: data.userResult.incidents,
|
||||
isClean: data.userResult.isClean,
|
||||
isPodium: data.userResult.isPodium,
|
||||
ratingChange: data.userResult.ratingChange,
|
||||
} : undefined,
|
||||
canReopenRace: false, // Not provided by API, default to false
|
||||
} : undefined;
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
|
||||
switch (error) {
|
||||
case 'notFound':
|
||||
notFound();
|
||||
case 'redirect':
|
||||
notFound();
|
||||
default:
|
||||
// Pass error to template via PageWrapper
|
||||
return (
|
||||
<PageWrapper
|
||||
data={null}
|
||||
Template={({ data: _data }) => (
|
||||
<RaceDetailTemplate
|
||||
viewModel={undefined}
|
||||
isLoading={false}
|
||||
error={new Error('Failed to load race details')}
|
||||
onBack={() => {}}
|
||||
onRegister={() => {}}
|
||||
onWithdraw={() => {}}
|
||||
onCancel={() => {}}
|
||||
onReopen={() => {}}
|
||||
onEndRace={() => {}}
|
||||
onFileProtest={() => {}}
|
||||
onResultsClick={() => {}}
|
||||
onStewardingClick={() => {}}
|
||||
onLeagueClick={() => {}}
|
||||
onDriverClick={() => {}}
|
||||
currentDriverId={''}
|
||||
isOwnerOrAdmin={false}
|
||||
showProtestModal={false}
|
||||
setShowProtestModal={() => {}}
|
||||
showEndRaceModal={false}
|
||||
setShowEndRaceModal={() => {}}
|
||||
mutationLoading={{
|
||||
register: false,
|
||||
withdraw: false,
|
||||
cancel: false,
|
||||
reopen: false,
|
||||
complete: false,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
loading={{ variant: 'skeleton', message: 'Loading race details...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
icon: require('lucide-react').Flag,
|
||||
title: 'Race not found',
|
||||
description: 'The race may have been cancelled or deleted',
|
||||
action: { label: 'Back to Races', onClick: () => {} }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const viewData = result.unwrap();
|
||||
|
||||
// Convert ViewData to ViewModel for the template
|
||||
// The template expects a ViewModel, so we need to adapt
|
||||
const viewModel = {
|
||||
race: viewData.race,
|
||||
league: viewData.league,
|
||||
entryList: viewData.entryList,
|
||||
registration: viewData.registration,
|
||||
userResult: viewData.userResult,
|
||||
canReopenRace: viewData.canReopenRace,
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={data}
|
||||
Template={({ data }) => (
|
||||
data={viewData}
|
||||
Template={({ data: _data }) => (
|
||||
<RaceDetailTemplate
|
||||
viewModel={templateViewModel}
|
||||
viewModel={viewModel}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
// These will be handled client-side in the template or a wrapper
|
||||
onBack={() => {}}
|
||||
onRegister={() => {}}
|
||||
onWithdraw={() => {}}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
||||
import { useRaceResultsPageData } from "@/lib/hooks/race/useRaceResultsPageData";
|
||||
import { RaceResultsDataTransformer } from '@/lib/view-models/RaceResultsDataTransformer';
|
||||
import { useLeagueMemberships } from "@/lib/hooks/league/useLeagueMemberships";
|
||||
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||
import { useState } from 'react';
|
||||
import { notFound, useRouter } from 'next/navigation';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { RaceResultsPageQuery } from '@/lib/page-queries/races/RaceResultsPageQuery';
|
||||
import { Trophy } from 'lucide-react';
|
||||
|
||||
interface RaceResultsPageProps {
|
||||
@@ -17,99 +10,101 @@ interface RaceResultsPageProps {
|
||||
};
|
||||
}
|
||||
|
||||
export default function RaceResultsPage({ params }: RaceResultsPageProps) {
|
||||
const router = useRouter();
|
||||
export default async function RaceResultsPage({ params }: RaceResultsPageProps) {
|
||||
const raceId = params.id;
|
||||
|
||||
if (!raceId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const currentDriverId = useEffectiveDriverId() || '';
|
||||
|
||||
// Fetch data using domain hook
|
||||
const { data: queries, isLoading, error, refetch } = useRaceResultsPageData(raceId, currentDriverId);
|
||||
|
||||
// Additional data - league memberships
|
||||
const leagueName = queries?.results?.league?.name || '';
|
||||
const { data: memberships } = useLeagueMemberships(leagueName, currentDriverId);
|
||||
|
||||
// Transform data
|
||||
const data = queries?.results && queries?.sof
|
||||
? RaceResultsDataTransformer.transform(
|
||||
queries.results,
|
||||
queries.sof,
|
||||
currentDriverId,
|
||||
memberships
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// UI State for import functionality
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importSuccess, setImportSuccess] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [showImportForm, setShowImportForm] = useState(false);
|
||||
|
||||
// Actions
|
||||
const handleBack = () => router.back();
|
||||
|
||||
const handleImportResults = async (importedResults: any[]) => {
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
|
||||
try {
|
||||
console.log('Import results:', importedResults);
|
||||
setImportSuccess(true);
|
||||
|
||||
// Refetch data after import
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
setImportError(err instanceof Error ? err.message : 'Failed to import results');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
// Execute PageQuery
|
||||
const result = await RaceResultsPageQuery.execute({ raceId });
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
|
||||
switch (error) {
|
||||
case 'notFound':
|
||||
notFound();
|
||||
case 'redirect':
|
||||
notFound();
|
||||
default:
|
||||
// Pass error to template via StatefulPageWrapper
|
||||
return (
|
||||
<StatefulPageWrapper
|
||||
data={null}
|
||||
isLoading={false}
|
||||
error={new Error('Failed to load race results')}
|
||||
retry={() => Promise.resolve()}
|
||||
Template={({ data: _data }) => (
|
||||
<RaceResultsTemplate
|
||||
raceTrack={undefined}
|
||||
raceScheduledAt={undefined}
|
||||
totalDrivers={undefined}
|
||||
leagueName={undefined}
|
||||
raceSOF={null}
|
||||
results={[]}
|
||||
penalties={[]}
|
||||
pointsSystem={{}}
|
||||
fastestLapTime={0}
|
||||
currentDriverId={''}
|
||||
isAdmin={false}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onImportResults={() => Promise.resolve()}
|
||||
onPenaltyClick={() => {}}
|
||||
importing={false}
|
||||
importSuccess={false}
|
||||
importError={null}
|
||||
showImportForm={false}
|
||||
setShowImportForm={() => {}}
|
||||
/>
|
||||
)}
|
||||
loading={{ variant: 'skeleton', message: 'Loading race results...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
icon: Trophy,
|
||||
title: 'No results available',
|
||||
description: 'Race results will appear here once the race is completed',
|
||||
action: { label: 'Back to Race', onClick: () => {} }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePenaltyClick = (driver: { id: string; name: string }) => {
|
||||
console.log('Penalty click for:', driver);
|
||||
};
|
||||
|
||||
// Determine admin status from memberships data
|
||||
const currentDriver = data?.results.find(r => r.isCurrentUser);
|
||||
const currentMembership = data?.memberships?.find(m => m.driverId === currentDriverId);
|
||||
const isAdmin = currentMembership
|
||||
? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role)
|
||||
: false;
|
||||
}
|
||||
|
||||
const viewData = result.unwrap();
|
||||
|
||||
return (
|
||||
<StatefulPageWrapper
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error as Error | null}
|
||||
retry={refetch}
|
||||
Template={({ data }) => (
|
||||
data={viewData}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
retry={() => Promise.resolve()}
|
||||
Template={({ data: _data }) => (
|
||||
<RaceResultsTemplate
|
||||
raceTrack={data.raceTrack}
|
||||
raceScheduledAt={data.raceScheduledAt}
|
||||
totalDrivers={data.totalDrivers}
|
||||
leagueName={data.leagueName}
|
||||
raceSOF={data.raceSOF}
|
||||
results={data.results}
|
||||
penalties={data.penalties}
|
||||
pointsSystem={data.pointsSystem}
|
||||
fastestLapTime={data.fastestLapTime}
|
||||
currentDriverId={currentDriverId}
|
||||
isAdmin={isAdmin}
|
||||
raceTrack={viewData.raceTrack}
|
||||
raceScheduledAt={viewData.raceScheduledAt}
|
||||
totalDrivers={viewData.totalDrivers}
|
||||
leagueName={viewData.leagueName}
|
||||
raceSOF={viewData.raceSOF}
|
||||
results={viewData.results}
|
||||
penalties={viewData.penalties}
|
||||
pointsSystem={viewData.pointsSystem}
|
||||
fastestLapTime={viewData.fastestLapTime}
|
||||
currentDriverId={''}
|
||||
isAdmin={false}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onBack={handleBack}
|
||||
onImportResults={handleImportResults}
|
||||
onPenaltyClick={handlePenaltyClick}
|
||||
importing={importing}
|
||||
importSuccess={importSuccess}
|
||||
importError={importError}
|
||||
showImportForm={showImportForm}
|
||||
setShowImportForm={setShowImportForm}
|
||||
onBack={() => {}}
|
||||
onImportResults={() => Promise.resolve()}
|
||||
onPenaltyClick={() => {}}
|
||||
importing={false}
|
||||
importSuccess={false}
|
||||
importError={null}
|
||||
showImportForm={false}
|
||||
setShowImportForm={() => {}}
|
||||
/>
|
||||
)}
|
||||
loading={{ variant: 'skeleton', message: 'Loading race results...' }}
|
||||
@@ -118,7 +113,7 @@ export default function RaceResultsPage({ params }: RaceResultsPageProps) {
|
||||
icon: Trophy,
|
||||
title: 'No results available',
|
||||
description: 'Race results will appear here once the race is completed',
|
||||
action: { label: 'Back to Race', onClick: handleBack }
|
||||
action: { label: 'Back to Race', onClick: () => {} }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,142 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
|
||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||
import { ProtestsApiClient } from '@/lib/api/protests/ProtestsApiClient';
|
||||
import { PenaltiesApiClient } from '@/lib/api/penalties/PenaltiesApiClient';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { useLeagueMemberships } from "@/lib/hooks/league/useLeagueMemberships";
|
||||
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { RaceStewardingPageQuery } from '@/lib/page-queries/races/RaceStewardingPageQuery';
|
||||
import { Gavel } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
// Define the view model structure locally to avoid type issues
|
||||
interface RaceStewardingViewModel {
|
||||
race: any;
|
||||
league: any;
|
||||
protests: any[];
|
||||
penalties: any[];
|
||||
driverMap: Record<string, any>;
|
||||
pendingProtests: any[];
|
||||
resolvedProtests: any[];
|
||||
pendingCount: number;
|
||||
resolvedCount: number;
|
||||
penaltiesCount: number;
|
||||
interface RaceStewardingPageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function RaceStewardingPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const raceId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId() || '';
|
||||
export default function RaceStewardingPage({ params }: RaceStewardingPageProps) {
|
||||
const raceId = params.id;
|
||||
const [activeTab, setActiveTab] = useState<StewardingTab>('pending');
|
||||
|
||||
if (!raceId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Data state
|
||||
const [pageData, setPageData] = useState<RaceStewardingViewModel | null>(null);
|
||||
const [pageData, setPageData] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// UI State
|
||||
const [activeTab, setActiveTab] = useState<StewardingTab>('pending');
|
||||
|
||||
// Fetch data on mount and when raceId/currentDriverId changes
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!raceId) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Manual wiring: create dependencies
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
|
||||
// Create API clients
|
||||
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||
const protestsApiClient = new ProtestsApiClient(baseUrl, errorReporter, logger);
|
||||
const penaltiesApiClient = new PenaltiesApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
// Fetch data in parallel
|
||||
const [raceDetail, protests, penalties] = await Promise.all([
|
||||
racesApiClient.getDetail(raceId, currentDriverId),
|
||||
protestsApiClient.getRaceProtests(raceId),
|
||||
penaltiesApiClient.getRacePenalties(raceId),
|
||||
]);
|
||||
|
||||
// Transform data to match view model structure
|
||||
const data: RaceStewardingViewModel = {
|
||||
race: raceDetail.race,
|
||||
league: raceDetail.league,
|
||||
protests: protests.protests.map(p => ({
|
||||
id: p.id,
|
||||
protestingDriverId: p.protestingDriverId,
|
||||
accusedDriverId: p.accusedDriverId,
|
||||
incident: {
|
||||
lap: p.lap,
|
||||
description: p.description,
|
||||
},
|
||||
filedAt: p.filedAt,
|
||||
status: p.status,
|
||||
})),
|
||||
penalties: penalties.penalties,
|
||||
driverMap: { ...protests.driverMap, ...penalties.driverMap },
|
||||
pendingProtests: [],
|
||||
resolvedProtests: [],
|
||||
pendingCount: 0,
|
||||
resolvedCount: 0,
|
||||
penaltiesCount: 0,
|
||||
};
|
||||
|
||||
// Calculate derived properties
|
||||
data.pendingProtests = data.protests.filter(p => p.status === 'pending' || p.status === 'under_review');
|
||||
data.resolvedProtests = data.protests.filter(p =>
|
||||
p.status === 'upheld' ||
|
||||
p.status === 'dismissed' ||
|
||||
p.status === 'withdrawn'
|
||||
);
|
||||
data.pendingCount = data.pendingProtests.length;
|
||||
data.resolvedCount = data.resolvedProtests.length;
|
||||
data.penaltiesCount = data.penalties.length;
|
||||
|
||||
if (data) {
|
||||
setPageData(data);
|
||||
} else {
|
||||
setPageData(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch stewarding data'));
|
||||
setPageData(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
// Fetch function
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchData();
|
||||
}, [raceId, currentDriverId]);
|
||||
|
||||
// Fetch membership
|
||||
const { data: membershipsData } = useLeagueMemberships(pageData?.league?.id || '', currentDriverId || '');
|
||||
const currentMembership = membershipsData?.members.find(m => m.driverId === currentDriverId);
|
||||
const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false;
|
||||
|
||||
// Actions
|
||||
const handleBack = () => {
|
||||
router.push(`/races/${raceId}`);
|
||||
};
|
||||
|
||||
const handleReviewProtest = (protestId: string) => {
|
||||
// Navigate to protest review page
|
||||
router.push(`/leagues/${pageData?.league?.id}/stewarding/protests/${protestId}`);
|
||||
try {
|
||||
const result = await RaceStewardingPageQuery.execute({ raceId });
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to fetch stewarding data');
|
||||
}
|
||||
|
||||
setPageData(result.unwrap());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Unknown error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Transform data for template
|
||||
@@ -152,74 +57,14 @@ export default function RaceStewardingPage() {
|
||||
penaltiesCount: pageData.penaltiesCount,
|
||||
} : undefined;
|
||||
|
||||
const retry = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Manual wiring: create dependencies
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
// Actions
|
||||
const handleBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
// Create API clients
|
||||
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||
const protestsApiClient = new ProtestsApiClient(baseUrl, errorReporter, logger);
|
||||
const penaltiesApiClient = new PenaltiesApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
// Fetch data in parallel
|
||||
const [raceDetail, protests, penalties] = await Promise.all([
|
||||
racesApiClient.getDetail(raceId, currentDriverId),
|
||||
protestsApiClient.getRaceProtests(raceId),
|
||||
penaltiesApiClient.getRacePenalties(raceId),
|
||||
]);
|
||||
|
||||
// Transform data to match view model structure
|
||||
const data: RaceStewardingViewModel = {
|
||||
race: raceDetail.race,
|
||||
league: raceDetail.league,
|
||||
protests: protests.protests.map(p => ({
|
||||
id: p.id,
|
||||
protestingDriverId: p.protestingDriverId,
|
||||
accusedDriverId: p.accusedDriverId,
|
||||
incident: {
|
||||
lap: p.lap,
|
||||
description: p.description,
|
||||
},
|
||||
filedAt: p.filedAt,
|
||||
status: p.status,
|
||||
})),
|
||||
penalties: penalties.penalties,
|
||||
driverMap: { ...protests.driverMap, ...penalties.driverMap },
|
||||
pendingProtests: [],
|
||||
resolvedProtests: [],
|
||||
pendingCount: 0,
|
||||
resolvedCount: 0,
|
||||
penaltiesCount: 0,
|
||||
};
|
||||
|
||||
// Calculate derived properties
|
||||
data.pendingProtests = data.protests.filter(p => p.status === 'pending' || p.status === 'under_review');
|
||||
data.resolvedProtests = data.protests.filter(p =>
|
||||
p.status === 'upheld' ||
|
||||
p.status === 'dismissed' ||
|
||||
p.status === 'withdrawn'
|
||||
);
|
||||
data.pendingCount = data.pendingProtests.length;
|
||||
data.resolvedCount = data.resolvedProtests.length;
|
||||
data.penaltiesCount = data.penalties.length;
|
||||
|
||||
if (data) {
|
||||
setPageData(data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch stewarding data'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const handleReviewProtest = (protestId: string) => {
|
||||
if (templateData?.league?.id) {
|
||||
window.location.href = `/leagues/${templateData.league.id}/stewarding/protests/${protestId}`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -228,15 +73,15 @@ export default function RaceStewardingPage() {
|
||||
data={pageData}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
Template={({ data }) => (
|
||||
retry={fetchData}
|
||||
Template={({ data: _data }) => (
|
||||
<RaceStewardingTemplate
|
||||
stewardingData={templateData}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onBack={handleBack}
|
||||
onReviewProtest={handleReviewProtest}
|
||||
isAdmin={isAdmin}
|
||||
isAdmin={false}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
|
||||
@@ -1,30 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
import { RacesAllTemplate, StatusFilter } from '@/templates/RacesAllTemplate';
|
||||
import { useAllRacesPageData } from "@/lib/hooks/race/useAllRacesPageData";
|
||||
import { RacesAllTemplate } from '@/templates/RacesAllTemplate';
|
||||
import { RacesAllPageQuery } from '@/lib/page-queries/races/RacesAllPageQuery';
|
||||
import { Flag } from 'lucide-react';
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
interface Race {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
sessionType: string;
|
||||
leagueId?: string;
|
||||
leagueName?: string;
|
||||
strengthOfField?: number;
|
||||
}
|
||||
|
||||
export default function RacesAllPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// Client-side state for filters and pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'>('all');
|
||||
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
|
||||
// Fetch data using domain hook
|
||||
const { data: pageData, isLoading, error, refetch } = useAllRacesPageData();
|
||||
// Data state
|
||||
const [pageData, setPageData] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Transform data for template
|
||||
const races = pageData?.races.map((race) => ({
|
||||
// Fetch data
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await RacesAllPageQuery.execute();
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to fetch races');
|
||||
}
|
||||
|
||||
setPageData(result.unwrap());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Unknown error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch on mount
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Transform data
|
||||
const races: Race[] = pageData?.races.map((race: any) => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
@@ -36,8 +75,8 @@ export default function RacesAllPage() {
|
||||
strengthOfField: race.strengthOfField ?? undefined,
|
||||
})) ?? [];
|
||||
|
||||
// Calculate total pages
|
||||
const filteredRaces = races.filter((race) => {
|
||||
// Filter and paginate (Note: This should be done by API per contract)
|
||||
const filteredRaces = races.filter((race: Race) => {
|
||||
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
@@ -60,6 +99,7 @@ export default function RacesAllPage() {
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
|
||||
const paginatedRaces = filteredRaces.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||
|
||||
// Actions
|
||||
const handleRaceClick = (raceId: string) => {
|
||||
@@ -79,10 +119,10 @@ export default function RacesAllPage() {
|
||||
data={pageData}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={refetch}
|
||||
Template={({ data }) => (
|
||||
retry={fetchData}
|
||||
Template={({ data: _data }) => (
|
||||
<RacesAllTemplate
|
||||
races={races}
|
||||
races={paginatedRaces}
|
||||
isLoading={false}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
|
||||
@@ -1,53 +1,55 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { RacesTemplate } from '@/templates/RacesTemplate';
|
||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { RacesPageQuery } from '@/lib/page-queries/races/RacesPageQuery';
|
||||
|
||||
export default async function Page() {
|
||||
// Manual wiring: create dependencies
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
|
||||
// Create API client
|
||||
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
// Fetch data
|
||||
const data = await racesApiClient.getPageData();
|
||||
const result = await RacesPageQuery.execute();
|
||||
|
||||
// Transform races
|
||||
const transformRace = (race: any) => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: 'race',
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField ?? undefined,
|
||||
isUpcoming: race.status === 'scheduled',
|
||||
isLive: race.status === 'running',
|
||||
isPast: race.status === 'completed',
|
||||
});
|
||||
|
||||
const races = data.races.map(transformRace);
|
||||
const scheduledRaces = races.filter(r => r.isUpcoming);
|
||||
const runningRaces = races.filter(r => r.isLive);
|
||||
const completedRaces = races.filter(r => r.isPast);
|
||||
const totalCount = races.length;
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
|
||||
switch (error) {
|
||||
case 'notFound':
|
||||
notFound();
|
||||
case 'redirect':
|
||||
// Would redirect to login or other page
|
||||
notFound();
|
||||
default:
|
||||
// For other errors, show error state in template
|
||||
return <RacesTemplate
|
||||
races={[]}
|
||||
totalCount={0}
|
||||
scheduledRaces={[]}
|
||||
runningRaces={[]}
|
||||
completedRaces={[]}
|
||||
isLoading={false}
|
||||
statusFilter="all"
|
||||
setStatusFilter={() => {}}
|
||||
leagueFilter="all"
|
||||
setLeagueFilter={() => {}}
|
||||
timeFilter="upcoming"
|
||||
setTimeFilter={() => {}}
|
||||
onRaceClick={() => {}}
|
||||
onLeagueClick={() => {}}
|
||||
onRegister={() => {}}
|
||||
onWithdraw={() => {}}
|
||||
onCancel={() => {}}
|
||||
showFilterModal={false}
|
||||
setShowFilterModal={() => {}}
|
||||
currentDriverId={undefined}
|
||||
userMemberships={[]}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
const viewData = result.unwrap();
|
||||
|
||||
return <RacesTemplate
|
||||
races={races}
|
||||
totalCount={totalCount}
|
||||
scheduledRaces={scheduledRaces}
|
||||
runningRaces={runningRaces}
|
||||
completedRaces={completedRaces}
|
||||
races={viewData.races}
|
||||
totalCount={viewData.totalCount}
|
||||
scheduledRaces={viewData.scheduledRaces}
|
||||
runningRaces={viewData.runningRaces}
|
||||
completedRaces={viewData.completedRaces}
|
||||
isLoading={false}
|
||||
statusFilter="all"
|
||||
setStatusFilter={() => {}}
|
||||
|
||||
@@ -11,8 +11,6 @@ import InfoBanner from '@/components/ui/InfoBanner';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import { useSponsorBilling } from "@/lib/hooks/sponsor/useSponsorBilling";
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import {
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
@@ -107,13 +105,13 @@ function PaymentMethodCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
|
||||
className={`p-4 rounded-xl border transition-all ${
|
||||
method.isDefault
|
||||
? 'border-primary-blue/50 bg-gradient-to-r from-primary-blue/10 to-transparent shadow-[0_0_20px_rgba(25,140,255,0.1)]'
|
||||
method.isDefault
|
||||
? 'border-primary-blue/50 bg-gradient-to-r from-primary-blue/10 to-transparent shadow-[0_0_20px_rgba(25,140,255,0.1)]'
|
||||
: 'border-charcoal-outline bg-iron-gray/30 hover:border-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
@@ -162,31 +160,31 @@ function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const statusConfig = {
|
||||
paid: {
|
||||
icon: Check,
|
||||
paid: {
|
||||
icon: Check,
|
||||
label: 'Paid',
|
||||
color: 'text-performance-green',
|
||||
color: 'text-performance-green',
|
||||
bg: 'bg-performance-green/10',
|
||||
border: 'border-performance-green/30'
|
||||
},
|
||||
pending: {
|
||||
icon: Clock,
|
||||
pending: {
|
||||
icon: Clock,
|
||||
label: 'Pending',
|
||||
color: 'text-warning-amber',
|
||||
color: 'text-warning-amber',
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/30'
|
||||
},
|
||||
overdue: {
|
||||
icon: AlertTriangle,
|
||||
overdue: {
|
||||
icon: AlertTriangle,
|
||||
label: 'Overdue',
|
||||
color: 'text-racing-red',
|
||||
color: 'text-racing-red',
|
||||
bg: 'bg-racing-red/10',
|
||||
border: 'border-racing-red/30'
|
||||
},
|
||||
failed: {
|
||||
icon: AlertTriangle,
|
||||
failed: {
|
||||
icon: AlertTriangle,
|
||||
label: 'Failed',
|
||||
color: 'text-racing-red',
|
||||
color: 'text-racing-red',
|
||||
bg: 'bg-racing-red/10',
|
||||
border: 'border-racing-red/30'
|
||||
},
|
||||
@@ -204,7 +202,7 @@ function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: shouldReduceMotion ? 0 : 0.2, delay: shouldReduceMotion ? 0 : index * 0.05 }}
|
||||
@@ -261,7 +259,6 @@ function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
|
||||
|
||||
export default function SponsorBillingPage() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const sponsorService = useInject(SPONSOR_SERVICE_TOKEN);
|
||||
const [showAllInvoices, setShowAllInvoices] = useState(false);
|
||||
|
||||
const { data: billingData, isLoading, error, retry } = useSponsorBilling('demo-sponsor-1');
|
||||
@@ -322,7 +319,7 @@ export default function SponsorBillingPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="max-w-5xl mx-auto py-8 px-4"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
@@ -353,7 +350,7 @@ export default function SponsorBillingPage() {
|
||||
icon={AlertTriangle}
|
||||
label="Pending Payments"
|
||||
value={data.stats.formattedPendingAmount}
|
||||
subValue={`${data.invoices.filter(i => i.status === 'pending' || i.status === 'overdue').length} invoices`}
|
||||
subValue={`${data.invoices.filter((i: { status: string }) => i.status === 'pending' || i.status === 'overdue').length} invoices`}
|
||||
color="text-warning-amber"
|
||||
bgColor="bg-warning-amber/10"
|
||||
/>
|
||||
@@ -378,8 +375,8 @@ export default function SponsorBillingPage() {
|
||||
{/* Payment Methods */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="mb-8 overflow-hidden">
|
||||
<SectionHeader
|
||||
icon={CreditCard}
|
||||
<SectionHeader
|
||||
icon={CreditCard}
|
||||
title="Payment Methods"
|
||||
action={
|
||||
<Button variant="secondary" className="text-sm">
|
||||
@@ -389,7 +386,7 @@ export default function SponsorBillingPage() {
|
||||
}
|
||||
/>
|
||||
<div className="p-5 space-y-3">
|
||||
{data.paymentMethods.map((method) => (
|
||||
{data.paymentMethods.map((method: { id: string; type: string; last4: string; brand: string; default: boolean }) => (
|
||||
<PaymentMethodCard
|
||||
key={method.id}
|
||||
method={method}
|
||||
@@ -410,8 +407,8 @@ export default function SponsorBillingPage() {
|
||||
{/* Billing History */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="mb-8 overflow-hidden">
|
||||
<SectionHeader
|
||||
icon={FileText}
|
||||
<SectionHeader
|
||||
icon={FileText}
|
||||
title="Billing History"
|
||||
color="text-warning-amber"
|
||||
action={
|
||||
@@ -422,7 +419,7 @@ export default function SponsorBillingPage() {
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
{data.invoices.slice(0, showAllInvoices ? data.invoices.length : 4).map((invoice, index) => (
|
||||
{data.invoices.slice(0, showAllInvoices ? data.invoices.length : 4).map((invoice: { id: string; date: string; amount: number; status: string }, index: number) => (
|
||||
<InvoiceRow key={invoice.id} invoice={invoice} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import InfoBanner from '@/components/ui/InfoBanner';
|
||||
import { useSponsorSponsorships } from "@/lib/hooks/sponsor/useSponsorSponsorships";
|
||||
import {
|
||||
@@ -44,33 +43,6 @@ import {
|
||||
type SponsorshipType = 'all' | 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
|
||||
type SponsorshipStatus = 'all' | 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired';
|
||||
|
||||
interface Sponsorship {
|
||||
id: string;
|
||||
type: SponsorshipType;
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
tier?: 'main' | 'secondary';
|
||||
status: 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired';
|
||||
applicationDate?: Date;
|
||||
approvalDate?: Date;
|
||||
rejectionReason?: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
price: number;
|
||||
impressions: number;
|
||||
impressionsChange?: number;
|
||||
engagement?: number;
|
||||
details?: string;
|
||||
// For pending approvals
|
||||
entityOwner?: string;
|
||||
applicationMessage?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data - Updated to show application workflow
|
||||
// ============================================================================
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
@@ -85,40 +57,40 @@ const TYPE_CONFIG = {
|
||||
};
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
active: {
|
||||
icon: Check,
|
||||
color: 'text-performance-green',
|
||||
bgColor: 'bg-performance-green/10',
|
||||
active: {
|
||||
icon: Check,
|
||||
color: 'text-performance-green',
|
||||
bgColor: 'bg-performance-green/10',
|
||||
borderColor: 'border-performance-green/30',
|
||||
label: 'Active'
|
||||
label: 'Active'
|
||||
},
|
||||
pending_approval: {
|
||||
icon: Clock,
|
||||
color: 'text-warning-amber',
|
||||
bgColor: 'bg-warning-amber/10',
|
||||
pending_approval: {
|
||||
icon: Clock,
|
||||
color: 'text-warning-amber',
|
||||
bgColor: 'bg-warning-amber/10',
|
||||
borderColor: 'border-warning-amber/30',
|
||||
label: 'Awaiting Approval'
|
||||
label: 'Awaiting Approval'
|
||||
},
|
||||
approved: {
|
||||
icon: ThumbsUp,
|
||||
color: 'text-primary-blue',
|
||||
bgColor: 'bg-primary-blue/10',
|
||||
approved: {
|
||||
icon: ThumbsUp,
|
||||
color: 'text-primary-blue',
|
||||
bgColor: 'bg-primary-blue/10',
|
||||
borderColor: 'border-primary-blue/30',
|
||||
label: 'Approved'
|
||||
label: 'Approved'
|
||||
},
|
||||
rejected: {
|
||||
icon: ThumbsDown,
|
||||
color: 'text-racing-red',
|
||||
bgColor: 'bg-racing-red/10',
|
||||
rejected: {
|
||||
icon: ThumbsDown,
|
||||
color: 'text-racing-red',
|
||||
bgColor: 'bg-racing-red/10',
|
||||
borderColor: 'border-racing-red/30',
|
||||
label: 'Declined'
|
||||
label: 'Declined'
|
||||
},
|
||||
expired: {
|
||||
icon: XCircle,
|
||||
color: 'text-gray-400',
|
||||
bgColor: 'bg-gray-400/10',
|
||||
expired: {
|
||||
icon: XCircle,
|
||||
color: 'text-gray-400',
|
||||
bgColor: 'bg-gray-400/10',
|
||||
borderColor: 'border-gray-400/30',
|
||||
label: 'Expired'
|
||||
label: 'Expired'
|
||||
},
|
||||
};
|
||||
|
||||
@@ -127,7 +99,6 @@ const STATUS_CONFIG = {
|
||||
// ============================================================================
|
||||
|
||||
function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
|
||||
const router = useRouter();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const typeConfig = TYPE_CONFIG[sponsorship.type as keyof typeof TYPE_CONFIG];
|
||||
@@ -159,8 +130,8 @@ function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Card className={`hover:border-primary-blue/30 transition-all duration-300 ${
|
||||
isPending ? 'border-warning-amber/30' :
|
||||
isRejected ? 'border-racing-red/20 opacity-75' :
|
||||
isPending ? 'border-warning-amber/30' :
|
||||
isRejected ? 'border-racing-red/20 opacity-75' :
|
||||
isApproved ? 'border-primary-blue/30' : ''
|
||||
}`}>
|
||||
{/* Header */}
|
||||
@@ -176,8 +147,8 @@ function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
|
||||
</span>
|
||||
{sponsorship.tier && (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${
|
||||
sponsorship.tier === 'main'
|
||||
? 'bg-primary-blue/20 text-primary-blue'
|
||||
sponsorship.tier === 'main'
|
||||
? 'bg-primary-blue/20 text-primary-blue'
|
||||
: 'bg-purple-400/20 text-purple-400'
|
||||
}`}>
|
||||
{sponsorship.tier === 'main' ? 'Main Sponsor' : 'Secondary'}
|
||||
@@ -360,7 +331,6 @@ function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
|
||||
// ============================================================================
|
||||
|
||||
export default function SponsorCampaignsPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
@@ -400,7 +370,7 @@ export default function SponsorCampaignsPage() {
|
||||
const data = sponsorshipsData;
|
||||
|
||||
// Filter sponsorships
|
||||
const filteredSponsorships = data.sponsorships.filter(s => {
|
||||
const filteredSponsorships = data.sponsorships.filter((s: any) => {
|
||||
if (typeFilter !== 'all' && s.type !== typeFilter) return false;
|
||||
if (statusFilter !== 'all' && s.status !== statusFilter) return false;
|
||||
if (searchQuery && !s.entityName.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
@@ -410,21 +380,21 @@ export default function SponsorCampaignsPage() {
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
total: data.sponsorships.length,
|
||||
active: data.sponsorships.filter(s => s.status === 'active').length,
|
||||
pending: data.sponsorships.filter(s => s.status === 'pending_approval').length,
|
||||
approved: data.sponsorships.filter(s => s.status === 'approved').length,
|
||||
rejected: data.sponsorships.filter(s => s.status === 'rejected').length,
|
||||
totalInvestment: data.sponsorships.filter(s => s.status === 'active').reduce((sum, s) => sum + s.price, 0),
|
||||
totalImpressions: data.sponsorships.reduce((sum, s) => sum + s.impressions, 0),
|
||||
active: data.sponsorships.filter((s: any) => s.status === 'active').length,
|
||||
pending: data.sponsorships.filter((s: any) => s.status === 'pending_approval').length,
|
||||
approved: data.sponsorships.filter((s: any) => s.status === 'approved').length,
|
||||
rejected: data.sponsorships.filter((s: any) => s.status === 'rejected').length,
|
||||
totalInvestment: data.sponsorships.filter((s: any) => s.status === 'active').reduce((sum: number, s: any) => sum + s.price, 0),
|
||||
totalImpressions: data.sponsorships.reduce((sum: number, s: any) => sum + s.impressions, 0),
|
||||
};
|
||||
|
||||
// Stats by type
|
||||
const statsByType = {
|
||||
leagues: data.sponsorships.filter(s => s.type === 'leagues').length,
|
||||
teams: data.sponsorships.filter(s => s.type === 'teams').length,
|
||||
drivers: data.sponsorships.filter(s => s.type === 'drivers').length,
|
||||
races: data.sponsorships.filter(s => s.type === 'races').length,
|
||||
platform: data.sponsorships.filter(s => s.type === 'platform').length,
|
||||
leagues: data.sponsorships.filter((s: any) => s.type === 'leagues').length,
|
||||
teams: data.sponsorships.filter((s: any) => s.type === 'teams').length,
|
||||
drivers: data.sponsorships.filter((s: any) => s.type === 'drivers').length,
|
||||
races: data.sponsorships.filter((s: any) => s.type === 'races').length,
|
||||
platform: data.sponsorships.filter((s: any) => s.type === 'platform').length,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -457,7 +427,7 @@ export default function SponsorCampaignsPage() {
|
||||
>
|
||||
<InfoBanner type="info" title="Sponsorship Applications">
|
||||
<p>
|
||||
You have <strong className="text-white">{stats.pending} pending application{stats.pending !== 1 ? 's' : ''}</strong> waiting for approval.
|
||||
You have <strong className="text-white">{stats.pending} pending application{stats.pending !== 1 ? 's' : ''}</strong> waiting for approval.
|
||||
League admins, team owners, and drivers review applications before accepting sponsorships.
|
||||
</p>
|
||||
</InfoBanner>
|
||||
@@ -540,7 +510,7 @@ export default function SponsorCampaignsPage() {
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 lg:pb-0">
|
||||
{(['all', 'leagues', 'teams', 'drivers', 'races', 'platform'] as const).map((type) => {
|
||||
@@ -572,12 +542,12 @@ export default function SponsorCampaignsPage() {
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{(['all', 'active', 'pending_approval', 'approved', 'rejected'] as const).map((status) => {
|
||||
const config = status === 'all'
|
||||
? { label: 'All', color: 'text-gray-400' }
|
||||
const config = status === 'all'
|
||||
? { label: 'All', color: 'text-gray-400' }
|
||||
: STATUS_CONFIG[status];
|
||||
const count = status === 'all'
|
||||
? stats.total
|
||||
: data.sponsorships.filter(s => s.status === status).length;
|
||||
: data.sponsorships.filter((s: any) => s.status === status).length;
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
@@ -635,7 +605,7 @@ export default function SponsorCampaignsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{filteredSponsorships.map((sponsorship) => (
|
||||
{filteredSponsorships.map((sponsorship: any) => (
|
||||
<SponsorshipCard key={sponsorship.id} sponsorship={sponsorship} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
@@ -37,48 +36,15 @@ import {
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { SPONSOR_SERVICE_TOKEN, POLICY_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
||||
import { useSponsorDashboard } from '@/lib/hooks/sponsor/useSponsorDashboard';
|
||||
|
||||
export default function SponsorDashboardPage() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const sponsorService = useInject(SPONSOR_SERVICE_TOKEN);
|
||||
const policyService = useInject(POLICY_SERVICE_TOKEN);
|
||||
|
||||
// Use the hook instead of manual query construction
|
||||
const { data: dashboardData, isLoading, error, retry } = useSponsorDashboard('demo-sponsor-1');
|
||||
|
||||
const policyQuery = useQuery({
|
||||
queryKey: ['policySnapshot'],
|
||||
queryFn: () => policyService.getSnapshot(),
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const enhancedPolicyQuery = enhanceQueryResult(policyQuery);
|
||||
const policySnapshot = enhancedPolicyQuery.data;
|
||||
const policyLoading = enhancedPolicyQuery.isLoading;
|
||||
const policyError = enhancedPolicyQuery.error;
|
||||
|
||||
const sponsorPortalState = policySnapshot
|
||||
? policyService.getCapabilityState(policySnapshot, 'sponsors.portal')
|
||||
: null;
|
||||
|
||||
const dashboardQuery = useQuery({
|
||||
queryKey: ['sponsorDashboard', 'demo-sponsor-1', sponsorPortalState],
|
||||
queryFn: () => sponsorService.getSponsorDashboard('demo-sponsor-1'),
|
||||
enabled: !!policySnapshot && sponsorPortalState === 'enabled',
|
||||
staleTime: 300_000,
|
||||
gcTime: 10 * 60_000,
|
||||
});
|
||||
|
||||
const enhancedDashboardQuery = enhanceQueryResult(dashboardQuery);
|
||||
const dashboardData = enhancedDashboardQuery.data;
|
||||
const dashboardLoading = enhancedDashboardQuery.isLoading;
|
||||
const dashboardError = enhancedDashboardQuery.error;
|
||||
|
||||
const loading = policyLoading || dashboardLoading;
|
||||
const error = policyError || dashboardError || (sponsorPortalState !== 'enabled' && sponsorPortalState !== null);
|
||||
|
||||
if (loading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[600px]">
|
||||
<div className="text-center">
|
||||
@@ -90,16 +56,15 @@ export default function SponsorDashboardPage() {
|
||||
}
|
||||
|
||||
if (error || !dashboardData) {
|
||||
const errorMessage = sponsorPortalState === 'coming_soon'
|
||||
? 'Sponsor portal is coming soon.'
|
||||
: sponsorPortalState === 'disabled'
|
||||
? 'Sponsor portal is currently unavailable.'
|
||||
: 'Failed to load dashboard data';
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[600px]">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-400">{errorMessage}</p>
|
||||
<p className="text-gray-400">{error?.getUserMessage() || 'Failed to load dashboard data'}</p>
|
||||
{error && (
|
||||
<Button variant="secondary" onClick={retry} className="mt-4">
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,5 +24,6 @@ export default async function Page({ params }: { params: { id: string } }) {
|
||||
|
||||
if (!data) notFound();
|
||||
|
||||
// Data is already in the right format from API client
|
||||
return <PageWrapper data={data} Template={SponsorLeagueDetailTemplate} />;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { AvailableLeaguesViewModel } from '@/lib/view-models/AvailableLeaguesViewModel';
|
||||
|
||||
export default async function Page() {
|
||||
// Manual wiring: create dependencies
|
||||
@@ -22,26 +21,24 @@ export default async function Page() {
|
||||
// Fetch data
|
||||
const leaguesData = await apiClient.getAvailableLeagues();
|
||||
|
||||
// Process data with view model to calculate stats
|
||||
// Process data - move business logic to template
|
||||
if (!leaguesData) {
|
||||
return <PageWrapper data={undefined} Template={SponsorLeaguesTemplate} />;
|
||||
}
|
||||
|
||||
const viewModel = new AvailableLeaguesViewModel(leaguesData);
|
||||
|
||||
// Calculate summary stats
|
||||
// Calculate summary stats (business logic moved from view model)
|
||||
const stats = {
|
||||
total: viewModel.leagues.length,
|
||||
mainAvailable: viewModel.leagues.filter(l => l.mainSponsorSlot.available).length,
|
||||
secondaryAvailable: viewModel.leagues.reduce((sum, l) => sum + l.secondarySlots.available, 0),
|
||||
totalDrivers: viewModel.leagues.reduce((sum, l) => sum + l.drivers, 0),
|
||||
total: leaguesData.length,
|
||||
mainAvailable: leaguesData.filter((l: any) => l.mainSponsorSlot.available).length,
|
||||
secondaryAvailable: leaguesData.reduce((sum: number, l: any) => sum + l.secondarySlots.available, 0),
|
||||
totalDrivers: leaguesData.reduce((sum: number, l: any) => sum + l.drivers, 0),
|
||||
avgCpm: Math.round(
|
||||
viewModel.leagues.reduce((sum, l) => sum + l.cpm, 0) / viewModel.leagues.length
|
||||
leaguesData.reduce((sum: number, l: any) => sum + l.cpm, 0) / leaguesData.length
|
||||
),
|
||||
};
|
||||
|
||||
const processedData = {
|
||||
leagues: viewModel.leagues,
|
||||
leagues: leaguesData,
|
||||
stats,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function SponsorPage() {
|
||||
// Redirect to dashboard - this will be handled by middleware for auth
|
||||
// Using permanent redirect to avoid cookie loss
|
||||
redirect('/sponsor/dashboard');
|
||||
// Redirect to dashboard
|
||||
redirect(routes.sponsor.dashboard);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
@@ -157,7 +156,6 @@ function SavedIndicator({ visible }: { visible: boolean }) {
|
||||
// ============================================================================
|
||||
|
||||
export default function SponsorSettingsPage() {
|
||||
const router = useRouter();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [profile, setProfile] = useState(MOCK_PROFILE);
|
||||
const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS);
|
||||
@@ -173,10 +171,17 @@ export default function SponsorSettingsPage() {
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
const handleDeleteAccount = async () => {
|
||||
if (confirm('Are you sure you want to delete your sponsor account? This action cannot be undone. All sponsorship data will be permanently removed.')) {
|
||||
// Call the logout action directly
|
||||
logoutAction();
|
||||
// Call the logout action and handle result
|
||||
const result = await logoutAction();
|
||||
if (result.isErr()) {
|
||||
console.error('Logout failed:', result.getError());
|
||||
// Could show error toast here
|
||||
return;
|
||||
}
|
||||
// Redirect to login after successful logout
|
||||
window.location.href = '/auth/login';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,7 +201,7 @@ export default function SponsorSettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="max-w-4xl mx-auto py-8 px-4"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
@@ -215,9 +220,9 @@ export default function SponsorSettingsPage() {
|
||||
{/* Company Profile */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<SectionHeader
|
||||
icon={Building2}
|
||||
title="Company Profile"
|
||||
<SectionHeader
|
||||
icon={Building2}
|
||||
title="Company Profile"
|
||||
description="Your public-facing company information"
|
||||
/>
|
||||
<div className="p-6 space-y-6">
|
||||
@@ -300,9 +305,9 @@ export default function SponsorSettingsPage() {
|
||||
<Input
|
||||
type="text"
|
||||
value={profile.address.street}
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
address: { ...profile.address, street: e.target.value }
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
address: { ...profile.address, street: e.target.value }
|
||||
})}
|
||||
placeholder="123 Main Street"
|
||||
/>
|
||||
@@ -313,9 +318,9 @@ export default function SponsorSettingsPage() {
|
||||
<Input
|
||||
type="text"
|
||||
value={profile.address.city}
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
address: { ...profile.address, city: e.target.value }
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
address: { ...profile.address, city: e.target.value }
|
||||
})}
|
||||
placeholder="City"
|
||||
/>
|
||||
@@ -325,9 +330,9 @@ export default function SponsorSettingsPage() {
|
||||
<Input
|
||||
type="text"
|
||||
value={profile.address.postalCode}
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
address: { ...profile.address, postalCode: e.target.value }
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
address: { ...profile.address, postalCode: e.target.value }
|
||||
})}
|
||||
placeholder="12345"
|
||||
/>
|
||||
@@ -337,9 +342,9 @@ export default function SponsorSettingsPage() {
|
||||
<Input
|
||||
type="text"
|
||||
value={profile.address.country}
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
address: { ...profile.address, country: e.target.value }
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
address: { ...profile.address, country: e.target.value }
|
||||
})}
|
||||
placeholder="Country"
|
||||
/>
|
||||
@@ -382,9 +387,9 @@ export default function SponsorSettingsPage() {
|
||||
<Input
|
||||
type="text"
|
||||
value={profile.socialLinks.twitter}
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
socialLinks: { ...profile.socialLinks, twitter: e.target.value }
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
socialLinks: { ...profile.socialLinks, twitter: e.target.value }
|
||||
})}
|
||||
placeholder="@username"
|
||||
/>
|
||||
@@ -394,9 +399,9 @@ export default function SponsorSettingsPage() {
|
||||
<Input
|
||||
type="text"
|
||||
value={profile.socialLinks.linkedin}
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
socialLinks: { ...profile.socialLinks, linkedin: e.target.value }
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
socialLinks: { ...profile.socialLinks, linkedin: e.target.value }
|
||||
})}
|
||||
placeholder="company-name"
|
||||
/>
|
||||
@@ -406,9 +411,9 @@ export default function SponsorSettingsPage() {
|
||||
<Input
|
||||
type="text"
|
||||
value={profile.socialLinks.instagram}
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
socialLinks: { ...profile.socialLinks, instagram: e.target.value }
|
||||
onChange={(e) => setProfile({
|
||||
...profile,
|
||||
socialLinks: { ...profile.socialLinks, instagram: e.target.value }
|
||||
})}
|
||||
placeholder="@username"
|
||||
/>
|
||||
@@ -482,9 +487,9 @@ export default function SponsorSettingsPage() {
|
||||
{/* Notification Preferences */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<SectionHeader
|
||||
icon={Bell}
|
||||
title="Email Notifications"
|
||||
<SectionHeader
|
||||
icon={Bell}
|
||||
title="Email Notifications"
|
||||
description="Control which emails you receive from GridPilot"
|
||||
color="text-warning-amber"
|
||||
/>
|
||||
@@ -534,9 +539,9 @@ export default function SponsorSettingsPage() {
|
||||
{/* Privacy & Visibility */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<SectionHeader
|
||||
icon={Eye}
|
||||
title="Privacy & Visibility"
|
||||
<SectionHeader
|
||||
icon={Eye}
|
||||
title="Privacy & Visibility"
|
||||
description="Control how your profile appears to others"
|
||||
color="text-performance-green"
|
||||
/>
|
||||
@@ -574,9 +579,9 @@ export default function SponsorSettingsPage() {
|
||||
{/* Security */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<SectionHeader
|
||||
icon={Shield}
|
||||
title="Account Security"
|
||||
<SectionHeader
|
||||
icon={Shield}
|
||||
title="Account Security"
|
||||
description="Protect your sponsor account"
|
||||
color="text-primary-blue"
|
||||
/>
|
||||
@@ -654,8 +659,8 @@ export default function SponsorSettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDeleteAccount}
|
||||
className="text-racing-red border-racing-red/30 hover:bg-racing-red/10"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
@@ -10,14 +9,14 @@ import SponsorHero from '@/components/sponsors/SponsorHero';
|
||||
import SponsorWorkflowMockup from '@/components/sponsors/SponsorWorkflowMockup';
|
||||
import SponsorBenefitCard from '@/components/sponsors/SponsorBenefitCard';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import {
|
||||
Building2,
|
||||
Mail,
|
||||
Globe,
|
||||
Upload,
|
||||
Eye,
|
||||
TrendingUp,
|
||||
Users,
|
||||
import {
|
||||
Building2,
|
||||
Mail,
|
||||
Globe,
|
||||
Upload,
|
||||
Eye,
|
||||
TrendingUp,
|
||||
Users,
|
||||
ArrowRight,
|
||||
Trophy,
|
||||
Car,
|
||||
@@ -123,7 +122,6 @@ const PLATFORM_STATS = [
|
||||
];
|
||||
|
||||
export default function SponsorSignupPage() {
|
||||
const router = useRouter();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [mode, setMode] = useState<'landing' | 'signup' | 'login'>('landing');
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -183,8 +181,8 @@ export default function SponsorSignupPage() {
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
// Create a sponsor account using the normal signup flow
|
||||
// The backend will handle creating the sponsor user with the appropriate role
|
||||
// Note: Business logic for auth should be moved to a mutation
|
||||
// This is a temporary implementation for contract compliance
|
||||
const response = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -192,7 +190,6 @@ export default function SponsorSignupPage() {
|
||||
email: formData.contactEmail,
|
||||
password: formData.password,
|
||||
displayName: formData.companyName,
|
||||
// Additional sponsor-specific data
|
||||
sponsorData: {
|
||||
companyName: formData.companyName,
|
||||
websiteUrl: formData.websiteUrl,
|
||||
@@ -206,7 +203,6 @@ export default function SponsorSignupPage() {
|
||||
throw new Error(errorData.message || 'Signup failed');
|
||||
}
|
||||
|
||||
// Auto-login after successful signup
|
||||
const loginResponse = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -220,7 +216,8 @@ export default function SponsorSignupPage() {
|
||||
throw new Error('Auto-login failed');
|
||||
}
|
||||
|
||||
router.push('/sponsor/dashboard');
|
||||
// Navigate to dashboard
|
||||
window.location.href = '/sponsor/dashboard';
|
||||
} catch (err) {
|
||||
console.error('Sponsor signup failed:', err);
|
||||
alert('Registration failed. ' + (err instanceof Error ? err.message : 'Try again.'));
|
||||
@@ -293,7 +290,7 @@ export default function SponsorSignupPage() {
|
||||
Sponsorship Opportunities
|
||||
</h2>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||
Choose how you want to connect with the sim racing community.
|
||||
Choose how you want to connect with the sim racing community.
|
||||
Multiple sponsorship tiers and types to fit every budget and goal.
|
||||
</p>
|
||||
</div>
|
||||
@@ -629,8 +626,8 @@ export default function SponsorSignupPage() {
|
||||
onClick={() => toggleInterest(type.id)}
|
||||
className={`
|
||||
p-3 rounded-lg border text-left transition-all
|
||||
${isSelected
|
||||
? 'bg-primary-blue/10 border-primary-blue/50'
|
||||
${isSelected
|
||||
? 'bg-primary-blue/10 border-primary-blue/50'
|
||||
: 'bg-iron-gray/50 border-charcoal-outline hover:border-charcoal-outline/80'
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -1,43 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import type { TeamsPageDto } from '@/lib/page-queries/page-queries/TeamsPageQuery';
|
||||
import { TeamsPresenter } from '@/lib/presenters/TeamsPresenter';
|
||||
import type { TeamSummaryData } from '@/lib/view-data/TeamsViewData';
|
||||
import type { TeamsViewData } from '@/lib/view-data/TeamsViewData';
|
||||
import { TeamsTemplate } from '@/templates/TeamsTemplate';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface TeamsPageClientProps {
|
||||
pageDto: TeamsPageDto;
|
||||
interface TeamsPageClientProps extends TeamsViewData {
|
||||
searchQuery?: string;
|
||||
showCreateForm?: boolean;
|
||||
onSearchChange?: (query: string) => void;
|
||||
onShowCreateForm?: () => void;
|
||||
onHideCreateForm?: () => void;
|
||||
onTeamClick?: (teamId: string) => void;
|
||||
onCreateSuccess?: (teamId: string) => void;
|
||||
onBrowseTeams?: () => void;
|
||||
onSkillLevelClick?: (level: string) => void;
|
||||
}
|
||||
|
||||
export function TeamsPageClient({ pageDto }: TeamsPageClientProps) {
|
||||
export function TeamsPageClient({ teams }: TeamsPageClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Use presenter to create ViewData
|
||||
const viewData = TeamsPresenter.createViewData(pageDto);
|
||||
|
||||
// UI state
|
||||
// UI state only (no business logic)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
// Filter teams based on search query
|
||||
const filteredTeams = useMemo(() => {
|
||||
if (!searchQuery) return viewData.teams;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return viewData.teams.filter((team: TeamSummaryData) =>
|
||||
team.teamName.toLowerCase().includes(query) ||
|
||||
team.leagueName.toLowerCase().includes(query)
|
||||
);
|
||||
}, [viewData.teams, searchQuery]);
|
||||
|
||||
// Update viewData with filtered teams
|
||||
const templateViewData = {
|
||||
...viewData,
|
||||
teams: filteredTeams,
|
||||
};
|
||||
|
||||
// Event handlers
|
||||
const handleSearchChange = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
@@ -76,7 +62,7 @@ export function TeamsPageClient({ pageDto }: TeamsPageClientProps) {
|
||||
|
||||
return (
|
||||
<TeamsTemplate
|
||||
teams={templateViewData.teams}
|
||||
teams={teams}
|
||||
searchQuery={searchQuery}
|
||||
showCreateForm={showCreateForm}
|
||||
onSearchChange={handleSearchChange}
|
||||
|
||||
@@ -2,23 +2,19 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { TeamDetailPageDto } from '@/lib/page-queries/page-queries/TeamDetailPageQuery';
|
||||
import { TeamDetailPresenter } from '@/lib/view-models/TeamDetailPresenter';
|
||||
import type { TeamDetailViewData } from '@/lib/view-data/TeamDetailViewData';
|
||||
import { TeamDetailTemplate } from '@/templates/TeamDetailTemplate';
|
||||
|
||||
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||
|
||||
interface TeamDetailPageClientProps {
|
||||
pageDto: TeamDetailPageDto;
|
||||
viewData: TeamDetailViewData;
|
||||
}
|
||||
|
||||
export function TeamDetailPageClient({ pageDto }: TeamDetailPageClientProps) {
|
||||
export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Use presenter to create ViewData
|
||||
const viewData = TeamDetailPresenter.createViewData(pageDto);
|
||||
|
||||
// UI state
|
||||
// UI state only (no business logic)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [loading] = useState(false);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { TeamDetailPageQuery } from '@/lib/page-queries/page-queries/TeamDetailPageQuery';
|
||||
import { TeamDetailViewDataBuilder } from '@/lib/builders/view-data/TeamDetailViewDataBuilder';
|
||||
import { TeamDetailPageClient } from './TeamDetailPageClient';
|
||||
|
||||
export default async function Page({ params }: { params: { id: string } }) {
|
||||
@@ -7,7 +8,8 @@ export default async function Page({ params }: { params: { id: string } }) {
|
||||
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
return <TeamDetailPageClient pageDto={result.dto} />;
|
||||
const viewData = TeamDetailViewDataBuilder.build(result.dto);
|
||||
return <TeamDetailPageClient viewData={viewData} />;
|
||||
case 'notFound':
|
||||
notFound();
|
||||
case 'redirect':
|
||||
|
||||
@@ -11,7 +11,7 @@ type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
||||
export function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewModel[] | null }) {
|
||||
const router = useRouter();
|
||||
|
||||
// Client-side state for filtering and sorting
|
||||
// Client-side UI state only (no business logic)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
|
||||
const [sortBy, setSortBy] = useState<SortBy>('rating');
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { TeamService } from '@/lib/services/teams/TeamService';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper';
|
||||
|
||||
// ============================================================================
|
||||
@@ -14,34 +11,29 @@ import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper';
|
||||
|
||||
export default async function TeamLeaderboardPage() {
|
||||
// Manual wiring: create dependencies
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
const service = new TeamService();
|
||||
|
||||
// Create API client
|
||||
const apiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
|
||||
// Fetch data through service
|
||||
const result = await service.getAllTeams();
|
||||
|
||||
// Fetch data
|
||||
const result = await apiClient.getAll();
|
||||
// Handle result
|
||||
let data = null;
|
||||
let error = null;
|
||||
|
||||
if (result.isOk()) {
|
||||
data = result.unwrap();
|
||||
} else {
|
||||
const domainError = result.getError();
|
||||
error = new Error(domainError.message);
|
||||
}
|
||||
|
||||
// Transform DTO to ViewModel
|
||||
const teamsData: TeamSummaryViewModel[] = result.teams.map(team => new TeamSummaryViewModel(team));
|
||||
|
||||
// Prepare data for template
|
||||
const data: TeamSummaryViewModel[] | null = teamsData;
|
||||
|
||||
const hasData = (teamsData?.length ?? 0) > 0;
|
||||
const hasData = (data?.length ?? 0) > 0;
|
||||
|
||||
// Handle loading state (should be fast since we're using async/await)
|
||||
const isLoading = false;
|
||||
const error = null;
|
||||
const retry = async () => {
|
||||
const retry = () => {
|
||||
// In server components, we can't retry without a reload
|
||||
redirect('/teams/leaderboard');
|
||||
redirect(routes.team.detail('leaderboard'));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { TeamsPageQuery } from '@/lib/page-queries/page-queries/TeamsPageQuery';
|
||||
import { TeamsViewDataBuilder } from '@/lib/builders/view-data/TeamsViewDataBuilder';
|
||||
import { TeamsPageClient } from './TeamsPageClient';
|
||||
|
||||
export default async function Page() {
|
||||
@@ -7,7 +8,8 @@ export default async function Page() {
|
||||
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
return <TeamsPageClient pageDto={result.dto} />;
|
||||
const viewData = TeamsViewDataBuilder.build(result.dto);
|
||||
return <TeamsPageClient teams={viewData.teams} />;
|
||||
case 'notFound':
|
||||
notFound();
|
||||
case 'redirect':
|
||||
|
||||
Reference in New Issue
Block a user