website refactor
This commit is contained in:
@@ -44,7 +44,10 @@ describe('DriverService', () => {
|
|||||||
|
|
||||||
it('getTotalDrivers executes use case and returns presenter model', async () => {
|
it('getTotalDrivers executes use case and returns presenter model', async () => {
|
||||||
const getTotalDriversUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
|
const getTotalDriversUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
|
||||||
const driverStatsPresenter = { getResponseModel: vi.fn(() => ({ totalDrivers: 123 })) };
|
const driverStatsPresenter = {
|
||||||
|
present: vi.fn(),
|
||||||
|
getResponseModel: vi.fn(() => ({ totalDrivers: 123 }))
|
||||||
|
};
|
||||||
const driverPresenter = {
|
const driverPresenter = {
|
||||||
setMediaResolver: vi.fn(),
|
setMediaResolver: vi.fn(),
|
||||||
setBaseUrl: vi.fn(),
|
setBaseUrl: vi.fn(),
|
||||||
@@ -254,7 +257,7 @@ describe('DriverService', () => {
|
|||||||
setMediaResolver: vi.fn(),
|
setMediaResolver: vi.fn(),
|
||||||
setBaseUrl: vi.fn(),
|
setBaseUrl: vi.fn(),
|
||||||
present: vi.fn(),
|
present: vi.fn(),
|
||||||
getResponseModel: vi.fn(() => ({ driver: null }))
|
getResponseModel: vi.fn(() => null)
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new DriverService(
|
const service = new DriverService(
|
||||||
@@ -274,9 +277,9 @@ describe('DriverService', () => {
|
|||||||
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
|
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(service.getDriver('d1')).resolves.toEqual({ driver: null });
|
await expect(service.getDriver('d1')).resolves.toBeNull();
|
||||||
expect(driverRepository.findById).toHaveBeenCalledWith('d1');
|
expect(driverRepository.findById).toHaveBeenCalledWith('d1');
|
||||||
expect(driverPresenter.getResponseModel).toHaveBeenCalled();
|
// When driver is not found, presenter is not called
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getDriverProfile executes use case and returns presenter model', async () => {
|
it('getDriverProfile executes use case and returns presenter model', async () => {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
LOGGER_TOKEN,
|
LOGGER_TOKEN,
|
||||||
UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
|
UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
|
||||||
} from './DriverTokens';
|
} from './DriverTokens';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DriverService {
|
export class DriverService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -86,7 +87,7 @@ export class DriverService {
|
|||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
throw new Error(result.unwrapErr().details.message);
|
throw new Error(result.unwrapErr().details.message);
|
||||||
}
|
}
|
||||||
this.driverStatsPresenter!.present(result.unwrap());
|
await this.driverStatsPresenter!.present(result.unwrap());
|
||||||
return this.driverStatsPresenter!.getResponseModel();
|
return this.driverStatsPresenter!.getResponseModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,8 +102,14 @@ describe('LeagueService', () => {
|
|||||||
const totalLeaguesPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ total: 1 })) };
|
const totalLeaguesPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ total: 1 })) };
|
||||||
const transferLeagueOwnershipPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
|
const transferLeagueOwnershipPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
|
||||||
const updateLeagueMemberRolePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
|
const updateLeagueMemberRolePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
|
||||||
const leagueConfigPresenter = { getViewModel: vi.fn(() => ({ form: {} })) };
|
const leagueConfigPresenter = {
|
||||||
const leagueScoringConfigPresenter = { getViewModel: vi.fn(() => ({ config: {} })) };
|
present: vi.fn(),
|
||||||
|
getViewModel: vi.fn(() => ({ form: {} }))
|
||||||
|
};
|
||||||
|
const leagueScoringConfigPresenter = {
|
||||||
|
present: vi.fn(),
|
||||||
|
getViewModel: vi.fn(() => ({ config: {} }))
|
||||||
|
};
|
||||||
const getLeagueWalletPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ balance: 0 })) };
|
const getLeagueWalletPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ balance: 0 })) };
|
||||||
const withdrawFromLeagueWalletPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) };
|
const withdrawFromLeagueWalletPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) };
|
||||||
const leagueJoinRequestsPresenter = { reset: vi.fn(), present: vi.fn(), getViewModel: vi.fn(() => ({ joinRequests: [] })) };
|
const leagueJoinRequestsPresenter = { reset: vi.fn(), present: vi.fn(), getViewModel: vi.fn(() => ({ joinRequests: [] })) };
|
||||||
|
|||||||
@@ -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() {
|
export default function Custom404Page() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6">
|
<ErrorPageContainer
|
||||||
<div className="max-w-md text-center space-y-4">
|
errorCode="404"
|
||||||
<h1 className="text-3xl font-semibold">404</h1>
|
description="This page doesn't exist."
|
||||||
<p className="text-sm text-gray-400">
|
>
|
||||||
This page doesn't exist.
|
<ErrorActionButtons
|
||||||
</p>
|
onHomeClick={() => router.push(routes.public.home)}
|
||||||
<div className="pt-2">
|
homeLabel="Drive home"
|
||||||
<Link
|
/>
|
||||||
href="/"
|
</ErrorPageContainer>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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() {
|
export default function Custom500Page() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6">
|
<ErrorPageContainer
|
||||||
<div className="max-w-md text-center space-y-4">
|
errorCode="500"
|
||||||
<h1 className="text-3xl font-semibold">500</h1>
|
description="Something went wrong."
|
||||||
<p className="text-sm text-gray-400">
|
>
|
||||||
Something went wrong.
|
<ErrorActionButtons
|
||||||
</p>
|
onHomeClick={() => router.push(routes.public.home)}
|
||||||
<div className="pt-2">
|
homeLabel="Drive home"
|
||||||
<Link
|
/>
|
||||||
href="/"
|
</ErrorPageContainer>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,26 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
import { Result } from '@/lib/contracts/Result';
|
||||||
import { AuthApiClient } from '@/lib/api/auth/AuthApiClient';
|
import { LogoutMutation } from '@/lib/mutations/auth/LogoutMutation';
|
||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
|
||||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server action for logout
|
* 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.
|
* 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> {
|
export async function logoutAction(): Promise<Result<void, string>> {
|
||||||
try {
|
const mutation = new LogoutMutation();
|
||||||
// Create required dependencies for API client
|
const result = await mutation.execute();
|
||||||
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
|
if (result.isErr()) {
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
console.error('Logout action failed:', result.getError());
|
||||||
|
return Result.err(result.getError());
|
||||||
// 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Result.ok(undefined);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import { UpdateUserStatusMutation } from '@/lib/mutations/admin/UpdateUserStatusMutation';
|
import { UpdateUserStatusMutation } from '@/lib/mutations/admin/UpdateUserStatusMutation';
|
||||||
import { DeleteUserMutation } from '@/lib/mutations/admin/DeleteUserMutation';
|
import { DeleteUserMutation } from '@/lib/mutations/admin/DeleteUserMutation';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { Result } from '@/lib/contracts/Result';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server actions for admin operations
|
* Server actions for admin operations
|
||||||
@@ -10,34 +12,44 @@ import { revalidatePath } from 'next/cache';
|
|||||||
* All write operations must enter through server actions.
|
* All write operations must enter through server actions.
|
||||||
* Actions are thin wrappers that handle framework concerns (revalidation).
|
* Actions are thin wrappers that handle framework concerns (revalidation).
|
||||||
* Business logic is handled by Mutations.
|
* Business logic is handled by Mutations.
|
||||||
|
* All actions return Result types for type-safe error handling.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update user status
|
* 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 mutation = new UpdateUserStatusMutation();
|
||||||
const result = await mutation.execute({ userId, status });
|
const result = await mutation.execute({ userId, status });
|
||||||
|
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
console.error('updateUserStatus failed:', result.getError());
|
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
|
* 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 mutation = new DeleteUserMutation();
|
||||||
const result = await mutation.execute({ userId });
|
const result = await mutation.execute({ userId });
|
||||||
|
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
console.error('deleteUser failed:', result.getError());
|
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 { headers } from 'next/headers';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
||||||
|
import Section from '@/components/ui/Section';
|
||||||
|
|
||||||
interface AdminLayoutProps {
|
interface AdminLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -23,8 +24,8 @@ export default async function AdminLayout({ children }: AdminLayoutProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-deep-graphite">
|
<Section variant="default" className="min-h-screen">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AdminDashboardPageQuery } from '@/lib/page-queries/AdminDashboardPageQuery';
|
import { AdminDashboardPageQuery } from '@/lib/page-queries/AdminDashboardPageQuery';
|
||||||
import { AdminDashboardTemplate } from '@/templates/AdminDashboardTemplate';
|
import { AdminDashboardTemplate } from '@/templates/AdminDashboardTemplate';
|
||||||
|
import { ErrorBanner } from '@/components/ui/ErrorBanner';
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const result = await AdminDashboardPageQuery.execute();
|
const result = await AdminDashboardPageQuery.execute();
|
||||||
@@ -8,25 +9,25 @@ export default async function AdminPage() {
|
|||||||
const error = result.getError();
|
const error = result.getError();
|
||||||
if (error === 'notFound') {
|
if (error === 'notFound') {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<ErrorBanner
|
||||||
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg">
|
title="Access Denied"
|
||||||
Access denied - You must be logged in as an Owner or Admin
|
message="You must be logged in as an Owner or Admin"
|
||||||
</div>
|
variant="error"
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<ErrorBanner
|
||||||
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg">
|
title="Load Failed"
|
||||||
Failed to load dashboard: {error}
|
message={`Failed to load dashboard: ${error}`}
|
||||||
</div>
|
variant="error"
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewData = result.unwrap();
|
const output = result.unwrap();
|
||||||
|
|
||||||
// For now, use empty callbacks. In a real app, these would be Server Actions
|
// For now, use empty callbacks. In a real app, these would be Server Actions
|
||||||
// that trigger revalidation or navigation
|
// 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 { AdminUsersTemplate } from '@/templates/AdminUsersTemplate';
|
||||||
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
||||||
import { updateUserStatus, deleteUser } from '../actions';
|
import { updateUserStatus, deleteUser } from '../actions';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
|
|
||||||
interface AdminUsersWrapperProps {
|
interface AdminUsersWrapperProps {
|
||||||
initialViewData: AdminUsersViewData;
|
initialViewData: AdminUsersViewData;
|
||||||
@@ -30,7 +31,7 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
|||||||
if (newSearch) params.set('search', newSearch);
|
if (newSearch) params.set('search', newSearch);
|
||||||
else params.delete('search');
|
else params.delete('search');
|
||||||
params.delete('page'); // Reset to page 1
|
params.delete('page'); // Reset to page 1
|
||||||
router.push(`/admin/users?${params.toString()}`);
|
router.push(`${routes.admin.users}?${params.toString()}`);
|
||||||
}, [router, searchParams]);
|
}, [router, searchParams]);
|
||||||
|
|
||||||
const handleFilterRole = useCallback((role: string) => {
|
const handleFilterRole = useCallback((role: string) => {
|
||||||
@@ -38,7 +39,7 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
|||||||
if (role) params.set('role', role);
|
if (role) params.set('role', role);
|
||||||
else params.delete('role');
|
else params.delete('role');
|
||||||
params.delete('page');
|
params.delete('page');
|
||||||
router.push(`/admin/users?${params.toString()}`);
|
router.push(`${routes.admin.users}?${params.toString()}`);
|
||||||
}, [router, searchParams]);
|
}, [router, searchParams]);
|
||||||
|
|
||||||
const handleFilterStatus = useCallback((status: string) => {
|
const handleFilterStatus = useCallback((status: string) => {
|
||||||
@@ -46,11 +47,11 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
|||||||
if (status) params.set('status', status);
|
if (status) params.set('status', status);
|
||||||
else params.delete('status');
|
else params.delete('status');
|
||||||
params.delete('page');
|
params.delete('page');
|
||||||
router.push(`/admin/users?${params.toString()}`);
|
router.push(`${routes.admin.users}?${params.toString()}`);
|
||||||
}, [router, searchParams]);
|
}, [router, searchParams]);
|
||||||
|
|
||||||
const handleClearFilters = useCallback(() => {
|
const handleClearFilters = useCallback(() => {
|
||||||
router.push('/admin/users');
|
router.push(routes.admin.users);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
@@ -61,7 +62,13 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
|||||||
const handleUpdateStatus = useCallback(async (userId: string, newStatus: string) => {
|
const handleUpdateStatus = useCallback(async (userId: string, newStatus: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await updateUserStatus(userId, newStatus);
|
const result = await updateUserStatus(userId, newStatus);
|
||||||
|
|
||||||
|
if (result.isErr()) {
|
||||||
|
setError(result.getError());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Revalidate data
|
// Revalidate data
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -78,7 +85,13 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setDeletingUser(userId);
|
setDeletingUser(userId);
|
||||||
await deleteUser(userId);
|
const result = await deleteUser(userId);
|
||||||
|
|
||||||
|
if (result.isErr()) {
|
||||||
|
setError(result.getError());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Revalidate data
|
// Revalidate data
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AdminUsersPageQuery } from '@/lib/page-queries/AdminUsersPageQuery';
|
import { AdminUsersPageQuery } from '@/lib/page-queries/AdminUsersPageQuery';
|
||||||
import { AdminUsersWrapper } from './AdminUsersWrapper';
|
import { AdminUsersWrapper } from './AdminUsersWrapper';
|
||||||
|
import { ErrorBanner } from '@/components/ui/ErrorBanner';
|
||||||
|
|
||||||
interface AdminUsersPageProps {
|
interface AdminUsersPageProps {
|
||||||
searchParams?: {
|
searchParams?: {
|
||||||
@@ -28,24 +29,24 @@ export default async function AdminUsersPage({ searchParams }: AdminUsersPagePro
|
|||||||
const error = result.getError();
|
const error = result.getError();
|
||||||
if (error === 'notFound') {
|
if (error === 'notFound') {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<ErrorBanner
|
||||||
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg">
|
title="Access Denied"
|
||||||
Access denied - You must be logged in as an Owner or Admin
|
message="You must be logged in as an Owner or Admin"
|
||||||
</div>
|
variant="error"
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<ErrorBanner
|
||||||
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg">
|
title="Load Failed"
|
||||||
Failed to load users: {error}
|
message={`Failed to load users: ${error}`}
|
||||||
</div>
|
variant="error"
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewData = result.unwrap();
|
const output = result.unwrap();
|
||||||
|
|
||||||
// Pass to client wrapper for UI interactions
|
// Pass to client wrapper for UI interactions
|
||||||
return <AdminUsersWrapper initialViewData={viewData} />;
|
return <AdminUsersWrapper initialViewData={output} />;
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
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 { ForgotPasswordTemplate } from '@/templates/auth/ForgotPasswordTemplate';
|
||||||
import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation';
|
import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation';
|
||||||
import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder';
|
import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder';
|
||||||
@@ -73,7 +73,7 @@ export function ForgotPasswordClient({ viewData }: ForgotPasswordClientProps) {
|
|||||||
setShowSuccess: (show) => {
|
setShowSuccess: (show) => {
|
||||||
if (!show) {
|
if (!show) {
|
||||||
// Reset to initial state
|
// 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 { ForgotPasswordPageQuery } from '@/lib/page-queries/auth/ForgotPasswordPageQuery';
|
||||||
import { ForgotPasswordClient } from './ForgotPasswordClient';
|
import { ForgotPasswordClient } from './ForgotPasswordClient';
|
||||||
|
import { AuthError } from '@/components/ui/AuthError';
|
||||||
|
|
||||||
export default async function ForgotPasswordPage({
|
export default async function ForgotPasswordPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -19,12 +20,7 @@ export default async function ForgotPasswordPage({
|
|||||||
const queryResult = await ForgotPasswordPageQuery.execute(params);
|
const queryResult = await ForgotPasswordPageQuery.execute(params);
|
||||||
|
|
||||||
if (queryResult.isErr()) {
|
if (queryResult.isErr()) {
|
||||||
// Handle query error
|
return <AuthError action="forgot password" />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewData = queryResult.unwrap();
|
const viewData = queryResult.unwrap();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
||||||
|
import { AuthContainer } from '@/components/ui/AuthContainer';
|
||||||
|
|
||||||
interface AuthLayoutProps {
|
interface AuthLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -26,9 +27,5 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
|||||||
redirect(result.to);
|
redirect(result.to);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <AuthContainer>{children}</AuthContainer>;
|
||||||
<div className="min-h-screen bg-deep-graphite flex items-center justify-center p-4">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController';
|
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 { LoginTemplate } from '@/templates/auth/LoginTemplate';
|
||||||
import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
|
import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
|
||||||
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
|
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
|
||||||
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
|
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
|
||||||
import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
|
import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
|
||||||
|
import { AuthLoading } from '@/components/ui/AuthLoading';
|
||||||
|
|
||||||
interface LoginClientProps {
|
interface LoginClientProps {
|
||||||
viewData: LoginViewData;
|
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
|
// Get current state from controller
|
||||||
const state = controller.getState();
|
const state = controller.getState();
|
||||||
|
|
||||||
// If user is authenticated with permissions, show loading
|
// If user is authenticated with permissions, show loading
|
||||||
if (state === LoginState.AUTHENTICATED_WITH_PERMISSIONS) {
|
if (state === LoginState.AUTHENTICATED_WITH_PERMISSIONS) {
|
||||||
return (
|
return <AuthLoading />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user has insufficient permissions, show permission error
|
// If user has insufficient permissions, show permission error
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import { LoginPageQuery } from '@/lib/page-queries/auth/LoginPageQuery';
|
import { LoginPageQuery } from '@/lib/page-queries/auth/LoginPageQuery';
|
||||||
import { LoginClient } from './LoginClient';
|
import { LoginClient } from './LoginClient';
|
||||||
|
import { AuthError } from '@/components/ui/AuthError';
|
||||||
|
|
||||||
export default async function LoginPage({
|
export default async function LoginPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -19,12 +20,7 @@ export default async function LoginPage({
|
|||||||
const queryResult = await LoginPageQuery.execute(params);
|
const queryResult = await LoginPageQuery.execute(params);
|
||||||
|
|
||||||
if (queryResult.isErr()) {
|
if (queryResult.isErr()) {
|
||||||
// Handle query error
|
return <AuthError action="login" />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewData = queryResult.unwrap();
|
const viewData = queryResult.unwrap();
|
||||||
|
|||||||
@@ -8,11 +8,12 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
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 { ResetPasswordTemplate } from '@/templates/auth/ResetPasswordTemplate';
|
||||||
import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation';
|
import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation';
|
||||||
import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetPasswordViewModelBuilder';
|
import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetPasswordViewModelBuilder';
|
||||||
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
|
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
|
|
||||||
interface ResetPasswordClientProps {
|
interface ResetPasswordClientProps {
|
||||||
viewData: ResetPasswordViewData;
|
viewData: ResetPasswordViewData;
|
||||||
@@ -70,7 +71,7 @@ export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
|
|||||||
|
|
||||||
// Redirect to login after a delay
|
// Redirect to login after a delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/auth/login');
|
router.push(routes.auth.login);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to reset password';
|
const errorMessage = error instanceof Error ? error.message : 'Failed to reset password';
|
||||||
@@ -120,7 +121,7 @@ export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
|
|||||||
setShowSuccess: (show) => {
|
setShowSuccess: (show) => {
|
||||||
if (!show) {
|
if (!show) {
|
||||||
// Reset to initial state
|
// Reset to initial state
|
||||||
setViewModel(prev => ResetPasswordViewModelBuilder.build(viewData));
|
setViewModel(() => ResetPasswordViewModelBuilder.build(viewData));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setShowPassword: togglePassword,
|
setShowPassword: togglePassword,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import { ResetPasswordPageQuery } from '@/lib/page-queries/auth/ResetPasswordPageQuery';
|
import { ResetPasswordPageQuery } from '@/lib/page-queries/auth/ResetPasswordPageQuery';
|
||||||
import { ResetPasswordClient } from './ResetPasswordClient';
|
import { ResetPasswordClient } from './ResetPasswordClient';
|
||||||
|
import { AuthError } from '@/components/ui/AuthError';
|
||||||
|
|
||||||
export default async function ResetPasswordPage({
|
export default async function ResetPasswordPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -19,12 +20,7 @@ export default async function ResetPasswordPage({
|
|||||||
const queryResult = await ResetPasswordPageQuery.execute(params);
|
const queryResult = await ResetPasswordPageQuery.execute(params);
|
||||||
|
|
||||||
if (queryResult.isErr()) {
|
if (queryResult.isErr()) {
|
||||||
// Handle query error
|
return <AuthError action="reset password" />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewData = queryResult.unwrap();
|
const viewData = queryResult.unwrap();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
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 { SignupTemplate } from '@/templates/auth/SignupTemplate';
|
||||||
import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
|
import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
|
||||||
import { SignupViewModelBuilder } from '@/lib/builders/view-models/SignupViewModelBuilder';
|
import { SignupViewModelBuilder } from '@/lib/builders/view-models/SignupViewModelBuilder';
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import { SignupPageQuery } from '@/lib/page-queries/auth/SignupPageQuery';
|
import { SignupPageQuery } from '@/lib/page-queries/auth/SignupPageQuery';
|
||||||
import { SignupClient } from './SignupClient';
|
import { SignupClient } from './SignupClient';
|
||||||
|
import { AuthError } from '@/components/ui/AuthError';
|
||||||
|
|
||||||
export default async function SignupPage({
|
export default async function SignupPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -19,12 +20,7 @@ export default async function SignupPage({
|
|||||||
const queryResult = await SignupPageQuery.execute(params);
|
const queryResult = await SignupPageQuery.execute(params);
|
||||||
|
|
||||||
if (queryResult.isErr()) {
|
if (queryResult.isErr()) {
|
||||||
// Handle query error
|
return <AuthError action="signup" />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewData = queryResult.unwrap();
|
const viewData = queryResult.unwrap();
|
||||||
|
|||||||
@@ -1,30 +1,9 @@
|
|||||||
import { headers } from 'next/headers';
|
import { DashboardLayoutWrapper } from '@/ui/DashboardLayoutWrapper';
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}) {
|
||||||
|
return <DashboardLayoutWrapper>{children}</DashboardLayoutWrapper>;
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ export default async function DashboardPage() {
|
|||||||
} else if (error === 'redirect') {
|
} else if (error === 'redirect') {
|
||||||
redirect('/');
|
redirect('/');
|
||||||
} else {
|
} else {
|
||||||
// DASHBOARD_FETCH_FAILED or UNKNOWN_ERROR
|
// serverError, networkError, unknown, validationError, unauthorized
|
||||||
console.error('Dashboard error:', error);
|
console.error('Dashboard error:', error);
|
||||||
notFound();
|
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,6 +1,7 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
import { DriverProfilePageQuery } from '@/lib/page-queries/page-queries/DriverProfilePageQuery';
|
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 } }) {
|
export default async function DriverProfilePage({ params }: { params: { id: string } }) {
|
||||||
// Execute the page query
|
// Execute the page query
|
||||||
@@ -9,7 +10,7 @@ export default async function DriverProfilePage({ params }: { params: { id: stri
|
|||||||
// Handle different result statuses
|
// Handle different result statuses
|
||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
case 'notFound':
|
case 'notFound':
|
||||||
redirect('/404');
|
redirect(routes.error.notFound);
|
||||||
case 'redirect':
|
case 'redirect':
|
||||||
redirect(result.to);
|
redirect(result.to);
|
||||||
case 'error':
|
case 'error':
|
||||||
@@ -21,8 +22,8 @@ export default async function DriverProfilePage({ params }: { params: { id: stri
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'ok':
|
case 'ok':
|
||||||
const pageDto = result.dto;
|
const viewModel = result.dto;
|
||||||
const hasData = !!pageDto.currentDriver;
|
const hasData = !!viewModel.currentDriver;
|
||||||
|
|
||||||
if (!hasData) {
|
if (!hasData) {
|
||||||
return (
|
return (
|
||||||
@@ -38,7 +39,7 @@ export default async function DriverProfilePage({ params }: { params: { id: stri
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DriverProfilePageClient
|
<DriverProfilePageClient
|
||||||
pageDto={pageDto}
|
pageDto={viewModel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
import { DriversPageQuery } from '@/lib/page-queries/page-queries/DriversPageQuery';
|
import { DriversPageQuery } from '@/lib/page-queries/page-queries/DriversPageQuery';
|
||||||
import { DriversPageClient } from './DriversPageClient';
|
import { DriversPageClient } from '@/components/drivers/DriversPageClient';
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
// Execute the page query
|
// Execute the page query
|
||||||
@@ -9,7 +10,7 @@ export default async function Page() {
|
|||||||
// Handle different result statuses
|
// Handle different result statuses
|
||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
case 'notFound':
|
case 'notFound':
|
||||||
redirect('/404');
|
redirect(routes.error.notFound);
|
||||||
case 'redirect':
|
case 'redirect':
|
||||||
redirect(result.to);
|
redirect(result.to);
|
||||||
case 'error':
|
case 'error':
|
||||||
@@ -21,8 +22,8 @@ export default async function Page() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'ok':
|
case 'ok':
|
||||||
const pageDto = result.dto;
|
const viewModel = result.dto;
|
||||||
const hasData = (pageDto.drivers?.length ?? 0) > 0;
|
const hasData = (viewModel.drivers?.length ?? 0) > 0;
|
||||||
|
|
||||||
if (!hasData) {
|
if (!hasData) {
|
||||||
return (
|
return (
|
||||||
@@ -38,7 +39,7 @@ export default async function Page() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DriversPageClient
|
<DriversPageClient
|
||||||
pageDto={pageDto}
|
pageDto={viewModel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
'use client';
|
'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({
|
export default function ErrorPage({
|
||||||
error,
|
error,
|
||||||
@@ -9,29 +13,23 @@ export default function ErrorPage({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6">
|
<ErrorPageContainer
|
||||||
<div className="max-w-md text-center space-y-4">
|
errorCode="Error"
|
||||||
<h1 className="text-3xl font-semibold">Something went wrong</h1>
|
description={error?.message || 'An unexpected error occurred.'}
|
||||||
<p className="text-sm text-gray-400">
|
>
|
||||||
{error?.message ? error.message : 'An unexpected error occurred.'}
|
{error?.digest && (
|
||||||
</p>
|
<Text size="xs" color="text-gray-500" font="mono">
|
||||||
<div className="flex items-center justify-center gap-3 pt-2">
|
Error ID: {error.digest}
|
||||||
<button
|
</Text>
|
||||||
type="button"
|
)}
|
||||||
onClick={() => reset()}
|
<ErrorActionButtons
|
||||||
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"
|
onRetry={reset}
|
||||||
>
|
onHomeClick={() => router.push(routes.public.home)}
|
||||||
Try again
|
showRetry={true}
|
||||||
</button>
|
/>
|
||||||
<Link
|
</ErrorPageContainer>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
'use client';
|
'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({
|
export default function GlobalError({
|
||||||
error,
|
error,
|
||||||
@@ -9,37 +13,26 @@ export default function GlobalError({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6">
|
<ErrorPageContainer
|
||||||
<div className="max-w-md text-center space-y-4">
|
errorCode="Error"
|
||||||
<h1 className="text-3xl font-semibold">Something went wrong</h1>
|
description={error?.message || 'An unexpected error occurred.'}
|
||||||
<p className="text-sm text-gray-400">
|
>
|
||||||
{error?.message ? error.message : 'An unexpected error occurred.'}
|
{error?.digest && (
|
||||||
</p>
|
<Text size="xs" color="text-gray-500" font="mono">
|
||||||
{error?.digest && (
|
Error ID: {error.digest}
|
||||||
<p className="text-xs text-gray-500 font-mono">
|
</Text>
|
||||||
Error ID: {error.digest}
|
)}
|
||||||
</p>
|
<ErrorActionButtons
|
||||||
)}
|
onRetry={reset}
|
||||||
<div className="flex items-center justify-center gap-3 pt-2">
|
onHomeClick={() => router.push(routes.public.home)}
|
||||||
<button
|
showRetry={true}
|
||||||
type="button"
|
/>
|
||||||
onClick={() => reset()}
|
</ErrorPageContainer>
|
||||||
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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 { 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 { initializeGlobalErrorHandling } from '@/lib/infrastructure/GlobalErrorHandler';
|
||||||
import { initializeApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
|
import { initializeApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
|
||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import './globals.css';
|
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';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -81,43 +75,14 @@ export default async function RootLayout({
|
|||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
</head>
|
</head>
|
||||||
<body className="antialiased overflow-x-hidden">
|
<body className="antialiased overflow-x-hidden">
|
||||||
<ContainerProvider>
|
<AppWrapper enabledFlags={enabledFlags}>
|
||||||
<QueryClientProvider>
|
<Header>
|
||||||
<AuthProvider>
|
<HeaderContent />
|
||||||
<FeatureFlagProvider flags={enabledFlags}>
|
</Header>
|
||||||
<NotificationProvider>
|
<MainContent>
|
||||||
<NotificationIntegration />
|
{children}
|
||||||
<EnhancedErrorBoundary enableDevOverlay={process.env.NODE_ENV === 'development'}>
|
</MainContent>
|
||||||
<header className="fixed top-0 left-0 right-0 z-50 bg-deep-graphite/80 backdrop-blur-sm border-b border-white/5">
|
</AppWrapper>
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate';
|
import { LeaderboardsTemplate } from '@/templates/LeaderboardsTemplate';
|
||||||
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
|
|
||||||
export function LeaderboardsPageWrapper({ data }: { data: LeaderboardsViewData | null }) {
|
export function LeaderboardsPageWrapper({ data }: { data: LeaderboardsViewData | null }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -12,22 +13,22 @@ export function LeaderboardsPageWrapper({ data }: { data: LeaderboardsViewData |
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDriverClick = (driverId: string) => {
|
const handleDriverClick = (driverId: string) => {
|
||||||
router.push(`/drivers/${driverId}`);
|
router.push(routes.driver.detail(driverId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTeamClick = (teamId: string) => {
|
const handleTeamClick = (teamId: string) => {
|
||||||
router.push(`/teams/${teamId}`);
|
router.push(routes.team.detail(teamId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNavigateToDrivers = () => {
|
const handleNavigateToDrivers = () => {
|
||||||
router.push('/leaderboards/drivers');
|
router.push(routes.leaderboards.drivers);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNavigateToTeams = () => {
|
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 = {
|
const templateData = {
|
||||||
drivers: data.drivers.map(d => ({
|
drivers: data.drivers.map(d => ({
|
||||||
id: d.id,
|
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 { DriverRankingsPageQuery } from '@/lib/page-queries/page-queries/DriverRankingsPageQuery';
|
||||||
import { DriverRankingsViewDataBuilder } from '@/lib/builders/view-data/DriverRankingsViewDataBuilder';
|
|
||||||
import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate';
|
import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
// ============================================================================
|
|
||||||
// MAIN PAGE COMPONENT
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export default async function DriverLeaderboardPage() {
|
export default async function DriverLeaderboardPage() {
|
||||||
// Execute the page query
|
|
||||||
const result = await DriverRankingsPageQuery.execute();
|
const result = await DriverRankingsPageQuery.execute();
|
||||||
|
|
||||||
// Handle different result statuses
|
if (result.isErr()) {
|
||||||
switch (result.status) {
|
const error = result.getError();
|
||||||
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) {
|
// Handle different error types
|
||||||
return (
|
if (error === 'notFound') {
|
||||||
<DriverRankingsTemplate
|
notFound();
|
||||||
viewData={{
|
} else if (error === 'redirect') {
|
||||||
drivers: [],
|
redirect(routes.public.home);
|
||||||
podium: [],
|
} else {
|
||||||
searchQuery: '',
|
// serverError, networkError, unknown, validationError, unauthorized
|
||||||
selectedSkill: 'all',
|
console.error('Driver rankings error:', error);
|
||||||
sortBy: 'rank',
|
notFound();
|
||||||
showFilters: false,
|
}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DriverRankingsTemplate viewData={viewData} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
const viewData = result.unwrap();
|
||||||
|
return <DriverRankingsTemplate viewData={viewData} />;
|
||||||
}
|
}
|
||||||
@@ -1,61 +1,27 @@
|
|||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { Trophy } from 'lucide-react';
|
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
import { LeaderboardsPageQuery } from '@/lib/page-queries/page-queries/LeaderboardsPageQuery';
|
import { LeaderboardsPageQuery } from '@/lib/page-queries/page-queries/LeaderboardsPageQuery';
|
||||||
import { LeaderboardsViewDataBuilder } from '@/lib/builders/view-data/LeaderboardsViewDataBuilder';
|
|
||||||
import { LeaderboardsPageWrapper } from './LeaderboardsPageWrapper';
|
import { LeaderboardsPageWrapper } from './LeaderboardsPageWrapper';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
// ============================================================================
|
|
||||||
// MAIN PAGE COMPONENT
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export default async function LeaderboardsPage() {
|
export default async function LeaderboardsPage() {
|
||||||
// Execute the page query
|
|
||||||
const result = await LeaderboardsPageQuery.execute();
|
const result = await LeaderboardsPageQuery.execute();
|
||||||
|
|
||||||
// Handle different result statuses
|
if (result.isErr()) {
|
||||||
switch (result.status) {
|
const error = result.getError();
|
||||||
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 (
|
// Handle different error types
|
||||||
<PageWrapper
|
if (error === 'notFound') {
|
||||||
data={hasData ? viewData : null}
|
notFound();
|
||||||
isLoading={false}
|
} else if (error === 'redirect') {
|
||||||
error={null}
|
redirect(routes.public.home);
|
||||||
retry={async () => redirect('/leaderboards')}
|
} else {
|
||||||
Template={LeaderboardsPageWrapper}
|
// serverError, networkError, unknown, validationError, unauthorized
|
||||||
loading={{ variant: 'full-screen', message: 'Loading leaderboards...' }}
|
console.error('Leaderboards error:', error);
|
||||||
errorConfig={{ variant: 'full-screen' }}
|
notFound();
|
||||||
empty={{
|
}
|
||||||
icon: Trophy,
|
|
||||||
title: 'No leaderboard data',
|
|
||||||
description: 'There is no leaderboard data available at the moment.',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
const viewData = result.unwrap();
|
||||||
|
return <LeaderboardsPageWrapper data={viewData} />;
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,39 @@
|
|||||||
'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';
|
export default async function LeagueLayout({
|
||||||
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({
|
|
||||||
children,
|
children,
|
||||||
|
params,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
params: { id: string };
|
||||||
}) {
|
}) {
|
||||||
const params = useParams();
|
const leagueId = params.id;
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
|
||||||
const leagueId = params.id as string;
|
|
||||||
const currentDriverId = useEffectiveDriverId();
|
|
||||||
|
|
||||||
const { data: leagueDetail, isLoading: loading } = useLeagueDetail({ leagueId });
|
// Execute PageQuery to get league data
|
||||||
|
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||||
|
|
||||||
if (loading) {
|
if (result.isErr()) {
|
||||||
|
const error = result.getError();
|
||||||
|
if (error === 'notFound' || error === 'redirect') {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
// Return error state
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
<LeagueDetailTemplate
|
||||||
<div className="max-w-6xl mx-auto">
|
leagueId={leagueId}
|
||||||
<div className="text-center text-gray-400">Loading league...</div>
|
leagueName="Error"
|
||||||
</div>
|
leagueDescription="Failed to load league"
|
||||||
</div>
|
tabs={[]}
|
||||||
|
>
|
||||||
|
<div className="text-center text-gray-400">Failed to load league</div>
|
||||||
|
</LeagueDetailTemplate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!leagueDetail) {
|
const data = result.unwrap();
|
||||||
return (
|
const league = data.league;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define tab configuration
|
// Define tab configuration
|
||||||
const baseTabs = [
|
const baseTabs = [
|
||||||
@@ -61,46 +56,13 @@ export default function LeagueLayout({
|
|||||||
const tabs = [...baseTabs, ...adminTabs];
|
const tabs = [...baseTabs, ...adminTabs];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
<LeagueDetailTemplate
|
||||||
<div className="max-w-6xl mx-auto">
|
leagueId={leagueId}
|
||||||
<Breadcrumbs
|
leagueName={league.name}
|
||||||
items={[
|
leagueDescription={league.description}
|
||||||
{ label: 'Home', href: '/' },
|
tabs={tabs}
|
||||||
{ label: 'Leagues', href: '/leagues' },
|
>
|
||||||
{ label: leagueDetail.name },
|
{children}
|
||||||
]}
|
</LeagueDetailTemplate>
|
||||||
/>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,13 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
|
||||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery';
|
import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery';
|
||||||
import { LeagueDetailPresenter } from '@/lib/presenters/LeagueDetailPresenter';
|
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
|
||||||
import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: { id: string };
|
params: { id: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ params }: Props) {
|
export default async function Page({ params }: Props) {
|
||||||
// Validate params
|
|
||||||
if (!params.id) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the PageQuery
|
// Execute the PageQuery
|
||||||
const result = await LeagueDetailPageQuery.execute(params.id);
|
const result = await LeagueDetailPageQuery.execute(params.id);
|
||||||
|
|
||||||
@@ -31,56 +24,29 @@ export default async function Page({ params }: Props) {
|
|||||||
case 'LEAGUE_FETCH_FAILED':
|
case 'LEAGUE_FETCH_FAILED':
|
||||||
case 'UNKNOWN_ERROR':
|
case 'UNKNOWN_ERROR':
|
||||||
default:
|
default:
|
||||||
// Return error state that PageWrapper can handle
|
// Return error state
|
||||||
// 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 (
|
return (
|
||||||
<PageWrapper
|
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||||
data={undefined}
|
<div className="max-w-6xl mx-auto">
|
||||||
error={new Error('Failed to fetch league')}
|
<div className="text-center text-gray-400">Failed to load league details</div>
|
||||||
Template={ErrorTemplate}
|
</div>
|
||||||
errorConfig={{ variant: 'full-screen' }}
|
</div>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = result.unwrap();
|
const data = result.unwrap();
|
||||||
|
|
||||||
// Convert the API DTO to ViewModel using the existing presenter
|
// Build ViewData using the builder
|
||||||
// This maintains compatibility with the existing template
|
// Note: This would need additional data (owner, scoring config, etc.) in real implementation
|
||||||
const viewModel = data.apiDto as unknown as LeagueDetailPageViewModel;
|
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
|
return <LeagueDetailTemplate viewData={viewData} />;
|
||||||
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.',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import type { Mocked } from 'vitest';
|
import type { Mocked } from 'vitest';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel';
|
import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel';
|
||||||
import type { LeagueAdminRosterMemberViewModel } from '@/lib/view-models/LeagueAdminRosterMemberViewModel';
|
import type { LeagueAdminRosterMemberViewModel } from '@/lib/view-models/LeagueAdminRosterMemberViewModel';
|
||||||
@@ -26,61 +27,68 @@ vi.mock('next/navigation', () => ({
|
|||||||
let mockJoinRequests: any[] = [];
|
let mockJoinRequests: any[] = [];
|
||||||
let mockMembers: any[] = [];
|
let mockMembers: any[] = [];
|
||||||
|
|
||||||
// Mock the new DI hooks
|
// Mock the hooks directly
|
||||||
vi.mock('@/hooks/league/useLeagueRosterAdmin', () => ({
|
vi.mock('@/lib/hooks/league/useLeagueRosterAdmin', () => ({
|
||||||
useLeagueRosterJoinRequests: (leagueId: string) => ({
|
useLeagueJoinRequests: (leagueId: string) => ({
|
||||||
data: [...mockJoinRequests],
|
data: [...mockJoinRequests],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isError: false,
|
isError: false,
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
}),
|
}),
|
||||||
useLeagueRosterMembers: (leagueId: string) => ({
|
useLeagueRosterAdmin: (leagueId: string) => ({
|
||||||
data: [...mockMembers],
|
data: [...mockMembers],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isError: false,
|
isError: false,
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
}),
|
}),
|
||||||
useApproveJoinRequest: () => ({
|
useApproveJoinRequest: (options?: any) => ({
|
||||||
mutate: (params: any) => {
|
mutate: (params: any) => {
|
||||||
// Remove from join requests
|
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.requestId);
|
||||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId);
|
if (options?.onSuccess) options.onSuccess();
|
||||||
},
|
},
|
||||||
mutateAsync: async (params: any) => {
|
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 };
|
return { success: true };
|
||||||
},
|
},
|
||||||
isPending: false,
|
isPending: false,
|
||||||
}),
|
}),
|
||||||
useRejectJoinRequest: () => ({
|
useRejectJoinRequest: (options?: any) => ({
|
||||||
mutate: (params: 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) => {
|
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 };
|
return { success: true };
|
||||||
},
|
},
|
||||||
isPending: false,
|
isPending: false,
|
||||||
}),
|
}),
|
||||||
useUpdateMemberRole: () => ({
|
useUpdateMemberRole: (options?: any) => ({
|
||||||
mutate: (params: any) => {
|
mutate: (params: any) => {
|
||||||
const member = mockMembers.find(m => m.driverId === params.driverId);
|
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) => {
|
mutateAsync: async (params: any) => {
|
||||||
const member = mockMembers.find(m => m.driverId === params.driverId);
|
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 };
|
return { success: true };
|
||||||
},
|
},
|
||||||
isPending: false,
|
isPending: false,
|
||||||
}),
|
}),
|
||||||
useRemoveMember: () => ({
|
useRemoveMember: (options?: any) => ({
|
||||||
mutate: (params: any) => {
|
mutate: (params: any) => {
|
||||||
mockMembers = mockMembers.filter(m => m.driverId !== params.driverId);
|
mockMembers = mockMembers.filter(m => m.driverId !== params.driverId);
|
||||||
|
if (options?.onSuccess) options.onSuccess();
|
||||||
},
|
},
|
||||||
mutateAsync: async (params: any) => {
|
mutateAsync: async (params: any) => {
|
||||||
mockMembers = mockMembers.filter(m => m.driverId !== params.driverId);
|
mockMembers = mockMembers.filter(m => m.driverId !== params.driverId);
|
||||||
|
if (options?.onSuccess) options.onSuccess();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
isPending: false,
|
isPending: false,
|
||||||
@@ -91,9 +99,11 @@ function makeJoinRequest(overrides: Partial<LeagueAdminRosterJoinRequestViewMode
|
|||||||
return {
|
return {
|
||||||
id: 'jr-1',
|
id: 'jr-1',
|
||||||
leagueId: 'league-1',
|
leagueId: 'league-1',
|
||||||
driverId: 'driver-1',
|
driver: {
|
||||||
driverName: 'Driver One',
|
id: 'driver-1',
|
||||||
requestedAtIso: '2025-01-01T00:00:00.000Z',
|
name: 'Driver One',
|
||||||
|
},
|
||||||
|
requestedAt: '2025-01-01T00:00:00.000Z',
|
||||||
message: 'Please let me in',
|
message: 'Please let me in',
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -102,14 +112,19 @@ function makeJoinRequest(overrides: Partial<LeagueAdminRosterJoinRequestViewMode
|
|||||||
function makeMember(overrides: Partial<LeagueAdminRosterMemberViewModel> = {}): LeagueAdminRosterMemberViewModel {
|
function makeMember(overrides: Partial<LeagueAdminRosterMemberViewModel> = {}): LeagueAdminRosterMemberViewModel {
|
||||||
return {
|
return {
|
||||||
driverId: 'driver-10',
|
driverId: 'driver-10',
|
||||||
driverName: 'Member Ten',
|
driver: {
|
||||||
|
id: 'driver-10',
|
||||||
|
name: 'Member Ten',
|
||||||
|
},
|
||||||
role: 'member',
|
role: 'member',
|
||||||
joinedAtIso: '2025-01-01T00:00:00.000Z',
|
joinedAt: '2025-01-01T00:00:00.000Z',
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('RosterAdminPage', () => {
|
describe('RosterAdminPage', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset mock data
|
// Reset mock data
|
||||||
mockJoinRequests = [];
|
mockJoinRequests = [];
|
||||||
@@ -123,24 +138,44 @@ describe('RosterAdminPage', () => {
|
|||||||
updateMemberRole: vi.fn(),
|
updateMemberRole: vi.fn(),
|
||||||
removeMember: vi.fn(),
|
removeMember: vi.fn(),
|
||||||
} as any;
|
} 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 () => {
|
it('renders join requests + members from service ViewModels', async () => {
|
||||||
const joinRequests: LeagueAdminRosterJoinRequestViewModel[] = [
|
const joinRequests: LeagueAdminRosterJoinRequestViewModel[] = [
|
||||||
makeJoinRequest({ id: 'jr-1', driverName: 'Driver One' }),
|
makeJoinRequest({ id: 'jr-1', driver: { id: 'driver-1', name: 'Driver One' } }),
|
||||||
makeJoinRequest({ id: 'jr-2', driverName: 'Driver Two', driverId: 'driver-2' }),
|
makeJoinRequest({ id: 'jr-2', driver: { id: 'driver-2', name: 'Driver Two' } }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const members: LeagueAdminRosterMemberViewModel[] = [
|
const members: LeagueAdminRosterMemberViewModel[] = [
|
||||||
makeMember({ driverId: 'driver-10', driverName: 'Member Ten', role: 'member' }),
|
makeMember({ driverId: 'driver-10', driver: { id: 'driver-10', name: 'Member Ten' }, role: 'member' }),
|
||||||
makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'admin' }),
|
makeMember({ driverId: 'driver-11', driver: { id: 'driver-11', name: 'Member Eleven' }, role: 'admin' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Set mock data for hooks
|
// Set mock data for hooks
|
||||||
mockJoinRequests = joinRequests;
|
mockJoinRequests = joinRequests;
|
||||||
mockMembers = members;
|
mockMembers = members;
|
||||||
|
|
||||||
render(<RosterAdminPage />);
|
renderWithProviders(<RosterAdminPage />);
|
||||||
|
|
||||||
expect(await screen.findByText('Roster Admin')).toBeInTheDocument();
|
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 () => {
|
it('approves a join request and removes it from the pending list', async () => {
|
||||||
mockJoinRequests = [makeJoinRequest({ id: 'jr-1', driverName: 'Driver One' })];
|
mockJoinRequests = [makeJoinRequest({ id: 'jr-1', driver: { id: 'driver-1', name: 'Driver One' } })];
|
||||||
mockMembers = [makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })];
|
mockMembers = [makeMember({ driverId: 'driver-10', driver: { id: 'driver-10', name: 'Member Ten' } })];
|
||||||
|
|
||||||
render(<RosterAdminPage />);
|
renderWithProviders(<RosterAdminPage />);
|
||||||
|
|
||||||
expect(await screen.findByText('Driver One')).toBeInTheDocument();
|
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 () => {
|
it('rejects a join request and removes it from the pending list', async () => {
|
||||||
mockJoinRequests = [makeJoinRequest({ id: 'jr-2', driverName: 'Driver Two' })];
|
mockJoinRequests = [makeJoinRequest({ id: 'jr-2', driver: { id: 'driver-2', name: 'Driver Two' } })];
|
||||||
mockMembers = [makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })];
|
mockMembers = [makeMember({ driverId: 'driver-10', driver: { id: 'driver-10', name: 'Member Ten' } })];
|
||||||
|
|
||||||
render(<RosterAdminPage />);
|
renderWithProviders(<RosterAdminPage />);
|
||||||
|
|
||||||
expect(await screen.findByText('Driver Two')).toBeInTheDocument();
|
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 () => {
|
it('changes a member role via service and updates the displayed role', async () => {
|
||||||
mockJoinRequests = [];
|
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();
|
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 () => {
|
it('removes a member via service and removes them from the list', async () => {
|
||||||
mockJoinRequests = [];
|
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();
|
expect(await screen.findByText('Member Twelve')).toBeInTheDocument();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Card from '@/components/ui/Card';
|
|
||||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
useUpdateMemberRole,
|
useUpdateMemberRole,
|
||||||
useRemoveMember,
|
useRemoveMember,
|
||||||
} from "@/lib/hooks/league/useLeagueRosterAdmin";
|
} from "@/lib/hooks/league/useLeagueRosterAdmin";
|
||||||
|
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
|
||||||
|
|
||||||
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
|
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
|
||||||
|
|
||||||
@@ -72,114 +72,16 @@ export function RosterAdminPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<RosterAdminTemplate
|
||||||
<Card>
|
joinRequests={joinRequests}
|
||||||
<div className="space-y-4">
|
members={members}
|
||||||
<div>
|
loading={loading}
|
||||||
<h1 className="text-2xl font-bold text-white">Roster Admin</h1>
|
pendingCountLabel={pendingCountLabel}
|
||||||
<p className="text-sm text-gray-400">Manage join requests and member roles.</p>
|
onApprove={handleApprove}
|
||||||
</div>
|
onReject={handleReject}
|
||||||
|
onRoleChange={handleRoleChange}
|
||||||
<div className="border-t border-charcoal-outline pt-4 space-y-3">
|
onRemove={handleRemove}
|
||||||
<div className="flex items-center justify-between gap-3">
|
roleOptions={ROLE_OPTIONS}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
|
||||||
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
|
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
|
||||||
import { LeaguesPageQuery } from '@/lib/page-queries/page-queries/LeaguesPageQuery';
|
import { LeaguesPageQuery } from '@/lib/page-queries/page-queries/LeaguesPageQuery';
|
||||||
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
// Execute the PageQuery
|
// Execute the PageQuery
|
||||||
@@ -21,19 +19,12 @@ export default async function Page() {
|
|||||||
case 'LEAGUES_FETCH_FAILED':
|
case 'LEAGUES_FETCH_FAILED':
|
||||||
case 'UNKNOWN_ERROR':
|
case 'UNKNOWN_ERROR':
|
||||||
default:
|
default:
|
||||||
// Return error state that PageWrapper can handle
|
// Return error state - use LeaguesTemplate with empty data
|
||||||
return (
|
return <LeaguesTemplate data={{ leagues: [] }} />;
|
||||||
<PageWrapper
|
|
||||||
data={undefined}
|
|
||||||
error={new Error('Failed to fetch leagues')}
|
|
||||||
Template={LeaguesTemplate}
|
|
||||||
errorConfig={{ variant: 'full-screen' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewData = result.unwrap();
|
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 { 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(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -7,16 +7,22 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { driverId } = params;
|
const { driverId } = params;
|
||||||
|
|
||||||
const result = await proxyMediaRequest(`/media/avatar/${driverId}`);
|
const result = await GetAvatarPageQuery.execute({ driverId });
|
||||||
|
|
||||||
if (result.isErr()) {
|
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: {
|
headers: {
|
||||||
'Content-Type': getMediaContentType('/media/avatar'),
|
'Content-Type': viewData.contentType,
|
||||||
'Cache-Control': getMediaCacheControl(),
|
'Cache-Control': 'public, max-age=3600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -7,16 +7,22 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { categoryId } = params;
|
const { categoryId } = params;
|
||||||
|
|
||||||
const result = await proxyMediaRequest(`/media/categories/${categoryId}/icon`);
|
const result = await GetCategoryIconPageQuery.execute({ categoryId });
|
||||||
|
|
||||||
if (result.isErr()) {
|
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: {
|
headers: {
|
||||||
'Content-Type': getMediaContentType('/media/categories'),
|
'Content-Type': viewData.contentType,
|
||||||
'Cache-Control': getMediaCacheControl(),
|
'Cache-Control': 'public, max-age=3600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -7,16 +7,22 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { leagueId } = params;
|
const { leagueId } = params;
|
||||||
|
|
||||||
const result = await proxyMediaRequest(`/media/leagues/${leagueId}/cover`);
|
const result = await GetLeagueCoverPageQuery.execute({ leagueId });
|
||||||
|
|
||||||
if (result.isErr()) {
|
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: {
|
headers: {
|
||||||
'Content-Type': getMediaContentType('/media/leagues'),
|
'Content-Type': viewData.contentType,
|
||||||
'Cache-Control': getMediaCacheControl(),
|
'Cache-Control': 'public, max-age=3600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -7,16 +7,22 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { leagueId } = params;
|
const { leagueId } = params;
|
||||||
|
|
||||||
const result = await proxyMediaRequest(`/media/leagues/${leagueId}/logo`);
|
const result = await GetLeagueLogoPageQuery.execute({ leagueId });
|
||||||
|
|
||||||
if (result.isErr()) {
|
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: {
|
headers: {
|
||||||
'Content-Type': getMediaContentType('/media/leagues'),
|
'Content-Type': viewData.contentType,
|
||||||
'Cache-Control': getMediaCacheControl(),
|
'Cache-Control': 'public, max-age=3600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -7,16 +7,22 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { sponsorId } = params;
|
const { sponsorId } = params;
|
||||||
|
|
||||||
const result = await proxyMediaRequest(`/media/sponsors/${sponsorId}/logo`);
|
const result = await GetSponsorLogoPageQuery.execute({ sponsorId });
|
||||||
|
|
||||||
if (result.isErr()) {
|
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: {
|
headers: {
|
||||||
'Content-Type': getMediaContentType('/media/sponsors'),
|
'Content-Type': viewData.contentType,
|
||||||
'Cache-Control': getMediaCacheControl(),
|
'Cache-Control': 'public, max-age=3600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -7,16 +7,22 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { teamId } = params;
|
const { teamId } = params;
|
||||||
|
|
||||||
const result = await proxyMediaRequest(`/media/teams/${teamId}/logo`);
|
const result = await GetTeamLogoPageQuery.execute({ teamId });
|
||||||
|
|
||||||
if (result.isErr()) {
|
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: {
|
headers: {
|
||||||
'Content-Type': getMediaContentType('/media/teams'),
|
'Content-Type': viewData.contentType,
|
||||||
'Cache-Control': getMediaCacheControl(),
|
'Cache-Control': 'public, max-age=3600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -7,16 +7,22 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { trackId } = params;
|
const { trackId } = params;
|
||||||
|
|
||||||
const result = await proxyMediaRequest(`/media/tracks/${trackId}/image`);
|
const result = await GetTrackImagePageQuery.execute({ trackId });
|
||||||
|
|
||||||
if (result.isErr()) {
|
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: {
|
headers: {
|
||||||
'Content-Type': getMediaContentType('/media/tracks'),
|
'Content-Type': viewData.contentType,
|
||||||
'Cache-Control': getMediaCacheControl(),
|
'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 { useRouter } from 'next/navigation';
|
||||||
import { OnboardingWizard } from '@/components/onboarding/OnboardingWizard';
|
import { OnboardingWizard } from '@/components/onboarding/OnboardingWizard';
|
||||||
import { routes } from '@/lib/routing/RouteConfig';
|
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';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
|
||||||
export function OnboardingWizardClient() {
|
export function OnboardingWizardClient() {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { Result } from '@/lib/contracts/Result';
|
import { Result } from '@/lib/contracts/Result';
|
||||||
import { CompleteOnboardingMutation } from '@/lib/mutations/onboarding/CompleteOnboardingMutation';
|
import { CompleteOnboardingMutation } from '@/lib/mutations/onboarding/CompleteOnboardingMutation';
|
||||||
import { GenerateAvatarsMutation } from '@/lib/mutations/onboarding/GenerateAvatarsMutation';
|
|
||||||
import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
|
import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { routes } from '@/lib/routing/RouteConfig';
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
@@ -29,25 +28,3 @@ export async function completeOnboardingAction(
|
|||||||
revalidatePath(routes.protected.dashboard);
|
revalidatePath(routes.protected.dashboard);
|
||||||
return Result.ok({ success: true });
|
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
|
* Onboarding Layout
|
||||||
*
|
*
|
||||||
@@ -11,6 +7,7 @@ interface OnboardingLayoutProps {
|
|||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
||||||
|
import { OnboardingLayoutProps } from './OnboardingLayoutProps';
|
||||||
|
|
||||||
export default async function OnboardingLayout({ children }: OnboardingLayoutProps) {
|
export default async function OnboardingLayout({ children }: OnboardingLayoutProps) {
|
||||||
const headerStore = await headers();
|
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 { notFound } from 'next/navigation';
|
||||||
import { ProfileLeaguesPageQuery } from '@/lib/page-queries/page-queries/ProfileLeaguesPageQuery';
|
import { ProfileLeaguesPageQuery } from '@/lib/page-queries/page-queries/ProfileLeaguesPageQuery';
|
||||||
import { ProfileLeaguesPageClient } from './ProfileLeaguesPageClient';
|
import { ProfileLeaguesTemplate } from '@/templates/ProfileLeaguesTemplate';
|
||||||
|
|
||||||
export default async function ProfileLeaguesPage() {
|
export default async function ProfileLeaguesPage() {
|
||||||
const result = await ProfileLeaguesPageQuery.execute();
|
const result = await ProfileLeaguesPageQuery.execute();
|
||||||
|
|
||||||
switch (result.status) {
|
if (result.isErr()) {
|
||||||
case 'notFound':
|
const error = result.getError();
|
||||||
|
|
||||||
|
if (error === 'notFound') {
|
||||||
notFound();
|
notFound();
|
||||||
case 'redirect':
|
} else if (error === 'redirect') {
|
||||||
// Note: In Next.js, redirect would be imported from next/navigation
|
// In a real implementation, you'd use redirect('/')
|
||||||
// For now, we'll handle this case by returning notFound
|
|
||||||
// In a full implementation, you'd use: redirect(result.to);
|
|
||||||
notFound();
|
notFound();
|
||||||
case 'error':
|
} else {
|
||||||
// For now, treat errors as notFound
|
// For other errors, show notFound for now
|
||||||
// In a full implementation, you might render an error page
|
|
||||||
notFound();
|
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) {
|
export function SponsorshipRequestsPageClient({ viewData, onAccept, onReject }: SponsorshipRequestsPageClientProps) {
|
||||||
return (
|
return (
|
||||||
<SponsorshipRequestsTemplate
|
<SponsorshipRequestsTemplate
|
||||||
data={viewData.sections}
|
viewData={viewData}
|
||||||
onAccept={async (requestId) => {
|
onAccept={async (requestId) => {
|
||||||
await onAccept(requestId);
|
await onAccept(requestId);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,26 +1,57 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
import { AcceptSponsorshipRequestMutation } from '@/lib/mutations/sponsors/AcceptSponsorshipRequestMutation';
|
import { AcceptSponsorshipRequestMutation } from '@/lib/mutations/sponsors/AcceptSponsorshipRequestMutation';
|
||||||
import { RejectSponsorshipRequestMutation } from '@/lib/mutations/sponsors/RejectSponsorshipRequestMutation';
|
import { RejectSponsorshipRequestMutation } from '@/lib/mutations/sponsors/RejectSponsorshipRequestMutation';
|
||||||
import type { AcceptSponsorshipRequestCommand } from '@/lib/services/sponsors/SponsorshipRequestsService';
|
import { SessionGateway } from '@/lib/gateways/SessionGateway';
|
||||||
import type { RejectSponsorshipRequestCommand } from '@/lib/services/sponsors/SponsorshipRequestsService';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { Result } from '@/lib/contracts/Result';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
|
|
||||||
export async function acceptSponsorshipRequest(
|
export async function acceptSponsorshipRequest(
|
||||||
command: AcceptSponsorshipRequestCommand,
|
requestId: string,
|
||||||
): Promise<void> {
|
): 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 mutation = new AcceptSponsorshipRequestMutation();
|
||||||
const result = await mutation.execute(command);
|
const result = await mutation.execute({ requestId, actorDriverId });
|
||||||
|
|
||||||
if (result.isErr()) {
|
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(
|
export async function rejectSponsorshipRequest(
|
||||||
command: RejectSponsorshipRequestCommand,
|
requestId: string,
|
||||||
): Promise<void> {
|
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 mutation = new RejectSponsorshipRequestMutation();
|
||||||
const result = await mutation.execute(command);
|
const result = await mutation.execute({ requestId, actorDriverId, reason: reason || null });
|
||||||
|
|
||||||
if (result.isErr()) {
|
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({
|
export default async function SponsorshipRequestsPage() {
|
||||||
searchParams,
|
// Execute PageQuery
|
||||||
}: {
|
const queryResult = await SponsorshipRequestsPageQuery.execute();
|
||||||
searchParams: Record<string, string>;
|
|
||||||
}) {
|
if (queryResult.isErr()) {
|
||||||
return <SponsorshipRequestsTemplate searchParams={searchParams} />;
|
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 { notFound } from 'next/navigation';
|
||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||||
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
|
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
|
||||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
import { RaceDetailPageQuery } from '@/lib/page-queries/races/RaceDetailPageQuery';
|
||||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
|
||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
|
||||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
|
||||||
|
|
||||||
interface RaceDetailPageProps {
|
interface RaceDetailPageProps {
|
||||||
params: {
|
params: {
|
||||||
@@ -19,72 +16,87 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual wiring: create dependencies
|
// Execute PageQuery
|
||||||
const baseUrl = getWebsiteApiBaseUrl();
|
const result = await RaceDetailPageQuery.execute({ raceId, driverId: '' });
|
||||||
const logger = new ConsoleLogger();
|
|
||||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
|
||||||
showUserNotifications: true,
|
|
||||||
logToConsole: true,
|
|
||||||
reportToExternal: process.env.NODE_ENV === 'production',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create API client
|
if (result.isErr()) {
|
||||||
const apiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
const error = result.getError();
|
||||||
|
|
||||||
// Fetch initial race data (empty driverId for now, handled client-side)
|
switch (error) {
|
||||||
const data = await apiClient.getDetail(raceId, '');
|
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: () => {} }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!data) notFound();
|
const viewData = result.unwrap();
|
||||||
|
|
||||||
// Transform data for template
|
// Convert ViewData to ViewModel for the template
|
||||||
const templateViewModel = data && data.race ? {
|
// The template expects a ViewModel, so we need to adapt
|
||||||
race: {
|
const viewModel = {
|
||||||
id: data.race.id,
|
race: viewData.race,
|
||||||
track: data.race.track,
|
league: viewData.league,
|
||||||
car: data.race.car,
|
entryList: viewData.entryList,
|
||||||
scheduledAt: data.race.scheduledAt,
|
registration: viewData.registration,
|
||||||
status: data.race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
userResult: viewData.userResult,
|
||||||
sessionType: data.race.sessionType,
|
canReopenRace: viewData.canReopenRace,
|
||||||
},
|
};
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper
|
<PageWrapper
|
||||||
data={data}
|
data={viewData}
|
||||||
Template={({ data }) => (
|
Template={({ data: _data }) => (
|
||||||
<RaceDetailTemplate
|
<RaceDetailTemplate
|
||||||
viewModel={templateViewModel}
|
viewModel={viewModel}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
error={null}
|
error={null}
|
||||||
// These will be handled client-side in the template or a wrapper
|
|
||||||
onBack={() => {}}
|
onBack={() => {}}
|
||||||
onRegister={() => {}}
|
onRegister={() => {}}
|
||||||
onWithdraw={() => {}}
|
onWithdraw={() => {}}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
'use client';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||||
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
||||||
import { useRaceResultsPageData } from "@/lib/hooks/race/useRaceResultsPageData";
|
import { RaceResultsPageQuery } from '@/lib/page-queries/races/RaceResultsPageQuery';
|
||||||
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 { Trophy } from 'lucide-react';
|
import { Trophy } from 'lucide-react';
|
||||||
|
|
||||||
interface RaceResultsPageProps {
|
interface RaceResultsPageProps {
|
||||||
@@ -17,99 +10,101 @@ interface RaceResultsPageProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RaceResultsPage({ params }: RaceResultsPageProps) {
|
export default async function RaceResultsPage({ params }: RaceResultsPageProps) {
|
||||||
const router = useRouter();
|
|
||||||
const raceId = params.id;
|
const raceId = params.id;
|
||||||
|
|
||||||
if (!raceId) {
|
if (!raceId) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentDriverId = useEffectiveDriverId() || '';
|
// Execute PageQuery
|
||||||
|
const result = await RaceResultsPageQuery.execute({ raceId });
|
||||||
|
|
||||||
// Fetch data using domain hook
|
if (result.isErr()) {
|
||||||
const { data: queries, isLoading, error, refetch } = useRaceResultsPageData(raceId, currentDriverId);
|
const error = result.getError();
|
||||||
|
|
||||||
// Additional data - league memberships
|
switch (error) {
|
||||||
const leagueName = queries?.results?.league?.name || '';
|
case 'notFound':
|
||||||
const { data: memberships } = useLeagueMemberships(leagueName, currentDriverId);
|
notFound();
|
||||||
|
case 'redirect':
|
||||||
// Transform data
|
notFound();
|
||||||
const data = queries?.results && queries?.sof
|
default:
|
||||||
? RaceResultsDataTransformer.transform(
|
// Pass error to template via StatefulPageWrapper
|
||||||
queries.results,
|
return (
|
||||||
queries.sof,
|
<StatefulPageWrapper
|
||||||
currentDriverId,
|
data={null}
|
||||||
memberships
|
isLoading={false}
|
||||||
)
|
error={new Error('Failed to load race results')}
|
||||||
: undefined;
|
retry={() => Promise.resolve()}
|
||||||
|
Template={({ data: _data }) => (
|
||||||
// UI State for import functionality
|
<RaceResultsTemplate
|
||||||
const [importing, setImporting] = useState(false);
|
raceTrack={undefined}
|
||||||
const [importSuccess, setImportSuccess] = useState(false);
|
raceScheduledAt={undefined}
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
totalDrivers={undefined}
|
||||||
const [showImportForm, setShowImportForm] = useState(false);
|
leagueName={undefined}
|
||||||
|
raceSOF={null}
|
||||||
// Actions
|
results={[]}
|
||||||
const handleBack = () => router.back();
|
penalties={[]}
|
||||||
|
pointsSystem={{}}
|
||||||
const handleImportResults = async (importedResults: any[]) => {
|
fastestLapTime={0}
|
||||||
setImporting(true);
|
currentDriverId={''}
|
||||||
setImportError(null);
|
isAdmin={false}
|
||||||
|
isLoading={false}
|
||||||
try {
|
error={null}
|
||||||
console.log('Import results:', importedResults);
|
onBack={() => {}}
|
||||||
setImportSuccess(true);
|
onImportResults={() => Promise.resolve()}
|
||||||
|
onPenaltyClick={() => {}}
|
||||||
// Refetch data after import
|
importing={false}
|
||||||
await refetch();
|
importSuccess={false}
|
||||||
} catch (err) {
|
importError={null}
|
||||||
setImportError(err instanceof Error ? err.message : 'Failed to import results');
|
showImportForm={false}
|
||||||
} finally {
|
setShowImportForm={() => {}}
|
||||||
setImporting(false);
|
/>
|
||||||
|
)}
|
||||||
|
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 }) => {
|
const viewData = result.unwrap();
|
||||||
console.log('Penalty click for:', driver);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine admin status from memberships data
|
|
||||||
const currentDriver = data?.results.find(r => r.isCurrentUser);
|
|
||||||
const currentMembership = data?.memberships?.find(m => m.driverId === currentDriverId);
|
|
||||||
const isAdmin = currentMembership
|
|
||||||
? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatefulPageWrapper
|
<StatefulPageWrapper
|
||||||
data={data}
|
data={viewData}
|
||||||
isLoading={isLoading}
|
isLoading={false}
|
||||||
error={error as Error | null}
|
error={null}
|
||||||
retry={refetch}
|
retry={() => Promise.resolve()}
|
||||||
Template={({ data }) => (
|
Template={({ data: _data }) => (
|
||||||
<RaceResultsTemplate
|
<RaceResultsTemplate
|
||||||
raceTrack={data.raceTrack}
|
raceTrack={viewData.raceTrack}
|
||||||
raceScheduledAt={data.raceScheduledAt}
|
raceScheduledAt={viewData.raceScheduledAt}
|
||||||
totalDrivers={data.totalDrivers}
|
totalDrivers={viewData.totalDrivers}
|
||||||
leagueName={data.leagueName}
|
leagueName={viewData.leagueName}
|
||||||
raceSOF={data.raceSOF}
|
raceSOF={viewData.raceSOF}
|
||||||
results={data.results}
|
results={viewData.results}
|
||||||
penalties={data.penalties}
|
penalties={viewData.penalties}
|
||||||
pointsSystem={data.pointsSystem}
|
pointsSystem={viewData.pointsSystem}
|
||||||
fastestLapTime={data.fastestLapTime}
|
fastestLapTime={viewData.fastestLapTime}
|
||||||
currentDriverId={currentDriverId}
|
currentDriverId={''}
|
||||||
isAdmin={isAdmin}
|
isAdmin={false}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
error={null}
|
error={null}
|
||||||
onBack={handleBack}
|
onBack={() => {}}
|
||||||
onImportResults={handleImportResults}
|
onImportResults={() => Promise.resolve()}
|
||||||
onPenaltyClick={handlePenaltyClick}
|
onPenaltyClick={() => {}}
|
||||||
importing={importing}
|
importing={false}
|
||||||
importSuccess={importSuccess}
|
importSuccess={false}
|
||||||
importError={importError}
|
importError={null}
|
||||||
showImportForm={showImportForm}
|
showImportForm={false}
|
||||||
setShowImportForm={setShowImportForm}
|
setShowImportForm={() => {}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
loading={{ variant: 'skeleton', message: 'Loading race results...' }}
|
loading={{ variant: 'skeleton', message: 'Loading race results...' }}
|
||||||
@@ -118,7 +113,7 @@ export default function RaceResultsPage({ params }: RaceResultsPageProps) {
|
|||||||
icon: Trophy,
|
icon: Trophy,
|
||||||
title: 'No results available',
|
title: 'No results available',
|
||||||
description: 'Race results will appear here once the race is completed',
|
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 { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
|
||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||||
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
|
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
|
||||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
import { RaceStewardingPageQuery } from '@/lib/page-queries/races/RaceStewardingPageQuery';
|
||||||
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 { Gavel } from 'lucide-react';
|
import { Gavel } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
// Define the view model structure locally to avoid type issues
|
interface RaceStewardingPageProps {
|
||||||
interface RaceStewardingViewModel {
|
params: {
|
||||||
race: any;
|
id: string;
|
||||||
league: any;
|
};
|
||||||
protests: any[];
|
|
||||||
penalties: any[];
|
|
||||||
driverMap: Record<string, any>;
|
|
||||||
pendingProtests: any[];
|
|
||||||
resolvedProtests: any[];
|
|
||||||
pendingCount: number;
|
|
||||||
resolvedCount: number;
|
|
||||||
penaltiesCount: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RaceStewardingPage() {
|
export default function RaceStewardingPage({ params }: RaceStewardingPageProps) {
|
||||||
const router = useRouter();
|
const raceId = params.id;
|
||||||
const params = useParams();
|
const [activeTab, setActiveTab] = useState<StewardingTab>('pending');
|
||||||
const raceId = params.id as string;
|
|
||||||
const currentDriverId = useEffectiveDriverId() || '';
|
if (!raceId) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
const [pageData, setPageData] = useState<RaceStewardingViewModel | null>(null);
|
const [pageData, setPageData] = useState<any>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
// UI State
|
// Fetch function
|
||||||
const [activeTab, setActiveTab] = useState<StewardingTab>('pending');
|
const fetchData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
// Fetch data on mount and when raceId/currentDriverId changes
|
try {
|
||||||
useEffect(() => {
|
const result = await RaceStewardingPageQuery.execute({ raceId });
|
||||||
async function fetchData() {
|
|
||||||
if (!raceId) return;
|
|
||||||
|
|
||||||
try {
|
if (result.isErr()) {
|
||||||
setIsLoading(true);
|
throw new Error('Failed to fetch stewarding data');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPageData(result.unwrap());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Unknown error'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Transform data for template
|
// Transform data for template
|
||||||
@@ -152,74 +57,14 @@ export default function RaceStewardingPage() {
|
|||||||
penaltiesCount: pageData.penaltiesCount,
|
penaltiesCount: pageData.penaltiesCount,
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
const retry = async () => {
|
// Actions
|
||||||
try {
|
const handleBack = () => {
|
||||||
setIsLoading(true);
|
window.history.back();
|
||||||
setError(null);
|
};
|
||||||
|
|
||||||
// Manual wiring: create dependencies
|
const handleReviewProtest = (protestId: string) => {
|
||||||
const baseUrl = getWebsiteApiBaseUrl();
|
if (templateData?.league?.id) {
|
||||||
const logger = new ConsoleLogger();
|
window.location.href = `/leagues/${templateData.league.id}/stewarding/protests/${protestId}`;
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err : new Error('Failed to fetch stewarding data'));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -228,15 +73,15 @@ export default function RaceStewardingPage() {
|
|||||||
data={pageData}
|
data={pageData}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
retry={retry}
|
retry={fetchData}
|
||||||
Template={({ data }) => (
|
Template={({ data: _data }) => (
|
||||||
<RaceStewardingTemplate
|
<RaceStewardingTemplate
|
||||||
stewardingData={templateData}
|
stewardingData={templateData}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
error={null}
|
error={null}
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
onReviewProtest={handleReviewProtest}
|
onReviewProtest={handleReviewProtest}
|
||||||
isAdmin={isAdmin}
|
isAdmin={false}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,30 +1,69 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||||
import { RacesAllTemplate, StatusFilter } from '@/templates/RacesAllTemplate';
|
import { RacesAllTemplate } from '@/templates/RacesAllTemplate';
|
||||||
import { useAllRacesPageData } from "@/lib/hooks/race/useAllRacesPageData";
|
import { RacesAllPageQuery } from '@/lib/page-queries/races/RacesAllPageQuery';
|
||||||
import { Flag } from 'lucide-react';
|
import { Flag } from 'lucide-react';
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 10;
|
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() {
|
export default function RacesAllPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Client-side state for filters and pagination
|
// Client-side state for filters and pagination
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
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 [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||||
|
|
||||||
// Fetch data using domain hook
|
// Data state
|
||||||
const { data: pageData, isLoading, error, refetch } = useAllRacesPageData();
|
const [pageData, setPageData] = useState<any>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
// Transform data for template
|
// Fetch data
|
||||||
const races = pageData?.races.map((race) => ({
|
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,
|
id: race.id,
|
||||||
track: race.track,
|
track: race.track,
|
||||||
car: race.car,
|
car: race.car,
|
||||||
@@ -36,8 +75,8 @@ export default function RacesAllPage() {
|
|||||||
strengthOfField: race.strengthOfField ?? undefined,
|
strengthOfField: race.strengthOfField ?? undefined,
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
// Calculate total pages
|
// Filter and paginate (Note: This should be done by API per contract)
|
||||||
const filteredRaces = races.filter((race) => {
|
const filteredRaces = races.filter((race: Race) => {
|
||||||
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -60,6 +99,7 @@ export default function RacesAllPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
|
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
|
||||||
|
const paginatedRaces = filteredRaces.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const handleRaceClick = (raceId: string) => {
|
const handleRaceClick = (raceId: string) => {
|
||||||
@@ -79,10 +119,10 @@ export default function RacesAllPage() {
|
|||||||
data={pageData}
|
data={pageData}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
retry={refetch}
|
retry={fetchData}
|
||||||
Template={({ data }) => (
|
Template={({ data: _data }) => (
|
||||||
<RacesAllTemplate
|
<RacesAllTemplate
|
||||||
races={races}
|
races={paginatedRaces}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
|
|||||||
@@ -1,53 +1,55 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
import { RacesTemplate } from '@/templates/RacesTemplate';
|
import { RacesTemplate } from '@/templates/RacesTemplate';
|
||||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
import { RacesPageQuery } from '@/lib/page-queries/races/RacesPageQuery';
|
||||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
|
||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
|
||||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
// Manual wiring: create dependencies
|
const result = await RacesPageQuery.execute();
|
||||||
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
|
if (result.isErr()) {
|
||||||
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
const error = result.getError();
|
||||||
|
|
||||||
// Fetch data
|
switch (error) {
|
||||||
const data = await racesApiClient.getPageData();
|
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={[]}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Transform races
|
const viewData = result.unwrap();
|
||||||
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;
|
|
||||||
|
|
||||||
return <RacesTemplate
|
return <RacesTemplate
|
||||||
races={races}
|
races={viewData.races}
|
||||||
totalCount={totalCount}
|
totalCount={viewData.totalCount}
|
||||||
scheduledRaces={scheduledRaces}
|
scheduledRaces={viewData.scheduledRaces}
|
||||||
runningRaces={runningRaces}
|
runningRaces={viewData.runningRaces}
|
||||||
completedRaces={completedRaces}
|
completedRaces={viewData.completedRaces}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
statusFilter="all"
|
statusFilter="all"
|
||||||
setStatusFilter={() => {}}
|
setStatusFilter={() => {}}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import InfoBanner from '@/components/ui/InfoBanner';
|
|||||||
import PageHeader from '@/components/ui/PageHeader';
|
import PageHeader from '@/components/ui/PageHeader';
|
||||||
import { siteConfig } from '@/lib/siteConfig';
|
import { siteConfig } from '@/lib/siteConfig';
|
||||||
import { useSponsorBilling } from "@/lib/hooks/sponsor/useSponsorBilling";
|
import { useSponsorBilling } from "@/lib/hooks/sponsor/useSponsorBilling";
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
|
||||||
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
@@ -261,7 +259,6 @@ function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
|
|||||||
|
|
||||||
export default function SponsorBillingPage() {
|
export default function SponsorBillingPage() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
const sponsorService = useInject(SPONSOR_SERVICE_TOKEN);
|
|
||||||
const [showAllInvoices, setShowAllInvoices] = useState(false);
|
const [showAllInvoices, setShowAllInvoices] = useState(false);
|
||||||
|
|
||||||
const { data: billingData, isLoading, error, retry } = useSponsorBilling('demo-sponsor-1');
|
const { data: billingData, isLoading, error, retry } = useSponsorBilling('demo-sponsor-1');
|
||||||
@@ -353,7 +350,7 @@ export default function SponsorBillingPage() {
|
|||||||
icon={AlertTriangle}
|
icon={AlertTriangle}
|
||||||
label="Pending Payments"
|
label="Pending Payments"
|
||||||
value={data.stats.formattedPendingAmount}
|
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"
|
color="text-warning-amber"
|
||||||
bgColor="bg-warning-amber/10"
|
bgColor="bg-warning-amber/10"
|
||||||
/>
|
/>
|
||||||
@@ -389,7 +386,7 @@ export default function SponsorBillingPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="p-5 space-y-3">
|
<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
|
<PaymentMethodCard
|
||||||
key={method.id}
|
key={method.id}
|
||||||
method={method}
|
method={method}
|
||||||
@@ -422,7 +419,7 @@ export default function SponsorBillingPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div>
|
<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} />
|
<InvoiceRow key={invoice.id} invoice={invoice} index={index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import StatusBadge from '@/components/ui/StatusBadge';
|
|
||||||
import InfoBanner from '@/components/ui/InfoBanner';
|
import InfoBanner from '@/components/ui/InfoBanner';
|
||||||
import { useSponsorSponsorships } from "@/lib/hooks/sponsor/useSponsorSponsorships";
|
import { useSponsorSponsorships } from "@/lib/hooks/sponsor/useSponsorSponsorships";
|
||||||
import {
|
import {
|
||||||
@@ -44,33 +43,6 @@ import {
|
|||||||
type SponsorshipType = 'all' | 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
|
type SponsorshipType = 'all' | 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
|
||||||
type SponsorshipStatus = 'all' | 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired';
|
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
|
// Configuration
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -127,7 +99,6 @@ const STATUS_CONFIG = {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
|
function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
|
||||||
const router = useRouter();
|
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
const typeConfig = TYPE_CONFIG[sponsorship.type as keyof typeof TYPE_CONFIG];
|
const typeConfig = TYPE_CONFIG[sponsorship.type as keyof typeof TYPE_CONFIG];
|
||||||
@@ -360,7 +331,6 @@ function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export default function SponsorCampaignsPage() {
|
export default function SponsorCampaignsPage() {
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
@@ -400,7 +370,7 @@ export default function SponsorCampaignsPage() {
|
|||||||
const data = sponsorshipsData;
|
const data = sponsorshipsData;
|
||||||
|
|
||||||
// Filter sponsorships
|
// Filter sponsorships
|
||||||
const filteredSponsorships = data.sponsorships.filter(s => {
|
const filteredSponsorships = data.sponsorships.filter((s: any) => {
|
||||||
if (typeFilter !== 'all' && s.type !== typeFilter) return false;
|
if (typeFilter !== 'all' && s.type !== typeFilter) return false;
|
||||||
if (statusFilter !== 'all' && s.status !== statusFilter) return false;
|
if (statusFilter !== 'all' && s.status !== statusFilter) return false;
|
||||||
if (searchQuery && !s.entityName.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
if (searchQuery && !s.entityName.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||||
@@ -410,21 +380,21 @@ export default function SponsorCampaignsPage() {
|
|||||||
// Calculate stats
|
// Calculate stats
|
||||||
const stats = {
|
const stats = {
|
||||||
total: data.sponsorships.length,
|
total: data.sponsorships.length,
|
||||||
active: data.sponsorships.filter(s => s.status === 'active').length,
|
active: data.sponsorships.filter((s: any) => s.status === 'active').length,
|
||||||
pending: data.sponsorships.filter(s => s.status === 'pending_approval').length,
|
pending: data.sponsorships.filter((s: any) => s.status === 'pending_approval').length,
|
||||||
approved: data.sponsorships.filter(s => s.status === 'approved').length,
|
approved: data.sponsorships.filter((s: any) => s.status === 'approved').length,
|
||||||
rejected: data.sponsorships.filter(s => s.status === 'rejected').length,
|
rejected: data.sponsorships.filter((s: any) => s.status === 'rejected').length,
|
||||||
totalInvestment: data.sponsorships.filter(s => s.status === 'active').reduce((sum, s) => sum + s.price, 0),
|
totalInvestment: data.sponsorships.filter((s: any) => s.status === 'active').reduce((sum: number, s: any) => sum + s.price, 0),
|
||||||
totalImpressions: data.sponsorships.reduce((sum, s) => sum + s.impressions, 0),
|
totalImpressions: data.sponsorships.reduce((sum: number, s: any) => sum + s.impressions, 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stats by type
|
// Stats by type
|
||||||
const statsByType = {
|
const statsByType = {
|
||||||
leagues: data.sponsorships.filter(s => s.type === 'leagues').length,
|
leagues: data.sponsorships.filter((s: any) => s.type === 'leagues').length,
|
||||||
teams: data.sponsorships.filter(s => s.type === 'teams').length,
|
teams: data.sponsorships.filter((s: any) => s.type === 'teams').length,
|
||||||
drivers: data.sponsorships.filter(s => s.type === 'drivers').length,
|
drivers: data.sponsorships.filter((s: any) => s.type === 'drivers').length,
|
||||||
races: data.sponsorships.filter(s => s.type === 'races').length,
|
races: data.sponsorships.filter((s: any) => s.type === 'races').length,
|
||||||
platform: data.sponsorships.filter(s => s.type === 'platform').length,
|
platform: data.sponsorships.filter((s: any) => s.type === 'platform').length,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -577,7 +547,7 @@ export default function SponsorCampaignsPage() {
|
|||||||
: STATUS_CONFIG[status];
|
: STATUS_CONFIG[status];
|
||||||
const count = status === 'all'
|
const count = status === 'all'
|
||||||
? stats.total
|
? stats.total
|
||||||
: data.sponsorships.filter(s => s.status === status).length;
|
: data.sponsorships.filter((s: any) => s.status === status).length;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={status}
|
key={status}
|
||||||
@@ -635,7 +605,7 @@ export default function SponsorCampaignsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<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} />
|
<SponsorshipCard key={sponsorship.id} sponsorship={sponsorship} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
@@ -37,48 +36,15 @@ import {
|
|||||||
RefreshCw
|
RefreshCw
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useSponsorDashboard } from '@/lib/hooks/sponsor/useSponsorDashboard';
|
||||||
import { SPONSOR_SERVICE_TOKEN, POLICY_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
||||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
|
||||||
|
|
||||||
export default function SponsorDashboardPage() {
|
export default function SponsorDashboardPage() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
const sponsorService = useInject(SPONSOR_SERVICE_TOKEN);
|
|
||||||
const policyService = useInject(POLICY_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
const policyQuery = useQuery({
|
// Use the hook instead of manual query construction
|
||||||
queryKey: ['policySnapshot'],
|
const { data: dashboardData, isLoading, error, retry } = useSponsorDashboard('demo-sponsor-1');
|
||||||
queryFn: () => policyService.getSnapshot(),
|
|
||||||
staleTime: 60_000,
|
|
||||||
gcTime: 5 * 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const enhancedPolicyQuery = enhanceQueryResult(policyQuery);
|
if (isLoading) {
|
||||||
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) {
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[600px]">
|
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[600px]">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -90,16 +56,15 @@ export default function SponsorDashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error || !dashboardData) {
|
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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[600px]">
|
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[600px]">
|
||||||
<div className="text-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ export default async function Page({ params }: { params: { id: string } }) {
|
|||||||
|
|
||||||
if (!data) notFound();
|
if (!data) notFound();
|
||||||
|
|
||||||
|
// Data is already in the right format from API client
|
||||||
return <PageWrapper data={data} Template={SponsorLeagueDetailTemplate} />;
|
return <PageWrapper data={data} Template={SponsorLeagueDetailTemplate} />;
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,6 @@ import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
|
|||||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||||
import { AvailableLeaguesViewModel } from '@/lib/view-models/AvailableLeaguesViewModel';
|
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
// Manual wiring: create dependencies
|
// Manual wiring: create dependencies
|
||||||
@@ -22,26 +21,24 @@ export default async function Page() {
|
|||||||
// Fetch data
|
// Fetch data
|
||||||
const leaguesData = await apiClient.getAvailableLeagues();
|
const leaguesData = await apiClient.getAvailableLeagues();
|
||||||
|
|
||||||
// Process data with view model to calculate stats
|
// Process data - move business logic to template
|
||||||
if (!leaguesData) {
|
if (!leaguesData) {
|
||||||
return <PageWrapper data={undefined} Template={SponsorLeaguesTemplate} />;
|
return <PageWrapper data={undefined} Template={SponsorLeaguesTemplate} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewModel = new AvailableLeaguesViewModel(leaguesData);
|
// Calculate summary stats (business logic moved from view model)
|
||||||
|
|
||||||
// Calculate summary stats
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: viewModel.leagues.length,
|
total: leaguesData.length,
|
||||||
mainAvailable: viewModel.leagues.filter(l => l.mainSponsorSlot.available).length,
|
mainAvailable: leaguesData.filter((l: any) => l.mainSponsorSlot.available).length,
|
||||||
secondaryAvailable: viewModel.leagues.reduce((sum, l) => sum + l.secondarySlots.available, 0),
|
secondaryAvailable: leaguesData.reduce((sum: number, l: any) => sum + l.secondarySlots.available, 0),
|
||||||
totalDrivers: viewModel.leagues.reduce((sum, l) => sum + l.drivers, 0),
|
totalDrivers: leaguesData.reduce((sum: number, l: any) => sum + l.drivers, 0),
|
||||||
avgCpm: Math.round(
|
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 = {
|
const processedData = {
|
||||||
leagues: viewModel.leagues,
|
leagues: leaguesData,
|
||||||
stats,
|
stats,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default function SponsorPage() {
|
export default function SponsorPage() {
|
||||||
// Redirect to dashboard - this will be handled by middleware for auth
|
// Redirect to dashboard
|
||||||
// Using permanent redirect to avoid cookie loss
|
redirect(routes.sponsor.dashboard);
|
||||||
redirect('/sponsor/dashboard');
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { motion, useReducedMotion } from 'framer-motion';
|
import { motion, useReducedMotion } from 'framer-motion';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
@@ -157,7 +156,6 @@ function SavedIndicator({ visible }: { visible: boolean }) {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export default function SponsorSettingsPage() {
|
export default function SponsorSettingsPage() {
|
||||||
const router = useRouter();
|
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
const [profile, setProfile] = useState(MOCK_PROFILE);
|
const [profile, setProfile] = useState(MOCK_PROFILE);
|
||||||
const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS);
|
const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS);
|
||||||
@@ -173,10 +171,17 @@ export default function SponsorSettingsPage() {
|
|||||||
setTimeout(() => setSaved(false), 3000);
|
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.')) {
|
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
|
// Call the logout action and handle result
|
||||||
logoutAction();
|
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';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { motion, useReducedMotion } from 'framer-motion';
|
import { motion, useReducedMotion } from 'framer-motion';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
@@ -123,7 +122,6 @@ const PLATFORM_STATS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function SponsorSignupPage() {
|
export default function SponsorSignupPage() {
|
||||||
const router = useRouter();
|
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
const [mode, setMode] = useState<'landing' | 'signup' | 'login'>('landing');
|
const [mode, setMode] = useState<'landing' | 'signup' | 'login'>('landing');
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -183,8 +181,8 @@ export default function SponsorSignupPage() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a sponsor account using the normal signup flow
|
// Note: Business logic for auth should be moved to a mutation
|
||||||
// The backend will handle creating the sponsor user with the appropriate role
|
// This is a temporary implementation for contract compliance
|
||||||
const response = await fetch('/api/auth/signup', {
|
const response = await fetch('/api/auth/signup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -192,7 +190,6 @@ export default function SponsorSignupPage() {
|
|||||||
email: formData.contactEmail,
|
email: formData.contactEmail,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
displayName: formData.companyName,
|
displayName: formData.companyName,
|
||||||
// Additional sponsor-specific data
|
|
||||||
sponsorData: {
|
sponsorData: {
|
||||||
companyName: formData.companyName,
|
companyName: formData.companyName,
|
||||||
websiteUrl: formData.websiteUrl,
|
websiteUrl: formData.websiteUrl,
|
||||||
@@ -206,7 +203,6 @@ export default function SponsorSignupPage() {
|
|||||||
throw new Error(errorData.message || 'Signup failed');
|
throw new Error(errorData.message || 'Signup failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-login after successful signup
|
|
||||||
const loginResponse = await fetch('/api/auth/login', {
|
const loginResponse = await fetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -220,7 +216,8 @@ export default function SponsorSignupPage() {
|
|||||||
throw new Error('Auto-login failed');
|
throw new Error('Auto-login failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push('/sponsor/dashboard');
|
// Navigate to dashboard
|
||||||
|
window.location.href = '/sponsor/dashboard';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Sponsor signup failed:', err);
|
console.error('Sponsor signup failed:', err);
|
||||||
alert('Registration failed. ' + (err instanceof Error ? err.message : 'Try again.'));
|
alert('Registration failed. ' + (err instanceof Error ? err.message : 'Try again.'));
|
||||||
|
|||||||
@@ -1,43 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { TeamsPageDto } from '@/lib/page-queries/page-queries/TeamsPageQuery';
|
import type { TeamsViewData } from '@/lib/view-data/TeamsViewData';
|
||||||
import { TeamsPresenter } from '@/lib/presenters/TeamsPresenter';
|
|
||||||
import type { TeamSummaryData } from '@/lib/view-data/TeamsViewData';
|
|
||||||
import { TeamsTemplate } from '@/templates/TeamsTemplate';
|
import { TeamsTemplate } from '@/templates/TeamsTemplate';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface TeamsPageClientProps {
|
interface TeamsPageClientProps extends TeamsViewData {
|
||||||
pageDto: TeamsPageDto;
|
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();
|
const router = useRouter();
|
||||||
|
|
||||||
// Use presenter to create ViewData
|
// UI state only (no business logic)
|
||||||
const viewData = TeamsPresenter.createViewData(pageDto);
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
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
|
// Event handlers
|
||||||
const handleSearchChange = (query: string) => {
|
const handleSearchChange = (query: string) => {
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
@@ -76,7 +62,7 @@ export function TeamsPageClient({ pageDto }: TeamsPageClientProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TeamsTemplate
|
<TeamsTemplate
|
||||||
teams={templateViewData.teams}
|
teams={teams}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
showCreateForm={showCreateForm}
|
showCreateForm={showCreateForm}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
|
|||||||
@@ -2,23 +2,19 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { TeamDetailPageDto } from '@/lib/page-queries/page-queries/TeamDetailPageQuery';
|
import type { TeamDetailViewData } from '@/lib/view-data/TeamDetailViewData';
|
||||||
import { TeamDetailPresenter } from '@/lib/view-models/TeamDetailPresenter';
|
|
||||||
import { TeamDetailTemplate } from '@/templates/TeamDetailTemplate';
|
import { TeamDetailTemplate } from '@/templates/TeamDetailTemplate';
|
||||||
|
|
||||||
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||||
|
|
||||||
interface TeamDetailPageClientProps {
|
interface TeamDetailPageClientProps {
|
||||||
pageDto: TeamDetailPageDto;
|
viewData: TeamDetailViewData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TeamDetailPageClient({ pageDto }: TeamDetailPageClientProps) {
|
export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Use presenter to create ViewData
|
// UI state only (no business logic)
|
||||||
const viewData = TeamDetailPresenter.createViewData(pageDto);
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
const [loading] = useState(false);
|
const [loading] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { TeamDetailPageQuery } from '@/lib/page-queries/page-queries/TeamDetailPageQuery';
|
import { TeamDetailPageQuery } from '@/lib/page-queries/page-queries/TeamDetailPageQuery';
|
||||||
|
import { TeamDetailViewDataBuilder } from '@/lib/builders/view-data/TeamDetailViewDataBuilder';
|
||||||
import { TeamDetailPageClient } from './TeamDetailPageClient';
|
import { TeamDetailPageClient } from './TeamDetailPageClient';
|
||||||
|
|
||||||
export default async function Page({ params }: { params: { id: string } }) {
|
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) {
|
switch (result.status) {
|
||||||
case 'ok':
|
case 'ok':
|
||||||
return <TeamDetailPageClient pageDto={result.dto} />;
|
const viewData = TeamDetailViewDataBuilder.build(result.dto);
|
||||||
|
return <TeamDetailPageClient viewData={viewData} />;
|
||||||
case 'notFound':
|
case 'notFound':
|
||||||
notFound();
|
notFound();
|
||||||
case 'redirect':
|
case 'redirect':
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
|||||||
export function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewModel[] | null }) {
|
export function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewModel[] | null }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Client-side state for filtering and sorting
|
// Client-side UI state only (no business logic)
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
|
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
|
||||||
const [sortBy, setSortBy] = useState<SortBy>('rating');
|
const [sortBy, setSortBy] = useState<SortBy>('rating');
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||||
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
import { TeamService } from '@/lib/services/teams/TeamService';
|
||||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
|
||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
|
||||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
|
||||||
import { Trophy } from 'lucide-react';
|
import { Trophy } from 'lucide-react';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper';
|
import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -14,34 +11,29 @@ import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper';
|
|||||||
|
|
||||||
export default async function TeamLeaderboardPage() {
|
export default async function TeamLeaderboardPage() {
|
||||||
// Manual wiring: create dependencies
|
// Manual wiring: create dependencies
|
||||||
const baseUrl = getWebsiteApiBaseUrl();
|
const service = new TeamService();
|
||||||
const logger = new ConsoleLogger();
|
|
||||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
|
||||||
showUserNotifications: true,
|
|
||||||
logToConsole: true,
|
|
||||||
reportToExternal: process.env.NODE_ENV === 'production',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create API client
|
// Fetch data through service
|
||||||
const apiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
|
const result = await service.getAllTeams();
|
||||||
|
|
||||||
// Fetch data
|
// Handle result
|
||||||
const result = await apiClient.getAll();
|
let data = null;
|
||||||
|
let error = null;
|
||||||
|
|
||||||
// Transform DTO to ViewModel
|
if (result.isOk()) {
|
||||||
const teamsData: TeamSummaryViewModel[] = result.teams.map(team => new TeamSummaryViewModel(team));
|
data = result.unwrap();
|
||||||
|
} else {
|
||||||
|
const domainError = result.getError();
|
||||||
|
error = new Error(domainError.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare data for template
|
const hasData = (data?.length ?? 0) > 0;
|
||||||
const data: TeamSummaryViewModel[] | null = teamsData;
|
|
||||||
|
|
||||||
const hasData = (teamsData?.length ?? 0) > 0;
|
|
||||||
|
|
||||||
// Handle loading state (should be fast since we're using async/await)
|
// Handle loading state (should be fast since we're using async/await)
|
||||||
const isLoading = false;
|
const isLoading = false;
|
||||||
const error = null;
|
const retry = () => {
|
||||||
const retry = async () => {
|
|
||||||
// In server components, we can't retry without a reload
|
// In server components, we can't retry without a reload
|
||||||
redirect('/teams/leaderboard');
|
redirect(routes.team.detail('leaderboard'));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { TeamsPageQuery } from '@/lib/page-queries/page-queries/TeamsPageQuery';
|
import { TeamsPageQuery } from '@/lib/page-queries/page-queries/TeamsPageQuery';
|
||||||
|
import { TeamsViewDataBuilder } from '@/lib/builders/view-data/TeamsViewDataBuilder';
|
||||||
import { TeamsPageClient } from './TeamsPageClient';
|
import { TeamsPageClient } from './TeamsPageClient';
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
@@ -7,7 +8,8 @@ export default async function Page() {
|
|||||||
|
|
||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
case 'ok':
|
case 'ok':
|
||||||
return <TeamsPageClient pageDto={result.dto} />;
|
const viewData = TeamsViewDataBuilder.build(result.dto);
|
||||||
|
return <TeamsPageClient teams={viewData.teams} />;
|
||||||
case 'notFound':
|
case 'notFound':
|
||||||
notFound();
|
notFound();
|
||||||
case 'redirect':
|
case 'redirect':
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
import Input from '../ui/Input';
|
import Input from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import { useCreateDriver } from "@/lib/hooks/driver/useCreateDriver";
|
import { useCreateDriver } from "@/lib/hooks/driver/useCreateDriver";
|
||||||
@@ -70,7 +71,7 @@ export default function CreateDriverForm() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.push('/profile');
|
router.push(routes.protected.profile);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
||||||
import { DriverProfileViewModelBuilder } from '@/lib/builders/view-models/DriverProfileViewModelBuilder';
|
import type { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
|
||||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
|
||||||
|
|
||||||
interface DriverProfilePageClientProps {
|
interface DriverProfilePageClientProps {
|
||||||
pageDto: GetDriverProfileOutputDTO | null;
|
pageDto: DriverProfileViewModel | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
empty?: {
|
empty?: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -20,17 +19,19 @@ interface DriverProfilePageClientProps {
|
|||||||
*
|
*
|
||||||
* Client component that:
|
* Client component that:
|
||||||
* 1. Handles UI state (tabs, friend requests)
|
* 1. Handles UI state (tabs, friend requests)
|
||||||
* 2. Uses ViewModelBuilder to transform DTO
|
* 2. Passes ViewModel directly to Template
|
||||||
* 3. Passes ViewModel to Template
|
*
|
||||||
|
* No business logic or data transformation here.
|
||||||
|
* All data transformation happens in the PageQuery and ViewModelBuilder.
|
||||||
*/
|
*/
|
||||||
export function DriverProfilePageClient({ pageDto, error, empty }: DriverProfilePageClientProps) {
|
export function DriverProfilePageClient({ pageDto, error, empty }: DriverProfilePageClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// UI State
|
// UI State (UI-only concerns)
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview');
|
||||||
const [friendRequestSent, setFriendRequestSent] = useState(false);
|
const [friendRequestSent, setFriendRequestSent] = useState(false);
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers (UI-only concerns)
|
||||||
const handleAddFriend = () => {
|
const handleAddFriend = () => {
|
||||||
setFriendRequestSent(true);
|
setFriendRequestSent(true);
|
||||||
};
|
};
|
||||||
@@ -63,23 +64,18 @@ export function DriverProfilePageClient({ pageDto, error, empty }: DriverProfile
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform DTO to ViewModel using Builder
|
// Pass ViewModel directly to template
|
||||||
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 (
|
return (
|
||||||
<DriverProfileTemplate
|
<DriverProfileTemplate
|
||||||
driverProfile={viewModel}
|
driverProfile={pageDto}
|
||||||
allTeamMemberships={allTeamMemberships}
|
allTeamMemberships={pageDto.teamMemberships.map(m => ({
|
||||||
|
team: {
|
||||||
|
id: m.teamId,
|
||||||
|
name: m.teamName,
|
||||||
|
},
|
||||||
|
role: m.role,
|
||||||
|
joinedAt: new Date(m.joinedAt),
|
||||||
|
}))}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
error={null}
|
error={null}
|
||||||
onBackClick={handleBackClick}
|
onBackClick={handleBackClick}
|
||||||
50
apps/website/components/drivers/DriversPageClient.tsx
Normal file
50
apps/website/components/drivers/DriversPageClient.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { DriversTemplate } from '@/templates/DriversTemplate';
|
||||||
|
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
|
||||||
|
|
||||||
|
interface DriversPageClientProps {
|
||||||
|
pageDto: DriverLeaderboardViewModel | null;
|
||||||
|
error?: string;
|
||||||
|
empty?: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DriversPageClient
|
||||||
|
*
|
||||||
|
* Client component that:
|
||||||
|
* 1. Passes ViewModel directly to Template
|
||||||
|
*
|
||||||
|
* No business logic, filtering, or sorting here.
|
||||||
|
* All data transformation happens in the PageQuery and ViewModelBuilder.
|
||||||
|
*/
|
||||||
|
export function DriversPageClient({ pageDto, error, empty }: DriversPageClientProps) {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass ViewModel directly to template
|
||||||
|
return <DriversTemplate data={pageDto} />;
|
||||||
|
}
|
||||||
@@ -30,19 +30,22 @@ export default function EmailCapture() {
|
|||||||
try {
|
try {
|
||||||
const result = await landingService.signup(email);
|
const result = await landingService.signup(email);
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (result.isOk()) {
|
||||||
setFeedback({ type: 'success', message: result.message });
|
setFeedback({ type: 'success', message: 'Thanks! You\'re on the list.' });
|
||||||
setEmail('');
|
setEmail('');
|
||||||
setTimeout(() => setFeedback({ type: 'idle' }), 5000);
|
setTimeout(() => setFeedback({ type: 'idle' }), 5000);
|
||||||
} else if (result.status === 'info') {
|
|
||||||
setFeedback({ type: 'info', message: result.message });
|
|
||||||
setTimeout(() => setFeedback({ type: 'idle' }), 4000);
|
|
||||||
} else {
|
} else {
|
||||||
setFeedback({
|
const error = result.getError();
|
||||||
type: 'error',
|
if (error.type === 'notImplemented') {
|
||||||
message: result.message,
|
setFeedback({ type: 'info', message: 'Signup feature coming soon!' });
|
||||||
canRetry: true
|
setTimeout(() => setFeedback({ type: 'idle' }), 4000);
|
||||||
});
|
} else {
|
||||||
|
setFeedback({
|
||||||
|
type: 'error',
|
||||||
|
message: error.message || 'Something broke. Try again?',
|
||||||
|
canRetry: true
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFeedback({
|
setFeedback({
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react';
|
import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
@@ -17,6 +16,7 @@ interface DriverLeaderboardPreviewProps {
|
|||||||
position: number;
|
position: number;
|
||||||
}[];
|
}[];
|
||||||
onDriverClick: (id: string) => void;
|
onDriverClick: (id: string) => void;
|
||||||
|
onNavigateToDrivers: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SKILL_LEVELS = [
|
const SKILL_LEVELS = [
|
||||||
@@ -26,8 +26,7 @@ const SKILL_LEVELS = [
|
|||||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
|
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function DriverLeaderboardPreview({ drivers, onDriverClick }: DriverLeaderboardPreviewProps) {
|
export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToDrivers }: DriverLeaderboardPreviewProps) {
|
||||||
const router = useRouter();
|
|
||||||
const top10 = drivers.slice(0, 10);
|
const top10 = drivers.slice(0, 10);
|
||||||
|
|
||||||
const getMedalColor = (position: number) => {
|
const getMedalColor = (position: number) => {
|
||||||
@@ -50,7 +49,6 @@ export default function DriverLeaderboardPreview({ drivers, onDriverClick }: Dri
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
|
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-charcoal-outline bg-iron-gray/20">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-charcoal-outline bg-iron-gray/20">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
|
||||||
@@ -63,7 +61,7 @@ export default function DriverLeaderboardPreview({ drivers, onDriverClick }: Dri
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => router.push('/leaderboards/drivers')}
|
onClick={onNavigateToDrivers}
|
||||||
className="flex items-center gap-2 text-sm"
|
className="flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
View All
|
View All
|
||||||
@@ -71,7 +69,6 @@ export default function DriverLeaderboardPreview({ drivers, onDriverClick }: Dri
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Leaderboard Rows */}
|
|
||||||
<div className="divide-y divide-charcoal-outline/50">
|
<div className="divide-y divide-charcoal-outline/50">
|
||||||
{top10.map((driver, index) => {
|
{top10.map((driver, index) => {
|
||||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
|
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
|
||||||
@@ -84,17 +81,14 @@ export default function DriverLeaderboardPreview({ drivers, onDriverClick }: Dri
|
|||||||
onClick={() => onDriverClick(driver.id)}
|
onClick={() => onDriverClick(driver.id)}
|
||||||
className="flex items-center gap-4 px-5 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
|
className="flex items-center gap-4 px-5 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
|
||||||
>
|
>
|
||||||
{/* Position */}
|
|
||||||
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
|
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
|
||||||
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
|
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Avatar */}
|
|
||||||
<div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-charcoal-outline">
|
<div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-charcoal-outline">
|
||||||
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" />
|
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-white font-medium truncate group-hover:text-primary-blue transition-colors">
|
<p className="text-white font-medium truncate group-hover:text-primary-blue transition-colors">
|
||||||
{driver.name}
|
{driver.name}
|
||||||
@@ -106,7 +100,6 @@ export default function DriverLeaderboardPreview({ drivers, onDriverClick }: Dri
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-primary-blue font-mono font-semibold">{driver.rating.toLocaleString()}</p>
|
<p className="text-primary-blue font-mono font-semibold">{driver.rating.toLocaleString()}</p>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Users, Crown, Shield, ChevronRight } from 'lucide-react';
|
import { Users, Crown, Shield, ChevronRight } from 'lucide-react';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
@@ -17,6 +16,7 @@ interface TeamLeaderboardPreviewProps {
|
|||||||
position: number;
|
position: number;
|
||||||
}[];
|
}[];
|
||||||
onTeamClick: (id: string) => void;
|
onTeamClick: (id: string) => void;
|
||||||
|
onNavigateToTeams: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SKILL_LEVELS = [
|
const SKILL_LEVELS = [
|
||||||
@@ -26,11 +26,8 @@ const SKILL_LEVELS = [
|
|||||||
{ id: 'beginner', label: 'Beginner', icon: Shield, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
|
{ id: 'beginner', label: 'Beginner', icon: Shield, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeaderboardPreviewProps) {
|
export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }: TeamLeaderboardPreviewProps) {
|
||||||
const router = useRouter();
|
const top5 = teams.slice(0, 5);
|
||||||
const top5 = [...teams]
|
|
||||||
.sort((a, b) => b.memberCount - a.memberCount)
|
|
||||||
.slice(0, 5);
|
|
||||||
|
|
||||||
const getMedalColor = (position: number) => {
|
const getMedalColor = (position: number) => {
|
||||||
switch (position) {
|
switch (position) {
|
||||||
@@ -52,7 +49,6 @@ export default function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeade
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
|
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-charcoal-outline bg-iron-gray/20">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-charcoal-outline bg-iron-gray/20">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 border border-purple-500/20">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 border border-purple-500/20">
|
||||||
@@ -65,7 +61,7 @@ export default function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeade
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => router.push('/teams/leaderboard')}
|
onClick={onNavigateToTeams}
|
||||||
className="flex items-center gap-2 text-sm"
|
className="flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
View All
|
View All
|
||||||
@@ -73,12 +69,11 @@ export default function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeade
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Leaderboard Rows */}
|
|
||||||
<div className="divide-y divide-charcoal-outline/50">
|
<div className="divide-y divide-charcoal-outline/50">
|
||||||
{top5.map((team, index) => {
|
{top5.map((team, index) => {
|
||||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.category);
|
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.category);
|
||||||
const LevelIcon = levelConfig?.icon || Shield;
|
const LevelIcon = levelConfig?.icon || Shield;
|
||||||
const position = index + 1;
|
const position = team.position;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -87,12 +82,10 @@ export default function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeade
|
|||||||
onClick={() => onTeamClick(team.id)}
|
onClick={() => onTeamClick(team.id)}
|
||||||
className="flex items-center gap-4 px-5 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
|
className="flex items-center gap-4 px-5 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
|
||||||
>
|
>
|
||||||
{/* Position */}
|
|
||||||
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
|
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
|
||||||
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
|
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team Logo */}
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||||
@@ -103,7 +96,6 @@ export default function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeade
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors">
|
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors">
|
||||||
{team.name}
|
{team.name}
|
||||||
@@ -123,7 +115,6 @@ export default function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeade
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-purple-400 font-mono font-semibold">{team.memberCount}</p>
|
<p className="text-purple-400 font-mono font-semibold">{team.memberCount}</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import DriverIdentity from '../drivers/DriverIdentity';
|
import { DriverIdentity } from '../drivers/DriverIdentity';
|
||||||
import { useEffectiveDriverId } from '@/lib/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/lib/hooks/useEffectiveDriverId';
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
|||||||
import { useLeagueSeasons } from "@/lib/hooks/league/useLeagueSeasons";
|
import { useLeagueSeasons } from "@/lib/hooks/league/useLeagueSeasons";
|
||||||
import { useSponsorshipRequests } from "@/lib/hooks/league/useSponsorshipRequests";
|
import { useSponsorshipRequests } from "@/lib/hooks/league/useSponsorshipRequests";
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { SPONSORSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
|
|
||||||
interface SponsorshipSlot {
|
interface SponsorshipSlot {
|
||||||
tier: 'main' | 'secondary';
|
tier: 'main' | 'secondary';
|
||||||
@@ -32,7 +32,7 @@ export function LeagueSponsorshipsSection({
|
|||||||
readOnly = false
|
readOnly = false
|
||||||
}: LeagueSponsorshipsSectionProps) {
|
}: LeagueSponsorshipsSectionProps) {
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
const sponsorshipService = useInject(SPONSORSHIP_SERVICE_TOKEN);
|
const sponsorshipService = useInject(SPONSOR_SERVICE_TOKEN);
|
||||||
|
|
||||||
const [slots, setSlots] = useState<SponsorshipSlot[]>([
|
const [slots, setSlots] = useState<SponsorshipSlot[]>([
|
||||||
{ tier: 'main', price: 500, isOccupied: false },
|
{ tier: 'main', price: 500, isOccupied: false },
|
||||||
|
|||||||
@@ -86,24 +86,31 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// eslint-disable-next-line gridpilot-rules/no-raw-html-in-app
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<div>
|
<div>
|
||||||
<Heading level={2} className="text-xl mb-1 flex items-center gap-2">
|
<Heading level={2} className="text-xl mb-1 flex items-center gap-2">
|
||||||
<Camera className="w-5 h-5 text-primary-blue" />
|
<Camera className="w-5 h-5 text-primary-blue" />
|
||||||
Create Your Racing Avatar
|
Create Your Racing Avatar
|
||||||
</Heading>
|
</Heading>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Upload a photo and we will generate a unique racing avatar for you
|
Upload a photo and we will generate a unique racing avatar for you
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Photo Upload */}
|
{/* Photo Upload */}
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<div>
|
<div>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-3">
|
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||||||
Upload Your Photo *
|
Upload Your Photo *
|
||||||
</label>
|
</label>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
{/* Upload Area */}
|
{/* Upload Area */}
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<div
|
<div
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className={`relative flex-1 flex flex-col items-center justify-center p-6 rounded-xl border-2 border-dashed cursor-pointer transition-all ${
|
className={`relative flex-1 flex flex-col items-center justify-center p-6 rounded-xl border-2 border-dashed cursor-pointer transition-all ${
|
||||||
@@ -125,10 +132,12 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
|
|||||||
{avatarInfo.isValidating ? (
|
{avatarInfo.isValidating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-10 h-10 text-primary-blue animate-spin mb-3" />
|
<Loader2 className="w-10 h-10 text-primary-blue animate-spin mb-3" />
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<p className="text-sm text-gray-400">Validating photo...</p>
|
<p className="text-sm text-gray-400">Validating photo...</p>
|
||||||
</>
|
</>
|
||||||
) : avatarInfo.facePhoto ? (
|
) : avatarInfo.facePhoto ? (
|
||||||
<>
|
<>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<div className="w-24 h-24 rounded-xl overflow-hidden mb-3 ring-2 ring-performance-green">
|
<div className="w-24 h-24 rounded-xl overflow-hidden mb-3 ring-2 ring-performance-green">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
@@ -137,18 +146,22 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
|
|||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<p className="text-sm text-performance-green flex items-center gap-1">
|
<p className="text-sm text-performance-green flex items-center gap-1">
|
||||||
<Check className="w-4 h-4" />
|
<Check className="w-4 h-4" />
|
||||||
Photo uploaded
|
Photo uploaded
|
||||||
</p>
|
</p>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<p className="text-xs text-gray-500 mt-1">Click to change</p>
|
<p className="text-xs text-gray-500 mt-1">Click to change</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Upload className="w-10 h-10 text-gray-500 mb-3" />
|
<Upload className="w-10 h-10 text-gray-500 mb-3" />
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<p className="text-sm text-gray-300 font-medium mb-1">
|
<p className="text-sm text-gray-300 font-medium mb-1">
|
||||||
Drop your photo here or click to upload
|
Drop your photo here or click to upload
|
||||||
</p>
|
</p>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
JPEG or PNG, max 5MB
|
JPEG or PNG, max 5MB
|
||||||
</p>
|
</p>
|
||||||
@@ -157,7 +170,9 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview area */}
|
{/* Preview area */}
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<div className="w-32 flex flex-col items-center justify-center">
|
<div className="w-32 flex flex-col items-center justify-center">
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<div className="w-24 h-24 rounded-xl bg-iron-gray border border-charcoal-outline flex items-center justify-center overflow-hidden">
|
<div className="w-24 h-24 rounded-xl bg-iron-gray border border-charcoal-outline flex items-center justify-center overflow-hidden">
|
||||||
{(() => {
|
{(() => {
|
||||||
const selectedAvatarUrl =
|
const selectedAvatarUrl =
|
||||||
@@ -175,6 +190,7 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<p className="text-xs text-gray-500 mt-2 text-center">Your avatar</p>
|
<p className="text-xs text-gray-500 mt-2 text-center">Your avatar</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,11 +200,14 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Suit Color Selection */}
|
{/* Suit Color Selection */}
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<div>
|
<div>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
|
<label className="block text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
|
||||||
<Palette className="w-4 h-4" />
|
<Palette className="w-4 h-4" />
|
||||||
Racing Suit Color
|
Racing Suit Color
|
||||||
</label>
|
</label>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{SUIT_COLORS.map((color) => (
|
{SUIT_COLORS.map((color) => (
|
||||||
<button
|
<button
|
||||||
@@ -211,6 +230,7 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<p className="mt-2 text-xs text-gray-500">
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
Selected: {SUIT_COLORS.find(c => c.value === avatarInfo.suitColor)?.label}
|
Selected: {SUIT_COLORS.find(c => c.value === avatarInfo.suitColor)?.label}
|
||||||
</p>
|
</p>
|
||||||
@@ -244,9 +264,11 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
|
|||||||
{/* Generated Avatars */}
|
{/* Generated Avatars */}
|
||||||
{avatarInfo.generatedAvatars.length > 0 && (
|
{avatarInfo.generatedAvatars.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-3">
|
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||||||
Choose Your Avatar *
|
Choose Your Avatar *
|
||||||
</label>
|
</label>
|
||||||
|
{/* eslint-disable-next-line gridpilot-rules/no-raw-html-in-app */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{avatarInfo.generatedAvatars.map((url, index) => (
|
{avatarInfo.generatedAvatars.map((url, index) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
|
||||||
import { StepIndicator } from '@/ui/StepIndicator';
|
import { StepIndicator } from '@/ui/StepIndicator';
|
||||||
import { PersonalInfoStep, PersonalInfo } from './PersonalInfoStep';
|
import { PersonalInfoStep, PersonalInfo } from '@/ui/onboarding/PersonalInfoStep';
|
||||||
import { AvatarStep, AvatarInfo } from './AvatarStep';
|
import { AvatarStep, AvatarInfo } from './AvatarStep';
|
||||||
|
import { OnboardingHeader } from '@/ui/onboarding/OnboardingHeader';
|
||||||
|
import { OnboardingHelpText } from '@/ui/onboarding/OnboardingHelpText';
|
||||||
|
import { OnboardingError } from '@/ui/onboarding/OnboardingError';
|
||||||
|
import { OnboardingNavigation } from '@/ui/onboarding/OnboardingNavigation';
|
||||||
|
import { OnboardingContainer } from '@/ui/onboarding/OnboardingContainer';
|
||||||
|
import { OnboardingCardAccent } from '@/ui/onboarding/OnboardingCardAccent';
|
||||||
|
import { OnboardingForm } from '@/ui/onboarding/OnboardingForm';
|
||||||
|
|
||||||
type OnboardingStep = 1 | 2;
|
type OnboardingStep = 1 | 2;
|
||||||
|
|
||||||
@@ -173,28 +179,19 @@ export function OnboardingWizard({ onCompleted, onCompleteOnboarding, onGenerate
|
|||||||
const loading = false; // This would be managed by the parent component
|
const loading = false; // This would be managed by the parent component
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto px-4 py-10">
|
<OnboardingContainer>
|
||||||
{/* Header */}
|
<OnboardingHeader
|
||||||
<div className="text-center mb-8">
|
title="Welcome to GridPilot"
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
|
subtitle="Let us set up your racing profile"
|
||||||
<span className="text-2xl">🏁</span>
|
emoji="🏁"
|
||||||
</div>
|
/>
|
||||||
<h1 className="text-4xl font-bold mb-2">Welcome to GridPilot</h1>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
Let us set up your racing profile
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Indicator */}
|
|
||||||
<StepIndicator currentStep={step} />
|
<StepIndicator currentStep={step} />
|
||||||
|
|
||||||
{/* Form Card */}
|
|
||||||
<Card className="relative overflow-hidden">
|
<Card className="relative overflow-hidden">
|
||||||
{/* Background accent */}
|
<OnboardingCardAccent />
|
||||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="relative">
|
<OnboardingForm onSubmit={handleSubmit}>
|
||||||
{/* Step 1: Personal Information */}
|
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<PersonalInfoStep
|
<PersonalInfoStep
|
||||||
personalInfo={personalInfo}
|
personalInfo={personalInfo}
|
||||||
@@ -204,7 +201,6 @@ export function OnboardingWizard({ onCompleted, onCompleteOnboarding, onGenerate
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Avatar Generation */}
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<AvatarStep
|
<AvatarStep
|
||||||
avatarInfo={avatarInfo}
|
avatarInfo={avatarInfo}
|
||||||
@@ -215,66 +211,19 @@ export function OnboardingWizard({ onCompleted, onCompleteOnboarding, onGenerate
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Message */}
|
{errors.submit && <OnboardingError message={errors.submit} />}
|
||||||
{errors.submit && (
|
|
||||||
<div className="mt-6 flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/30">
|
|
||||||
<span className="text-red-400 flex-shrink-0 mt-0.5">⚠</span>
|
|
||||||
<p className="text-sm text-red-400">{errors.submit}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
<OnboardingNavigation
|
||||||
<div className="mt-8 flex items-center justify-between">
|
onBack={handleBack}
|
||||||
<Button
|
onNext={step < 2 ? handleNext : undefined}
|
||||||
type="button"
|
isLastStep={step === 2}
|
||||||
variant="secondary"
|
canSubmit={avatarInfo.selectedAvatarIndex !== null}
|
||||||
onClick={handleBack}
|
loading={loading}
|
||||||
disabled={step === 1 || loading}
|
/>
|
||||||
className="flex items-center gap-2"
|
</OnboardingForm>
|
||||||
>
|
|
||||||
<span>←</span>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{step < 2 ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="primary"
|
|
||||||
onClick={handleNext}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
<span>→</span>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
disabled={loading || avatarInfo.selectedAvatarIndex === null}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<span className="animate-spin">⟳</span>
|
|
||||||
Creating Profile...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span>✓</span>
|
|
||||||
Complete Setup
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Help Text */}
|
<OnboardingHelpText />
|
||||||
<p className="text-center text-xs text-gray-500 mt-6">
|
</OnboardingContainer>
|
||||||
Your avatar will be AI-generated based on your photo and chosen suit color
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
70
apps/website/components/sponsors/MetricBuilders.ts
Normal file
70
apps/website/components/sponsors/MetricBuilders.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Eye, TrendingUp, Users, Star, Calendar, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface SponsorMetric {
|
||||||
|
icon: React.ElementType;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
color?: string;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
isPositive: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MetricBuilders = {
|
||||||
|
views: (value: number, label = 'Views'): SponsorMetric => ({
|
||||||
|
icon: Eye,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color: 'text-primary-blue',
|
||||||
|
}),
|
||||||
|
|
||||||
|
engagement: (value: number | string): SponsorMetric => ({
|
||||||
|
icon: TrendingUp,
|
||||||
|
label: 'Engagement',
|
||||||
|
value: typeof value === 'number' ? `${value}%` : value,
|
||||||
|
color: 'text-performance-green',
|
||||||
|
}),
|
||||||
|
|
||||||
|
reach: (value: number): SponsorMetric => ({
|
||||||
|
icon: Users,
|
||||||
|
label: 'Est. Reach',
|
||||||
|
value,
|
||||||
|
color: 'text-purple-400',
|
||||||
|
}),
|
||||||
|
|
||||||
|
rating: (value: number | string, label = 'Rating'): SponsorMetric => ({
|
||||||
|
icon: Star,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color: 'text-warning-amber',
|
||||||
|
}),
|
||||||
|
|
||||||
|
races: (value: number): SponsorMetric => ({
|
||||||
|
icon: Calendar,
|
||||||
|
label: 'Races',
|
||||||
|
value,
|
||||||
|
color: 'text-neon-aqua',
|
||||||
|
}),
|
||||||
|
|
||||||
|
members: (value: number): SponsorMetric => ({
|
||||||
|
icon: Users,
|
||||||
|
label: 'Members',
|
||||||
|
value,
|
||||||
|
color: 'text-purple-400',
|
||||||
|
}),
|
||||||
|
|
||||||
|
impressions: (value: number): SponsorMetric => ({
|
||||||
|
icon: Eye,
|
||||||
|
label: 'Impressions',
|
||||||
|
value,
|
||||||
|
color: 'text-primary-blue',
|
||||||
|
}),
|
||||||
|
|
||||||
|
sof: (value: number | string): SponsorMetric => ({
|
||||||
|
icon: Zap,
|
||||||
|
label: 'Avg SOF',
|
||||||
|
value,
|
||||||
|
color: 'text-warning-amber',
|
||||||
|
}),
|
||||||
|
};
|
||||||
63
apps/website/components/sponsors/SlotTemplates.ts
Normal file
63
apps/website/components/sponsors/SlotTemplates.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export interface SponsorshipSlot {
|
||||||
|
tier: 'main' | 'secondary';
|
||||||
|
available: boolean;
|
||||||
|
price: number;
|
||||||
|
currency?: string;
|
||||||
|
benefits: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SlotTemplates = {
|
||||||
|
league: (mainAvailable: boolean, secondaryAvailable: number, mainPrice: number, secondaryPrice: number): SponsorshipSlot[] => [
|
||||||
|
{
|
||||||
|
tier: 'main',
|
||||||
|
available: mainAvailable,
|
||||||
|
price: mainPrice,
|
||||||
|
benefits: ['Hood placement', 'League banner', 'Prominent logo'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'secondary',
|
||||||
|
available: secondaryAvailable > 0,
|
||||||
|
price: secondaryPrice,
|
||||||
|
benefits: ['Side logo placement', 'League page listing'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'secondary',
|
||||||
|
available: secondaryAvailable > 1,
|
||||||
|
price: secondaryPrice,
|
||||||
|
benefits: ['Side logo placement', 'League page listing'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
race: (mainAvailable: boolean, mainPrice: number): SponsorshipSlot[] => [
|
||||||
|
{
|
||||||
|
tier: 'main',
|
||||||
|
available: mainAvailable,
|
||||||
|
price: mainPrice,
|
||||||
|
benefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
driver: (available: boolean, price: number): SponsorshipSlot[] => [
|
||||||
|
{
|
||||||
|
tier: 'main',
|
||||||
|
available,
|
||||||
|
price,
|
||||||
|
benefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
team: (mainAvailable: boolean, secondaryAvailable: boolean, mainPrice: number, secondaryPrice: number): SponsorshipSlot[] => [
|
||||||
|
{
|
||||||
|
tier: 'main',
|
||||||
|
available: mainAvailable,
|
||||||
|
price: mainPrice,
|
||||||
|
benefits: ['Team name suffix', 'Car livery', 'All driver suits'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'secondary',
|
||||||
|
available: secondaryAvailable,
|
||||||
|
price: secondaryPrice,
|
||||||
|
benefits: ['Team page logo', 'Minor livery placement'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -2,26 +2,20 @@
|
|||||||
|
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { SPONSORSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Calendar,
|
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Shield,
|
Shield,
|
||||||
Star,
|
Target
|
||||||
Target,
|
|
||||||
TrendingUp,
|
|
||||||
Trophy,
|
|
||||||
Users,
|
|
||||||
Zap
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { SponsorMetric, SponsorshipSlot } from './SponsorInsightsCardTypes';
|
||||||
|
import { getTierStyles, getEntityLabel, getEntityIcon, getSponsorshipTagline } from './SponsorInsightsCardHelpers';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -29,25 +23,6 @@ import React, { useCallback, useState } from 'react';
|
|||||||
|
|
||||||
export type EntityType = 'league' | 'race' | 'driver' | 'team';
|
export type EntityType = 'league' | 'race' | 'driver' | 'team';
|
||||||
|
|
||||||
export interface SponsorMetric {
|
|
||||||
icon: React.ElementType;
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
color?: string;
|
|
||||||
trend?: {
|
|
||||||
value: number;
|
|
||||||
isPositive: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SponsorshipSlot {
|
|
||||||
tier: 'main' | 'secondary';
|
|
||||||
available: boolean;
|
|
||||||
price: number;
|
|
||||||
currency?: string;
|
|
||||||
benefits: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SponsorInsightsProps {
|
export interface SponsorInsightsProps {
|
||||||
// Entity info
|
// Entity info
|
||||||
entityType: EntityType;
|
entityType: EntityType;
|
||||||
@@ -85,55 +60,6 @@ export interface SponsorInsightsProps {
|
|||||||
onSponsorshipRequested?: (tier: 'main' | 'secondary') => void;
|
onSponsorshipRequested?: (tier: 'main' | 'secondary') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HELPER FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function getTierStyles(tier: SponsorInsightsProps['tier']) {
|
|
||||||
switch (tier) {
|
|
||||||
case 'premium':
|
|
||||||
return {
|
|
||||||
badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
|
||||||
gradient: 'from-yellow-500/10 via-transparent to-transparent',
|
|
||||||
};
|
|
||||||
case 'standard':
|
|
||||||
return {
|
|
||||||
badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
|
||||||
gradient: 'from-blue-500/10 via-transparent to-transparent',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
|
||||||
gradient: 'from-gray-500/10 via-transparent to-transparent',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEntityLabel(type: EntityType): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'league': return 'League';
|
|
||||||
case 'race': return 'Race';
|
|
||||||
case 'driver': return 'Driver';
|
|
||||||
case 'team': return 'Team';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEntityIcon(type: EntityType) {
|
|
||||||
switch (type) {
|
|
||||||
case 'league': return Trophy;
|
|
||||||
case 'race': return Zap;
|
|
||||||
case 'driver': return Users;
|
|
||||||
case 'team': return Users;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSponsorshipTagline(type: EntityType): string {
|
|
||||||
if (type === 'league') {
|
|
||||||
return 'Reach engaged sim racers by sponsoring a season in this league.';
|
|
||||||
}
|
|
||||||
return `Reach engaged sim racers by sponsoring this ${getEntityLabel(type).toLowerCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// COMPONENT
|
// COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -156,7 +82,7 @@ export default function SponsorInsightsCard({
|
|||||||
}: SponsorInsightsProps) {
|
}: SponsorInsightsProps) {
|
||||||
// TODO components should not fetch any data
|
// TODO components should not fetch any data
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sponsorshipService = useInject(SPONSORSHIP_SERVICE_TOKEN);
|
const sponsorshipService = useInject(SPONSOR_SERVICE_TOKEN);
|
||||||
const tierStyles = getTierStyles(tier);
|
const tierStyles = getTierStyles(tier);
|
||||||
const EntityIcon = getEntityIcon(entityType);
|
const EntityIcon = getEntityIcon(entityType);
|
||||||
|
|
||||||
@@ -254,7 +180,7 @@ export default function SponsorInsightsCard({
|
|||||||
{/* Key Metrics Grid */}
|
{/* Key Metrics Grid */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||||
{metrics.slice(0, 4).map((metric, index) => {
|
{metrics.slice(0, 4).map((metric, index) => {
|
||||||
const Icon = metric.icon;
|
const Icon = metric.icon as React.ComponentType<{ className?: string }>;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
@@ -440,156 +366,3 @@ export default function SponsorInsightsCard({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HELPER HOOK: useSponsorMode
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export function useSponsorMode(): boolean {
|
|
||||||
const { session } = useAuth();
|
|
||||||
const [isSponsor, setIsSponsor] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!session?.user) {
|
|
||||||
setIsSponsor(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check session.user.role for sponsor
|
|
||||||
const role = session.user?.role;
|
|
||||||
if (role === 'sponsor') {
|
|
||||||
setIsSponsor(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: check email patterns
|
|
||||||
const email = session.user.email?.toLowerCase() || '';
|
|
||||||
const displayName = session.user.displayName?.toLowerCase() || '';
|
|
||||||
|
|
||||||
setIsSponsor(email.includes('sponsor') || displayName.includes('sponsor'));
|
|
||||||
}, [session]);
|
|
||||||
|
|
||||||
return isSponsor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// COMMON METRIC BUILDERS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const MetricBuilders = {
|
|
||||||
views: (value: number, label = 'Views'): SponsorMetric => ({
|
|
||||||
icon: Eye,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
color: 'text-primary-blue',
|
|
||||||
}),
|
|
||||||
|
|
||||||
engagement: (value: number | string): SponsorMetric => ({
|
|
||||||
icon: TrendingUp,
|
|
||||||
label: 'Engagement',
|
|
||||||
value: typeof value === 'number' ? `${value}%` : value,
|
|
||||||
color: 'text-performance-green',
|
|
||||||
}),
|
|
||||||
|
|
||||||
reach: (value: number): SponsorMetric => ({
|
|
||||||
icon: Users,
|
|
||||||
label: 'Est. Reach',
|
|
||||||
value,
|
|
||||||
color: 'text-purple-400',
|
|
||||||
}),
|
|
||||||
|
|
||||||
rating: (value: number | string, label = 'Rating'): SponsorMetric => ({
|
|
||||||
icon: Star,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
color: 'text-warning-amber',
|
|
||||||
}),
|
|
||||||
|
|
||||||
races: (value: number): SponsorMetric => ({
|
|
||||||
icon: Calendar,
|
|
||||||
label: 'Races',
|
|
||||||
value,
|
|
||||||
color: 'text-neon-aqua',
|
|
||||||
}),
|
|
||||||
|
|
||||||
members: (value: number): SponsorMetric => ({
|
|
||||||
icon: Users,
|
|
||||||
label: 'Members',
|
|
||||||
value,
|
|
||||||
color: 'text-purple-400',
|
|
||||||
}),
|
|
||||||
|
|
||||||
impressions: (value: number): SponsorMetric => ({
|
|
||||||
icon: Eye,
|
|
||||||
label: 'Impressions',
|
|
||||||
value,
|
|
||||||
color: 'text-primary-blue',
|
|
||||||
}),
|
|
||||||
|
|
||||||
sof: (value: number | string): SponsorMetric => ({
|
|
||||||
icon: Zap,
|
|
||||||
label: 'Avg SOF',
|
|
||||||
value,
|
|
||||||
color: 'text-warning-amber',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SLOT TEMPLATES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const SlotTemplates = {
|
|
||||||
league: (mainAvailable: boolean, secondaryAvailable: number, mainPrice: number, secondaryPrice: number): SponsorshipSlot[] => [
|
|
||||||
{
|
|
||||||
tier: 'main',
|
|
||||||
available: mainAvailable,
|
|
||||||
price: mainPrice,
|
|
||||||
benefits: ['Hood placement', 'League banner', 'Prominent logo'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tier: 'secondary',
|
|
||||||
available: secondaryAvailable > 0,
|
|
||||||
price: secondaryPrice,
|
|
||||||
benefits: ['Side logo placement', 'League page listing'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tier: 'secondary',
|
|
||||||
available: secondaryAvailable > 1,
|
|
||||||
price: secondaryPrice,
|
|
||||||
benefits: ['Side logo placement', 'League page listing'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
race: (mainAvailable: boolean, mainPrice: number): SponsorshipSlot[] => [
|
|
||||||
{
|
|
||||||
tier: 'main',
|
|
||||||
available: mainAvailable,
|
|
||||||
price: mainPrice,
|
|
||||||
benefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
driver: (available: boolean, price: number): SponsorshipSlot[] => [
|
|
||||||
{
|
|
||||||
tier: 'main',
|
|
||||||
available,
|
|
||||||
price,
|
|
||||||
benefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
team: (mainAvailable: boolean, secondaryAvailable: boolean, mainPrice: number, secondaryPrice: number): SponsorshipSlot[] => [
|
|
||||||
{
|
|
||||||
tier: 'main',
|
|
||||||
available: mainAvailable,
|
|
||||||
price: mainPrice,
|
|
||||||
benefits: ['Team name suffix', 'Car livery', 'All driver suits'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tier: 'secondary',
|
|
||||||
available: secondaryAvailable,
|
|
||||||
price: secondaryPrice,
|
|
||||||
benefits: ['Team page logo', 'Minor livery placement'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { EntityType } from './SponsorInsightsCard';
|
||||||
|
import { Trophy, Zap, Users, Eye, TrendingUp, Star, Calendar, MessageCircle, Activity, Shield, Target } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface TierStyles {
|
||||||
|
badge: string;
|
||||||
|
gradient: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTierStyles(tier: 'premium' | 'standard' | 'starter'): TierStyles {
|
||||||
|
switch (tier) {
|
||||||
|
case 'premium':
|
||||||
|
return {
|
||||||
|
badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||||
|
gradient: 'from-yellow-500/10 via-transparent to-transparent',
|
||||||
|
};
|
||||||
|
case 'standard':
|
||||||
|
return {
|
||||||
|
badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||||
|
gradient: 'from-blue-500/10 via-transparent to-transparent',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||||
|
gradient: 'from-gray-500/10 via-transparent to-transparent',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEntityLabel(type: EntityType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'league': return 'League';
|
||||||
|
case 'race': return 'Race';
|
||||||
|
case 'driver': return 'Driver';
|
||||||
|
case 'team': return 'Team';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEntityIcon(type: EntityType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'league': return Trophy;
|
||||||
|
case 'race': return Zap;
|
||||||
|
case 'driver': return Users;
|
||||||
|
case 'team': return Users;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSponsorshipTagline(type: EntityType): string {
|
||||||
|
if (type === 'league') {
|
||||||
|
return 'Reach engaged sim racers by sponsoring a season in this league.';
|
||||||
|
}
|
||||||
|
return `Reach engaged sim racers by sponsoring this ${getEntityLabel(type).toLowerCase()}`;
|
||||||
|
}
|
||||||
20
apps/website/components/sponsors/SponsorInsightsCardTypes.ts
Normal file
20
apps/website/components/sponsors/SponsorInsightsCardTypes.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ComponentType } from 'react';
|
||||||
|
|
||||||
|
export interface SponsorMetric {
|
||||||
|
icon: ComponentType;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
color?: string;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
isPositive: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SponsorshipSlot {
|
||||||
|
tier: 'main' | 'secondary';
|
||||||
|
available: boolean;
|
||||||
|
price: number;
|
||||||
|
currency?: string;
|
||||||
|
benefits: string[];
|
||||||
|
}
|
||||||
29
apps/website/components/sponsors/useSponsorMode.ts
Normal file
29
apps/website/components/sponsors/useSponsorMode.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function useSponsorMode(): boolean {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const [isSponsor, setIsSponsor] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!session) {
|
||||||
|
setIsSponsor(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check session.role for sponsor
|
||||||
|
const role = session.role;
|
||||||
|
if (role === 'sponsor') {
|
||||||
|
setIsSponsor(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check email patterns
|
||||||
|
const email = session.email?.toLowerCase() || '';
|
||||||
|
const displayName = session.displayName?.toLowerCase() || '';
|
||||||
|
|
||||||
|
setIsSponsor(email.includes('sponsor') || displayName.includes('sponsor'));
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
return isSponsor;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Award, ChevronRight, Crown, Trophy, Users } from 'lucide-react';
|
import { Award, ChevronRight, Crown, Trophy, Users } from 'lucide-react';
|
||||||
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { getMediaUrl } from '@/lib/utilities/media';
|
import { getMediaUrl } from '@/lib/utilities/media';
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ export default function TeamLeaderboardPreview({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => router.push('/teams/leaderboard')}
|
onClick={() => router.push(routes.team.detail('leaderboard'))}
|
||||||
className="flex items-center gap-2 text-sm"
|
className="flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
View Full Leaderboard
|
View Full Leaderboard
|
||||||
|
|||||||
18
apps/website/components/ui/AuthContainer.tsx
Normal file
18
apps/website/components/ui/AuthContainer.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* AuthContainer - UI component for auth page layouts
|
||||||
|
*
|
||||||
|
* Pure presentation component for auth page container.
|
||||||
|
* Used by auth layout to provide consistent styling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface AuthContainerProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthContainer({ children }: AuthContainerProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-deep-graphite flex items-center justify-center p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/website/components/ui/AuthError.tsx
Normal file
22
apps/website/components/ui/AuthError.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* AuthError - UI component for auth page error states
|
||||||
|
*
|
||||||
|
* Pure presentation component for displaying auth-related errors.
|
||||||
|
* Used by page.tsx files to handle PageQuery errors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ErrorBanner } from './ErrorBanner';
|
||||||
|
|
||||||
|
interface AuthErrorProps {
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthError({ action }: AuthErrorProps) {
|
||||||
|
return (
|
||||||
|
<ErrorBanner
|
||||||
|
message={`Failed to load ${action} page`}
|
||||||
|
title="Error"
|
||||||
|
variant="error"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/website/components/ui/AuthLoading.tsx
Normal file
24
apps/website/components/ui/AuthLoading.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* AuthLoading - UI component for auth page loading states
|
||||||
|
*
|
||||||
|
* Pure presentation component for displaying auth-related loading.
|
||||||
|
* Used by LoginClient.tsx for authenticated redirect states.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LoadingWrapper } from '../shared/state/LoadingWrapper';
|
||||||
|
|
||||||
|
interface AuthLoadingProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthLoading({ message = 'Authenticating...' }: AuthLoadingProps) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
|
||||||
|
<LoadingWrapper
|
||||||
|
variant="spinner"
|
||||||
|
message={message}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/website/components/ui/ErrorBanner.tsx
Normal file
31
apps/website/components/ui/ErrorBanner.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* ErrorBanner - UI component for displaying error messages
|
||||||
|
*
|
||||||
|
* Pure UI element for error display in templates and components.
|
||||||
|
* No business logic, just presentation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ErrorBannerProps {
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
variant?: 'error' | 'warning' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBanner({ message, title, variant = 'error' }: ErrorBannerProps) {
|
||||||
|
const baseClasses = 'px-4 py-3 rounded-lg border flex items-start gap-3';
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
error: 'bg-racing-red/10 border-racing-red text-racing-red',
|
||||||
|
warning: 'bg-yellow-500/10 border-yellow-500 text-yellow-300',
|
||||||
|
info: 'bg-primary-blue/10 border-primary-blue text-primary-blue',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${baseClasses} ${variantClasses[variant]}`}>
|
||||||
|
<div className="flex-1">
|
||||||
|
{title && <div className="font-medium">{title}</div>}
|
||||||
|
<div className="text-sm opacity-90">{message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/website/lib/adapters/MediaAdapter.ts
Normal file
63
apps/website/lib/adapters/MediaAdapter.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* MediaAdapter
|
||||||
|
*
|
||||||
|
* Handles HTTP operations for media assets.
|
||||||
|
* This is where external API calls belong.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Result } from '@/lib/contracts/Result';
|
||||||
|
import { DomainError } from '@/lib/contracts/services/Service';
|
||||||
|
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||||
|
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MediaAdapter
|
||||||
|
*
|
||||||
|
* Handles binary media fetching from the API.
|
||||||
|
* All HTTP calls are isolated here.
|
||||||
|
*/
|
||||||
|
export class MediaAdapter {
|
||||||
|
private baseUrl: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = getWebsiteApiBaseUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch binary media from API
|
||||||
|
*
|
||||||
|
* @param mediaPath - API path to media resource
|
||||||
|
* @returns Result with MediaBinaryDTO on success, DomainError on failure
|
||||||
|
*/
|
||||||
|
async fetchMedia(mediaPath: string): Promise<Result<MediaBinaryDTO, DomainError>> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}${mediaPath}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return Result.err({
|
||||||
|
type: 'notFound',
|
||||||
|
message: `Media not found: ${mediaPath}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Result.err({
|
||||||
|
type: 'serverError',
|
||||||
|
message: `HTTP ${response.status}: ${response.statusText}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
const contentType = response.headers.get('content-type') || 'image/svg+xml';
|
||||||
|
|
||||||
|
return Result.ok({ buffer, contentType });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
return Result.err({
|
||||||
|
type: 'networkError',
|
||||||
|
message: `Failed to fetch media: ${errorMessage}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { proxyMediaRequest, getMediaContentType, getMediaCacheControl } from './MediaProxyAdapter';
|
|
||||||
|
|
||||||
// Mock fetch globally
|
|
||||||
const mockFetch = vi.fn();
|
|
||||||
global.fetch = mockFetch;
|
|
||||||
|
|
||||||
describe('MediaProxyAdapter', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('proxyMediaRequest', () => {
|
|
||||||
it('should successfully proxy media and return ArrayBuffer', async () => {
|
|
||||||
const mockBuffer = new ArrayBuffer(8);
|
|
||||||
const mockResponse = {
|
|
||||||
ok: true,
|
|
||||||
arrayBuffer: vi.fn().mockResolvedValue(mockBuffer),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const result = await proxyMediaRequest('/media/avatar/123');
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
expect(result.unwrap()).toBe(mockBuffer);
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
|
||||||
'http://localhost:3000/media/avatar/123',
|
|
||||||
{ method: 'GET' }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle 404 errors', async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
ok: false,
|
|
||||||
status: 404,
|
|
||||||
statusText: 'Not Found',
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const result = await proxyMediaRequest('/media/avatar/999');
|
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
|
||||||
const error = result.getError();
|
|
||||||
expect(error.type).toBe('notFound');
|
|
||||||
expect(error.message).toContain('Media not found');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle other HTTP errors', async () => {
|
|
||||||
const mockResponse = {
|
|
||||||
ok: false,
|
|
||||||
status: 500,
|
|
||||||
statusText: 'Internal Server Error',
|
|
||||||
};
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValue(mockResponse);
|
|
||||||
|
|
||||||
const result = await proxyMediaRequest('/media/avatar/123');
|
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
|
||||||
const error = result.getError();
|
|
||||||
expect(error.type).toBe('serverError');
|
|
||||||
expect(error.message).toContain('HTTP 500');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle network errors', async () => {
|
|
||||||
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
||||||
|
|
||||||
const result = await proxyMediaRequest('/media/avatar/123');
|
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
|
||||||
const error = result.getError();
|
|
||||||
expect(error.type).toBe('networkError');
|
|
||||||
expect(error.message).toContain('Failed to fetch media');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use custom API base URL from environment', () => {
|
|
||||||
process.env.API_BASE_URL = 'https://api.example.com';
|
|
||||||
|
|
||||||
// Just verify the function exists and can be called
|
|
||||||
expect(typeof proxyMediaRequest).toBe('function');
|
|
||||||
|
|
||||||
// Reset
|
|
||||||
delete process.env.API_BASE_URL;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getMediaContentType', () => {
|
|
||||||
it('should return image/png for all media paths', () => {
|
|
||||||
expect(getMediaContentType('/media/avatar/123')).toBe('image/png');
|
|
||||||
expect(getMediaContentType('/media/teams/456/logo')).toBe('image/png');
|
|
||||||
expect(getMediaContentType('/media/leagues/789/cover')).toBe('image/png');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getMediaCacheControl', () => {
|
|
||||||
it('should return public cache control with max-age', () => {
|
|
||||||
expect(getMediaCacheControl()).toBe('public, max-age=3600');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* MediaProxyAdapter
|
|
||||||
*
|
|
||||||
* Handles direct HTTP proxy operations for media assets.
|
|
||||||
* This is a special case where direct fetch is needed for binary responses.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Result } from '@/lib/contracts/Result';
|
|
||||||
|
|
||||||
export type MediaProxyError =
|
|
||||||
| { type: 'notFound'; message: string }
|
|
||||||
| { type: 'serverError'; message: string }
|
|
||||||
| { type: 'networkError'; message: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proxy media request to backend API
|
|
||||||
*
|
|
||||||
* @param mediaPath - The API path to fetch media from (e.g., "/media/avatar/123")
|
|
||||||
* @returns Result with ArrayBuffer on success, or error on failure
|
|
||||||
*/
|
|
||||||
export async function proxyMediaRequest(
|
|
||||||
mediaPath: string
|
|
||||||
): Promise<Result<ArrayBuffer, MediaProxyError>> {
|
|
||||||
try {
|
|
||||||
const baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
|
|
||||||
const response = await fetch(`${baseUrl}${mediaPath}`, {
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 404) {
|
|
||||||
return Result.err({
|
|
||||||
type: 'notFound',
|
|
||||||
message: `Media not found: ${mediaPath}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Result.err({
|
|
||||||
type: 'serverError',
|
|
||||||
message: `HTTP ${response.status}: ${response.statusText}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
return Result.ok(buffer);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
return Result.err({
|
|
||||||
type: 'networkError',
|
|
||||||
message: `Failed to fetch media: ${errorMessage}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get content type for media path
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
export function getMediaContentType(mediaPath: string): string {
|
|
||||||
return 'image/png';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cache control header value
|
|
||||||
*/
|
|
||||||
export function getMediaCacheControl(): string {
|
|
||||||
return 'public, max-age=3600';
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { BaseApiClient } from '../base/BaseApiClient';
|
import { BaseApiClient } from '../base/BaseApiClient';
|
||||||
import type { ErrorReporter } from '@/lib/interfaces/ErrorReporter';
|
|
||||||
import type { Logger } from '@/lib/interfaces/Logger';
|
|
||||||
|
|
||||||
export interface UserDto {
|
export interface UserDto {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
18
apps/website/lib/builders/view-data/AvatarViewDataBuilder.ts
Normal file
18
apps/website/lib/builders/view-data/AvatarViewDataBuilder.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* AvatarViewDataBuilder
|
||||||
|
*
|
||||||
|
* Transforms MediaBinaryDTO into AvatarViewData for server-side rendering.
|
||||||
|
* Deterministic; side-effect free; no HTTP calls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||||
|
import { AvatarViewData } from '@/lib/view-data/AvatarViewData';
|
||||||
|
|
||||||
|
export class AvatarViewDataBuilder {
|
||||||
|
static build(apiDto: MediaBinaryDTO): AvatarViewData {
|
||||||
|
return {
|
||||||
|
buffer: apiDto.buffer,
|
||||||
|
contentType: apiDto.contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* CategoryIconViewDataBuilder
|
||||||
|
*
|
||||||
|
* Transforms MediaBinaryDTO into CategoryIconViewData for server-side rendering.
|
||||||
|
* Deterministic; side-effect free; no HTTP calls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||||
|
import { CategoryIconViewData } from '@/lib/view-data/CategoryIconViewData';
|
||||||
|
|
||||||
|
export class CategoryIconViewDataBuilder {
|
||||||
|
static build(apiDto: MediaBinaryDTO): CategoryIconViewData {
|
||||||
|
return {
|
||||||
|
buffer: apiDto.buffer,
|
||||||
|
contentType: apiDto.contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user