page wrapper

This commit is contained in:
2026-01-07 12:40:52 +01:00
parent e589c30bf8
commit 0db80fa98d
128 changed files with 7386 additions and 8096 deletions

View File

@@ -23,107 +23,26 @@ import { useAuth } from '@/lib/auth/AuthContext';
import { useLogin } from '@/hooks/auth/useLogin';
import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup';
import UserRolesPreview from '@/components/auth/UserRolesPreview';
import { EnhancedFormError, FormErrorSummary } from '@/components/errors/EnhancedFormError';
import { EnhancedFormError } from '@/components/errors/EnhancedFormError';
import { useEnhancedForm } from '@/lib/hooks/useEnhancedForm';
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
import { logErrorWithContext } from '@/lib/utils/errorUtils';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession, session } = useAuth();
const returnTo = searchParams.get('returnTo') ?? '/dashboard';
const [showPassword, setShowPassword] = useState(false);
const [showErrorDetails, setShowErrorDetails] = useState(false);
const [hasInsufficientPermissions, setHasInsufficientPermissions] = useState(false);
// Use login mutation hook
const loginMutation = useLogin({
onSuccess: async () => {
// Refresh session in context so header updates immediately
await refreshSession();
router.push(returnTo);
},
onError: (error) => {
// Show error details toggle in development
if (process.env.NODE_ENV === 'development') {
setShowErrorDetails(true);
}
},
});
// Check if user is already authenticated
useEffect(() => {
console.log('[LoginPage] useEffect running', {
session: session ? 'exists' : 'null',
returnTo: searchParams.get('returnTo'),
pathname: window.location.pathname,
search: window.location.search,
});
if (session) {
// Check if this is a returnTo redirect (user lacks permissions)
const isPermissionRedirect = searchParams.get('returnTo') !== null;
console.log('[LoginPage] Authenticated user check', {
isPermissionRedirect,
returnTo: searchParams.get('returnTo'),
});
if (isPermissionRedirect) {
// User was redirected here due to insufficient permissions
// Show permission error instead of redirecting
console.log('[LoginPage] Showing permission error');
setHasInsufficientPermissions(true);
} else {
// User navigated here directly while authenticated, redirect to dashboard
console.log('[LoginPage] Redirecting to dashboard');
router.replace('/dashboard');
}
}
}, [session, router, searchParams]);
// Use enhanced form hook
// Template component for login UI
function LoginTemplate({ data }: { data: { returnTo: string; hasInsufficientPermissions: boolean; showPassword: boolean; showErrorDetails: boolean; formState: any; handleChange: any; handleSubmit: any; setFormState: any; setShowPassword: any; setShowErrorDetails: any; } }) {
const {
returnTo,
hasInsufficientPermissions,
showPassword,
showErrorDetails,
formState,
setFormState,
handleChange,
handleSubmit,
setFormError,
} = useEnhancedForm<LoginFormValues>({
initialValues: {
email: '',
password: '',
rememberMe: false,
},
validate: validateLoginForm,
component: 'LoginPage',
onSubmit: async (values) => {
// Log the attempt for debugging
logErrorWithContext(
{ message: 'Login attempt', values: { ...values, password: '[REDACTED]' } },
{
component: 'LoginPage',
action: 'login-submit',
formData: { ...values, password: '[REDACTED]' },
}
);
await loginMutation.mutateAsync({
email: values.email,
password: values.password,
rememberMe: values.rememberMe,
});
},
onError: (error, values) => {
// Error handling is done in the mutation's onError callback
},
onSuccess: () => {
// Reset error details on success
setShowErrorDetails(false);
},
});
setFormState,
setShowPassword,
setShowErrorDetails,
} = data;
return (
<main className="min-h-screen bg-deep-graphite flex">
@@ -303,7 +222,7 @@ export default function LoginPage() {
error={new Error(formState.submitError)}
onDismiss={() => {
// Clear the error by setting submitError to undefined
setFormState(prev => ({ ...prev, submitError: undefined }));
setFormState((prev: typeof formState) => ({ ...prev, submitError: undefined }));
}}
showDeveloperDetails={showErrorDetails}
/>
@@ -377,4 +296,131 @@ export default function LoginPage() {
</div>
</main>
);
}
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession, session } = useAuth();
const returnTo = searchParams.get('returnTo') ?? '/dashboard';
const [showPassword, setShowPassword] = useState(false);
const [showErrorDetails, setShowErrorDetails] = useState(false);
const [hasInsufficientPermissions, setHasInsufficientPermissions] = useState(false);
// Use login mutation hook
const loginMutation = useLogin({
onSuccess: async () => {
// Refresh session in context so header updates immediately
await refreshSession();
router.push(returnTo);
},
onError: (error) => {
// Show error details toggle in development
if (process.env.NODE_ENV === 'development') {
setShowErrorDetails(true);
}
},
});
// Check if user is already authenticated
useEffect(() => {
console.log('[LoginPage] useEffect running', {
session: session ? 'exists' : 'null',
returnTo: searchParams.get('returnTo'),
pathname: window.location.pathname,
search: window.location.search,
});
if (session) {
// Check if this is a returnTo redirect (user lacks permissions)
const isPermissionRedirect = searchParams.get('returnTo') !== null;
console.log('[LoginPage] Authenticated user check', {
isPermissionRedirect,
returnTo: searchParams.get('returnTo'),
});
if (isPermissionRedirect) {
// User was redirected here due to insufficient permissions
// Show permission error instead of redirecting
console.log('[LoginPage] Showing permission error');
setHasInsufficientPermissions(true);
} else {
// User navigated here directly while authenticated, redirect to dashboard
console.log('[LoginPage] Redirecting to dashboard');
router.replace('/dashboard');
}
}
}, [session, router, searchParams]);
// Use enhanced form hook
const {
formState,
setFormState,
handleChange,
handleSubmit,
} = useEnhancedForm<LoginFormValues>({
initialValues: {
email: '',
password: '',
rememberMe: false,
},
validate: validateLoginForm,
component: 'LoginPage',
onSubmit: async (values) => {
// Log the attempt for debugging
logErrorWithContext(
{ message: 'Login attempt', values: { ...values, password: '[REDACTED]' } },
{
component: 'LoginPage',
action: 'login-submit',
formData: { ...values, password: '[REDACTED]' },
}
);
await loginMutation.mutateAsync({
email: values.email,
password: values.password,
rememberMe: values.rememberMe,
});
},
onError: (error, values) => {
// Error handling is done in the mutation's onError callback
},
onSuccess: () => {
// Reset error details on success
setShowErrorDetails(false);
},
});
// Prepare template data
const templateData = {
returnTo,
hasInsufficientPermissions,
showPassword,
showErrorDetails,
formState,
handleChange,
handleSubmit,
setFormState,
setShowPassword,
setShowErrorDetails,
};
// Mutation state for wrapper
const isLoading = loginMutation.isPending;
const error = loginMutation.error;
return (
<StatefulPageWrapper
data={templateData}
isLoading={isLoading}
error={error}
retry={() => loginMutation.mutate({ email: '', password: '', rememberMe: false })}
Template={LoginTemplate}
loading={{ variant: 'full-screen', message: 'Loading login...' }}
errorConfig={{ variant: 'full-screen' }}
/>
);
}

View File

@@ -15,7 +15,6 @@ import {
User,
Check,
X,
Loader2,
Car,
Users,
Trophy,
@@ -29,6 +28,7 @@ import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import { useAuth } from '@/lib/auth/AuthContext';
import { useSignup } from '@/hooks/auth/useSignup';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
interface FormErrors {
firstName?: string;
@@ -45,6 +45,10 @@ interface PasswordStrength {
color: string;
}
interface SignupData {
placeholder: string;
}
function checkPasswordStrength(password: string): PasswordStrength {
let score = 0;
if (password.length >= 8) score++;
@@ -89,13 +93,12 @@ const FEATURES = [
'Access detailed performance analytics',
];
export default function SignupPage() {
const SignupTemplate = ({ data }: { data: SignupData }) => {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession, session } = useAuth();
const { refreshSession } = useAuth();
const returnTo = searchParams.get('returnTo') ?? '/onboarding';
const [checkingAuth, setCheckingAuth] = useState(true);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
@@ -107,32 +110,6 @@ export default function SignupPage() {
confirmPassword: '',
});
// Check if already authenticated
useEffect(() => {
if (session) {
// Already logged in, redirect to dashboard or return URL
router.replace(returnTo === '/onboarding' ? '/dashboard' : returnTo);
return;
}
// If no session, still check via API for consistency
async function checkAuth() {
try {
const response = await fetch('/api/auth/session');
const data = await response.json();
if (data.authenticated) {
// Already logged in, redirect to dashboard or return URL
router.replace(returnTo === '/onboarding' ? '/dashboard' : returnTo);
}
} catch {
// Not authenticated, continue showing signup page
} finally {
setCheckingAuth(false);
}
}
checkAuth();
}, [session, router, returnTo]);
const passwordStrength = checkPasswordStrength(formData.password);
const passwordRequirements = [
@@ -239,18 +216,6 @@ export default function SignupPage() {
}
};
// Loading state from mutation
const loading = signupMutation.isPending;
// Show loading while checking auth
if (checkingAuth) {
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<Loader2 className="w-8 h-8 text-primary-blue animate-spin" />
</main>
);
}
return (
<main className="min-h-screen bg-deep-graphite flex">
{/* Background Pattern */}
@@ -375,7 +340,7 @@ export default function SignupPage() {
error={!!errors.firstName}
errorMessage={errors.firstName}
placeholder="John"
disabled={loading}
disabled={signupMutation.isPending}
className="pl-10"
autoComplete="given-name"
/>
@@ -397,7 +362,7 @@ export default function SignupPage() {
error={!!errors.lastName}
errorMessage={errors.lastName}
placeholder="Smith"
disabled={loading}
disabled={signupMutation.isPending}
className="pl-10"
autoComplete="family-name"
/>
@@ -428,7 +393,7 @@ export default function SignupPage() {
error={!!errors.email}
errorMessage={errors.email}
placeholder="you@example.com"
disabled={loading}
disabled={signupMutation.isPending}
className="pl-10"
autoComplete="email"
/>
@@ -450,7 +415,7 @@ export default function SignupPage() {
error={!!errors.password}
errorMessage={errors.password}
placeholder="••••••••"
disabled={loading}
disabled={signupMutation.isPending}
className="pl-10 pr-10"
autoComplete="new-password"
/>
@@ -517,7 +482,7 @@ export default function SignupPage() {
error={!!errors.confirmPassword}
errorMessage={errors.confirmPassword}
placeholder="••••••••"
disabled={loading}
disabled={signupMutation.isPending}
className="pl-10 pr-10"
autoComplete="new-password"
/>
@@ -536,29 +501,14 @@ export default function SignupPage() {
)}
</div>
{/* Error Message */}
<AnimatePresence>
{errors.submit && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30"
>
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-400">{errors.submit}</p>
</motion.div>
)}
</AnimatePresence>
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={loading}
disabled={signupMutation.isPending}
className="w-full flex items-center justify-center gap-2"
>
{loading ? (
{signupMutation.isPending ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Creating account...
@@ -620,4 +570,82 @@ export default function SignupPage() {
</div>
</main>
);
};
export default function SignupPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession, session } = useAuth();
const returnTo = searchParams.get('returnTo') ?? '/onboarding';
const [checkingAuth, setCheckingAuth] = useState(true);
// Check if already authenticated
useEffect(() => {
if (session) {
// Already logged in, redirect to dashboard or return URL
router.replace(returnTo === '/onboarding' ? '/dashboard' : returnTo);
return;
}
// If no session, still check via API for consistency
async function checkAuth() {
try {
const response = await fetch('/api/auth/session');
const data = await response.json();
if (data.authenticated) {
// Already logged in, redirect to dashboard or return URL
router.replace(returnTo === '/onboarding' ? '/dashboard' : returnTo);
}
} catch {
// Not authenticated, continue showing signup page
} finally {
setCheckingAuth(false);
}
}
checkAuth();
}, [session, router, returnTo]);
// Use signup mutation hook for state management
const signupMutation = useSignup({
onSuccess: async () => {
// Refresh session in context so header updates immediately
try {
await refreshSession();
} catch (error) {
console.error('Failed to refresh session after signup:', error);
}
// Always redirect to dashboard after signup
router.push('/dashboard');
},
onError: (error) => {
// Error will be handled in the template
console.error('Signup error:', error);
},
});
// Loading state from mutation
const loading = signupMutation.isPending;
// Show loading while checking auth
if (checkingAuth) {
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
</main>
);
}
// Map mutation states to StatefulPageWrapper
return (
<StatefulPageWrapper
data={{ placeholder: 'data' } as SignupData}
isLoading={loading}
error={signupMutation.error}
retry={() => signupMutation.mutate({ email: '', password: '', displayName: '' })}
Template={SignupTemplate}
loading={{ variant: 'full-screen', message: 'Processing signup...' }}
errorConfig={{ variant: 'full-screen' }}
/>
);
}

View File

@@ -1,339 +1,17 @@
'use client';
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { DashboardTemplate } from '@/templates/DashboardTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { DASHBOARD_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { DashboardService } from '@/lib/services/dashboard/DashboardService';
import {
Activity,
Award,
Calendar,
ChevronRight,
Clock,
Flag,
Medal,
Play,
Star,
Target,
Trophy,
UserPlus,
Users,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { FeedItemRow } from '@/components/dashboard/FeedItemRow';
import { FriendItem } from '@/components/dashboard/FriendItem';
import { LeagueStandingItem } from '@/components/dashboard/LeagueStandingItem';
import { StatCard } from '@/components/dashboard/StatCard';
import { UpcomingRaceItem } from '@/components/dashboard/UpcomingRaceItem';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { getCountryFlag } from '@/lib/utilities/country';
import { getGreeting, timeUntil } from '@/lib/utilities/time';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useDashboardOverview } from '@/hooks/dashboard/useDashboardOverview';
import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
export default function DashboardPage() {
const { data: dashboardData, isLoading, error, retry } = useDashboardOverview();
return (
<StateContainer
data={dashboardData}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'full-screen', message: 'Loading dashboard...' },
error: { variant: 'full-screen' },
empty: {
icon: Activity,
title: 'No dashboard data',
description: 'Try refreshing the page',
action: { label: 'Refresh', onClick: retry }
}
}}
>
{(data: DashboardOverviewViewModel) => {
// StateContainer ensures data is non-null when this renders
const dashboardData = data!;
const currentDriver = dashboardData.currentDriver;
const nextRace = dashboardData.nextRace;
const upcomingRaces = dashboardData.upcomingRaces;
const leagueStandingsSummaries = dashboardData.leagueStandings;
const feedSummary = { items: dashboardData.feedItems };
const friends = dashboardData.friends;
const activeLeaguesCount = dashboardData.activeLeaguesCount;
const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver;
return (
<main className="min-h-screen bg-deep-graphite">
{/* Hero Section */}
<section className="relative overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/10 via-deep-graphite to-purple-600/5" />
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
<div className="relative max-w-7xl mx-auto px-6 py-10">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
{/* Welcome Message */}
<div className="flex items-start gap-5">
<div className="relative">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-0.5 shadow-xl shadow-primary-blue/20">
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<Image
src={currentDriver.avatarUrl}
alt={currentDriver.name}
width={80}
height={80}
className="w-full h-full object-cover"
/>
</div>
</div>
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-performance-green border-3 border-deep-graphite" />
</div>
<div>
<p className="text-gray-400 text-sm mb-1">{getGreeting()},</p>
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">
{currentDriver.name}
<span className="ml-3 text-2xl">{getCountryFlag(currentDriver.country)}</span>
</h1>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary-blue/10 border border-primary-blue/30">
<Star className="w-3.5 h-3.5 text-primary-blue" />
<span className="text-sm font-semibold text-primary-blue">{rating}</span>
</div>
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-yellow-400/10 border border-yellow-400/30">
<Trophy className="w-3.5 h-3.5 text-yellow-400" />
<span className="text-sm font-semibold text-yellow-400">#{globalRank}</span>
</div>
<span className="text-xs text-gray-500">{totalRaces} races completed</span>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-3">
<Link href="/leagues">
<Button variant="secondary" className="flex items-center gap-2">
<Flag className="w-4 h-4" />
Browse Leagues
</Button>
</Link>
<Link href="/profile">
<Button variant="primary" className="flex items-center gap-2">
<Activity className="w-4 h-4" />
View Profile
</Button>
</Link>
</div>
</div>
{/* Quick Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
<StatCard icon={Trophy} value={wins} label="Wins" color="bg-performance-green/20 text-performance-green" />
<StatCard icon={Medal} value={podiums} label="Podiums" color="bg-warning-amber/20 text-warning-amber" />
<StatCard icon={Target} value={`${consistency}%`} label="Consistency" color="bg-primary-blue/20 text-primary-blue" />
<StatCard icon={Users} value={activeLeaguesCount} label="Active Leagues" color="bg-purple-500/20 text-purple-400" />
</div>
</div>
</section>
{/* Main Content */}
<section className="max-w-7xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Next Race Card */}
{nextRace && (
<Card className="relative overflow-hidden bg-gradient-to-br from-iron-gray to-iron-gray/80 border-primary-blue/30">
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/20 to-transparent rounded-bl-full" />
<div className="relative">
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-primary-blue/20 border border-primary-blue/30">
<Play className="w-3.5 h-3.5 text-primary-blue" />
<span className="text-xs font-semibold text-primary-blue uppercase tracking-wider">Next Race</span>
</div>
{nextRace.isMyLeague && (
<span className="px-2 py-0.5 rounded-full bg-performance-green/20 text-performance-green text-xs font-medium">
Your League
</span>
)}
</div>
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-white mb-2">{nextRace.track}</h2>
<p className="text-gray-400 mb-3">{nextRace.car}</p>
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-gray-400">
<Calendar className="w-4 h-4" />
{nextRace.scheduledAt.toLocaleDateString('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric',
})}
</span>
<span className="flex items-center gap-1.5 text-gray-400">
<Clock className="w-4 h-4" />
{nextRace.scheduledAt.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-3">
<div className="text-right">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Starts in</p>
<p className="text-3xl font-bold text-primary-blue font-mono">{timeUntil(nextRace.scheduledAt)}</p>
</div>
<Link href={`/races/${nextRace.id}`}>
<Button variant="primary" className="flex items-center gap-2">
View Details
<ChevronRight className="w-4 h-4" />
</Button>
</Link>
</div>
</div>
</div>
</Card>
)}
{/* League Standings Preview */}
{leagueStandingsSummaries.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Award className="w-5 h-5 text-yellow-400" />
Your Championship Standings
</h2>
<Link href="/profile/leagues" className="text-sm text-primary-blue hover:underline flex items-center gap-1">
View all <ChevronRight className="w-4 h-4" />
</Link>
</div>
<div className="space-y-3">
{leagueStandingsSummaries.map((summary: any) => (
<LeagueStandingItem
key={summary.leagueId}
leagueId={summary.leagueId}
leagueName={summary.leagueName}
position={summary.position}
points={summary.points}
totalDrivers={summary.totalDrivers}
/>
))}
</div>
</Card>
)}
{/* Activity Feed */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Activity className="w-5 h-5 text-cyan-400" />
Recent Activity
</h2>
</div>
{feedSummary.items.length > 0 ? (
<div className="space-y-4">
{feedSummary.items.slice(0, 5).map((item: any) => (
<FeedItemRow key={item.id} item={item} />
))}
</div>
) : (
<div className="text-center py-8">
<Activity className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400 mb-2">No activity yet</p>
<p className="text-sm text-gray-500">Join leagues and add friends to see activity here</p>
</div>
)}
</Card>
</div>
{/* Right Column - Sidebar */}
<div className="space-y-6">
{/* Upcoming Races */}
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-red-400" />
Upcoming Races
</h3>
<Link href="/races" className="text-xs text-primary-blue hover:underline">
View all
</Link>
</div>
{upcomingRaces.length > 0 ? (
<div className="space-y-3">
{upcomingRaces.slice(0, 5).map((race: any) => (
<UpcomingRaceItem
key={race.id}
id={race.id}
track={race.track}
car={race.car}
scheduledAt={race.scheduledAt}
isMyLeague={race.isMyLeague}
/>
))}
</div>
) : (
<p className="text-gray-500 text-sm text-center py-4">No upcoming races</p>
)}
</Card>
{/* Friends */}
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Users className="w-5 h-5 text-purple-400" />
Friends
</h3>
<span className="text-xs text-gray-500">{friends.length} friends</span>
</div>
{friends.length > 0 ? (
<div className="space-y-2">
{friends.slice(0, 6).map((friend: any) => (
<FriendItem
key={friend.id}
id={friend.id}
name={friend.name}
avatarUrl={friend.avatarUrl}
country={friend.country}
/>
))}
{friends.length > 6 && (
<Link
href="/profile"
className="block text-center py-2 text-sm text-primary-blue hover:underline"
>
+{friends.length - 6} more
</Link>
)}
</div>
) : (
<div className="text-center py-6">
<UserPlus className="w-10 h-10 text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-400 mb-2">No friends yet</p>
<Link href="/drivers">
<Button variant="secondary" className="text-xs">
Find Drivers
</Button>
</Link>
</div>
)}
</Card>
</div>
</div>
</section>
</main>
);
}}
</StateContainer>
export default async function DashboardPage() {
const data = await PageDataFetcher.fetch<DashboardService, 'getDashboardOverview'>(
DASHBOARD_SERVICE_TOKEN,
'getDashboardOverview'
);
if (!data) notFound();
return <PageWrapper data={data} Template={DashboardTemplate} />;
}

View File

@@ -1,53 +0,0 @@
'use client';
import { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import { DriversTemplate } from '@/templates/DriversTemplate';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useDriverLeaderboard } from '@/hooks/driver/useDriverLeaderboard';
import { Users } from 'lucide-react';
export function DriversInteractive() {
const { data: viewModel, isLoading: loading, error, retry } = useDriverLeaderboard();
const drivers = viewModel?.drivers || [];
const totalRaces = viewModel?.totalRaces || 0;
const totalWins = viewModel?.totalWins || 0;
const activeCount = viewModel?.activeCount || 0;
// TODO this should not be done in a page, thats part of the service??
// Transform data for template
const driverViewModels = drivers.map((driver, index) =>
new DriverLeaderboardItemViewModel(driver, index + 1)
);
return (
<StateContainer
data={viewModel}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading driver leaderboard...' },
error: { variant: 'inline' },
empty: {
icon: Users,
title: 'No drivers found',
description: 'There are no drivers in the system yet',
}
}}
>
{() => (
<DriversTemplate
drivers={driverViewModels}
totalRaces={totalRaces}
totalWins={totalWins}
activeCount={activeCount}
isLoading={false}
/>
)}
</StateContainer>
);
}

View File

@@ -1,24 +0,0 @@
import { DriversTemplate } from '@/templates/DriversTemplate';
import { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
interface DriversStaticProps {
leaderboardData: DriverLeaderboardViewModel;
}
export async function DriversStatic({ leaderboardData }: DriversStaticProps) {
// Transform the data for the template
const drivers = leaderboardData.drivers.map((driver, index) =>
new DriverLeaderboardItemViewModel(driver, index + 1)
);
return (
<DriversTemplate
drivers={drivers}
totalRaces={leaderboardData.totalRaces}
totalWins={leaderboardData.totalWins}
activeCount={leaderboardData.activeCount}
isLoading={false}
/>
);
}

View File

@@ -1,169 +0,0 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN, TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
import { Car } from 'lucide-react';
interface Team {
id: string;
name: string;
}
interface TeamMembershipInfo {
team: Team;
role: string;
joinedAt: Date;
}
export function DriverProfileInteractive() {
const router = useRouter();
const params = useParams();
const driverId = params.id as string;
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const teamService = useInject(TEAM_SERVICE_TOKEN);
const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
const isSponsorMode = useSponsorMode();
// Fetch driver profile using React-Query
const { data: driverProfile, isLoading, error, refetch } = useQuery({
queryKey: ['driverProfile', driverId],
queryFn: () => driverService.getDriverProfile(driverId),
});
// Fetch team memberships using React-Query
const { data: allTeamMemberships } = useQuery({
queryKey: ['driverTeamMemberships', driverId],
queryFn: async () => {
if (!driverProfile?.currentDriver) return [];
const allTeams = await teamService.getAllTeams();
const memberships: TeamMembershipInfo[] = [];
for (const team of allTeams) {
const teamMembers = await teamService.getTeamMembers(team.id, driverId, '');
const membership = teamMembers.find(member => member.driverId === driverId);
if (membership) {
memberships.push({
team: {
id: team.id,
name: team.name,
} as Team,
role: membership.role,
joinedAt: new Date(membership.joinedAt),
});
}
}
return memberships;
},
enabled: !!driverProfile?.currentDriver,
});
const handleAddFriend = () => {
setFriendRequestSent(true);
};
const handleBackClick = () => {
router.push('/drivers');
};
// Build sponsor insights for driver
const friendsCount = driverProfile?.socialSummary?.friends?.length ?? 0;
const stats = driverProfile?.stats || null;
const driver = driverProfile?.currentDriver;
const driverMetrics = [
MetricBuilders.rating(stats?.rating ?? 0, 'Driver Rating'),
MetricBuilders.views((friendsCount * 8) + 50),
MetricBuilders.engagement(stats?.consistency ?? 75),
MetricBuilders.reach((friendsCount * 12) + 100),
];
const sponsorInsights = isSponsorMode && driver ? (
<SponsorInsightsCard
entityType="driver"
entityId={driver.id}
entityName={driver.name}
tier="standard"
metrics={driverMetrics}
slots={SlotTemplates.driver(true, 200)}
trustScore={88}
monthlyActivity={stats?.consistency ?? 75}
/>
) : null;
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading driver profile...</p>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center max-w-md">
<div className="text-red-600 text-4xl mb-4"></div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Error loading driver profile</h2>
<p className="text-gray-600 mb-4">{error.message}</p>
<button
onClick={() => refetch()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Retry
</button>
</div>
</div>
);
}
// Empty state
if (!driverProfile) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center max-w-md">
<div className="text-gray-400 text-4xl mb-4">
<Car size={48} />
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Driver not found</h2>
<p className="text-gray-600 mb-4">The driver profile may not exist or you may not have access</p>
<button
onClick={handleBackClick}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Back to Drivers
</button>
</div>
</div>
);
}
return (
<DriverProfileTemplate
driverProfile={driverProfile}
allTeamMemberships={allTeamMemberships || []}
isLoading={false}
error={null}
onBackClick={handleBackClick}
onAddFriend={handleAddFriend}
friendRequestSent={friendRequestSent}
activeTab={activeTab}
setActiveTab={setActiveTab}
isSponsorMode={isSponsorMode}
sponsorInsights={sponsorInsights}
/>
);
}

View File

@@ -1,37 +0,0 @@
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
interface DriverProfileStaticProps {
profileData: DriverProfileViewModel;
teamMemberships: Array<{
team: { id: string; name: string };
role: string;
joinedAt: Date;
}>;
}
export async function DriverProfileStatic({ profileData, teamMemberships }: DriverProfileStaticProps) {
return (
<DriverProfileTemplate
driverProfile={profileData}
allTeamMemberships={teamMemberships}
isLoading={false}
error={null}
onBackClick={() => {
// This will be handled by the parent page component
window.history.back();
}}
onAddFriend={() => {
// Server component - no-op for static version
console.log('Add friend - static mode');
}}
friendRequestSent={false}
activeTab="overview"
setActiveTab={() => {
// Server component - no-op for static version
console.log('Set tab - static mode');
}}
isSponsorMode={false}
/>
);
}

View File

@@ -1,3 +1,81 @@
import { DriverProfileInteractive } from './DriverProfileInteractive';
'use client';
export default DriverProfileInteractive;
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
import { useDriverProfilePageData } from '@/hooks/driver/useDriverProfilePageData';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
interface DriverProfileData {
driverProfile: any;
teamMemberships: Array<{
team: { id: string; name: string };
role: string;
joinedAt: Date;
}>;
}
export default function DriverProfilePage() {
const router = useRouter();
const params = useParams();
const driverId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
// UI State
const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
// Fetch data using domain hook
const { data: queries, isLoading, error, refetch } = useDriverProfilePageData(driverId);
// Transform data for template
const data: DriverProfileData | undefined = queries?.driverProfile && queries?.teamMemberships
? {
driverProfile: queries.driverProfile,
teamMemberships: queries.teamMemberships,
}
: undefined;
// Actions
const handleAddFriend = () => {
setFriendRequestSent(true);
};
const handleBackClick = () => {
router.push('/drivers');
};
return (
<StatefulPageWrapper
data={data}
isLoading={isLoading}
error={error as Error | null}
retry={refetch}
Template={({ data }) => {
if (!data) return null;
return (
<DriverProfileTemplate
driverProfile={data.driverProfile}
allTeamMemberships={data.teamMemberships}
isLoading={false}
error={null}
onBackClick={handleBackClick}
onAddFriend={handleAddFriend}
friendRequestSent={friendRequestSent}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
);
}}
loading={{ variant: 'skeleton', message: 'Loading driver profile...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: require('lucide-react').Car,
title: 'Driver not found',
description: 'The driver profile may not exist or you may not have access',
action: { label: 'Back to Drivers', onClick: handleBackClick }
}}
/>
);
}

View File

@@ -1,3 +1,14 @@
import { DriversInteractive } from './DriversInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { DriversTemplate } from '@/templates/DriversTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { DriverService } from '@/lib/services/drivers/DriverService';
export default DriversInteractive;
export default async function Page() {
const data = await PageDataFetcher.fetch<DriverService, 'getDriverLeaderboard'>(
DRIVER_SERVICE_TOKEN,
'getDriverLeaderboard'
);
return <PageWrapper data={data} Template={DriversTemplate} />;
}

View File

@@ -1,42 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
interface LeaderboardsInteractiveProps {
drivers: DriverLeaderboardItemViewModel[];
teams: TeamSummaryViewModel[];
}
export default function LeaderboardsInteractive({ drivers, teams }: LeaderboardsInteractiveProps) {
const router = useRouter();
const handleDriverClick = (driverId: string) => {
router.push(`/drivers/${driverId}`);
};
const handleTeamClick = (teamId: string) => {
router.push(`/teams/${teamId}`);
};
const handleNavigateToDrivers = () => {
router.push('/leaderboards/drivers');
};
const handleNavigateToTeams = () => {
router.push('/teams/leaderboard');
};
return (
<LeaderboardsTemplate
drivers={drivers}
teams={teams}
onDriverClick={handleDriverClick}
onTeamClick={handleTeamClick}
onNavigateToDrivers={handleNavigateToDrivers}
onNavigateToTeams={handleNavigateToTeams}
/>
);
}

View File

@@ -1,80 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useDriverLeaderboard } from '@/hooks/driver/useDriverLeaderboard';
import { useAllTeams } from '@/hooks/team/useAllTeams';
import { Trophy } from 'lucide-react';
export default function LeaderboardsStatic() {
const router = useRouter();
const { data: driverData, isLoading: driversLoading, error: driversError, retry: driversRetry } = useDriverLeaderboard();
const { data: teams, isLoading: teamsLoading, error: teamsError, retry: teamsRetry } = useAllTeams();
const handleDriverClick = (driverId: string) => {
router.push(`/drivers/${driverId}`);
};
const handleTeamClick = (teamId: string) => {
router.push(`/teams/${teamId}`);
};
const handleNavigateToDrivers = () => {
router.push('/leaderboards/drivers');
};
const handleNavigateToTeams = () => {
router.push('/teams/leaderboard');
};
// Combine loading states
const isLoading = driversLoading || teamsLoading;
// Combine errors (prioritize drivers error)
const error = driversError || teamsError;
// Combine retry functions
const retry = async () => {
if (driversError) await driversRetry();
if (teamsError) await teamsRetry();
};
// Prepare data for template
const drivers = driverData?.drivers || [];
const teamsData = teams || [];
const hasData = drivers.length > 0 || teamsData.length > 0;
return (
<StateContainer
data={hasData ? { drivers, teams: teamsData } : null}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading leaderboards...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'No leaderboard data',
description: 'There is no leaderboard data available at the moment.',
}
}}
isEmpty={(data) => !data || (data.drivers.length === 0 && data.teams.length === 0)}
>
{(data) => (
<LeaderboardsTemplate
drivers={data.drivers}
teams={data.teams}
onDriverClick={handleDriverClick}
onTeamClick={handleTeamClick}
onNavigateToDrivers={handleNavigateToDrivers}
onNavigateToTeams={handleNavigateToTeams}
/>
)}
</StateContainer>
);
}

View File

@@ -1,46 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
interface DriverRankingsInteractiveProps {
drivers: DriverLeaderboardItemViewModel[];
}
export default function DriverRankingsInteractive({ drivers }: DriverRankingsInteractiveProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all');
const [sortBy, setSortBy] = useState<SortBy>('rank');
const [showFilters, setShowFilters] = useState(false);
const handleDriverClick = (driverId: string) => {
if (driverId.startsWith('demo-')) return;
router.push(`/drivers/${driverId}`);
};
const handleBackToLeaderboards = () => {
router.push('/leaderboards');
};
return (
<DriverRankingsTemplate
drivers={drivers}
searchQuery={searchQuery}
selectedSkill={selectedSkill}
sortBy={sortBy}
showFilters={showFilters}
onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onSortChange={setSortBy}
onToggleFilters={() => setShowFilters(!showFilters)}
onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>
);
}

View File

@@ -1,70 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useDriverLeaderboard } from '@/hooks/driver/useDriverLeaderboard';
import { Users } from 'lucide-react';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
export default function DriverRankingsStatic() {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all');
const [sortBy, setSortBy] = useState<SortBy>('rank');
const [showFilters, setShowFilters] = useState(false);
const { data: driverData, isLoading, error, retry } = useDriverLeaderboard();
const handleDriverClick = (driverId: string) => {
if (driverId.startsWith('demo-')) return;
router.push(`/drivers/${driverId}`);
};
const handleBackToLeaderboards = () => {
router.push('/leaderboards');
};
const drivers = driverData?.drivers || [];
return (
<StateContainer
data={drivers}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading driver rankings...' },
error: { variant: 'full-screen' },
empty: {
icon: Users,
title: 'No drivers found',
description: 'There are no drivers in the system yet.',
}
}}
>
{(driversData) => (
<DriverRankingsTemplate
drivers={driversData}
searchQuery={searchQuery}
selectedSkill={selectedSkill}
sortBy={sortBy}
showFilters={showFilters}
onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onSortChange={setSortBy}
onToggleFilters={() => setShowFilters(!showFilters)}
onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>
)}
</StateContainer>
);
}

View File

@@ -1,9 +1,101 @@
import DriverRankingsStatic from './DriverRankingsStatic';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { DriverService } from '@/lib/services/drivers/DriverService';
import { Users } from 'lucide-react';
import { redirect , useRouter } from 'next/navigation';
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
import { useState } from 'react';
// ============================================================================
// TYPES
// ============================================================================
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
// ============================================================================
// WRAPPER COMPONENT (Client-side state management)
// ============================================================================
function DriverRankingsPageWrapper({ data }: { data: DriverLeaderboardViewModel | null }) {
const router = useRouter();
// Client-side state for filtering and sorting
const [searchQuery, setSearchQuery] = useState('');
const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all');
const [sortBy, setSortBy] = useState<SortBy>('rank');
const [showFilters, setShowFilters] = useState(false);
if (!data || !data.drivers) {
return null;
}
const handleDriverClick = (driverId: string) => {
if (driverId.startsWith('demo-')) return;
router.push(`/drivers/${driverId}`);
};
const handleBackToLeaderboards = () => {
router.push('/leaderboards');
};
return (
<DriverRankingsTemplate
drivers={data.drivers}
searchQuery={searchQuery}
selectedSkill={selectedSkill}
sortBy={sortBy}
showFilters={showFilters}
onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onSortChange={setSortBy}
onToggleFilters={() => setShowFilters(!showFilters)}
onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>
);
}
// ============================================================================
// MAIN PAGE COMPONENT
// ============================================================================
export default function DriverLeaderboardPage() {
return <DriverRankingsStatic />;
export default async function DriverLeaderboardPage() {
// Fetch data using PageDataFetcher
const driverData = await PageDataFetcher.fetch<DriverService, 'getDriverLeaderboard'>(
DRIVER_SERVICE_TOKEN,
'getDriverLeaderboard'
);
// Prepare data for template
const data: DriverLeaderboardViewModel | null = driverData as DriverLeaderboardViewModel | null;
const hasData = (driverData as any)?.drivers?.length > 0;
// Handle loading state (should be fast since we're using async/await)
const isLoading = false;
const error = null;
const retry = async () => {
// In server components, we can't retry without a reload
redirect('/leaderboards/drivers');
};
return (
<PageWrapper
data={hasData ? data : null}
isLoading={isLoading}
error={error}
retry={retry}
Template={DriverRankingsPageWrapper}
loading={{ variant: 'full-screen', message: 'Loading driver rankings...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Users,
title: 'No drivers found',
description: 'There are no drivers in the system yet.',
}}
/>
);
}

View File

@@ -1,9 +1,113 @@
import LeaderboardsStatic from './LeaderboardsStatic';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { DRIVER_SERVICE_TOKEN, TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { DriverService } from '@/lib/services/drivers/DriverService';
import { TeamService } from '@/lib/services/teams/TeamService';
import { Trophy } from 'lucide-react';
import { redirect , useRouter } from 'next/navigation';
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// ============================================================================
// TYPES
// ============================================================================
interface LeaderboardsPageData {
drivers: DriverLeaderboardViewModel | null;
teams: TeamSummaryViewModel[] | null;
}
// ============================================================================
// WRAPPER COMPONENT
// ============================================================================
function LeaderboardsPageWrapper({ data }: { data: LeaderboardsPageData | null }) {
const router = useRouter();
if (!data || (!data.drivers && !data.teams)) {
return null;
}
const drivers = data.drivers?.drivers || [];
const teams = data.teams || [];
const handleDriverClick = (driverId: string) => {
router.push(`/drivers/${driverId}`);
};
const handleTeamClick = (teamId: string) => {
router.push(`/teams/${teamId}`);
};
const handleNavigateToDrivers = () => {
router.push('/leaderboards/drivers');
};
const handleNavigateToTeams = () => {
router.push('/teams/leaderboard');
};
return (
<LeaderboardsTemplate
drivers={drivers}
teams={teams}
onDriverClick={handleDriverClick}
onTeamClick={handleTeamClick}
onNavigateToDrivers={handleNavigateToDrivers}
onNavigateToTeams={handleNavigateToTeams}
/>
);
}
// ============================================================================
// MAIN PAGE COMPONENT
// ============================================================================
export default function LeaderboardsPage() {
return <LeaderboardsStatic />;
export default async function LeaderboardsPage() {
// Fetch data using PageDataFetcher with proper type annotations
const [driverData, teamsData] = await Promise.all([
PageDataFetcher.fetch<DriverService, 'getDriverLeaderboard'>(
DRIVER_SERVICE_TOKEN,
'getDriverLeaderboard'
),
PageDataFetcher.fetch<TeamService, 'getAllTeams'>(
TEAM_SERVICE_TOKEN,
'getAllTeams'
),
]);
// Prepare data for template
const data: LeaderboardsPageData = {
drivers: driverData as DriverLeaderboardViewModel | null,
teams: teamsData as TeamSummaryViewModel[] | null,
};
const hasData = (driverData as any)?.drivers?.length > 0 || (teamsData as any)?.length > 0;
// Handle loading state (should be fast since we're using async/await)
const isLoading = false;
const error = null;
const retry = async () => {
// In server components, we can't retry without a reload
// This would typically trigger a page reload
redirect('/leaderboards');
};
return (
<PageWrapper
data={hasData ? data : null}
isLoading={isLoading}
error={error}
retry={retry}
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.',
}}
/>
);
}

View File

@@ -1,53 +0,0 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useAllLeagues } from '@/hooks/league/useAllLeagues';
import { Trophy } from 'lucide-react';
export default function LeaguesInteractive() {
const router = useRouter();
const { data: realLeagues = [], isLoading: loading, error, retry } = useAllLeagues();
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
};
const handleCreateLeagueClick = () => {
router.push('/leagues/create');
};
return (
<StateContainer
data={realLeagues}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading leagues...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'No leagues yet',
description: 'Create your first league to start organizing races and events.',
action: { label: 'Create League', onClick: handleCreateLeagueClick }
}
}}
>
{(leaguesData) => (
<LeaguesTemplate
leagues={leaguesData}
loading={false}
onLeagueClick={handleLeagueClick}
onCreateLeagueClick={handleCreateLeagueClick}
/>
)}
</StateContainer>
);
}

View File

@@ -1,32 +0,0 @@
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
export default async function LeaguesStatic() {
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
const leagueService = serviceFactory.createLeagueService();
let leagues: LeagueSummaryViewModel[] = [];
let loading = false;
try {
loading = true;
leagues = await leagueService.getAllLeagues();
} catch (error) {
console.error('Failed to load leagues:', error);
} finally {
loading = false;
}
// Server components can't have event handlers, so we provide empty functions
// The Interactive wrapper will add the actual handlers
return (
<LeaguesTemplate
leagues={leagues}
loading={loading}
onLeagueClick={() => {}}
onCreateLeagueClick={() => {}}
/>
);
}

View File

@@ -1,111 +0,0 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
import EndRaceModal from '@/components/leagues/EndRaceModal';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useLeagueDetailWithSponsors } from '@/hooks/league/useLeagueDetailWithSponsors';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { Trophy } from 'lucide-react';
export default function LeagueDetailInteractive() {
const router = useRouter();
const params = useParams();
const leagueId = params.id as string;
const isSponsor = useSponsorMode();
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const raceService = useInject(RACE_SERVICE_TOKEN);
const [endRaceModalRaceId, setEndRaceModalRaceId] = useState<string | null>(null);
const currentDriverId = useEffectiveDriverId();
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
const { data: viewModel, isLoading, error, retry } = useLeagueDetailWithSponsors(leagueId);
const handleMembershipChange = () => {
retry();
};
const handleEndRaceModalOpen = (raceId: string) => {
setEndRaceModalRaceId(raceId);
};
const handleLiveRaceClick = (raceId: string) => {
router.push(`/races/${raceId}`);
};
const handleBackToLeagues = () => {
router.push('/leagues');
};
const handleEndRaceConfirm = async () => {
if (!endRaceModalRaceId) return;
try {
await raceService.completeRace(endRaceModalRaceId);
await retry();
setEndRaceModalRaceId(null);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to complete race');
}
};
const handleEndRaceCancel = () => {
setEndRaceModalRaceId(null);
};
return (
<StateContainer
data={viewModel}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading league...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'League not found',
description: 'The league may have been deleted or you may not have access',
action: { label: 'Back to Leagues', onClick: handleBackToLeagues }
}
}}
>
{(leagueData) => (
<>
<LeagueDetailTemplate
viewModel={leagueData!}
leagueId={leagueId}
isSponsor={isSponsor}
membership={membership}
currentDriverId={currentDriverId}
onMembershipChange={handleMembershipChange}
onEndRaceModalOpen={handleEndRaceModalOpen}
onLiveRaceClick={handleLiveRaceClick}
onBackToLeagues={handleBackToLeagues}
>
{/* End Race Modal */}
{endRaceModalRaceId && leagueData && (() => {
const race = leagueData.runningRaces.find(r => r.id === endRaceModalRaceId);
return race ? (
<EndRaceModal
raceId={race.id}
raceName={race.name}
onConfirm={handleEndRaceConfirm}
onCancel={handleEndRaceCancel}
/>
) : null;
})()}
</LeagueDetailTemplate>
</>
)}
</StateContainer>
);
}

View File

@@ -1,60 +0,0 @@
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
interface LeagueDetailStaticProps {
leagueId: string;
}
export default async function LeagueDetailStatic({ leagueId }: LeagueDetailStaticProps) {
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
const leagueService = serviceFactory.createLeagueService();
let viewModel: LeagueDetailPageViewModel | null = null;
let loading = false;
let error: string | null = null;
try {
loading = true;
viewModel = await leagueService.getLeagueDetailPageData(leagueId);
if (!viewModel) {
error = 'League not found';
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load league';
} finally {
loading = false;
}
if (loading) {
return (
<div className="text-center text-gray-400">Loading league...</div>
);
}
if (error || !viewModel) {
return (
<div className="text-center text-warning-amber">
{error || 'League not found'}
</div>
);
}
// Server components can't have event handlers, so we provide empty functions
// The Interactive wrapper will add the actual handlers
return (
<LeagueDetailTemplate
viewModel={viewModel}
leagueId={leagueId}
isSponsor={false}
membership={null}
currentDriverId={null}
onMembershipChange={() => {}}
onEndRaceModalOpen={() => {}}
onLiveRaceClick={() => {}}
onBackToLeagues={() => {}}
/>
);
}

View File

@@ -18,7 +18,7 @@ export default function LeagueLayout({
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const { data: leagueDetail, isLoading: loading } = useLeagueDetail(leagueId, currentDriverId);
const { data: leagueDetail, isLoading: loading } = useLeagueDetail(leagueId, currentDriverId ?? '');
if (loading) {
return (
@@ -102,4 +102,4 @@ export default function LeagueLayout({
</div>
</div>
);
}
}

View File

@@ -1,3 +1,93 @@
import LeagueDetailInteractive from './LeagueDetailInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { LeagueService } from '@/lib/services/leagues/LeagueService';
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { notFound } from 'next/navigation';
import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
export default LeagueDetailInteractive;
interface Props {
params: { id: string };
}
export default async function Page({ params }: Props) {
// Validate params
if (!params.id) {
notFound();
}
// Fetch data using PageDataFetcher.fetchManual for multiple dependencies
const data = await PageDataFetcher.fetchManual(async () => {
// Create dependencies
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Create API clients
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new LeagueService(
leaguesApiClient,
driversApiClient,
sponsorsApiClient,
racesApiClient
);
// Fetch data
const result = await service.getLeagueDetailPageData(params.id);
if (!result) {
throw new Error('League not found');
}
return result;
});
if (!data) {
notFound();
}
// Create a wrapper component that passes data to the template
const TemplateWrapper = ({ data }: { data: LeagueDetailPageViewModel }) => {
// The LeagueDetailTemplate expects multiple props beyond just data
// We need to provide the additional props it requires
return (
<LeagueDetailTemplate
viewModel={data}
leagueId={data.id}
isSponsor={false}
membership={null}
currentDriverId={null}
onMembershipChange={() => {}}
onEndRaceModalOpen={() => {}}
onLiveRaceClick={() => {}}
onBackToLeagues={() => {}}
/>
);
};
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.',
}}
/>
);
}

View File

@@ -1,48 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
export default function LeagueRulebookInteractive() {
const params = useParams();
const leagueId = params.id as string;
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadData() {
try {
const data = await leagueService.getLeagueDetailPageData(leagueId);
if (!data) {
setLoading(false);
return;
}
setViewModel(data);
} catch (err) {
console.error('Failed to load scoring config:', err);
} finally {
setLoading(false);
}
}
loadData();
}, [leagueId, leagueService]);
if (!viewModel && !loading) {
return (
<div className="text-center text-gray-400 py-12">
Unable to load rulebook
</div>
);
}
return <LeagueRulebookTemplate viewModel={viewModel!} loading={loading} />;
}

View File

@@ -1,38 +0,0 @@
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
interface LeagueRulebookStaticProps {
leagueId: string;
}
export default async function LeagueRulebookStatic({ leagueId }: LeagueRulebookStaticProps) {
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
const leagueService = serviceFactory.createLeagueService();
let viewModel: LeagueDetailPageViewModel | null = null;
let loading = false;
try {
loading = true;
const data = await leagueService.getLeagueDetailPageData(leagueId);
if (data) {
viewModel = data;
}
} catch (err) {
console.error('Failed to load scoring config:', err);
} finally {
loading = false;
}
if (!viewModel && !loading) {
return (
<div className="text-center text-gray-400 py-12">
Unable to load rulebook
</div>
);
}
return <LeagueRulebookTemplate viewModel={viewModel!} loading={loading} />;
}

View File

@@ -1,3 +1,63 @@
import LeagueRulebookInteractive from './LeagueRulebookInteractive';
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { LeagueService } from '@/lib/services/leagues/LeagueService';
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { notFound } from 'next/navigation';
import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
export default LeagueRulebookInteractive;
interface Props {
params: { id: string };
}
export default async function Page({ params }: Props) {
// Validate params
if (!params.id) {
notFound();
}
// Fetch data using PageDataFetcher.fetchManual
const data = await PageDataFetcher.fetchManual(async () => {
// Create dependencies for API clients
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Create API clients
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new LeagueService(
leaguesApiClient,
driversApiClient,
sponsorsApiClient,
racesApiClient
);
return await service.getLeagueDetailPageData(params.id);
});
if (!data) {
notFound();
}
// Create a Template wrapper that matches PageWrapper's expected interface
const Template = ({ data }: { data: LeagueDetailPageViewModel }) => {
return <LeagueRulebookTemplate viewModel={data} loading={false} />;
};
return <PageWrapper data={data} Template={Template} />;
}

View File

@@ -1,11 +0,0 @@
'use client';
import { useParams } from 'next/navigation';
import { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate';
export default function LeagueScheduleInteractive() {
const params = useParams();
const leagueId = params.id as string;
return <LeagueScheduleTemplate leagueId={leagueId} />;
}

View File

@@ -1,10 +0,0 @@
import { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate';
interface LeagueScheduleStaticProps {
leagueId: string;
}
export default async function LeagueScheduleStatic({ leagueId }: LeagueScheduleStaticProps) {
// The LeagueScheduleTemplate doesn't need data fetching - it delegates to LeagueSchedule component
return <LeagueScheduleTemplate leagueId={leagueId} />;
}

View File

@@ -1,140 +1,124 @@
'use client';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
import type { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemplate';
import {
useLeagueAdminStatus,
useLeagueSeasons,
useLeagueAdminSchedule
} from '@/hooks/league/useLeagueScheduleAdminPageData';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
export default function LeagueAdminSchedulePage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const currentDriverId = useEffectiveDriverId() || '';
const queryClient = useQueryClient();
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const [isAdmin, setIsAdmin] = useState(false);
const [membershipLoading, setMembershipLoading] = useState(true);
const [seasons, setSeasons] = useState<LeagueSeasonSummaryViewModel[]>([]);
// Form state
const [seasonId, setSeasonId] = useState<string>('');
const [schedule, setSchedule] = useState<LeagueAdminScheduleViewModel | null>(null);
const [loading, setLoading] = useState(false);
const [track, setTrack] = useState('');
const [car, setCar] = useState('');
const [scheduledAtIso, setScheduledAtIso] = useState('');
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
const isEditing = editingRaceId !== null;
const publishedLabel = schedule?.published ? 'Published' : 'Unpublished';
// Check admin status using domain hook
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
const selectedSeasonLabel = useMemo(() => {
const selected = seasons.find((s) => s.seasonId === seasonId);
return selected?.name ?? seasonId;
}, [seasons, seasonId]);
// Load seasons using domain hook
const { data: seasonsData, isLoading: seasonsLoading } = useLeagueSeasons(leagueId, !!isAdmin);
const loadSchedule = async (leagueIdToLoad: string, seasonIdToLoad: string) => {
setLoading(true);
try {
const vm = await leagueService.getAdminSchedule(leagueIdToLoad, seasonIdToLoad);
setSchedule(vm);
} finally {
setLoading(false);
}
};
// Auto-select season
const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId
: '');
useEffect(() => {
async function checkAdmin() {
setMembershipLoading(true);
try {
await leagueMembershipService.fetchLeagueMemberships(leagueId);
} finally {
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
setMembershipLoading(false);
// Load schedule using domain hook
const { data: schedule, isLoading: scheduleLoading, refetch: refetchSchedule } = useLeagueAdminSchedule(leagueId, selectedSeasonId, !!isAdmin);
// Mutations
const publishMutation = useMutation({
mutationFn: async () => {
if (!schedule || !selectedSeasonId) return null;
return schedule.published
? await leagueService.unpublishAdminSchedule(leagueId, selectedSeasonId)
: await leagueService.publishAdminSchedule(leagueId, selectedSeasonId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
const saveMutation = useMutation({
mutationFn: async () => {
if (!selectedSeasonId || !scheduledAtIso) return null;
if (!editingRaceId) {
return await leagueService.createAdminScheduleRace(leagueId, selectedSeasonId, {
track,
car,
scheduledAtIso,
});
} else {
return await leagueService.updateAdminScheduleRace(leagueId, selectedSeasonId, editingRaceId, {
...(track ? { track } : {}),
...(car ? { car } : {}),
...(scheduledAtIso ? { scheduledAtIso } : {}),
});
}
}
checkAdmin();
}, [leagueId, currentDriverId, leagueMembershipService]);
useEffect(() => {
async function loadSeasons() {
const loaded = await leagueService.getLeagueSeasonSummaries(leagueId);
setSeasons(loaded);
if (loaded.length > 0) {
const active = loaded.find((s) => s.status === 'active') ?? loaded[0];
setSeasonId(active?.seasonId ?? '');
}
}
if (isAdmin) {
loadSeasons();
}
}, [leagueId, isAdmin, leagueService]);
useEffect(() => {
if (!isAdmin) return;
if (!seasonId) return;
loadSchedule(leagueId, seasonId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leagueId, seasonId, isAdmin]);
const handlePublishToggle = async () => {
if (!schedule) return;
if (schedule.published) {
const vm = await leagueService.unpublishAdminSchedule(leagueId, seasonId);
setSchedule(vm);
return;
}
const vm = await leagueService.publishAdminSchedule(leagueId, seasonId);
setSchedule(vm);
};
const handleAddOrSave = async () => {
if (!seasonId) return;
if (!scheduledAtIso) return;
if (!isEditing) {
const vm = await leagueService.createAdminScheduleRace(leagueId, seasonId, {
track,
car,
scheduledAtIso,
});
setSchedule(vm);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
// Reset form
setTrack('');
setCar('');
setScheduledAtIso('');
return;
}
setEditingRaceId(null);
},
});
const vm = await leagueService.updateAdminScheduleRace(leagueId, seasonId, editingRaceId, {
...(track ? { track } : {}),
...(car ? { car } : {}),
...(scheduledAtIso ? { scheduledAtIso } : {}),
});
const deleteMutation = useMutation({
mutationFn: async (raceId: string) => {
return await leagueService.deleteAdminScheduleRace(leagueId, selectedSeasonId, raceId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
setSchedule(vm);
// Derived states
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
const isPublishing = publishMutation.isPending;
const isSaving = saveMutation.isPending;
const isDeleting = deleteMutation.variables || null;
// Handlers
const handleSeasonChange = (newSeasonId: string) => {
setSeasonId(newSeasonId);
setEditingRaceId(null);
setTrack('');
setCar('');
setScheduledAtIso('');
};
const handlePublishToggle = () => {
publishMutation.mutate();
};
const handleAddOrSave = () => {
if (!scheduledAtIso) return;
saveMutation.mutate();
};
const handleEdit = (raceId: string) => {
if (!schedule) return;
const race = schedule.races.find((r) => r.id === raceId);
if (!race) return;
@@ -144,190 +128,78 @@ export default function LeagueAdminSchedulePage() {
setScheduledAtIso(race.scheduledAt.toISOString());
};
const handleDelete = async (raceId: string) => {
const handleDelete = (raceId: string) => {
const confirmed = window.confirm('Delete this race?');
if (!confirmed) return;
const vm = await leagueService.deleteAdminScheduleRace(leagueId, seasonId, raceId);
setSchedule(vm);
deleteMutation.mutate(raceId);
};
if (membershipLoading) {
return (
<Card>
<div className="py-6 text-sm text-gray-400">Loading</div>
</Card>
);
}
const handleCancelEdit = () => {
setEditingRaceId(null);
setTrack('');
setCar('');
setScheduledAtIso('');
};
if (!isAdmin) {
// Prepare template data
const templateData = schedule && seasonsData && selectedSeasonId
? {
schedule,
seasons: seasonsData,
seasonId: selectedSeasonId,
}
: undefined;
// Render admin access required if not admin
if (!isLoading && !isAdmin) {
return (
<Card>
<div className="text-center py-12">
<div className="space-y-6">
<div className="bg-deep-graphite border border-charcoal-outline rounded-lg p-6 text-center">
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">Only league admins can manage the schedule.</p>
</div>
</Card>
</div>
);
}
// Template component that wraps the actual template with all props
const TemplateWrapper = ({ data }: { data: typeof templateData }) => {
if (!data) return null;
return (
<LeagueAdminScheduleTemplate
data={data}
onSeasonChange={handleSeasonChange}
onPublishToggle={handlePublishToggle}
onAddOrSave={handleAddOrSave}
onEdit={handleEdit}
onDelete={handleDelete}
onCancelEdit={handleCancelEdit}
track={track}
car={car}
scheduledAtIso={scheduledAtIso}
editingRaceId={editingRaceId}
isPublishing={isPublishing}
isSaving={isSaving}
isDeleting={isDeleting}
setTrack={setTrack}
setCar={setCar}
setScheduledAtIso={setScheduledAtIso}
/>
);
};
return (
<div className="space-y-6">
<Card>
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-white">Schedule Admin</h1>
<p className="text-sm text-gray-400">Create, edit, and publish season races.</p>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm text-gray-300" htmlFor="seasonId">
Season
</label>
{seasons.length > 0 ? (
<select
id="seasonId"
value={seasonId}
onChange={(e) => setSeasonId(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
>
{seasons.map((s) => (
<option key={s.seasonId} value={s.seasonId}>
{s.name}
</option>
))}
</select>
) : (
<input
id="seasonId"
value={seasonId}
onChange={(e) => setSeasonId(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
placeholder="season-id"
/>
)}
<p className="text-xs text-gray-500">Selected: {selectedSeasonLabel}</p>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-gray-300">
Status: <span className="font-medium text-white">{publishedLabel}</span>
</p>
<button
type="button"
onClick={handlePublishToggle}
disabled={!schedule}
className="px-3 py-1.5 rounded bg-primary-blue text-white disabled:opacity-50"
>
{schedule?.published ? 'Unpublish' : 'Publish'}
</button>
</div>
<div className="border-t border-charcoal-outline pt-4 space-y-3">
<h2 className="text-lg font-semibold text-white">{isEditing ? 'Edit race' : 'Add race'}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="track" className="text-sm text-gray-300">
Track
</label>
<input
id="track"
value={track}
onChange={(e) => setTrack(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="car" className="text-sm text-gray-300">
Car
</label>
<input
id="car"
value={car}
onChange={(e) => setCar(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="scheduledAtIso" className="text-sm text-gray-300">
Scheduled At (ISO)
</label>
<input
id="scheduledAtIso"
value={scheduledAtIso}
onChange={(e) => setScheduledAtIso(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
placeholder="2025-01-01T12:00:00.000Z"
/>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleAddOrSave}
className="px-3 py-1.5 rounded bg-primary-blue text-white"
>
{isEditing ? 'Save' : 'Add race'}
</button>
{isEditing && (
<button
type="button"
onClick={() => setEditingRaceId(null)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
>
Cancel
</button>
)}
</div>
</div>
<div className="border-t border-charcoal-outline pt-4 space-y-3">
<h2 className="text-lg font-semibold text-white">Races</h2>
{loading ? (
<div className="py-4 text-sm text-gray-400">Loading schedule</div>
) : schedule?.races.length ? (
<div className="space-y-2">
{schedule.races.map((race) => (
<div
key={race.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">{race.name}</p>
<p className="text-xs text-gray-400 truncate">{race.scheduledAt.toISOString()}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleEdit(race.id)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
>
Edit
</button>
<button
type="button"
onClick={() => handleDelete(race.id)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
>
Delete
</button>
</div>
</div>
))}
</div>
) : (
<div className="py-4 text-sm text-gray-500">No races yet.</div>
)}
</div>
</div>
</Card>
</div>
<PageWrapper
data={templateData}
isLoading={isLoading}
error={null}
Template={TemplateWrapper}
loading={{ variant: 'full-screen', message: 'Loading schedule admin...' }}
empty={{
title: 'No schedule data available',
description: 'Unable to load schedule administration data',
}}
/>
);
}

View File

@@ -1,3 +1,84 @@
import LeagueScheduleInteractive from './LeagueScheduleInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { LeagueService } from '@/lib/services/leagues/LeagueService';
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { notFound } from 'next/navigation';
import type { LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
export default LeagueScheduleInteractive;
interface Props {
params: { id: string };
}
export default async function Page({ params }: Props) {
// Validate params
if (!params.id) {
notFound();
}
// Fetch data using PageDataFetcher.fetchManual for multiple dependencies
const data = await PageDataFetcher.fetchManual(async () => {
// Create dependencies
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Create API clients
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new LeagueService(
leaguesApiClient,
driversApiClient,
sponsorsApiClient,
racesApiClient
);
// Fetch data
const result = await service.getLeagueSchedule(params.id);
if (!result) {
throw new Error('League schedule not found');
}
return result;
});
if (!data) {
notFound();
}
// Create a wrapper component that passes data to the template
const TemplateWrapper = ({ data }: { data: LeagueScheduleViewModel }) => {
return (
<LeagueScheduleTemplate
data={data}
leagueId={params.id}
/>
);
};
return (
<PageWrapper
data={data}
Template={TemplateWrapper}
loading={{ variant: 'skeleton', message: 'Loading schedule...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'Schedule not found',
description: 'The schedule for this league is not available.',
}}
/>
);
}

View File

@@ -23,14 +23,14 @@ export default function LeagueSettingsPage() {
const router = useRouter();
// Check admin status using DI + React-Query
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId ?? '');
// Load settings (only if admin) using DI + React-Query
const { data: settings, isLoading: settingsLoading, error, retry } = useLeagueSettings(leagueId, { enabled: !!isAdmin });
const handleTransferOwnership = async (newOwnerId: string) => {
try {
await leagueSettingsService.transferOwnership(leagueId, currentDriverId, newOwnerId);
await leagueSettingsService.transferOwnership(leagueId, currentDriverId ?? '', newOwnerId);
router.refresh();
} catch (err) {
throw err; // Let the component handle the error
@@ -94,7 +94,7 @@ export default function LeagueSettingsPage() {
<LeagueOwnershipTransfer
settings={settingsData!}
currentDriverId={currentDriverId}
currentDriverId={currentDriverId ?? ''}
onTransferOwnership={handleTransferOwnership}
/>
</div>

View File

@@ -1,83 +1,20 @@
'use client';
import { LeagueSponsorshipsSection } from '@/components/leagues/LeagueSponsorshipsSection';
import Card from '@/components/ui/Card';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LeaguePageDetailViewModel } from '@/lib/view-models/LeaguePageDetailViewModel';
import { AlertTriangle, Building } from 'lucide-react';
import { useLeagueSponsorshipsPageData } from '@/hooks/league/useLeagueSponsorshipsPageData';
import { ApiError } from '@/lib/api/base/ApiError';
import { Building } from 'lucide-react';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
export default function LeagueSponsorshipsPage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const [league, setLeague] = useState<LeaguePageDetailViewModel | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadData() {
try {
const [leagueDetail] = await Promise.all([
leagueService.getLeagueDetail(leagueId, currentDriverId),
leagueMembershipService.fetchLeagueMemberships(leagueId),
]);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setLeague(leagueDetail);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
} catch (err) {
console.error('Failed to load league:', err);
} finally {
setLoading(false);
}
}
loadData();
}, [leagueId, currentDriverId, leagueService, leagueMembershipService]);
if (loading) {
return (
<Card>
<div className="py-6 text-sm text-gray-400 text-center">Loading sponsorships...</div>
</Card>
);
}
if (!isAdmin) {
return (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">
Only league admins can manage sponsorships.
</p>
</div>
</Card>
);
}
if (!league) {
return (
<Card>
<div className="py-6 text-sm text-gray-500 text-center">
League not found.
</div>
</Card>
);
}
interface SponsorshipsData {
league: any;
isAdmin: boolean;
}
function SponsorshipsTemplate({ data }: { data: SponsorshipsData }) {
return (
<div className="space-y-6">
{/* Header */}
@@ -92,12 +29,51 @@ export default function LeagueSponsorshipsPage() {
</div>
{/* Sponsorships Section */}
<Card>
<LeagueSponsorshipsSection
leagueId={leagueId}
readOnly={false}
/>
</Card>
<LeagueSponsorshipsSection
leagueId={data.league.id}
readOnly={false}
/>
</div>
);
}
export default function LeagueSponsorshipsPage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
// Fetch data using domain hook
const { data, isLoading, error, refetch } = useLeagueSponsorshipsPageData(leagueId, currentDriverId);
// Transform data for the template
const transformedData: SponsorshipsData | undefined = data?.league && data.membership !== null
? {
league: data.league,
isAdmin: LeagueRoleUtility.isLeagueAdminOrHigherRole(data.membership?.role || 'member'),
}
: undefined;
// Check if user is not admin to show appropriate state
const isNotAdmin = transformedData && !transformedData.isAdmin;
return (
<StatefulPageWrapper
data={transformedData}
isLoading={isLoading}
error={error as ApiError | null}
retry={refetch}
Template={SponsorshipsTemplate}
loading={{ variant: 'skeleton', message: 'Loading sponsorships...' }}
errorConfig={{ variant: 'full-screen' }}
empty={isNotAdmin ? {
icon: Building,
title: 'Admin Access Required',
description: 'Only league admins can manage sponsorships.',
} : {
icon: Building,
title: 'League not found',
description: 'The league may have been deleted or is no longer accessible.',
}}
/>
);
}

View File

@@ -1,98 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
export default function LeagueStandingsInteractive() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const [standings, setStandings] = useState<StandingEntryViewModel[]>([]);
const [drivers, setDrivers] = useState<DriverViewModel[]>([]);
const [memberships, setMemberships] = useState<LeagueMembership[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const loadData = useCallback(async () => {
try {
const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId);
setStandings(vm.standings);
setDrivers(vm.drivers.map((d) => new DriverViewModel({ ...d, avatarUrl: (d as any).avatarUrl ?? null })));
setMemberships(vm.memberships);
// Check if current user is admin
const membership = vm.memberships.find(m => m.driverId === currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load standings');
} finally {
setLoading(false);
}
}, [leagueId, currentDriverId, leagueService]);
useEffect(() => {
loadData();
}, [loadData]);
const handleRemoveMember = async (driverId: string) => {
if (!confirm('Are you sure you want to remove this member?')) {
return;
}
try {
await leagueService.removeMember(leagueId, currentDriverId, driverId);
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove member');
}
};
const handleUpdateRole = async (driverId: string, newRole: string) => {
try {
await leagueService.updateMemberRole(leagueId, currentDriverId, driverId, newRole);
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
if (loading) {
return (
<div className="text-center text-gray-400">
Loading standings...
</div>
);
}
if (error) {
return (
<div className="text-center text-warning-amber">
{error}
</div>
);
}
return (
<LeagueStandingsTemplate
standings={standings}
drivers={drivers}
memberships={memberships}
leagueId={leagueId}
currentDriverId={currentDriverId}
isAdmin={isAdmin}
onRemoveMember={handleRemoveMember}
onUpdateRole={handleUpdateRole}
/>
);
}

View File

@@ -1,71 +0,0 @@
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
interface LeagueStandingsStaticProps {
leagueId: string;
currentDriverId?: string | null;
}
export default async function LeagueStandingsStatic({ leagueId, currentDriverId }: LeagueStandingsStaticProps) {
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
const leagueService = serviceFactory.createLeagueService();
let standings: StandingEntryViewModel[] = [];
let drivers: DriverViewModel[] = [];
let memberships: LeagueMembership[] = [];
let loading = false;
let error: string | null = null;
let isAdmin = false;
try {
loading = true;
const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId || '');
standings = vm.standings;
drivers = vm.drivers.map((d) => new DriverViewModel({ ...d, avatarUrl: (d as any).avatarUrl ?? null }));
memberships = vm.memberships;
// Check if current user is admin
const membership = vm.memberships.find(m => m.driverId === currentDriverId);
isAdmin = membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load standings';
} finally {
loading = false;
}
if (loading) {
return (
<div className="text-center text-gray-400">
Loading standings...
</div>
);
}
if (error) {
return (
<div className="text-center text-warning-amber">
{error}
</div>
);
}
// Server components can't have event handlers, so we provide empty functions
// The Interactive wrapper will add the actual handlers
return (
<LeagueStandingsTemplate
standings={standings}
drivers={drivers}
memberships={memberships}
leagueId={leagueId}
currentDriverId={currentDriverId ?? null}
isAdmin={isAdmin}
onRemoveMember={() => {}}
onUpdateRole={() => {}}
/>
);
}

View File

@@ -1,3 +1,107 @@
import LeagueStandingsInteractive from './LeagueStandingsInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { LeagueService } from '@/lib/services/leagues/LeagueService';
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { notFound } from 'next/navigation';
import type { LeagueStandingsViewModel } from '@/lib/view-models/LeagueStandingsViewModel';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
export default LeagueStandingsInteractive;
interface Props {
params: { id: string };
}
export default async function Page({ params }: Props) {
// Validate params
if (!params.id) {
notFound();
}
// Fetch data using PageDataFetcher.fetchManual for multiple dependencies
const data = await PageDataFetcher.fetchManual(async () => {
// Create dependencies
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Create API clients
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new LeagueService(
leaguesApiClient,
driversApiClient,
sponsorsApiClient,
racesApiClient
);
// Fetch data - using empty string for currentDriverId since this is SSR without session
const result = await service.getLeagueStandings(params.id, '');
if (!result) {
throw new Error('League standings not found');
}
return result;
});
if (!data) {
notFound();
}
// Transform data for template
const standings = data.standings ?? [];
const drivers: DriverViewModel[] = data.drivers?.map((d) =>
new DriverViewModel({
id: d.id,
name: d.name,
avatarUrl: d.avatarUrl || null,
iracingId: d.iracingId,
rating: d.rating,
country: d.country,
})
) ?? [];
const memberships: LeagueMembership[] = data.memberships ?? [];
// Create a wrapper component that passes data to the template
const TemplateWrapper = () => {
return (
<LeagueStandingsTemplate
standings={standings}
drivers={drivers}
memberships={memberships}
leagueId={params.id}
currentDriverId={null}
isAdmin={false}
onRemoveMember={() => {}}
onUpdateRole={() => {}}
loading={false}
/>
);
};
return (
<PageWrapper
data={data}
Template={TemplateWrapper}
loading={{ variant: 'skeleton', message: 'Loading standings...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'Standings not found',
description: 'The standings for this league are not available.',
}}
/>
);
}

View File

@@ -0,0 +1,359 @@
'use client';
import PenaltyFAB from '@/components/leagues/PenaltyFAB';
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import StewardingStats from '@/components/leagues/StewardingStats';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useLeagueStewardingMutations } from '@/hooks/league/useLeagueStewardingMutations';
import {
AlertCircle,
AlertTriangle,
Calendar,
ChevronRight,
Flag,
Gavel,
MapPin,
Video
} from 'lucide-react';
import Link from 'next/link';
import { useMemo, useState } from 'react';
interface StewardingData {
totalPending: number;
totalResolved: number;
totalPenalties: number;
racesWithData: Array<{
race: { id: string; track: string; scheduledAt: Date };
pendingProtests: any[];
resolvedProtests: any[];
penalties: any[];
}>;
allDrivers: any[];
driverMap: Record<string, any>;
}
interface StewardingTemplateProps {
data: StewardingData;
leagueId: string;
currentDriverId: string;
onRefetch: () => void;
}
export function StewardingTemplate({ data, leagueId, currentDriverId, onRefetch }: StewardingTemplateProps) {
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
// Mutations using domain hook
const { acceptProtestMutation, rejectProtestMutation } = useLeagueStewardingMutations(onRefetch);
// Filter races based on active tab
const filteredRaces = useMemo(() => {
return activeTab === 'pending' ? data.racesWithData.filter(r => r.pendingProtests.length > 0) : data.racesWithData.filter(r => r.resolvedProtests.length > 0 || r.penalties.length > 0);
}, [data, activeTab]);
const handleAcceptProtest = async (
protestId: string,
penaltyType: string,
penaltyValue: number,
stewardNotes: string
) => {
// Find the protest to get details for penalty
let foundProtest: any | undefined;
data.racesWithData.forEach(raceData => {
const p = raceData.pendingProtests.find(pr => pr.id === protestId) ||
raceData.resolvedProtests.find(pr => pr.id === protestId);
if (p) foundProtest = { ...p, raceId: raceData.race.id };
});
if (foundProtest) {
acceptProtestMutation.mutate({
protestId,
penaltyType,
penaltyValue,
stewardNotes,
raceId: foundProtest.raceId,
accusedDriverId: foundProtest.accusedDriverId,
reason: foundProtest.incident.description,
});
}
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
rejectProtestMutation.mutate({
protestId,
stewardNotes,
});
};
const toggleRaceExpanded = (raceId: string) => {
setExpandedRaces(prev => {
const next = new Set(prev);
if (next.has(raceId)) {
next.delete(raceId);
} else {
next.add(raceId);
}
return next;
});
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
case 'under_review':
return <span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</span>;
case 'upheld':
return <span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">Upheld</span>;
case 'dismissed':
return <span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">Dismissed</span>;
case 'withdrawn':
return <span className="px-2 py-0.5 text-xs font-medium bg-blue-500/20 text-blue-400 rounded-full">Withdrawn</span>;
default:
return null;
}
};
return (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
Quick overview of protests and penalties across all races
</p>
</div>
</div>
{/* Stats summary */}
<StewardingStats
totalPending={data.totalPending}
totalResolved={data.totalResolved}
totalPenalties={data.totalPenalties}
/>
{/* Tab navigation */}
<div className="border-b border-charcoal-outline mb-6">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'pending'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Protests
{data.totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{data.totalPending}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'history'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
History
</button>
</div>
</div>
{/* Content */}
{filteredRaces.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="w-8 h-8 text-performance-green" />
</div>
<p className="font-semibold text-lg text-white mb-2">
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
</p>
<p className="text-sm text-gray-400">
{activeTab === 'pending'
? 'No pending protests to review'
: 'No resolved protests or penalties'}
</p>
</div>
) : (
<div className="space-y-4">
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
const isExpanded = expandedRaces.has(race.id);
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
return (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
{/* Race Header */}
<button
onClick={() => toggleRaceExpanded(race.id)}
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>{race.scheduledAt.toLocaleDateString()}</span>
</div>
{activeTab === 'pending' && pendingProtests.length > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
{pendingProtests.length} pending
</span>
)}
{activeTab === 'history' && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
{resolvedProtests.length} protests, {penalties.length} penalties
</span>
)}
</div>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 space-y-3 bg-deep-graphite/50">
{displayProtests.length === 0 && penalties.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
) : (
<>
{displayProtests.map((protest) => {
const protester = data.driverMap[protest.protestingDriverId];
const accused = data.driverMap[protest.accusedDriverId];
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
return (
<div
key={protest.id}
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
<span className="font-medium text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span>
{getStatusBadge(protest.status)}
{isUrgent && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
{protest.proofVideoUrl && (
<>
<span></span>
<span className="flex items-center gap-1 text-primary-blue">
<Video className="w-3 h-3" />
Video
</span>
</>
)}
</div>
<p className="text-sm text-gray-300 line-clamp-2">
{protest.incident.description}
</p>
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
{(protest.status === 'pending' || protest.status === 'under_review') && (
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button variant="primary">
Review
</Button>
</Link>
)}
</div>
</div>
);
})}
{activeTab === 'history' && penalties.map((penalty) => {
const driver = data.driverMap[penalty.driverId];
return (
<div
key={penalty.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<Gavel className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-red-400">
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
{penalty.type === 'disqualification' && 'DSQ'}
{penalty.type === 'warning' && 'Warning'}
{penalty.type === 'license_points' && `${penalty.value} LP`}
</span>
</div>
</div>
</div>
);
})}
</>
)}
</div>
)}
</div>
);
})}
</div>
)}
</Card>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{showQuickPenaltyModal && (
<QuickPenaltyModal
drivers={data.allDrivers}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId || ''}
races={data.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
/>
)}
</div>
);
}

View File

@@ -1,134 +1,24 @@
'use client';
import PenaltyFAB from '@/components/leagues/PenaltyFAB';
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import StewardingStats from '@/components/leagues/StewardingStats';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useCurrentDriver } from '@/hooks/driver/useCurrentDriver';
import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus';
import { useLeagueStewardingData } from '@/hooks/league/useLeagueStewardingData';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import {
AlertCircle,
AlertTriangle,
Calendar,
ChevronRight,
Flag,
Gavel,
MapPin,
Video
} from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMemo, useState } from 'react';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useLeagueStewardingMutations } from '@/hooks/league/useLeagueStewardingMutations';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { StewardingTemplate } from './StewardingTemplate';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import Card from '@/components/ui/Card';
import { AlertTriangle } from 'lucide-react';
import { useParams } from 'next/navigation';
export default function LeagueStewardingPage() {
const params = useParams();
const leagueId = params.id as string;
const { data: currentDriver } = useCurrentDriver();
const currentDriverId = currentDriver?.id;
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
const currentDriverId = currentDriver?.id || '';
// Check admin status
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
// Load stewarding data (only if admin)
const { data: stewardingData, isLoading: dataLoading, error, retry } = useLeagueStewardingData(leagueId);
// Filter races based on active tab
const filteredRaces = useMemo(() => {
return activeTab === 'pending' ? stewardingData?.pendingRaces ?? [] : stewardingData?.historyRaces ?? [];
}, [stewardingData, activeTab]);
const handleAcceptProtest = async (
protestId: string,
penaltyType: string,
penaltyValue: number,
stewardNotes: string
) => {
// Find the protest to get details for penalty
let foundProtest: any | undefined;
stewardingData?.racesWithData.forEach(raceData => {
const p = raceData.pendingProtests.find(pr => pr.id === protestId) ||
raceData.resolvedProtests.find(pr => pr.id === protestId);
if (p) foundProtest = { ...p, raceId: raceData.race.id };
});
if (foundProtest) {
// TODO: Implement protest review and penalty application
// await leagueStewardingService.reviewProtest({
// protestId,
// stewardId: currentDriverId,
// decision: 'uphold',
// decisionNotes: stewardNotes,
// });
// await leagueStewardingService.applyPenalty({
// raceId: foundProtest.raceId,
// driverId: foundProtest.accusedDriverId,
// stewardId: currentDriverId,
// type: penaltyType,
// value: penaltyValue,
// reason: foundProtest.incident.description,
// protestId,
// notes: stewardNotes,
// });
}
// Retry to refresh data
await retry();
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
// TODO: Implement protest rejection
// await leagueStewardingService.reviewProtest({
// protestId,
// stewardId: currentDriverId,
// decision: 'dismiss',
// decisionNotes: stewardNotes,
// });
// Retry to refresh data
await retry();
};
const toggleRaceExpanded = (raceId: string) => {
setExpandedRaces(prev => {
const next = new Set(prev);
if (next.has(raceId)) {
next.delete(raceId);
} else {
next.add(raceId);
}
return next;
});
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
case 'under_review':
return <span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</span>;
case 'upheld':
return <span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">Upheld</span>;
case 'dismissed':
return <span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">Dismissed</span>;
case 'withdrawn':
return <span className="px-2 py-0.5 text-xs font-medium bg-blue-500/20 text-blue-400 rounded-full">Withdrawn</span>;
default:
return null;
}
};
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
// Show loading for admin check
if (adminLoading) {
@@ -152,265 +42,30 @@ export default function LeagueStewardingPage() {
);
}
// Load stewarding data using domain hook
const { data, isLoading, error, refetch } = useLeagueStewardingData(leagueId);
return (
<StateContainer
data={stewardingData}
isLoading={dataLoading}
<StatefulPageWrapper
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading stewarding data...' },
error: { variant: 'full-screen' },
empty: {
icon: Flag,
title: 'No stewarding data',
description: 'There are no protests or penalties to review.',
}
retry={refetch}
Template={({ data }) => (
<StewardingTemplate
data={data}
leagueId={leagueId}
currentDriverId={currentDriverId}
onRefetch={refetch}
/>
)}
loading={{ variant: 'skeleton', message: 'Loading stewarding data...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: require('lucide-react').Flag,
title: 'No stewarding data',
description: 'There are no protests or penalties to review.',
}}
>
{(data) => {
if (!data) return null;
return (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
Quick overview of protests and penalties across all races
</p>
</div>
</div>
{/* Stats summary */}
<StewardingStats
totalPending={data.totalPending}
totalResolved={data.totalResolved}
totalPenalties={data.totalPenalties}
/>
{/* Tab navigation */}
<div className="border-b border-charcoal-outline mb-6">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'pending'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Protests
{data.totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{data.totalPending}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'history'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
History
</button>
</div>
</div>
{/* Content */}
{filteredRaces.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="w-8 h-8 text-performance-green" />
</div>
<p className="font-semibold text-lg text-white mb-2">
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
</p>
<p className="text-sm text-gray-400">
{activeTab === 'pending'
? 'No pending protests to review'
: 'No resolved protests or penalties'}
</p>
</div>
) : (
<div className="space-y-4">
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
const isExpanded = expandedRaces.has(race.id);
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
return (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
{/* Race Header */}
<button
onClick={() => toggleRaceExpanded(race.id)}
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>{race.scheduledAt.toLocaleDateString()}</span>
</div>
{activeTab === 'pending' && pendingProtests.length > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
{pendingProtests.length} pending
</span>
)}
{activeTab === 'history' && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
{resolvedProtests.length} protests, {penalties.length} penalties
</span>
)}
</div>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 space-y-3 bg-deep-graphite/50">
{displayProtests.length === 0 && penalties.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
) : (
<>
{displayProtests.map((protest) => {
const protester = data.driverMap[protest.protestingDriverId];
const accused = data.driverMap[protest.accusedDriverId];
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
return (
<div
key={protest.id}
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
<span className="font-medium text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span>
{getStatusBadge(protest.status)}
{isUrgent && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
{protest.proofVideoUrl && (
<>
<span></span>
<span className="flex items-center gap-1 text-primary-blue">
<Video className="w-3 h-3" />
Video
</span>
</>
)}
</div>
<p className="text-sm text-gray-300 line-clamp-2">
{protest.incident.description}
</p>
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
{(protest.status === 'pending' || protest.status === 'under_review') && (
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button variant="primary">
Review
</Button>
</Link>
)}
</div>
</div>
);
})}
{activeTab === 'history' && penalties.map((penalty) => {
const driver = data.driverMap[penalty.driverId];
return (
<div
key={penalty.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<Gavel className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-red-400">
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
{penalty.type === 'disqualification' && 'DSQ'}
{penalty.type === 'warning' && 'Warning'}
{penalty.type === 'license_points' && `${penalty.value} LP`}
</span>
</div>
</div>
</div>
);
})}
</>
)}
</div>
)}
</div>
);
})}
</div>
)}
</Card>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{showQuickPenaltyModal && stewardingData && (
<QuickPenaltyModal
drivers={stewardingData.allDrivers}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId || ''}
races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
/>
)}
</div>
);
}}
</StateContainer>
/>
);
}

View File

@@ -156,7 +156,7 @@ export default function ProtestReviewPage() {
}, [penaltyTypes, penaltyType]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !detail) return;
if (!decision || !stewardNotes.trim() || !detail || !currentDriverId) return;
setSubmitting(true);
try {
@@ -241,7 +241,7 @@ export default function ProtestReviewPage() {
};
const handleRequestDefense = async () => {
if (!detail) return;
if (!detail || !currentDriverId) return;
try {
// Request defense
@@ -734,4 +734,4 @@ export default function ProtestReviewPage() {
}}
</StateContainer>
);
}
}

View File

@@ -0,0 +1,300 @@
'use client';
import React, { useState } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import TransactionRow from '@/components/leagues/TransactionRow';
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
import {
Wallet,
DollarSign,
ArrowUpRight,
Clock,
AlertTriangle,
Download,
TrendingUp
} from 'lucide-react';
interface WalletTemplateProps {
data: LeagueWalletViewModel;
onWithdraw?: (amount: number) => void;
onExport?: () => void;
mutationLoading?: boolean;
}
export function WalletTemplate({ data, onWithdraw, onExport, mutationLoading = false }: WalletTemplateProps) {
const [withdrawAmount, setWithdrawAmount] = useState('');
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all');
const filteredTransactions = data.getFilteredTransactions(filterType);
const handleWithdrawClick = () => {
const amount = parseFloat(withdrawAmount);
if (!amount || amount <= 0) return;
if (onWithdraw) {
onWithdraw(amount);
setShowWithdrawModal(false);
setWithdrawAmount('');
}
};
return (
<div className="max-w-6xl mx-auto py-8 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">League Wallet</h1>
<p className="text-gray-400">Manage your league's finances and payouts</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onExport}>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button
variant="primary"
onClick={() => setShowWithdrawModal(true)}
disabled={!data.canWithdraw || !onWithdraw}
>
<ArrowUpRight className="w-4 h-4 mr-2" />
Withdraw
</Button>
</div>
</div>
{/* Withdrawal Warning */}
{!data.canWithdraw && data.withdrawalBlockReason && (
<div className="mb-6 p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-warning-amber">Withdrawals Temporarily Unavailable</h3>
<p className="text-sm text-gray-400 mt-1">{data.withdrawalBlockReason}</p>
</div>
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-performance-green/10">
<Wallet className="w-6 h-6 text-performance-green" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedBalance}</div>
<div className="text-sm text-gray-400">Available Balance</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<TrendingUp className="w-6 h-6 text-primary-blue" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedTotalRevenue}</div>
<div className="text-sm text-gray-400">Total Revenue</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10">
<DollarSign className="w-6 h-6 text-warning-amber" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedTotalFees}</div>
<div className="text-sm text-gray-400">Platform Fees (10%)</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-500/10">
<Clock className="w-6 h-6 text-purple-400" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedPendingPayouts}</div>
<div className="text-sm text-gray-400">Pending Payouts</div>
</div>
</div>
</Card>
</div>
{/* Transactions */}
<Card>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white">Transaction History</h2>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as typeof filterType)}
className="px-3 py-1.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white text-sm focus:border-primary-blue focus:outline-none"
>
<option value="all">All Transactions</option>
<option value="sponsorship">Sponsorships</option>
<option value="membership">Memberships</option>
<option value="withdrawal">Withdrawals</option>
<option value="prize">Prizes</option>
</select>
</div>
{filteredTransactions.length === 0 ? (
<div className="text-center py-12">
<Wallet className="w-12 h-12 text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No Transactions</h3>
<p className="text-gray-400">
{filterType === 'all'
? 'Revenue from sponsorships and fees will appear here.'
: `No ${filterType} transactions found.`}
</p>
</div>
) : (
<div>
{filteredTransactions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))}
</div>
)}
</Card>
{/* Revenue Breakdown */}
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Revenue Breakdown</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-primary-blue" />
<span className="text-gray-400">Sponsorships</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-performance-green" />
<span className="text-gray-400">Membership Fees</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-gray-300 font-medium">Total Gross Revenue</span>
<span className="font-bold text-white">$3,200.00</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-warning-amber">Platform Fee (10%)</span>
<span className="text-warning-amber">-$320.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-performance-green font-medium">Net Revenue</span>
<span className="font-bold text-performance-green">$2,880.00</span>
</div>
</div>
</Card>
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Payout Schedule</h3>
<div className="space-y-3">
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Season 2 Prize Pool</span>
<span className="text-sm font-medium text-warning-amber">Pending</span>
</div>
<p className="text-xs text-gray-500">
Distributed after season completion to top 3 drivers
</p>
</div>
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Available for Withdrawal</span>
<span className="text-sm font-medium text-performance-green">{data.formattedBalance}</span>
</div>
<p className="text-xs text-gray-500">
Available after Season 2 ends (estimated: Jan 15, 2026)
</p>
</div>
</div>
</Card>
</div>
{/* Withdraw Modal */}
{showWithdrawModal && onWithdraw && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md p-6">
<h2 className="text-xl font-semibold text-white mb-4">Withdraw Funds</h2>
{!data.canWithdraw ? (
<div className="p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30 mb-4">
<p className="text-sm text-warning-amber">{data.withdrawalBlockReason}</p>
</div>
) : (
<>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Amount to Withdraw
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
max={data.balance}
className="w-full pl-8 pr-4 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
placeholder="0.00"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Available: {data.formattedBalance}
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Destination
</label>
<select className="w-full px-3 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none">
<option>Bank Account ***1234</option>
</select>
</div>
</>
)}
<div className="flex gap-3 mt-6">
<Button
variant="secondary"
onClick={() => setShowWithdrawModal(false)}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleWithdrawClick}
disabled={!data.canWithdraw || mutationLoading || !withdrawAmount}
className="flex-1"
>
{mutationLoading ? 'Processing...' : 'Withdraw'}
</Button>
</div>
</Card>
</div>
)}
{/* Alpha Notice */}
<div className="mt-6 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Wallet management is demonstration-only.
Real payment processing and bank integrations will be available when the payment system is fully implemented.
The 10% platform fee and season-based withdrawal restrictions are enforced in the actual implementation.
</p>
</div>
</div>
);
}

View File

@@ -1,340 +1,52 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import TransactionRow from '@/components/leagues/TransactionRow';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
import {
Wallet,
DollarSign,
ArrowUpRight,
Clock,
AlertTriangle,
Download,
TrendingUp
} from 'lucide-react';
import { useLeagueWalletPageData, useLeagueWalletWithdrawal } from '@/hooks/league/useLeagueWalletPageData';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { WalletTemplate } from './WalletTemplate';
import { Wallet } from 'lucide-react';
export default function LeagueWalletPage() {
const params = useParams();
const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
const [wallet, setWallet] = useState<LeagueWalletViewModel | null>(null);
const [withdrawAmount, setWithdrawAmount] = useState('');
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
const [processing, setProcessing] = useState(false);
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all');
const leagueId = params.id as string;
useEffect(() => {
const loadWallet = async () => {
if (params.id) {
try {
const walletData = await leagueWalletService.getWalletForLeague(params.id as string);
setWallet(walletData);
} catch (error) {
console.error('Failed to load wallet:', error);
}
}
};
loadWallet();
}, [params.id, leagueWalletService]);
// Query for wallet data using domain hook
const { data, isLoading, error, refetch } = useLeagueWalletPageData(leagueId);
if (!wallet) {
return <div>Loading...</div>;
}
// Mutation for withdrawal using domain hook
const withdrawMutation = useLeagueWalletWithdrawal(leagueId, data, refetch);
const filteredTransactions = wallet.getFilteredTransactions(filterType);
// Export handler (placeholder)
const handleExport = () => {
alert('Export functionality coming soon!');
};
const handleWithdraw = async () => {
if (!withdrawAmount || parseFloat(withdrawAmount) <= 0) return;
setProcessing(true);
try {
const result = await leagueWalletService.withdraw(
params.id as string,
parseFloat(withdrawAmount),
wallet.currency,
'season-2', // Current active season
'bank-account-***1234'
);
if (!result.success) {
alert(result.message || 'Withdrawal failed');
return;
}
alert(`Withdrawal of $${withdrawAmount} processed successfully!`);
setShowWithdrawModal(false);
setWithdrawAmount('');
// Refresh wallet data
const updatedWallet = await leagueWalletService.getWalletForLeague(params.id as string);
setWallet(updatedWallet);
} catch (err) {
console.error('Withdrawal error:', err);
alert('Failed to process withdrawal');
} finally {
setProcessing(false);
}
// Render function for the template
const renderTemplate = (walletData: any) => {
return (
<WalletTemplate
data={walletData}
onWithdraw={(amount) => withdrawMutation.mutate({ amount })}
onExport={handleExport}
mutationLoading={withdrawMutation.isPending}
/>
);
};
return (
<div className="max-w-6xl mx-auto py-8 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">League Wallet</h1>
<p className="text-gray-400">Manage your league's finances and payouts</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary">
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button
variant="primary"
onClick={() => setShowWithdrawModal(true)}
disabled={!wallet.canWithdraw}
>
<ArrowUpRight className="w-4 h-4 mr-2" />
Withdraw
</Button>
</div>
</div>
{/* Withdrawal Warning */}
{!wallet.canWithdraw && wallet.withdrawalBlockReason && (
<div className="mb-6 p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-warning-amber">Withdrawals Temporarily Unavailable</h3>
<p className="text-sm text-gray-400 mt-1">{wallet.withdrawalBlockReason}</p>
</div>
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-performance-green/10">
<Wallet className="w-6 h-6 text-performance-green" />
</div>
<div>
<div className="text-2xl font-bold text-white">{wallet.formattedBalance}</div>
<div className="text-sm text-gray-400">Available Balance</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<TrendingUp className="w-6 h-6 text-primary-blue" />
</div>
<div>
<div className="text-2xl font-bold text-white">{wallet.formattedTotalRevenue}</div>
<div className="text-sm text-gray-400">Total Revenue</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10">
<DollarSign className="w-6 h-6 text-warning-amber" />
</div>
<div>
<div className="text-2xl font-bold text-white">{wallet.formattedTotalFees}</div>
<div className="text-sm text-gray-400">Platform Fees (10%)</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-500/10">
<Clock className="w-6 h-6 text-purple-400" />
</div>
<div>
<div className="text-2xl font-bold text-white">{wallet.formattedPendingPayouts}</div>
<div className="text-sm text-gray-400">Pending Payouts</div>
</div>
</div>
</Card>
</div>
{/* Transactions */}
<Card>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white">Transaction History</h2>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as typeof filterType)}
className="px-3 py-1.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white text-sm focus:border-primary-blue focus:outline-none"
>
<option value="all">All Transactions</option>
<option value="sponsorship">Sponsorships</option>
<option value="membership">Memberships</option>
<option value="withdrawal">Withdrawals</option>
<option value="prize">Prizes</option>
</select>
</div>
{filteredTransactions.length === 0 ? (
<div className="text-center py-12">
<Wallet className="w-12 h-12 text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No Transactions</h3>
<p className="text-gray-400">
{filterType === 'all'
? 'Revenue from sponsorships and fees will appear here.'
: `No ${filterType} transactions found.`}
</p>
</div>
) : (
<div>
{filteredTransactions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))}
</div>
)}
</Card>
{/* Revenue Breakdown */}
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Revenue Breakdown</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-primary-blue" />
<span className="text-gray-400">Sponsorships</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-performance-green" />
<span className="text-gray-400">Membership Fees</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-gray-300 font-medium">Total Gross Revenue</span>
<span className="font-bold text-white">$3,200.00</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-warning-amber">Platform Fee (10%)</span>
<span className="text-warning-amber">-$320.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-performance-green font-medium">Net Revenue</span>
<span className="font-bold text-performance-green">$2,880.00</span>
</div>
</div>
</Card>
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Payout Schedule</h3>
<div className="space-y-3">
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Season 2 Prize Pool</span>
<span className="text-sm font-medium text-warning-amber">Pending</span>
</div>
<p className="text-xs text-gray-500">
Distributed after season completion to top 3 drivers
</p>
</div>
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Available for Withdrawal</span>
<span className="text-sm font-medium text-performance-green">{wallet.formattedBalance}</span>
</div>
<p className="text-xs text-gray-500">
Available after Season 2 ends (estimated: Jan 15, 2026)
</p>
</div>
</div>
</Card>
</div>
{/* Withdraw Modal */}
{showWithdrawModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md p-6">
<h2 className="text-xl font-semibold text-white mb-4">Withdraw Funds</h2>
{!wallet.canWithdraw ? (
<div className="p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30 mb-4">
<p className="text-sm text-warning-amber">{wallet.withdrawalBlockReason}</p>
</div>
) : (
<>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Amount to Withdraw
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
max={wallet.balance}
className="w-full pl-8 pr-4 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
placeholder="0.00"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Available: {wallet.formattedBalance}
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Destination
</label>
<select className="w-full px-3 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none">
<option>Bank Account ***1234</option>
</select>
</div>
</>
)}
<div className="flex gap-3 mt-6">
<Button
variant="secondary"
onClick={() => setShowWithdrawModal(false)}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleWithdraw}
disabled={!wallet.canWithdraw || processing || !withdrawAmount}
className="flex-1"
>
{processing ? 'Processing...' : 'Withdraw'}
</Button>
</div>
</Card>
</div>
)}
{/* Alpha Notice */}
<div className="mt-6 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Wallet management is demonstration-only.
Real payment processing and bank integrations will be available when the payment system is fully implemented.
The 10% platform fee and season-based withdrawal restrictions are enforced in the actual implementation.
</p>
</div>
</div>
<PageWrapper
data={data}
isLoading={isLoading}
error={error}
retry={refetch}
Template={({ data }) => renderTemplate(data)}
loading={{ variant: 'skeleton', message: 'Loading wallet...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Wallet,
title: 'No wallet data available',
description: 'Wallet data will appear here once loaded',
}}
/>
);
}

View File

@@ -1,3 +1,19 @@
import LeaguesInteractive from './LeaguesInteractive';
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { LeagueService } from '@/lib/services/leagues/LeagueService';
export default LeaguesInteractive;
export default async function Page() {
const data = await PageDataFetcher.fetch<LeagueService, 'getAllLeagues'>(
LEAGUE_SERVICE_TOKEN,
'getAllLeagues'
);
if (!data) {
notFound();
}
return <PageWrapper data={data} Template={LeaguesTemplate} />;
}

View File

@@ -8,7 +8,12 @@ import { useAuth } from '@/lib/auth/AuthContext';
// Shared state components
import { useCurrentDriver } from '@/hooks/driver/useCurrentDriver';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
// Template component that accepts data
function OnboardingTemplate({ data }: { data: any }) {
return <OnboardingWizard />;
}
export default function OnboardingPage() {
const router = useRouter();
@@ -18,7 +23,7 @@ export default function OnboardingPage() {
const shouldRedirectToLogin = !session;
// Fetch current driver data using DI + React-Query
const { data: driver, isLoading } = useCurrentDriver({
const { data: driver, isLoading, error, refetch } = useCurrentDriver({
enabled: !!session,
});
@@ -38,21 +43,24 @@ export default function OnboardingPage() {
return null;
}
if (isLoading) {
return (
<main className="min-h-screen bg-deep-graphite">
<LoadingWrapper variant="full-screen" message="Loading onboarding..." />
</main>
);
}
if (shouldRedirectToDashboard) {
return null;
}
// For the StatefulPageWrapper, we need to provide data even if it's empty
// The page is workflow-driven, not data-driven
const wrapperData = driver || {};
return (
<main className="min-h-screen bg-deep-graphite">
<OnboardingWizard />
<StatefulPageWrapper
data={wrapperData}
isLoading={isLoading}
error={error}
retry={refetch}
Template={OnboardingTemplate}
loading={{ variant: 'full-screen', message: 'Loading onboarding...' }}
/>
</main>
);
}

View File

@@ -1,366 +1,15 @@
import { redirect } from 'next/navigation';
import Image from 'next/image';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import HomeTemplate from '@/templates/HomeTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { getHomeData } from '@/lib/services/home/getHomeData';
import { notFound } from 'next/navigation';
import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
import Hero from '@/components/landing/Hero';
import AlternatingSection from '@/components/landing/AlternatingSection';
import FeatureGrid from '@/components/landing/FeatureGrid';
import DiscordCTA from '@/components/landing/DiscordCTA';
import FAQ from '@/components/landing/FAQ';
import Footer from '@/components/landing/Footer';
import CareerProgressionMockup from '@/components/mockups/CareerProgressionMockup';
import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup';
import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup';
import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
import MockupStack from '@/components/ui/MockupStack';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getMediaUrl } from '@/lib/utilities/media';
export default async function HomePage() {
const baseUrl = getWebsiteApiBaseUrl();
const serviceFactory = new ServiceFactory(baseUrl);
const sessionService = serviceFactory.createSessionService();
const landingService = serviceFactory.createLandingService();
const session = await sessionService.getSession();
if (session) {
redirect('/dashboard');
export default async function Page() {
const data = await PageDataFetcher.fetchManual(async () => getHomeData());
if (!data) {
notFound();
}
const featureService = FeatureFlagService.fromEnv();
const isAlpha = featureService.isEnabled('alpha_features');
const discovery = await landingService.getHomeDiscovery();
const upcomingRaces = discovery.upcomingRaces;
const topLeagues = discovery.topLeagues;
const teams = discovery.teams;
return (
<main className="min-h-screen">
<Hero />
{/* Section 1: A Persistent Identity */}
<AlternatingSection
heading="A Persistent Identity"
backgroundVideo="/gameplay.mp4"
description={
<>
<p>
Your races, your seasons, your progress finally in one place.
</p>
<div className="space-y-3 mt-4">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">
Lifetime stats and season history across all your leagues
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">
Track your performance, consistency, and team contributions
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">
Your own rating that reflects real league competition
</span>
</div>
</div>
</div>
<p className="mt-4">
iRacing gives you physics. GridPilot gives you a career.
</p>
</>
}
mockup={<CareerProgressionMockup />}
layout="text-left"
/>
<FeatureGrid />
{/* Section 2: Results That Actually Stay */}
<AlternatingSection
heading="Results That Actually Stay"
backgroundImage="/images/ff1600.jpeg"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
Every race you run stays with you.
</p>
<div className="space-y-3 mt-4 md:mt-6">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
Your stats, your team, your story all connected
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
One race result updates your profile, team points, rating, and season history
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
No more fragmented data across spreadsheets and forums
</span>
</div>
</div>
</div>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
Your racing career, finally in one place.
</p>
</>
}
mockup={<MockupStack index={1}><RaceHistoryMockup /></MockupStack>}
layout="text-right"
/>
{/* Section 3: Automatic Session Creation */}
<AlternatingSection
heading="Automatic Session Creation"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
Setting up league races used to mean clicking through iRacing's wizard 20 times.
</p>
<div className="space-y-3 mt-4 md:mt-6">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">1</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
Our companion app syncs with your league schedule
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">2</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
When it's race time, it creates the iRacing session automatically
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">3</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
No clicking through wizards. No manual setup
</span>
</div>
</div>
</div>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
Automation instead of repetition.
</p>
</>
}
mockup={<CompanionAutomationMockup />}
layout="text-left"
/>
{/* Section 4: Game-Agnostic Platform */}
<AlternatingSection
heading="Built for iRacing. Ready for the future."
backgroundImage="/images/lmp3.jpeg"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
Right now, we're focused on making iRacing league racing better.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
But sims come and go. Your leagues, your teams, your rating — those stay.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
GridPilot is built to outlast any single platform.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
When the next sim arrives, your competitive identity moves with you.
</p>
</>
}
mockup={<SimPlatformMockup />}
layout="text-right"
/>
{/* Alpha-only discovery section */}
{isAlpha && (
<section className="max-w-7xl mx-auto mt-20 mb-20 px-6">
<div className="flex items-baseline justify-between mb-8">
<div>
<h2 className="text-2xl font-semibold text-white">Discover the grid</h2>
<p className="text-sm text-gray-400">
Explore leagues, teams, and races that make up the GridPilot ecosystem.
</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-3">
{/* Top leagues */}
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Featured leagues</h3>
<Button
as="a"
href="/leagues"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
View all
</Button>
</div>
<ul className="space-y-3 text-sm">
{topLeagues.slice(0, 4).map(league => (
<li key={league.id} className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-primary-blue/15 border border-primary-blue/30 flex items-center justify-center text-xs font-semibold text-primary-blue">
{league.name
.split(' ')
.map(word => word[0])
.join('')
.slice(0, 3)
.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-white truncate">{league.name}</p>
<p className="text-xs text-gray-400 line-clamp-2">
{league.description}
</p>
</div>
</li>
))}
</ul>
</Card>
{/* Teams */}
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Teams on the grid</h3>
<Button
as="a"
href="/teams"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
Browse teams
</Button>
</div>
<ul className="space-y-3 text-sm">
{teams.slice(0, 4).map(team => (
<li key={team.id} className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-charcoal-outline flex items-center justify-center overflow-hidden border border-charcoal-outline">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-white truncate">{team.name}</p>
<p className="text-xs text-gray-400 line-clamp-2">
{team.description}
</p>
</div>
</li>
))}
</ul>
</Card>
{/* Upcoming races */}
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Upcoming races</h3>
<Button
as="a"
href="/races"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
View schedule
</Button>
</div>
{upcomingRaces.length === 0 ? (
<p className="text-xs text-gray-400">
No races scheduled in this demo snapshot.
</p>
) : (
<ul className="space-y-3 text-sm">
{upcomingRaces.map(race => (
<li key={race.id} className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-white truncate">{race.track}</p>
<p className="text-xs text-gray-400 truncate">{race.car}</p>
</div>
<div className="text-right text-xs text-gray-500 whitespace-nowrap">
{race.formattedDate}
</div>
</li>
))}
</ul>
)}
</Card>
</div>
</section>
)}
<DiscordCTA />
<FAQ />
<Footer />
</main>
);
}
return <PageWrapper data={data} Template={HomeTemplate} />;
}

View File

@@ -1,103 +1,85 @@
'use client';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useInject } from '@/lib/di/hooks/useInject';
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { LeagueService } from '@/lib/services/leagues/LeagueService';
import type { LeagueMembershipService } from '@/lib/services/leagues/LeagueMembershipService';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import { SessionGateway } from '@/lib/gateways/SessionGateway';
import { ContainerManager } from '@/lib/di/container';
interface LeagueWithRole {
league: LeagueSummaryViewModel;
membership: LeagueMembership;
}
export default function ManageLeaguesPage() {
const [ownedLeagues, setOwnedLeagues] = useState<LeagueWithRole[]>([]);
const [memberLeagues, setMemberLeagues] = useState<LeagueWithRole[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const effectiveDriverId = useEffectiveDriverId();
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
interface ProfileLeaguesData {
ownedLeagues: LeagueWithRole[];
memberLeagues: LeagueWithRole[];
}
useEffect(() => {
let cancelled = false;
async function fetchProfileLeaguesData(): Promise<ProfileLeaguesData | null> {
try {
// Get current driver ID from session
const sessionGateway = new SessionGateway();
const session = await sessionGateway.getSession();
if (!session?.user?.primaryDriverId) {
return null;
}
const currentDriverId = session.user.primaryDriverId;
const load = async () => {
setLoading(true);
try {
const leagues = await leagueService.getAllLeagues();
// Fetch leagues using PageDataFetcher
const leagues = await PageDataFetcher.fetch<LeagueService, 'getAllLeagues'>(
LEAGUE_SERVICE_TOKEN,
'getAllLeagues'
);
const memberships = await Promise.all(
leagues.map(async (league) => {
await leagueMembershipService.fetchLeagueMemberships(league.id);
const membership = leagueMembershipService.getMembership(league.id, effectiveDriverId);
return { league, membership };
}),
);
if (!leagues) {
return null;
}
if (cancelled) {
return;
}
// Get membership service from container
const container = ContainerManager.getInstance().getContainer();
const membershipService = container.get<LeagueMembershipService>(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const owned: LeagueWithRole[] = [];
const member: LeagueWithRole[] = [];
// Fetch memberships for each league
const memberships = await Promise.all(
leagues.map(async (league) => {
await membershipService.fetchLeagueMemberships(league.id);
const membership = membershipService.getMembership(league.id, currentDriverId);
return membership ? { league, membership } : null;
})
);
for (const entry of memberships) {
if (!entry.membership || entry.membership.status !== 'active') {
continue;
}
// Filter and categorize leagues
const owned: LeagueWithRole[] = [];
const member: LeagueWithRole[] = [];
if (entry.membership.role === 'owner') {
owned.push(entry as LeagueWithRole);
} else {
member.push(entry as LeagueWithRole);
}
}
setOwnedLeagues(owned);
setMemberLeagues(member);
setError(null);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load leagues');
}
} finally {
if (!cancelled) {
setLoading(false);
}
for (const entry of memberships) {
if (!entry || !entry.membership || entry.membership.status !== 'active') {
continue;
}
};
void load();
if (entry.membership.role === 'owner') {
owned.push(entry);
} else {
member.push(entry);
}
}
return () => {
cancelled = true;
};
}, [effectiveDriverId, leagueService, leagueMembershipService]);
if (loading) {
return (
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading your leagues...</div>
</div>
);
}
if (error) {
return (
<div className="max-w-6xl mx-auto">
<Card>
<div className="text-center py-8 text-red-400">{error}</div>
</Card>
</div>
);
return { ownedLeagues: owned, memberLeagues: member };
} catch (error) {
console.error('Failed to fetch profile leagues data:', error);
return null;
}
}
// Template component
function ProfileLeaguesTemplate({ data }: { data: ProfileLeaguesData }) {
return (
<div className="max-w-6xl mx-auto space-y-8">
<div>
@@ -107,23 +89,24 @@ export default function ManageLeaguesPage() {
</p>
</div>
<Card>
{/* Leagues You Own */}
<div className="bg-charcoal rounded-lg border border-charcoal-outline p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Leagues you own</h2>
{ownedLeagues.length > 0 && (
{data.ownedLeagues.length > 0 && (
<span className="text-xs text-gray-400">
{ownedLeagues.length} {ownedLeagues.length === 1 ? 'league' : 'leagues'}
{data.ownedLeagues.length} {data.ownedLeagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
{ownedLeagues.length === 0 ? (
{data.ownedLeagues.length === 0 ? (
<p className="text-sm text-gray-400">
You don't own any leagues yet in this session.
</p>
) : (
<div className="space-y-3">
{ownedLeagues.map(({ league }) => (
{data.ownedLeagues.map(({ league }) => (
<div
key={league.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
@@ -135,41 +118,42 @@ export default function ManageLeaguesPage() {
</p>
</div>
<div className="flex items-center gap-2">
<Link
<a
href={`/leagues/${league.id}`}
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
>
View
</Link>
<Link href={`/leagues/${league.id}?tab=admin`}>
<Button variant="primary" className="text-xs px-3 py-1.5">
</a>
<a href={`/leagues/${league.id}?tab=admin`}>
<button className="bg-primary hover:bg-primary/90 text-white text-xs px-3 py-1.5 rounded transition-colors">
Manage
</Button>
</Link>
</button>
</a>
</div>
</div>
))}
</div>
)}
</Card>
</div>
<Card>
{/* Leagues You're In */}
<div className="bg-charcoal rounded-lg border border-charcoal-outline p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Leagues you're in</h2>
{memberLeagues.length > 0 && (
{data.memberLeagues.length > 0 && (
<span className="text-xs text-gray-400">
{memberLeagues.length} {memberLeagues.length === 1 ? 'league' : 'leagues'}
{data.memberLeagues.length} {data.memberLeagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
{memberLeagues.length === 0 ? (
{data.memberLeagues.length === 0 ? (
<p className="text-sm text-gray-400">
You're not a member of any other leagues yet.
</p>
) : (
<div className="space-y-3">
{memberLeagues.map(({ league, membership }) => (
{data.memberLeagues.map(({ league, membership }) => (
<div
key={league.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
@@ -184,17 +168,38 @@ export default function ManageLeaguesPage() {
{membership.role.charAt(0).toUpperCase() + membership.role.slice(1)}
</p>
</div>
<Link
<a
href={`/leagues/${league.id}`}
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
>
View league
</Link>
</a>
</div>
))}
</div>
)}
</Card>
</div>
</div>
);
}
export default async function ProfileLeaguesPage() {
const data = await fetchProfileLeaguesData();
if (!data) {
notFound();
}
return (
<PageWrapper
data={data}
Template={ProfileLeaguesTemplate}
loading={{ variant: 'skeleton', message: 'Loading your leagues...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'No leagues found',
description: 'You are not a member of any leagues yet.',
}}
/>
);
}

View File

@@ -17,8 +17,10 @@ import type {
} from '@/lib/view-models/DriverProfileViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
// New architecture components
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
// Icons
import {
Activity,
Award,
@@ -255,30 +257,60 @@ function FinishDistributionChart({ wins, podiums, topTen, total }: FinishDistrib
}
// ============================================================================
// MAIN PAGE COMPONENT
// TEMPLATE COMPONENT
// ============================================================================
export default function ProfilePage() {
interface ProfileTemplateProps {
data: DriverProfileViewModel;
onEdit: () => void;
onAddFriend: () => void;
activeTab: ProfileTab;
setActiveTab: (tab: ProfileTab) => void;
friendRequestSent: boolean;
isOwnProfile: boolean;
handleSaveSettings: (updates: { bio?: string; country?: string }) => Promise<void>;
editMode: boolean;
setEditMode: (edit: boolean) => void;
}
function ProfileTemplate({
data,
onEdit,
onAddFriend,
activeTab,
setActiveTab,
friendRequestSent,
isOwnProfile,
handleSaveSettings,
editMode,
setEditMode
}: ProfileTemplateProps) {
const router = useRouter();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') as ProfileTab | null;
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const mediaService = useInject(MEDIA_SERVICE_TOKEN);
// Extract data from ViewModel
const currentDriver = data.currentDriver;
if (!currentDriver) {
return (
<div className="max-w-4xl mx-auto px-4">
<Card className="text-center py-12">
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No driver profile found</p>
<p className="text-sm text-gray-500">Please create a driver profile to continue</p>
</Card>
</div>
);
}
const effectiveDriverId = useEffectiveDriverId();
const isOwnProfile = true; // This page is always your own profile
// Use React-Query hook for profile data
const { data: profileData, isLoading: loading, error, retry } = useDriverProfile(effectiveDriverId || '');
const [editMode, setEditMode] = useState(false);
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
const stats = data.stats;
const teamMemberships = data.teamMemberships;
const socialSummary = data.socialSummary;
const extendedProfile = data.extendedProfile;
const globalRank = currentDriver.globalRank || null;
// Update URL when tab changes
useEffect(() => {
if (tabParam !== activeTab) {
if (searchParams.get('tab') !== activeTab) {
const params = new URLSearchParams(searchParams.toString());
if (activeTab === 'overview') {
params.delete('tab');
@@ -288,62 +320,18 @@ export default function ProfilePage() {
const query = params.toString();
router.replace(`/profile${query ? `?${query}` : ''}`, { scroll: false });
}
}, [activeTab, tabParam, searchParams, router]);
}, [activeTab, searchParams, router]);
// Sync tab from URL on mount and param change
useEffect(() => {
const tabParam = searchParams.get('tab') as ProfileTab | null;
if (tabParam && tabParam !== activeTab) {
setActiveTab(tabParam);
}
}, [tabParam]);
const handleSaveSettings = async (updates: { bio?: string; country?: string }) => {
if (!profileData?.currentDriver) return;
try {
const updatedProfile = await driverService.updateProfile(updates);
// Update local state
retry();
setEditMode(false);
} catch (error) {
console.error('Failed to update profile:', error);
}
};
const handleAddFriend = () => {
setFriendRequestSent(true);
// In production, this would call a use case
};
// Show create form if no profile exists
if (!loading && !profileData?.currentDriver && !error) {
return (
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-8">
<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">
<User className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={1} className="mb-2">Create Your Driver Profile</Heading>
<p className="text-gray-400">
Join the GridPilot community and start your racing journey
</p>
</div>
<Card className="max-w-2xl mx-auto">
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">Get Started</h2>
<p className="text-gray-400 text-sm">
Create your driver profile to join leagues, compete in races, and connect with other drivers.
</p>
</div>
<CreateDriverForm />
</Card>
</div>
);
}
}, [searchParams]);
// Show edit mode
if (editMode && profileData?.currentDriver) {
if (editMode && currentDriver) {
return (
<div className="max-w-4xl mx-auto px-4 space-y-6">
<div className="flex items-center justify-between mb-4">
@@ -352,55 +340,13 @@ export default function ProfilePage() {
Cancel
</Button>
</div>
<ProfileSettings driver={profileData.currentDriver} onSave={handleSaveSettings} />
<ProfileSettings driver={currentDriver} onSave={handleSaveSettings} />
</div>
);
}
return (
<StateContainer
data={profileData}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'full-screen', message: 'Loading profile...' },
error: { variant: 'full-screen' },
empty: {
icon: User,
title: 'No profile data',
description: 'Unable to load your profile information',
action: { label: 'Retry', onClick: retry }
}
}}
>
{(profileData) => {
// Extract data from profileData ViewModel
// At this point, we know profileData exists and currentDriver should exist
// (otherwise we would have shown the create form above)
const currentDriver = profileData.currentDriver;
// If currentDriver is null despite our checks, show empty state
if (!currentDriver) {
return (
<div className="max-w-4xl mx-auto px-4">
<Card className="text-center py-12">
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No driver profile found</p>
<p className="text-sm text-gray-500">Please create a driver profile to continue</p>
</Card>
</div>
);
}
const stats = profileData.stats;
const teamMemberships = profileData.teamMemberships;
const socialSummary = profileData.socialSummary;
const extendedProfile = profileData.extendedProfile;
const globalRank = currentDriver.globalRank || null;
return (
<div className="max-w-7xl mx-auto px-4 pb-12 space-y-6">
<div className="max-w-7xl mx-auto px-4 pb-12 space-y-6">
{/* Hero Header Section */}
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-iron-gray/80 via-iron-gray/60 to-deep-graphite border border-charcoal-outline">
{/* Background Pattern */}
@@ -497,7 +443,7 @@ export default function ProfilePage() {
{isOwnProfile ? (
<Button
variant="primary"
onClick={() => setEditMode(true)}
onClick={onEdit}
className="flex items-center gap-2"
>
<Edit3 className="w-4 h-4" />
@@ -507,7 +453,7 @@ export default function ProfilePage() {
<>
<Button
variant="primary"
onClick={handleAddFriend}
onClick={onAddFriend}
disabled={friendRequestSent}
className="flex items-center gap-2"
>
@@ -1055,16 +1001,112 @@ export default function ProfilePage() {
</div>
)}
{activeTab === 'stats' && !stats && (
<Card className="text-center py-12">
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No statistics available yet</p>
<p className="text-sm text-gray-500">Join a league and complete races to see detailed stats</p>
</Card>
)}
{activeTab === 'stats' && !stats && (
<Card className="text-center py-12">
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No statistics available yet</p>
<p className="text-sm text-gray-500">Join a league and complete races to see detailed stats</p>
</Card>
)}
</div>
);
}
// ============================================================================
// MAIN PAGE COMPONENT
// ============================================================================
export default function ProfilePage() {
const router = useRouter();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') as ProfileTab | null;
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const mediaService = useInject(MEDIA_SERVICE_TOKEN);
const effectiveDriverId = useEffectiveDriverId();
const isOwnProfile = true; // This page is always your own profile
// Use React-Query hook for profile data
const { data: profileData, isLoading: loading, error, refetch } = useDriverProfile(effectiveDriverId || '');
const [editMode, setEditMode] = useState(false);
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
const handleSaveSettings = async (updates: { bio?: string; country?: string }) => {
if (!profileData?.currentDriver) return;
try {
const updatedProfile = await driverService.updateProfile(updates);
// Update local state
refetch();
setEditMode(false);
} catch (error) {
console.error('Failed to update profile:', error);
}
};
const handleAddFriend = () => {
setFriendRequestSent(true);
// In production, this would call a use case
};
// Show create form if no profile exists
if (!loading && !profileData?.currentDriver && !error) {
return (
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-8">
<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">
<User className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={1} className="mb-2">Create Your Driver Profile</Heading>
<p className="text-gray-400">
Join the GridPilot community and start your racing journey
</p>
</div>
);
}}
</StateContainer>
);
<Card className="max-w-2xl mx-auto">
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">Get Started</h2>
<p className="text-gray-400 text-sm">
Create your driver profile to join leagues, compete in races, and connect with other drivers.
</p>
</div>
<CreateDriverForm />
</Card>
</div>
);
}
return (
<StatefulPageWrapper
data={profileData}
isLoading={loading}
error={error}
retry={refetch}
Template={({ data }) => (
<ProfileTemplate
data={data}
onEdit={() => setEditMode(true)}
onAddFriend={handleAddFriend}
activeTab={activeTab}
setActiveTab={setActiveTab}
friendRequestSent={friendRequestSent}
isOwnProfile={isOwnProfile}
handleSaveSettings={handleSaveSettings}
editMode={editMode}
setEditMode={setEditMode}
/>
)}
loading={{ variant: 'full-screen', message: 'Loading profile...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: User,
title: 'No profile data',
description: 'Unable to load your profile information',
action: { label: 'Retry', onClick: refetch }
}}
/>
);
}

View File

@@ -1,6 +1,13 @@
import { Bell, Shield, Eye, Volume2 } from 'lucide-react';
'use client';
export default function SettingsPage() {
import { Bell, Shield, Eye, Volume2 } from 'lucide-react';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
interface SettingsData {
// Settings page is static, no data needed
}
function SettingsTemplate({ data }: { data: SettingsData }) {
return (
<div className="min-h-screen bg-deep-graphite">
<div className="container mx-auto px-4 py-8">
@@ -199,4 +206,21 @@ export default function SettingsPage() {
</div>
</div>
);
}
export default function SettingsPage() {
return (
<StatefulPageWrapper
data={{} as SettingsData}
isLoading={false}
error={null}
Template={SettingsTemplate}
loading={{ variant: 'skeleton', message: 'Loading settings...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'No settings available',
description: 'Unable to load settings page',
}}
/>
);
}

View File

@@ -1,292 +1,48 @@
'use client';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import PendingSponsorshipRequests from '@/components/sponsors/PendingSponsorshipRequests';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useCallback, useEffect, useState } from 'react';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
import {
useSponsorshipRequestsPageData,
useSponsorshipRequestMutations
} from '@/hooks/sponsor/useSponsorshipRequestsPageData';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSORSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN, LEAGUE_SERVICE_TOKEN, TEAM_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { SponsorshipRequestViewModel } from '@/lib/view-models/SponsorshipRequestViewModel';
import { AlertTriangle, Building, ChevronRight, Handshake, Trophy, User, Users } from 'lucide-react';
import Link from 'next/link';
interface EntitySection {
entityType: 'driver' | 'team' | 'race' | 'season';
entityId: string;
entityName: string;
requests: SponsorshipRequestViewModel[];
}
export default function SponsorshipRequestsPage() {
const currentDriverId = useEffectiveDriverId();
const sponsorshipService = useInject(SPONSORSHIP_SERVICE_TOKEN);
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const teamService = useInject(TEAM_SERVICE_TOKEN);
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
// Fetch data using domain hook
const { data: sections, isLoading, error, refetch } = useSponsorshipRequestsPageData(currentDriverId);
const [sections, setSections] = useState<EntitySection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Mutations using domain hook
const { acceptMutation, rejectMutation } = useSponsorshipRequestMutations(currentDriverId, refetch);
const loadAllRequests = useCallback(async () => {
setLoading(true);
setError(null);
try {
if (!currentDriverId) {
setSections([]);
return;
}
const allSections: EntitySection[] = [];
// 1. Driver's own sponsorship requests
const driverRequests = await sponsorshipService.getPendingSponsorshipRequests({
entityType: 'driver',
entityId: currentDriverId,
});
if (driverRequests.length > 0) {
const driverProfile = await driverService.getDriverProfile(currentDriverId);
allSections.push({
entityType: 'driver',
entityId: currentDriverId,
entityName: driverProfile?.currentDriver?.name ?? 'Your Profile',
requests: driverRequests,
});
}
// 2. Leagues where the user is admin/owner
const allLeagues = await leagueService.getAllLeagues();
for (const league of allLeagues) {
const membership = await leagueMembershipService.getMembership(league.id, currentDriverId);
if (membership && LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role)) {
// Load sponsorship requests for this league's active season
try {
// For simplicity, we'll query by season entityType - in production you'd get the active season ID
const leagueRequests = await sponsorshipService.getPendingSponsorshipRequests({
entityType: 'season',
entityId: league.id, // Using league ID as a proxy for now
});
if (leagueRequests.length > 0) {
allSections.push({
entityType: 'season',
entityId: league.id,
entityName: league.name,
requests: leagueRequests,
});
}
} catch (err) {
// Silently skip if no requests found
}
}
}
// 3. Teams where the user is owner/manager
const allTeams = await teamService.getAllTeams();
for (const team of allTeams) {
const membership = await teamService.getMembership(team.id, currentDriverId);
if (membership && (membership.role === 'owner' || membership.role === 'manager')) {
const teamRequests = await sponsorshipService.getPendingSponsorshipRequests({
entityType: 'team',
entityId: team.id,
});
if (teamRequests.length > 0) {
allSections.push({
entityType: 'team',
entityId: team.id,
entityName: team.name,
requests: teamRequests,
});
}
}
}
setSections(allSections);
} catch (err) {
console.error('Failed to load sponsorship requests:', err);
setError(err instanceof Error ? err.message : 'Failed to load requests');
} finally {
setLoading(false);
}
}, [currentDriverId, sponsorshipService, driverService, leagueService, teamService, leagueMembershipService]);
useEffect(() => {
loadAllRequests();
}, [loadAllRequests]);
const handleAccept = async (requestId: string) => {
if (!currentDriverId) return;
await sponsorshipService.acceptSponsorshipRequest(requestId, currentDriverId);
await loadAllRequests();
};
const handleReject = async (requestId: string, reason?: string) => {
if (!currentDriverId) return;
await sponsorshipService.rejectSponsorshipRequest(requestId, currentDriverId, reason);
await loadAllRequests();
};
const getEntityIcon = (type: 'driver' | 'team' | 'race' | 'season') => {
switch (type) {
case 'driver':
return User;
case 'team':
return Users;
case 'race':
return Trophy;
case 'season':
return Trophy;
default:
return Building;
}
};
const getEntityLink = (type: 'driver' | 'team' | 'race' | 'season', id: string) => {
switch (type) {
case 'driver':
return `/drivers/${id}`;
case 'team':
return `/teams/${id}`;
case 'race':
return `/races/${id}`;
case 'season':
return `/leagues/${id}/sponsorships`;
default:
return '#';
}
};
const totalRequests = sections.reduce((sum, s) => sum + s.requests.length, 0);
// Template needs to handle mutations
const TemplateWithMutations = ({ data }: { data: any[] }) => (
<SponsorshipRequestsTemplate
data={data}
onAccept={async (requestId) => {
await acceptMutation.mutateAsync({ requestId });
}}
onReject={async (requestId, reason) => {
await rejectMutation.mutateAsync({ requestId, reason });
}}
/>
);
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<Breadcrumbs
items={[
{ label: 'Profile', href: '/profile' },
{ label: 'Sponsorship Requests' },
]}
/>
{/* Header */}
<div className="flex items-center gap-4 mt-6 mb-8">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-performance-green/10 border border-performance-green/30">
<Handshake className="w-7 h-7 text-performance-green" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Sponsorship Requests</h1>
<p className="text-sm text-gray-400">
Manage sponsorship requests for your profile, teams, and leagues
</p>
</div>
{totalRequests > 0 && (
<div className="ml-auto px-3 py-1 rounded-full bg-performance-green/20 text-performance-green text-sm font-semibold">
{totalRequests} pending
</div>
)}
</div>
{loading ? (
<Card>
<div className="text-center py-12 text-gray-400">
<div className="animate-pulse">Loading sponsorship requests...</div>
</div>
</Card>
) : error ? (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-500/10 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-red-400" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Error Loading Requests</h3>
<p className="text-sm text-gray-400">{error}</p>
<Button variant="secondary" onClick={loadAllRequests} className="mt-4">
Try Again
</Button>
</div>
</Card>
) : sections.length === 0 ? (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<Handshake className="w-8 h-8 text-gray-500" />
</div>
<h3 className="text-lg font-medium text-white mb-2">No Pending Requests</h3>
<p className="text-sm text-gray-400">
You don't have any pending sponsorship requests at the moment.
</p>
<p className="text-xs text-gray-500 mt-2">
Sponsors can apply to sponsor your profile, teams, or leagues you manage.
</p>
</div>
</Card>
) : (
<div className="space-y-6">
{sections.map((section) => {
const Icon = getEntityIcon(section.entityType);
const entityLink = getEntityLink(section.entityType, section.entityId);
return (
<Card key={`${section.entityType}-${section.entityId}`}>
{/* Section Header */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-charcoal-outline">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-iron-gray/50">
<Icon className="w-5 h-5 text-gray-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">{section.entityName}</h2>
<p className="text-xs text-gray-500 capitalize">{section.entityType}</p>
</div>
</div>
<Link
href={entityLink}
className="flex items-center gap-1 text-sm text-primary-blue hover:text-primary-blue/80 transition-colors"
>
View {section.entityType === 'season' ? 'Sponsorships' : section.entityType}
<ChevronRight className="w-4 h-4" />
</Link>
</div>
{/* Requests */}
<PendingSponsorshipRequests
entityType={section.entityType}
entityId={section.entityId}
entityName={section.entityName}
requests={section.requests}
onAccept={handleAccept}
onReject={handleReject}
/>
</Card>
);
})}
</div>
)}
{/* Info Card */}
<Card className="mt-8 bg-gradient-to-r from-primary-blue/5 to-transparent border-primary-blue/20">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/20 flex-shrink-0">
<Building className="w-5 h-5 text-primary-blue" />
</div>
<div>
<h3 className="text-sm font-semibold text-white mb-1">How Sponsorships Work</h3>
<p className="text-xs text-gray-400 leading-relaxed">
Sponsors can apply to sponsor your driver profile, teams you manage, or leagues you administer.
Review each request carefully - accepting will activate the sponsorship and the sponsor will be
charged. You'll receive the payment minus a 10% platform fee.
</p>
</div>
</div>
</Card>
</div>
<StatefulPageWrapper
data={sections}
isLoading={isLoading}
error={error as Error | null}
retry={refetch}
Template={TemplateWithMutations}
loading={{ variant: 'skeleton', message: 'Loading sponsorship requests...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'No Pending Requests',
description: 'You don\'t have any pending sponsorship requests at the moment.',
}}
/>
);
}

View File

@@ -1,180 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { RacesTemplate, TimeFilter, RaceStatusFilter } from '@/templates/RacesTemplate';
import { useRacesPageData } from '@/hooks/race/useRacesPageData';
import { useRegisterForRace } from '@/hooks/race/useRegisterForRace';
import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace';
import { useCancelRace } from '@/hooks/race/useCancelRace';
import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
export function RacesInteractive() {
const router = useRouter();
const currentDriverId = useEffectiveDriverId();
// Fetch data
const { data: pageData, isLoading } = useRacesPageData();
// Mutations
const registerMutation = useRegisterForRace();
const withdrawMutation = useWithdrawFromRace();
const cancelMutation = useCancelRace();
// Filter state
const [statusFilter, setStatusFilter] = useState<RaceStatusFilter>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [timeFilter, setTimeFilter] = useState<TimeFilter>('upcoming');
const [showFilterModal, setShowFilterModal] = useState(false);
// Transform data for template
const races = pageData?.races.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: 'race', // Not in RaceListItemViewModel, using default
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField ?? undefined,
isUpcoming: race.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
})) ?? [];
const scheduledRaces = pageData?.scheduledRaces.map(race => ({
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.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
})) ?? [];
const runningRaces = pageData?.runningRaces.map(race => ({
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.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
})) ?? [];
const completedRaces = pageData?.completedRaces.map(race => ({
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.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
})) ?? [];
// Actions
const handleRaceClick = (raceId: string) => {
router.push(`/races/${raceId}`);
};
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
};
const handleRegister = async (raceId: string, leagueId: string) => {
if (!currentDriverId) {
router.push('/auth/login');
return;
}
const confirmed = window.confirm(
`Register for this race?\n\nYou'll be added to the entry list.`,
);
if (!confirmed) return;
try {
await registerMutation.mutateAsync({ raceId, leagueId, driverId: currentDriverId });
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register for race');
}
};
const handleWithdraw = async (raceId: string) => {
if (!currentDriverId) return;
const confirmed = window.confirm(
'Withdraw from this race?\n\nYou can register again later if you change your mind.',
);
if (!confirmed) return;
try {
await withdrawMutation.mutateAsync({ raceId, driverId: currentDriverId });
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');
}
};
const handleCancel = async (raceId: string) => {
const confirmed = window.confirm(
'Are you sure you want to cancel this race? This action cannot be undone.',
);
if (!confirmed) return;
try {
await cancelMutation.mutateAsync(raceId);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to cancel race');
}
};
// User memberships for admin check
// For now, we'll handle permissions in the template using LeagueMembershipUtility
// This would need actual membership data to work properly
const userMemberships: Array<{ leagueId: string; role: string }> = [];
return (
<RacesTemplate
races={races}
totalCount={pageData?.totalCount ?? 0}
scheduledRaces={scheduledRaces}
runningRaces={runningRaces}
completedRaces={completedRaces}
isLoading={isLoading}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
timeFilter={timeFilter}
setTimeFilter={setTimeFilter}
onRaceClick={handleRaceClick}
onLeagueClick={handleLeagueClick}
onRegister={handleRegister}
onWithdraw={handleWithdraw}
onCancel={handleCancel}
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
currentDriverId={currentDriverId}
userMemberships={userMemberships}
/>
);
}

View File

@@ -1,79 +0,0 @@
import { RacesTemplate } from '@/templates/RacesTemplate';
import { ContainerManager } from '@/lib/di/container';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { RaceListItemViewModel } from '@/lib/view-models/RaceListItemViewModel';
import type { RaceService } from '@/lib/services/races/RaceService';
// This is a server component that fetches data and passes it to the template
export async function RacesStatic() {
const container = ContainerManager.getInstance().getContainer();
const raceService = container.get<RaceService>(RACE_SERVICE_TOKEN);
// Fetch race data server-side
const pageData = await raceService.getRacesPageData();
// Extract races from the response
const races = pageData.races.map((race: RaceListItemViewModel) => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: 'race', // Default since RaceListItemViewModel doesn't have sessionType
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField,
isUpcoming: race.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
}));
// Transform the categorized races as well
const transformRaces = (raceList: RaceListItemViewModel[]) =>
raceList.map((race: RaceListItemViewModel) => ({
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,
isUpcoming: race.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
}));
// For the static wrapper, we'll use client-side data fetching
// This component will be used as a server component that renders the client template
return (
<RacesTemplate
races={races}
totalCount={pageData.totalCount}
scheduledRaces={transformRaces(pageData.scheduledRaces)}
runningRaces={transformRaces(pageData.runningRaces)}
completedRaces={transformRaces(pageData.completedRaces)}
isLoading={false}
// Filter state - will be managed by Interactive component
statusFilter="all"
setStatusFilter={() => {}}
leagueFilter="all"
setLeagueFilter={() => {}}
timeFilter="upcoming"
setTimeFilter={() => {}}
// Actions
onRaceClick={() => {}}
onLeagueClick={() => {}}
onRegister={() => {}}
onWithdraw={() => {}}
onCancel={() => {}}
// UI State
showFilterModal={false}
setShowFilterModal={() => {}}
// User state
currentDriverId={undefined}
userMemberships={undefined}
/>
);
}

View File

@@ -1,240 +0,0 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
import { useRegisterForRace } from '@/hooks/race/useRegisterForRace';
import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace';
import { useCancelRace } from '@/hooks/race/useCancelRace';
import { useCompleteRace } from '@/hooks/race/useCompleteRace';
import { useReopenRace } from '@/hooks/race/useReopenRace';
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useRaceDetail } from '@/hooks/race/useRaceDetail';
import { Flag } from 'lucide-react';
export function RaceDetailInteractive() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
// Fetch data using DI + React-Query
const { data: viewModel, isLoading, error, retry } = useRaceDetail(raceId, currentDriverId);
// Fetch membership
const { data: membership } = useLeagueMembership(viewModel?.league?.id || '', currentDriverId);
// UI State
const [showProtestModal, setShowProtestModal] = useState(false);
const [showEndRaceModal, setShowEndRaceModal] = useState(false);
// Mutations
const registerMutation = useRegisterForRace();
const withdrawMutation = useWithdrawFromRace();
const cancelMutation = useCancelRace();
const completeMutation = useCompleteRace();
const reopenMutation = useReopenRace();
// Determine if user is owner/admin
const isOwnerOrAdmin = membership
? LeagueMembershipUtility.isOwnerOrAdmin(viewModel?.league?.id || '', currentDriverId)
: false;
// Actions
const handleBack = () => {
router.back();
};
const handleRegister = async () => {
const race = viewModel?.race;
const league = viewModel?.league;
if (!race || !league) return;
const confirmed = window.confirm(
`Register for ${race.track}?\n\nYou'll be added to the entry list for this race.`,
);
if (!confirmed) return;
try {
await registerMutation.mutateAsync({ raceId: race.id, leagueId: league.id, driverId: currentDriverId });
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register for race');
}
};
const handleWithdraw = async () => {
const race = viewModel?.race;
const league = viewModel?.league;
if (!race || !league) return;
const confirmed = window.confirm(
'Withdraw from this race?\n\nYou can register again later if you change your mind.',
);
if (!confirmed) return;
try {
await withdrawMutation.mutateAsync({ raceId: race.id, driverId: currentDriverId });
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');
}
};
const handleCancel = async () => {
const race = viewModel?.race;
if (!race || race.status !== 'scheduled') return;
const confirmed = window.confirm(
'Are you sure you want to cancel this race? This action cannot be undone.',
);
if (!confirmed) return;
try {
await cancelMutation.mutateAsync(race.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to cancel race');
}
};
const handleReopen = async () => {
const race = viewModel?.race;
if (!race || !viewModel?.canReopenRace) return;
const confirmed = window.confirm(
'Re-open this race? This will allow re-registration and re-running. Results will be archived.',
);
if (!confirmed) return;
try {
await reopenMutation.mutateAsync(race.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to re-open race');
}
};
const handleEndRace = async () => {
const race = viewModel?.race;
if (!race) return;
setShowEndRaceModal(true);
};
const handleFileProtest = () => {
setShowProtestModal(true);
};
const handleResultsClick = () => {
router.push(`/races/${raceId}/results`);
};
const handleStewardingClick = () => {
router.push(`/races/${raceId}/stewarding`);
};
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
};
const handleDriverClick = (driverId: string) => {
router.push(`/drivers/${driverId}`);
};
// Transform data for template - handle null values
const templateViewModel = viewModel && viewModel.race ? {
race: {
id: viewModel.race.id,
track: viewModel.race.track,
car: viewModel.race.car,
scheduledAt: viewModel.race.scheduledAt,
status: viewModel.race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: viewModel.race.sessionType,
},
league: viewModel.league ? {
id: viewModel.league.id,
name: viewModel.league.name,
description: viewModel.league.description || undefined,
settings: viewModel.league.settings as { maxDrivers: number; qualifyingFormat: string },
} : undefined,
entryList: viewModel.entryList.map(entry => ({
id: entry.id,
name: entry.name,
avatarUrl: entry.avatarUrl,
country: entry.country,
rating: entry.rating,
isCurrentUser: entry.isCurrentUser,
})),
registration: {
isUserRegistered: viewModel.registration.isUserRegistered,
canRegister: viewModel.registration.canRegister,
},
userResult: viewModel.userResult ? {
position: viewModel.userResult.position,
startPosition: viewModel.userResult.startPosition,
positionChange: viewModel.userResult.positionChange,
incidents: viewModel.userResult.incidents,
isClean: viewModel.userResult.isClean,
isPodium: viewModel.userResult.isPodium,
ratingChange: viewModel.userResult.ratingChange,
} : undefined,
canReopenRace: viewModel.canReopenRace,
} : undefined;
return (
<StateContainer
data={viewModel}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading race details...' },
error: { variant: 'full-screen' },
empty: {
icon: Flag,
title: 'Race not found',
description: 'The race may have been cancelled or deleted',
action: { label: 'Back to Races', onClick: handleBack }
}
}}
>
{(raceData) => (
<RaceDetailTemplate
viewModel={templateViewModel}
isLoading={false}
error={null}
onBack={handleBack}
onRegister={handleRegister}
onWithdraw={handleWithdraw}
onCancel={handleCancel}
onReopen={handleReopen}
onEndRace={handleEndRace}
onFileProtest={handleFileProtest}
onResultsClick={handleResultsClick}
onStewardingClick={handleStewardingClick}
onLeagueClick={handleLeagueClick}
onDriverClick={handleDriverClick}
currentDriverId={currentDriverId}
isOwnerOrAdmin={isOwnerOrAdmin}
showProtestModal={showProtestModal}
setShowProtestModal={setShowProtestModal}
showEndRaceModal={showEndRaceModal}
setShowEndRaceModal={setShowEndRaceModal}
mutationLoading={{
register: registerMutation.isPending,
withdraw: withdrawMutation.isPending,
cancel: cancelMutation.isPending,
reopen: reopenMutation.isPending,
complete: completeMutation.isPending,
}}
/>
)}
</StateContainer>
);
}

View File

@@ -1,238 +0,0 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RaceDetailInteractive } from './RaceDetailInteractive';
import type { RaceDetailsViewModel } from '@/lib/view-models/RaceDetailsViewModel';
// Mocks for Next.js navigation
const mockPush = vi.fn();
const mockBack = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: mockBack,
}),
useParams: () => ({ id: 'race-123' }),
}));
// Mock effective driver id hook
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-1',
}));
// Mock sponsor mode hook to avoid rendering heavy sponsor card
vi.mock('@/components/sponsors/SponsorInsightsCard', () => ({
__esModule: true,
default: () => <div data-testid="sponsor-insights-mock" />,
MetricBuilders: {
views: vi.fn(() => ({ label: 'Views', value: '100' })),
engagement: vi.fn(() => ({ label: 'Engagement', value: '50%' })),
reach: vi.fn(() => ({ label: 'Reach', value: '1000' })),
},
SlotTemplates: {
race: vi.fn(() => []),
},
useSponsorMode: () => false,
}));
// Mock the new DI hooks
const mockGetRaceDetails = vi.fn();
const mockReopenRace = vi.fn();
const mockFetchLeagueMemberships = vi.fn();
const mockGetMembership = vi.fn();
// Mock race detail hook
vi.mock('@/hooks/race/useRaceDetail', () => ({
useRaceDetail: (raceId: string, driverId: string) => ({
data: mockGetRaceDetails.mock.results[0]?.value || null,
isLoading: false,
isError: false,
isSuccess: !!mockGetRaceDetails.mock.results[0]?.value,
refetch: vi.fn(),
retry: vi.fn(),
}),
}));
// Mock reopen race hook
vi.mock('@/hooks/race/useReopenRace', () => ({
useReopenRace: () => ({
mutateAsync: mockReopenRace,
mutate: mockReopenRace,
isPending: false,
isLoading: false,
}),
}));
// Mock league membership service static method
vi.mock('@/lib/services/leagues/LeagueMembershipService', () => ({
LeagueMembershipService: {
getMembership: mockGetMembership,
fetchLeagueMemberships: mockFetchLeagueMemberships,
setLeagueMemberships: vi.fn(),
clearLeagueMemberships: vi.fn(),
getCachedMembershipsIterator: vi.fn(() => [][Symbol.iterator]()),
getAllMembershipsForDriver: vi.fn(() => []),
getLeagueMembers: vi.fn(() => []),
},
}));
// Mock league membership hook (if used by component)
vi.mock('@/hooks/league/useLeagueMemberships', () => ({
useLeagueMemberships: (leagueId: string, currentUserId: string) => ({
data: mockFetchLeagueMemberships.mock.results[0]?.value || null,
isLoading: false,
isError: false,
isSuccess: !!mockFetchLeagueMemberships.mock.results[0]?.value,
refetch: vi.fn(),
}),
}));
// Mock the useLeagueMembership hook that the component imports
vi.mock('@/hooks/useLeagueMembershipService', () => ({
useLeagueMembership: (leagueId: string, driverId: string) => ({
data: mockGetMembership.mock.results[0]?.value || null,
isLoading: false,
isError: false,
isSuccess: !!mockGetMembership.mock.results[0]?.value,
refetch: vi.fn(),
}),
}));
// We'll use the actual hooks but they will use the mocked services
// The hooks are already mocked above via the service mocks
// Mock league membership utility to control admin vs non-admin behavior
const mockIsOwnerOrAdmin = vi.fn();
vi.mock('@/lib/utilities/LeagueMembershipUtility', () => ({
LeagueMembershipUtility: {
isOwnerOrAdmin: (...args: unknown[]) => mockIsOwnerOrAdmin(...args),
},
}));
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const createViewModel = (status: string): RaceDetailsViewModel => {
const canReopenRace = status === 'completed' || status === 'cancelled';
return {
race: {
id: 'race-123',
track: 'Test Track',
car: 'Test Car',
scheduledAt: '2023-12-31T20:00:00Z',
status,
sessionType: 'race',
},
league: {
id: 'league-1',
name: 'Test League',
description: 'Test league description',
settings: {
maxDrivers: 32,
qualifyingFormat: 'open',
},
},
entryList: [],
registration: {
isUserRegistered: false,
canRegister: false,
},
userResult: null,
canReopenRace,
};
};
describe('RaceDetailPage - Re-open Race behavior', () => {
beforeEach(() => {
// Reset all mocks
mockGetRaceDetails.mockReset();
mockReopenRace.mockReset();
mockFetchLeagueMemberships.mockReset();
mockGetMembership.mockReset();
mockIsOwnerOrAdmin.mockReset();
// Set up default mock implementations
mockFetchLeagueMemberships.mockResolvedValue(undefined);
mockGetMembership.mockReturnValue({ role: 'owner' }); // Return owner role by default
});
it('shows Re-open Race button for admin when race is completed and calls reopen + reload on confirm', async () => {
mockIsOwnerOrAdmin.mockReturnValue(true);
const viewModel = createViewModel('completed');
// Mock the hooks to return the right data
mockGetRaceDetails.mockReturnValue(viewModel);
mockGetMembership.mockReturnValue({ role: 'owner' });
mockReopenRace.mockResolvedValue(undefined);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
renderWithQueryClient(<RaceDetailInteractive />);
// Wait for the component to load and render
await waitFor(() => {
const tracks = screen.getAllByText('Test Track');
expect(tracks.length).toBeGreaterThan(0);
});
// Check if the reopen button is present
const reopenButton = screen.getByText('Re-open Race');
expect(reopenButton).toBeInTheDocument();
fireEvent.click(reopenButton);
await waitFor(() => {
expect(mockReopenRace).toHaveBeenCalledWith('race-123');
});
confirmSpy.mockRestore();
});
it('does not render Re-open Race button for non-admin viewer', async () => {
mockIsOwnerOrAdmin.mockReturnValue(false);
const viewModel = createViewModel('completed');
mockGetRaceDetails.mockReturnValue(viewModel);
mockGetMembership.mockReturnValue({ role: 'member' });
renderWithQueryClient(<RaceDetailInteractive />);
await waitFor(() => {
const tracks = screen.getAllByText('Test Track');
expect(tracks.length).toBeGreaterThan(0);
});
expect(screen.queryByText('Re-open Race')).toBeNull();
});
it('does not render Re-open Race button when race is not completed or cancelled even for admin', async () => {
mockIsOwnerOrAdmin.mockReturnValue(true);
const viewModel = createViewModel('scheduled');
mockGetRaceDetails.mockReturnValue(viewModel);
mockGetMembership.mockReturnValue({ role: 'owner' });
renderWithQueryClient(<RaceDetailInteractive />);
await waitFor(() => {
const tracks = screen.getAllByText('Test Track');
expect(tracks.length).toBeGreaterThan(0);
});
expect(screen.queryByText('Re-open Race')).toBeNull();
});
});

View File

@@ -1,3 +1,117 @@
import { RaceDetailInteractive } from './RaceDetailInteractive';
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { RaceService } from '@/lib/services/races/RaceService';
import type { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
export default RaceDetailInteractive;
interface RaceDetailPageProps {
params: {
id: string;
};
}
export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
const raceId = params.id;
if (!raceId) {
notFound();
}
// Fetch initial race data
const data = await PageDataFetcher.fetch<RaceService, 'getRaceDetail'>(
RACE_SERVICE_TOKEN,
'getRaceDetail',
raceId,
'' // currentDriverId - will be handled client-side for auth
);
if (!data) notFound();
// Transform data for template
const templateViewModel = data && data.race ? {
race: {
id: data.race.id,
track: data.race.track,
car: data.race.car,
scheduledAt: data.race.scheduledAt,
status: data.race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: data.race.sessionType,
},
league: data.league ? {
id: data.league.id,
name: data.league.name,
description: data.league.description || undefined,
settings: data.league.settings as { maxDrivers: number; qualifyingFormat: string },
} : undefined,
entryList: data.entryList.map((entry: any) => ({
id: entry.id,
name: entry.name,
avatarUrl: entry.avatarUrl,
country: entry.country,
rating: entry.rating,
isCurrentUser: entry.isCurrentUser,
})),
registration: {
isUserRegistered: data.registration.isUserRegistered,
canRegister: data.registration.canRegister,
},
userResult: data.userResult ? {
position: data.userResult.position,
startPosition: data.userResult.startPosition,
positionChange: data.userResult.positionChange,
incidents: data.userResult.incidents,
isClean: data.userResult.isClean,
isPodium: data.userResult.isPodium,
ratingChange: data.userResult.ratingChange,
} : undefined,
canReopenRace: data.canReopenRace,
} : undefined;
return (
<PageWrapper
data={data}
Template={({ data }) => (
<RaceDetailTemplate
viewModel={templateViewModel}
isLoading={false}
error={null}
// These will be handled client-side in the template or a wrapper
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: () => {} }
}}
/>
);
}

View File

@@ -1,137 +0,0 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useRaceResultsDetail } from '@/hooks/race/useRaceResultsDetail';
import { useRaceWithSOF } from '@/hooks/race/useRaceWithSOF';
import { Trophy } from 'lucide-react';
export function RaceResultsInteractive() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
// Fetch data using existing hooks
const { data: raceData, isLoading, error, retry } = useRaceResultsDetail(raceId, currentDriverId);
const { data: sofData } = useRaceWithSOF(raceId);
// Fetch membership
const { data: membershipsData } = useLeagueMemberships(raceData?.league?.id || '', currentDriverId || '');
// UI State
const [importing, setImporting] = useState(false);
const [importSuccess, setImportSuccess] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [showImportForm, setShowImportForm] = useState(false);
const raceSOF = sofData?.strengthOfField || null;
const currentMembership = membershipsData?.memberships.find(m => m.driverId === currentDriverId);
const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false;
// Transform data for template
const results = raceData?.results.map(result => ({
position: result.position,
driverId: result.driverId,
driverName: result.driverName,
driverAvatar: result.avatarUrl,
country: 'US', // Default since view model doesn't have country
car: 'Unknown', // Default since view model doesn't have car
laps: 0, // Default since view model doesn't have laps
time: '0:00.00', // Default since view model doesn't have time
fastestLap: result.fastestLap.toString(), // Convert number to string
points: 0, // Default since view model doesn't have points
incidents: result.incidents,
isCurrentUser: result.driverId === currentDriverId,
})) ?? [];
const penalties = raceData?.penalties.map(penalty => ({
driverId: penalty.driverId,
driverName: raceData.results.find(r => r.driverId === penalty.driverId)?.driverName || 'Unknown',
type: penalty.type as 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points',
value: penalty.value || 0,
reason: 'Penalty applied', // Default since view model doesn't have reason
notes: undefined, // Default since view model doesn't have notes
})) ?? [];
// Actions
const handleBack = () => {
router.back();
};
const handleImportResults = async (importedResults: any[]) => {
setImporting(true);
setImportError(null);
try {
// TODO: Implement race results service
// await raceResultsService.importRaceResults(raceId, {
// resultsFileContent: JSON.stringify(importedResults),
// });
setImportSuccess(true);
// await loadData();
} catch (err) {
setImportError(err instanceof Error ? err.message : 'Failed to import results');
} finally {
setImporting(false);
}
};
const handlePenaltyClick = (driver: { id: string; name: string }) => {
// This would open a penalty modal in a real implementation
console.log('Penalty click for:', driver);
};
return (
<StateContainer
data={raceData}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading race results...' },
error: { 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: handleBack }
}
}}
>
{(raceResultsData) => (
<RaceResultsTemplate
raceTrack={raceResultsData?.race?.track}
raceScheduledAt={raceResultsData?.race?.scheduledAt}
totalDrivers={raceResultsData?.stats.totalDrivers}
leagueName={raceResultsData?.league?.name}
raceSOF={raceSOF}
results={results}
penalties={penalties}
pointsSystem={raceResultsData?.pointsSystem ?? {}}
fastestLapTime={raceResultsData?.fastestLapTime ?? 0}
currentDriverId={currentDriverId}
isAdmin={isAdmin}
isLoading={false}
error={null}
onBack={handleBack}
onImportResults={handleImportResults}
onPenaltyClick={handlePenaltyClick}
importing={importing}
importSuccess={importSuccess}
importError={importError}
showImportForm={showImportForm}
setShowImportForm={setShowImportForm}
/>
)}
</StateContainer>
);
}

View File

@@ -1,3 +1,125 @@
import { RaceResultsInteractive } from './RaceResultsInteractive';
'use client';
export default RaceResultsInteractive;
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
import { useRaceResultsPageData } from '@/hooks/race/useRaceResultsPageData';
import { RaceResultsDataTransformer } from '@/lib/transformers/RaceResultsDataTransformer';
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useState } from 'react';
import { notFound, useRouter } from 'next/navigation';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { Trophy } from 'lucide-react';
interface RaceResultsPageProps {
params: {
id: string;
};
}
export default function RaceResultsPage({ params }: RaceResultsPageProps) {
const router = useRouter();
const raceId = params.id;
if (!raceId) {
notFound();
}
const currentDriverId = useEffectiveDriverId() || '';
// Fetch data using domain hook
const { data: queries, isLoading, error, refetch } = useRaceResultsPageData(raceId, currentDriverId);
// Additional data - league memberships
const leagueName = queries?.results?.league?.name || '';
const { data: memberships } = useLeagueMemberships(leagueName, currentDriverId);
// Transform data
const data = queries?.results && queries?.sof
? RaceResultsDataTransformer.transform(
queries.results,
queries.sof,
currentDriverId,
memberships
)
: undefined;
// UI State for import functionality
const [importing, setImporting] = useState(false);
const [importSuccess, setImportSuccess] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [showImportForm, setShowImportForm] = useState(false);
// Actions
const handleBack = () => router.back();
const handleImportResults = async (importedResults: any[]) => {
setImporting(true);
setImportError(null);
try {
console.log('Import results:', importedResults);
setImportSuccess(true);
// Refetch data after import
await refetch();
} catch (err) {
setImportError(err instanceof Error ? err.message : 'Failed to import results');
} finally {
setImporting(false);
}
};
const handlePenaltyClick = (driver: { id: string; name: string }) => {
console.log('Penalty click for:', driver);
};
// Determine admin status from memberships data
const currentDriver = data?.results.find(r => r.isCurrentUser);
const currentMembership = data?.memberships?.find(m => m.driverId === currentDriverId);
const isAdmin = currentMembership
? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role)
: false;
return (
<StatefulPageWrapper
data={data}
isLoading={isLoading}
error={error as Error | null}
retry={refetch}
Template={({ data }) => (
<RaceResultsTemplate
raceTrack={data.raceTrack}
raceScheduledAt={data.raceScheduledAt}
totalDrivers={data.totalDrivers}
leagueName={data.leagueName}
raceSOF={data.raceSOF}
results={data.results}
penalties={data.penalties}
pointsSystem={data.pointsSystem}
fastestLapTime={data.fastestLapTime}
currentDriverId={currentDriverId}
isAdmin={isAdmin}
isLoading={false}
error={null}
onBack={handleBack}
onImportResults={handleImportResults}
onPenaltyClick={handlePenaltyClick}
importing={importing}
importSuccess={importSuccess}
importError={importError}
showImportForm={showImportForm}
setShowImportForm={setShowImportForm}
/>
)}
loading={{ variant: 'skeleton', message: 'Loading race results...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Trophy,
title: 'No results available',
description: 'Race results will appear here once the race is completed',
action: { label: 'Back to Race', onClick: handleBack }
}}
/>
);
}

View File

@@ -1,87 +0,0 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useRaceStewardingData } from '@/hooks/race/useRaceStewardingData';
import { Gavel } from 'lucide-react';
export function RaceStewardingInteractive() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
// Fetch data using existing hooks
const { data: stewardingData, isLoading, error, retry } = useRaceStewardingData(raceId, currentDriverId);
// Fetch membership
const { data: membershipsData } = useLeagueMemberships(stewardingData?.league?.id || '', currentDriverId || '');
// UI State
const [activeTab, setActiveTab] = useState<StewardingTab>('pending');
const currentMembership = membershipsData?.memberships.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/${stewardingData?.league?.id}/stewarding/protests/${protestId}`);
};
// Transform data for template
const templateData = stewardingData ? {
race: stewardingData.race,
league: stewardingData.league,
pendingProtests: stewardingData.pendingProtests,
resolvedProtests: stewardingData.resolvedProtests,
penalties: stewardingData.penalties,
driverMap: stewardingData.driverMap,
pendingCount: stewardingData.pendingCount,
resolvedCount: stewardingData.resolvedCount,
penaltiesCount: stewardingData.penaltiesCount,
} : undefined;
return (
<StateContainer
data={stewardingData}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading stewarding data...' },
error: { variant: 'full-screen' },
empty: {
icon: Gavel,
title: 'No stewarding data',
description: 'No protests or penalties for this race',
action: { label: 'Back to Race', onClick: handleBack }
}
}}
>
{(stewardingData) => (
<RaceStewardingTemplate
stewardingData={templateData}
isLoading={false}
error={null}
onBack={handleBack}
onReviewProtest={handleReviewProtest}
isAdmin={isAdmin}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
)}
</StateContainer>
);
}

View File

@@ -1,3 +1,140 @@
import { RaceStewardingInteractive } from './RaceStewardingInteractive';
'use client';
export default RaceStewardingInteractive;
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { RACE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
import { RaceStewardingService } from '@/lib/services/races/RaceStewardingService';
import type { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { Gavel } from 'lucide-react';
export default function RaceStewardingPage() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
// Data state
const [pageData, setPageData] = useState<RaceStewardingViewModel | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// UI State
const [activeTab, setActiveTab] = useState<StewardingTab>('pending');
// Fetch data on mount and when raceId/currentDriverId changes
useEffect(() => {
async function fetchData() {
if (!raceId) return;
try {
setIsLoading(true);
setError(null);
const data = await PageDataFetcher.fetch<RaceStewardingService, 'getRaceStewardingData'>(
RACE_STEWARDING_SERVICE_TOKEN,
'getRaceStewardingData',
raceId,
currentDriverId
);
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);
}
}
fetchData();
}, [raceId, currentDriverId]);
// Fetch membership
const { data: membershipsData } = useLeagueMemberships(pageData?.league?.id || '', currentDriverId || '');
const currentMembership = membershipsData?.memberships.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
const templateData = pageData ? {
race: pageData.race,
league: pageData.league,
pendingProtests: pageData.pendingProtests,
resolvedProtests: pageData.resolvedProtests,
penalties: pageData.penalties,
driverMap: pageData.driverMap,
pendingCount: pageData.pendingCount,
resolvedCount: pageData.resolvedCount,
penaltiesCount: pageData.penaltiesCount,
} : undefined;
const retry = async () => {
try {
setIsLoading(true);
setError(null);
const data = await PageDataFetcher.fetch<RaceStewardingService, 'getRaceStewardingData'>(
RACE_STEWARDING_SERVICE_TOKEN,
'getRaceStewardingData',
raceId,
currentDriverId
);
if (data) {
setPageData(data);
}
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch stewarding data'));
} finally {
setIsLoading(false);
}
};
return (
<PageWrapper
data={pageData}
isLoading={isLoading}
error={error}
retry={retry}
Template={({ data }) => (
<RaceStewardingTemplate
stewardingData={templateData}
isLoading={false}
error={null}
onBack={handleBack}
onReviewProtest={handleReviewProtest}
isAdmin={isAdmin}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
)}
loading={{ variant: 'skeleton', message: 'Loading stewarding data...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Gavel,
title: 'No stewarding data',
description: 'No protests or penalties for this race',
action: { label: 'Back to Race', onClick: handleBack }
}}
/>
);
}

View File

@@ -1,99 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { RacesAllTemplate, StatusFilter } from '@/templates/RacesAllTemplate';
import { useAllRacesPageData } from '@/hooks/useRaceService';
const ITEMS_PER_PAGE = 10;
export function RacesAllInteractive() {
const router = useRouter();
// Fetch data
const { data: pageData, isLoading } = useAllRacesPageData();
// Pagination
const [currentPage, setCurrentPage] = useState(1);
// Filters
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [showFilterModal, setShowFilterModal] = useState(false);
// Transform data for template
const races = pageData?.races.map(race => ({
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,
})) ?? [];
// Calculate total pages
const filteredRaces = races.filter(race => {
if (statusFilter !== 'all' && race.status !== statusFilter) {
return false;
}
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
return false;
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesTrack = race.track.toLowerCase().includes(query);
const matchesCar = race.car.toLowerCase().includes(query);
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
if (!matchesTrack && !matchesCar && !matchesLeague) {
return false;
}
}
return true;
});
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
// Actions
const handleRaceClick = (raceId: string) => {
router.push(`/races/${raceId}`);
};
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return (
<RacesAllTemplate
races={races}
isLoading={isLoading}
currentPage={currentPage}
totalPages={totalPages}
itemsPerPage={ITEMS_PER_PAGE}
onPageChange={handlePageChange}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
showFilters={showFilters}
setShowFilters={setShowFilters}
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
onRaceClick={handleRaceClick}
onLeagueClick={handleLeagueClick}
/>
);
}

View File

@@ -1,3 +1,114 @@
import { RacesAllInteractive } from './RacesAllInteractive';
'use client';
export default RacesAllInteractive;
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { RacesAllTemplate, StatusFilter } from '@/templates/RacesAllTemplate';
import { useAllRacesPageData } from '@/hooks/race/useAllRacesPageData';
import { Flag } from 'lucide-react';
const ITEMS_PER_PAGE = 10;
export default function RacesAllPage() {
const router = useRouter();
// Client-side state for filters and pagination
const [currentPage, setCurrentPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [showFilterModal, setShowFilterModal] = useState(false);
// Fetch data using domain hook
const { data: pageData, isLoading, error, refetch } = useAllRacesPageData();
// Transform data for template
const races = pageData?.races.map((race) => ({
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,
})) ?? [];
// Calculate total pages
const filteredRaces = races.filter((race) => {
if (statusFilter !== 'all' && race.status !== statusFilter) {
return false;
}
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
return false;
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesTrack = race.track.toLowerCase().includes(query);
const matchesCar = race.car.toLowerCase().includes(query);
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
if (!matchesTrack && !matchesCar && !matchesLeague) {
return false;
}
}
return true;
});
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
// Actions
const handleRaceClick = (raceId: string) => {
router.push(`/races/${raceId}`);
};
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return (
<StatefulPageWrapper
data={pageData}
isLoading={isLoading}
error={error}
retry={refetch}
Template={({ data }) => (
<RacesAllTemplate
races={races}
isLoading={false}
currentPage={currentPage}
totalPages={totalPages}
itemsPerPage={ITEMS_PER_PAGE}
onPageChange={handlePageChange}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
showFilters={showFilters}
setShowFilters={setShowFilters}
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
onRaceClick={handleRaceClick}
onLeagueClick={handleLeagueClick}
/>
)}
loading={{ variant: 'skeleton', message: 'Loading races...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Flag,
title: 'No races found',
description: 'There are no races available at the moment',
}}
/>
);
}

View File

@@ -1,3 +1,71 @@
import { RacesInteractive } from './RacesInteractive';
import { RacesTemplate } from '@/templates/RacesTemplate';
import { RaceService } from '@/lib/services/races/RaceService';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
export default RacesInteractive;
export default async function Page() {
// Create dependencies for API clients
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Create API client
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new RaceService(racesApiClient);
const data = await service.getRacesPageData();
// Transform data for template
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.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
});
const races = data.races.map(transformRace);
const scheduledRaces = data.scheduledRaces.map(transformRace);
const runningRaces = data.runningRaces.map(transformRace);
const completedRaces = data.completedRaces.map(transformRace);
const totalCount = data.totalCount;
return <RacesTemplate
races={races}
totalCount={totalCount}
scheduledRaces={scheduledRaces}
runningRaces={runningRaces}
completedRaces={completedRaces}
isLoading={false}
statusFilter="all"
setStatusFilter={() => {}}
leagueFilter="all"
setLeagueFilter={() => {}}
timeFilter="upcoming"
setTimeFilter={() => {}}
onRaceClick={() => {}}
onLeagueClick={() => {}}
onRegister={() => {}}
onWithdraw={() => {}}
onCancel={() => {}}
showFilterModal={false}
setShowFilterModal={() => {}}
currentDriverId={undefined}
userMemberships={[]}
/>;
}

View File

@@ -1,439 +0,0 @@
'use client';
import { useState } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { siteConfig } from '@/lib/siteConfig';
import { useAvailableLeagues } from '@/hooks/sponsor/useAvailableLeagues';
import {
Trophy,
Users,
Eye,
Search,
Star,
ChevronRight,
Filter,
Car,
Flag,
TrendingUp,
CheckCircle2,
Clock,
Megaphone,
ArrowUpDown
} from 'lucide-react';
interface AvailableLeague {
id: string;
name: string;
game: string;
drivers: number;
avgViewsPerRace: number;
mainSponsorSlot: { available: boolean; price: number };
secondarySlots: { available: number; total: number; price: number };
rating: number;
tier: 'premium' | 'standard' | 'starter';
nextRace?: string;
seasonStatus: 'active' | 'upcoming' | 'completed';
description: string;
}
type SortOption = 'rating' | 'drivers' | 'price' | 'views';
type TierFilter = 'all' | 'premium' | 'standard' | 'starter';
type AvailabilityFilter = 'all' | 'main' | 'secondary';
function LeagueCard({ league, index }: { league: any; index: number }) {
const shouldReduceMotion = useReducedMotion();
const tierConfig = {
premium: {
bg: 'bg-gradient-to-br from-yellow-500/10 to-amber-500/5',
border: 'border-yellow-500/30',
badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
icon: '⭐'
},
standard: {
bg: 'bg-gradient-to-br from-primary-blue/10 to-cyan-500/5',
border: 'border-primary-blue/30',
badge: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30',
icon: '🏆'
},
starter: {
bg: 'bg-gradient-to-br from-gray-500/10 to-slate-500/5',
border: 'border-gray-500/30',
badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
icon: '🚀'
},
};
const statusConfig = {
active: { color: 'text-performance-green', bg: 'bg-performance-green/10', label: 'Active Season' },
upcoming: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', label: 'Starting Soon' },
completed: { color: 'text-gray-400', bg: 'bg-gray-400/10', label: 'Season Ended' },
};
const config = league.tierConfig;
const status = league.statusConfig;
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Card className={`overflow-hidden border ${config.border} ${config.bg} hover:border-primary-blue/50 transition-all duration-300 h-full`}>
<div className="p-5">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs font-medium capitalize border ${config.badge}`}>
{config.icon} {league.tier}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${status.bg} ${status.color}`}>
{status.label}
</span>
</div>
<h3 className="font-semibold text-white text-lg">{league.name}</h3>
<p className="text-sm text-gray-500">{league.game}</p>
</div>
<div className="flex items-center gap-1 bg-iron-gray/50 px-2 py-1 rounded">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-sm font-medium text-white">{league.rating}</span>
</div>
</div>
{/* Description */}
<p className="text-sm text-gray-400 mb-4 line-clamp-2">{league.description}</p>
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
<div className="text-lg font-bold text-white">{league.drivers}</div>
<div className="text-xs text-gray-500">Drivers</div>
</div>
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
<div className="text-lg font-bold text-white">{league.formattedAvgViews}</div>
<div className="text-xs text-gray-500">Avg Views</div>
</div>
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
<div className="text-lg font-bold text-performance-green">{league.formattedCpm}</div>
<div className="text-xs text-gray-500">CPM</div>
</div>
</div>
{/* Next Race */}
{league.nextRace && (
<div className="flex items-center gap-2 mb-4 text-sm">
<Clock className="w-4 h-4 text-gray-500" />
<span className="text-gray-400">Next:</span>
<span className="text-white">{league.nextRace}</span>
</div>
)}
{/* Sponsorship Slots */}
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between p-2.5 bg-iron-gray/30 rounded-lg">
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${league.mainSponsorSlot.available ? 'bg-performance-green' : 'bg-racing-red'}`} />
<span className="text-sm text-gray-300">Main Sponsor</span>
</div>
<div className="text-sm">
{league.mainSponsorSlot.available ? (
<span className="text-white font-semibold">${league.mainSponsorSlot.price}/season</span>
) : (
<span className="text-gray-500 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Filled
</span>
)}
</div>
</div>
<div className="flex items-center justify-between p-2.5 bg-iron-gray/30 rounded-lg">
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${league.secondarySlots.available > 0 ? 'bg-performance-green' : 'bg-racing-red'}`} />
<span className="text-sm text-gray-300">Secondary Slots</span>
</div>
<div className="text-sm">
{league.secondarySlots.available > 0 ? (
<span className="text-white font-semibold">
{league.secondarySlots.available}/{league.secondarySlots.total} @ ${league.secondarySlots.price}
</span>
) : (
<span className="text-gray-500 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Full
</span>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Link href={`/sponsor/leagues/${league.id}`} className="flex-1">
<Button variant="secondary" className="w-full text-sm">
View Details
</Button>
</Link>
{(league.mainSponsorSlot.available || league.secondarySlots.available > 0) && (
<Link href={`/sponsor/leagues/${league.id}?action=sponsor`} className="flex-1">
<Button variant="primary" className="w-full text-sm">
Sponsor
</Button>
</Link>
)}
</div>
</div>
</Card>
</motion.div>
);
}
export default function SponsorLeaguesInteractive() {
const shouldReduceMotion = useReducedMotion();
const { data, isLoading, isError, error } = useAvailableLeagues();
const [searchQuery, setSearchQuery] = useState('');
const [tierFilter, setTierFilter] = useState<TierFilter>('all');
const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all');
const [sortBy, setSortBy] = useState<SortOption>('rating');
if (isLoading) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400">Loading leagues...</p>
</div>
</div>
);
}
if (isError || !data) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="text-gray-400">{error?.message || 'No leagues data available'}</p>
</div>
</div>
);
}
// Filter and sort leagues
const filteredLeagues = data.leagues
.filter(league => {
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
if (tierFilter !== 'all' && league.tier !== tierFilter) {
return false;
}
if (availabilityFilter === 'main' && !league.mainSponsorSlot.available) {
return false;
}
if (availabilityFilter === 'secondary' && league.secondarySlots.available === 0) {
return false;
}
return true;
})
.sort((a, b) => {
switch (sortBy) {
case 'rating': return b.rating - a.rating;
case 'drivers': return b.drivers - a.drivers;
case 'price': return a.mainSponsorSlot.price - b.mainSponsorSlot.price;
case 'views': return b.avgViewsPerRace - a.avgViewsPerRace;
default: return 0;
}
});
// Calculate summary stats
const stats = {
total: data.leagues.length,
mainAvailable: data.leagues.filter(l => l.mainSponsorSlot.available).length,
secondaryAvailable: data.leagues.reduce((sum, l) => sum + l.secondarySlots.available, 0),
totalDrivers: data.leagues.reduce((sum, l) => sum + l.drivers, 0),
avgCpm: Math.round(
data.leagues.reduce((sum, l) => sum + l.cpm, 0) / data.leagues.length
),
};
return (
<div className="max-w-7xl mx-auto py-8 px-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6">
<Link href="/sponsor/dashboard" className="hover:text-white transition-colors">Dashboard</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-white">Browse Leagues</span>
</div>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-2 flex items-center gap-3">
<Trophy className="w-7 h-7 text-primary-blue" />
League Sponsorship Marketplace
</h1>
<p className="text-gray-400">
Discover racing leagues looking for sponsors. All prices shown exclude VAT.
</p>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-white">{stats.total}</div>
<div className="text-sm text-gray-400">Leagues</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-performance-green">{stats.mainAvailable}</div>
<div className="text-sm text-gray-400">Main Slots</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-primary-blue">{stats.secondaryAvailable}</div>
<div className="text-sm text-gray-400">Secondary Slots</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-white">{stats.totalDrivers}</div>
<div className="text-sm text-gray-400">Total Drivers</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-warning-amber">${stats.avgCpm}</div>
<div className="text-sm text-gray-400">Avg CPM</div>
</Card>
</motion.div>
</div>
{/* Filters */}
<div className="flex flex-col lg:flex-row gap-4 mb-6">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Search leagues..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none"
/>
</div>
{/* Tier Filter */}
<select
value={tierFilter}
onChange={(e) => setTierFilter(e.target.value as TierFilter)}
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
>
<option value="all">All Tiers</option>
<option value="premium"> Premium</option>
<option value="standard">🏆 Standard</option>
<option value="starter">🚀 Starter</option>
</select>
{/* Availability Filter */}
<select
value={availabilityFilter}
onChange={(e) => setAvailabilityFilter(e.target.value as AvailabilityFilter)}
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
>
<option value="all">All Slots</option>
<option value="main">Main Available</option>
<option value="secondary">Secondary Available</option>
</select>
{/* Sort */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
>
<option value="rating">Sort by Rating</option>
<option value="drivers">Sort by Drivers</option>
<option value="views">Sort by Views</option>
<option value="price">Sort by Price</option>
</select>
</div>
{/* Results Count */}
<div className="flex items-center justify-between mb-6">
<p className="text-sm text-gray-400">
Showing {filteredLeagues.length} of {data.leagues.length} leagues
</p>
<div className="flex items-center gap-2">
<Link href="/teams">
<Button variant="secondary" className="text-sm">
<Users className="w-4 h-4 mr-2" />
Browse Teams
</Button>
</Link>
<Link href="/drivers">
<Button variant="secondary" className="text-sm">
<Car className="w-4 h-4 mr-2" />
Browse Drivers
</Button>
</Link>
</div>
</div>
{/* League Grid */}
{filteredLeagues.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredLeagues.map((league, index) => (
<LeagueCard key={league.id} league={league} index={index} />
))}
</div>
) : (
<Card className="text-center py-16">
<Trophy className="w-12 h-12 text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No leagues found</h3>
<p className="text-gray-400 mb-6">Try adjusting your filters to see more results</p>
<Button variant="secondary" onClick={() => {
setSearchQuery('');
setTierFilter('all');
setAvailabilityFilter('all');
}}>
Clear Filters
</Button>
</Card>
)}
{/* Platform Fee Notice */}
<div className="mt-8 rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-start gap-3">
<Megaphone className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-gray-300 font-medium mb-1">Platform Fee</p>
<p className="text-xs text-gray-500">
A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,546 +1,18 @@
'use client';
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { SponsorLeagueDetailTemplate } from '@/templates/SponsorLeagueDetailTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { SponsorService } from '@/lib/services/sponsors/SponsorService';
import { useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { motion, useReducedMotion } from 'framer-motion';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { siteConfig } from '@/lib/siteConfig';
import { useSponsorLeagueDetail } from '@/hooks/sponsor/useSponsorLeagueDetail';
import {
Trophy,
Users,
Calendar,
Eye,
TrendingUp,
Download,
Image as ImageIcon,
ExternalLink,
ChevronRight,
Star,
Clock,
CheckCircle2,
Flag,
Car,
BarChart3,
ArrowUpRight,
Megaphone,
CreditCard,
FileText
} from 'lucide-react';
type TabType = 'overview' | 'drivers' | 'races' | 'sponsor';
export default function SponsorLeagueDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const shouldReduceMotion = useReducedMotion();
const leagueId = params.id as string;
const showSponsorAction = searchParams.get('action') === 'sponsor';
const [activeTab, setActiveTab] = useState<TabType>(showSponsorAction ? 'sponsor' : 'overview');
const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
const { data: leagueData, isLoading, error, retry } = useSponsorLeagueDetail(leagueId);
if (isLoading) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400">Loading league details...</p>
</div>
</div>
);
}
if (error || !leagueData) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="text-gray-400">{error?.getUserMessage() || 'No league data available'}</p>
{error && (
<Button variant="secondary" onClick={retry} className="mt-4">
Retry
</Button>
)}
</div>
</div>
);
}
const data = leagueData;
const league = data.league;
const config = league.tierConfig;
return (
<div className="max-w-7xl mx-auto py-8 px-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6">
<Link href="/sponsor/dashboard" className="hover:text-white transition-colors">Dashboard</Link>
<ChevronRight className="w-4 h-4" />
<Link href="/sponsor/leagues" className="hover:text-white transition-colors">Leagues</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-white">{data.league.name}</span>
</div>
{/* Header */}
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-6 mb-8">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className={`px-3 py-1 rounded-full text-sm font-medium capitalize ${config.bgColor} ${config.color} border ${config.border}`}>
{data.league.tier}
</span>
<span className="px-3 py-1 rounded-full text-sm font-medium bg-performance-green/10 text-performance-green">
Active Season
</span>
<div className="flex items-center gap-1 px-2 py-1 rounded bg-iron-gray/50">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-sm font-medium text-white">{league.rating}</span>
</div>
</div>
<h1 className="text-3xl font-bold text-white mb-2">{data.league.name}</h1>
<p className="text-gray-400 mb-4">{data.league.game} {data.league.season} {data.league.completedRaces}/{data.league.races} races completed</p>
<p className="text-gray-400 max-w-2xl">{data.league.description}</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<Link href={`/leagues/${league.id}`}>
<Button variant="secondary">
<ExternalLink className="w-4 h-4 mr-2" />
View League
</Button>
</Link>
{(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && (
<Button variant="primary" onClick={() => setActiveTab('sponsor')}>
<Megaphone className="w-4 h-4 mr-2" />
Become a Sponsor
</Button>
)}
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
<Eye className="w-5 h-5 text-primary-blue" />
</div>
<div>
<div className="text-xl font-bold text-white">{data.league.formattedTotalImpressions}</div>
<div className="text-xs text-gray-400">Total Views</div>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10">
<TrendingUp className="w-5 h-5 text-performance-green" />
</div>
<div>
<div className="text-xl font-bold text-white">{data.league.formattedAvgViewsPerRace}</div>
<div className="text-xs text-gray-400">Avg/Race</div>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10">
<Users className="w-5 h-5 text-purple-400" />
</div>
<div>
<div className="text-xl font-bold text-white">{data.league.drivers}</div>
<div className="text-xs text-gray-400">Drivers</div>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10">
<BarChart3 className="w-5 h-5 text-warning-amber" />
</div>
<div>
<div className="text-xl font-bold text-white">{data.league.engagement}%</div>
<div className="text-xs text-gray-400">Engagement</div>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-racing-red/10">
<Calendar className="w-5 h-5 text-racing-red" />
</div>
<div>
<div className="text-xl font-bold text-white">{data.league.racesLeft}</div>
<div className="text-xs text-gray-400">Races Left</div>
</div>
</div>
</Card>
</motion.div>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-charcoal-outline overflow-x-auto">
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-3 text-sm font-medium capitalize transition-colors border-b-2 -mb-px whitespace-nowrap ${
activeTab === tab
? 'text-primary-blue border-primary-blue'
: 'text-gray-400 border-transparent hover:text-white'
}`}
>
{tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-5">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Trophy className="w-5 h-5 text-primary-blue" />
League Information
</h3>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Platform</span>
<span className="text-white font-medium">{data.league.game}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Season</span>
<span className="text-white font-medium">{league.season}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Duration</span>
<span className="text-white font-medium">Oct 2025 - Feb 2026</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Drivers</span>
<span className="text-white font-medium">{league.drivers}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">Races</span>
<span className="text-white font-medium">{league.races}</span>
</div>
</div>
</Card>
<Card className="p-5">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-performance-green" />
Sponsorship Value
</h3>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Total Season Views</span>
<span className="text-white font-medium">{data.league.formattedTotalImpressions}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Projected Total</span>
<span className="text-white font-medium">{data.league.formattedProjectedTotal}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Main Sponsor CPM</span>
<span className="text-performance-green font-medium">
{data.league.formattedMainSponsorCpm}
</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Engagement Rate</span>
<span className="text-white font-medium">{league.engagement}%</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">League Rating</span>
<div className="flex items-center gap-1">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-white font-medium">{league.rating}/5.0</span>
</div>
</div>
</div>
</Card>
{/* Next Race */}
{league.nextRace && (
<Card className="p-5 lg:col-span-2">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Flag className="w-5 h-5 text-warning-amber" />
Next Race
</h3>
<div className="flex items-center justify-between p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-warning-amber/20 flex items-center justify-center">
<Flag className="w-6 h-6 text-warning-amber" />
</div>
<div>
<p className="font-semibold text-white text-lg">{league.nextRace.name}</p>
<p className="text-sm text-gray-400">{league.nextRace.date}</p>
</div>
</div>
<Button variant="secondary">
View Schedule
</Button>
</div>
</Card>
)}
</div>
)}
{activeTab === 'drivers' && (
<Card>
<div className="p-4 border-b border-charcoal-outline">
<h3 className="text-lg font-semibold text-white">Championship Standings</h3>
<p className="text-sm text-gray-400">Top drivers carrying sponsor branding</p>
</div>
<div className="divide-y divide-charcoal-outline/50">
{data.drivers.map((driver) => (
<div key={driver.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center text-lg font-bold text-white">
{driver.position}
</div>
<div>
<div className="font-medium text-white">{driver.name}</div>
<div className="text-sm text-gray-500">{driver.team} {driver.country}</div>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<div className="font-medium text-white">{driver.races}</div>
<div className="text-xs text-gray-500">races</div>
</div>
<div className="text-right">
<div className="font-semibold text-white">{driver.formattedImpressions}</div>
<div className="text-xs text-gray-500">views</div>
</div>
</div>
</div>
))}
</div>
</Card>
)}
{activeTab === 'races' && (
<Card>
<div className="p-4 border-b border-charcoal-outline">
<h3 className="text-lg font-semibold text-white">Race Calendar</h3>
<p className="text-sm text-gray-400">Season schedule with view statistics</p>
</div>
<div className="divide-y divide-charcoal-outline/50">
{data.races.map((race) => (
<div key={race.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`w-3 h-3 rounded-full ${
race.status === 'completed' ? 'bg-performance-green' : 'bg-warning-amber'
}`} />
<div>
<div className="font-medium text-white">{race.name}</div>
<div className="text-sm text-gray-500">{race.formattedDate}</div>
</div>
</div>
<div className="flex items-center gap-4">
{race.status === 'completed' ? (
<div className="text-right">
<div className="font-semibold text-white">{race.views.toLocaleString()}</div>
<div className="text-xs text-gray-500">views</div>
</div>
) : (
<span className="px-3 py-1 rounded-full text-xs font-medium bg-warning-amber/20 text-warning-amber">
Upcoming
</span>
)}
</div>
</div>
))}
</div>
</Card>
)}
{activeTab === 'sponsor' && (
<div className="space-y-6">
{/* Tier Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Main Sponsor */}
<Card
className={`p-5 cursor-pointer transition-all ${
selectedTier === 'main'
? 'border-primary-blue ring-2 ring-primary-blue/20'
: 'hover:border-charcoal-outline/80'
} ${!league.sponsorSlots.main.available ? 'opacity-60' : ''}`}
onClick={() => league.sponsorSlots.main.available && setSelectedTier('main')}
>
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Trophy className="w-5 h-5 text-yellow-400" />
<h3 className="text-lg font-semibold text-white">Main Sponsor</h3>
</div>
<p className="text-sm text-gray-400">Primary branding position</p>
</div>
{league.sponsorSlots.main.available ? (
<span className="px-2 py-1 rounded text-xs font-medium bg-performance-green/20 text-performance-green">
Available
</span>
) : (
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-500/20 text-gray-400">
Filled
</span>
)}
</div>
<div className="text-3xl font-bold text-white mb-4">
${league.sponsorSlots.main.price}
<span className="text-sm font-normal text-gray-500">/season</span>
</div>
<ul className="space-y-2 mb-4">
{league.sponsorSlots.main.benefits.map((benefit, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-gray-300">
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0" />
{benefit}
</li>
))}
</ul>
{selectedTier === 'main' && league.sponsorSlots.main.available && (
<div className="w-4 h-4 rounded-full bg-primary-blue absolute top-4 right-4 flex items-center justify-center">
<CheckCircle2 className="w-3 h-3 text-white" />
</div>
)}
</Card>
{/* Secondary Sponsor */}
<Card
className={`p-5 cursor-pointer transition-all ${
selectedTier === 'secondary'
? 'border-primary-blue ring-2 ring-primary-blue/20'
: 'hover:border-charcoal-outline/80'
} ${league.sponsorSlots.secondary.available === 0 ? 'opacity-60' : ''}`}
onClick={() => league.sponsorSlots.secondary.available > 0 && setSelectedTier('secondary')}
>
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Star className="w-5 h-5 text-purple-400" />
<h3 className="text-lg font-semibold text-white">Secondary Sponsor</h3>
</div>
<p className="text-sm text-gray-400">Supporting branding position</p>
</div>
{league.sponsorSlots.secondary.available > 0 ? (
<span className="px-2 py-1 rounded text-xs font-medium bg-performance-green/20 text-performance-green">
{league.sponsorSlots.secondary.available}/{league.sponsorSlots.secondary.total} Available
</span>
) : (
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-500/20 text-gray-400">
Full
</span>
)}
</div>
<div className="text-3xl font-bold text-white mb-4">
${league.sponsorSlots.secondary.price}
<span className="text-sm font-normal text-gray-500">/season</span>
</div>
<ul className="space-y-2 mb-4">
{league.sponsorSlots.secondary.benefits.map((benefit, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-gray-300">
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0" />
{benefit}
</li>
))}
</ul>
{selectedTier === 'secondary' && league.sponsorSlots.secondary.available > 0 && (
<div className="w-4 h-4 rounded-full bg-primary-blue absolute top-4 right-4 flex items-center justify-center">
<CheckCircle2 className="w-3 h-3 text-white" />
</div>
)}
</Card>
</div>
{/* Checkout Summary */}
<Card className="p-5">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-primary-blue" />
Sponsorship Summary
</h3>
<div className="space-y-3 mb-6">
<div className="flex justify-between py-2">
<span className="text-gray-400">Selected Tier</span>
<span className="text-white font-medium capitalize">{selectedTier} Sponsor</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">Season Price</span>
<span className="text-white font-medium">
${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">Platform Fee ({siteConfig.fees.platformFeePercent}%)</span>
<span className="text-white font-medium">
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}
</span>
</div>
<div className="flex justify-between py-2 border-t border-charcoal-outline pt-4">
<span className="text-white font-semibold">Total (excl. VAT)</span>
<span className="text-white font-bold text-xl">
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
</span>
</div>
</div>
<p className="text-xs text-gray-500 mb-4">
{siteConfig.vat.notice}
</p>
<div className="flex gap-3">
<Button variant="primary" className="flex-1">
<Megaphone className="w-4 h-4 mr-2" />
Request Sponsorship
</Button>
<Button variant="secondary">
<FileText className="w-4 h-4 mr-2" />
Download Info Pack
</Button>
</div>
</Card>
</div>
)}
</div>
export default async function Page({ params }: { params: { id: string } }) {
const data = await PageDataFetcher.fetch<SponsorService, 'getLeagueDetail'>(
SPONSOR_SERVICE_TOKEN,
'getLeagueDetail',
params.id
);
}
if (!data) notFound();
return <PageWrapper data={data} Template={SponsorLeagueDetailTemplate} />;
}

View File

@@ -1,3 +1,38 @@
import SponsorLeaguesInteractive from './SponsorLeaguesInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { SponsorLeaguesTemplate } from '@/templates/SponsorLeaguesTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
import { SponsorService } from '@/lib/services/sponsors/SponsorService';
import { AvailableLeaguesViewModel } from '@/lib/view-models/AvailableLeaguesViewModel';
export default SponsorLeaguesInteractive;
export default async function Page() {
const leaguesData = await PageDataFetcher.fetch<SponsorService, 'getAvailableLeagues'>(
SPONSOR_SERVICE_TOKEN,
'getAvailableLeagues'
);
// Process data with view model to calculate stats
if (!leaguesData) {
return <PageWrapper data={undefined} Template={SponsorLeaguesTemplate} />;
}
const viewModel = new AvailableLeaguesViewModel(leaguesData);
// Calculate summary stats
const stats = {
total: viewModel.leagues.length,
mainAvailable: viewModel.leagues.filter(l => l.mainSponsorSlot.available).length,
secondaryAvailable: viewModel.leagues.reduce((sum, l) => sum + l.secondarySlots.available, 0),
totalDrivers: viewModel.leagues.reduce((sum, l) => sum + l.drivers, 0),
avgCpm: Math.round(
viewModel.leagues.reduce((sum, l) => sum + l.cpm, 0) / viewModel.leagues.length
),
};
const processedData = {
leagues: viewModel.leagues,
stats,
};
return <PageWrapper data={processedData} Template={SponsorLeaguesTemplate} />;
}

View File

@@ -1,237 +0,0 @@
'use client';
import { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Users, Search, Sparkles, Crown, Star, TrendingUp, Shield } from 'lucide-react';
import TeamsTemplate from '@/templates/TeamsTemplate';
import TeamHeroSection from '@/components/teams/TeamHeroSection';
import TeamSearchBar from '@/components/teams/TeamSearchBar';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import CreateTeamForm from '@/components/teams/CreateTeamForm';
import WhyJoinTeamSection from '@/components/teams/WhyJoinTeamSection';
import SkillLevelSection from '@/components/teams/SkillLevelSection';
import FeaturedRecruiting from '@/components/teams/FeaturedRecruiting';
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useAllTeams } from '@/hooks/team/useAllTeams';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
const SKILL_LEVELS: SkillLevel[] = ['pro', 'advanced', 'intermediate', 'beginner'];
export default function TeamsInteractive() {
const router = useRouter();
const { data: teams = [], isLoading: loading, error, retry } = useAllTeams();
const [searchQuery, setSearchQuery] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
// Derive groups by skill level from the loaded teams
const groupsBySkillLevel = useMemo(() => {
const byLevel: Record<string, typeof teams> = {
beginner: [],
intermediate: [],
advanced: [],
pro: [],
};
if (teams) {
teams.forEach((team) => {
const level = team.performanceLevel || 'intermediate';
if (byLevel[level]) {
byLevel[level].push(team);
}
});
}
return byLevel;
}, [teams]);
// Select top teams by rating for the preview section
const topTeams = useMemo(() => {
if (!teams) return [];
const sortedByRating = [...teams].sort((a, b) => {
// Rating is not currently part of TeamSummaryViewModel in this build.
// Keep deterministic ordering by name until a rating field is exposed.
return a.name.localeCompare(b.name);
});
return sortedByRating.slice(0, 5);
}, [teams]);
const handleTeamClick = (teamId: string) => {
if (teamId.startsWith('demo-team-')) {
return;
}
router.push(`/teams/${teamId}`);
};
const handleCreateSuccess = (teamId: string) => {
setShowCreateForm(false);
router.push(`/teams/${teamId}`);
};
// Filter by search query
const filteredTeams = teams ? teams.filter((team) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
team.name.toLowerCase().includes(query) ||
(team.description ?? '').toLowerCase().includes(query) ||
(team.region ?? '').toLowerCase().includes(query) ||
(team.languages ?? []).some((lang) => lang.toLowerCase().includes(query))
);
}) : [];
// Group teams by skill level
const teamsByLevel = useMemo(() => {
return SKILL_LEVELS.reduce(
(acc, level) => {
const fromGroup = groupsBySkillLevel[level] ?? [];
acc[level] = filteredTeams.filter((team) =>
fromGroup.some((groupTeam) => groupTeam.id === team.id),
);
return acc;
},
{
beginner: [],
intermediate: [],
advanced: [],
pro: [],
} as Record<string, TeamSummaryViewModel[]>,
);
}, [groupsBySkillLevel, filteredTeams]);
const recruitingCount = teams ? teams.filter((t) => t.isRecruiting).length : 0;
const handleSkillLevelClick = (level: SkillLevel) => {
const element = document.getElementById(`level-${level}`);
element?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleBrowseTeams = () => {
const element = document.getElementById('teams-list');
element?.scrollIntoView({ behavior: 'smooth' });
};
if (showCreateForm) {
return (
<div className="max-w-4xl mx-auto px-4">
<div className="mb-6">
<Button variant="secondary" onClick={() => setShowCreateForm(false)}>
Back to Teams
</Button>
</div>
<Card>
<h2 className="text-2xl font-bold text-white mb-6">Create New Team</h2>
<CreateTeamForm onCancel={() => setShowCreateForm(false)} onSuccess={handleCreateSuccess} />
</Card>
</div>
);
}
return (
<StateContainer
data={teams}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading teams...' },
error: { variant: 'full-screen' },
empty: {
icon: Users,
title: 'No teams yet',
description: 'Be the first to create a racing team. Gather drivers and compete together in endurance events.',
action: { label: 'Create Your First Team', onClick: () => setShowCreateForm(true) }
}
}}
>
{(teamsData) => (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Hero Section */}
<TeamHeroSection
teams={teamsData}
teamsByLevel={teamsByLevel}
recruitingCount={recruitingCount}
onShowCreateForm={() => setShowCreateForm(true)}
onBrowseTeams={handleBrowseTeams}
onSkillLevelClick={handleSkillLevelClick}
/>
{/* Search Bar */}
<TeamSearchBar searchQuery={searchQuery} onSearchChange={setSearchQuery} />
{/* Why Join Section */}
{!searchQuery && <WhyJoinTeamSection />}
{/* Team Leaderboard Preview */}
{!searchQuery && <TeamLeaderboardPreview topTeams={topTeams} onTeamClick={handleTeamClick} />}
{/* Featured Recruiting */}
{!searchQuery && <FeaturedRecruiting teams={teamsData} onTeamClick={handleTeamClick} />}
{/* Teams by Skill Level */}
{teamsData.length === 0 ? (
<Card className="text-center py-16">
<div className="max-w-md mx-auto">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-purple-500/10 border border-purple-500/20 mb-6">
<Users className="w-8 h-8 text-purple-400" />
</div>
<Heading level={2} className="text-2xl mb-3">
No teams yet
</Heading>
<p className="text-gray-400 mb-8">
Be the first to create a racing team. Gather drivers and compete together in endurance events.
</p>
<Button
variant="primary"
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2 mx-auto bg-purple-600 hover:bg-purple-500"
>
<Sparkles className="w-4 h-4" />
Create Your First Team
</Button>
</div>
</Card>
) : filteredTeams.length === 0 ? (
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<Search className="w-10 h-10 text-gray-600" />
<p className="text-gray-400">No teams found matching "{searchQuery}"</p>
<Button variant="secondary" onClick={() => setSearchQuery('')}>
Clear search
</Button>
</div>
</Card>
) : (
<div>
{SKILL_LEVELS.map((level, index) => (
<div key={level} id={`level-${level}`} className="scroll-mt-8">
<SkillLevelSection
level={{
id: level,
label: level.charAt(0).toUpperCase() + level.slice(1),
icon: level === 'pro' ? Crown : level === 'advanced' ? Star : level === 'intermediate' ? TrendingUp : Shield,
color: level === 'pro' ? 'text-yellow-400' : level === 'advanced' ? 'text-purple-400' : level === 'intermediate' ? 'text-primary-blue' : 'text-green-400',
bgColor: level === 'pro' ? 'bg-yellow-400/10' : level === 'advanced' ? 'bg-purple-400/10' : level === 'intermediate' ? 'bg-primary-blue/10' : 'bg-green-400/10',
borderColor: level === 'pro' ? 'border-yellow-400/30' : level === 'advanced' ? 'border-purple-400/30' : level === 'intermediate' ? 'border-primary-blue/30' : 'border-green-400/30',
description: level === 'pro' ? 'Elite competition, sponsored teams' : level === 'advanced' ? 'Competitive racing, high consistency' : level === 'intermediate' ? 'Growing skills, regular practice' : 'Learning the basics, friendly environment',
}}
teams={teamsByLevel[level] ?? []}
onTeamClick={handleTeamClick}
defaultExpanded={index === 0}
/>
</div>
))}
</div>
)}
</div>
)}
</StateContainer>
);
}

View File

@@ -1,59 +0,0 @@
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import TeamsTemplate from '@/templates/TeamsTemplate';
// This is a static component that receives data as props
// It can be used in server components or parent components that fetch data
// For client-side data fetching, use TeamsInteractive instead
interface TeamsStaticProps {
teams: TeamSummaryViewModel[];
isLoading?: boolean;
}
export default function TeamsStatic({ teams, isLoading = false }: TeamsStaticProps) {
// Calculate derived data that would normally be done in the template
const teamsBySkillLevel = teams.reduce(
(acc, team) => {
const level = team.performanceLevel || 'intermediate';
if (!acc[level]) {
acc[level] = [];
}
acc[level].push(team);
return acc;
},
{
beginner: [],
intermediate: [],
advanced: [],
pro: [],
} as Record<string, TeamSummaryViewModel[]>,
);
const topTeams = [...teams]
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 5);
const recruitingCount = teams.filter((t) => t.isRecruiting).length;
// For static rendering, we don't have interactive state
// So we pass empty values and handlers that won't be used
return (
<TeamsTemplate
teams={teams}
isLoading={isLoading}
searchQuery=""
showCreateForm={false}
teamsByLevel={teamsBySkillLevel}
topTeams={topTeams}
recruitingCount={recruitingCount}
filteredTeams={teams}
onSearchChange={() => {}}
onShowCreateForm={() => {}}
onHideCreateForm={() => {}}
onTeamClick={() => {}}
onCreateSuccess={() => {}}
onBrowseTeams={() => {}}
onSkillLevelClick={() => {}}
/>
);
}

View File

@@ -1,139 +0,0 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useTeamDetails } from '@/hooks/team/useTeamDetails';
import { useTeamMembers } from '@/hooks/team/useTeamMembers';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { Users } from 'lucide-react';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
export default function TeamDetailInteractive() {
const params = useParams();
const teamId = params.id as string;
const router = useRouter();
const currentDriverId = useEffectiveDriverId();
const teamService = useInject(TEAM_SERVICE_TOKEN);
const [activeTab, setActiveTab] = useState<Tab>('overview');
// Fetch team details using DI + React-Query
const { data: teamDetails, isLoading: teamLoading, error: teamError, retry: teamRetry } = useTeamDetails(teamId, currentDriverId);
// Fetch team members using DI + React-Query
const { data: memberships, isLoading: membersLoading, error: membersError, retry: membersRetry } = useTeamMembers(
teamId,
currentDriverId,
teamDetails?.ownerId || ''
);
const isLoading = teamLoading || membersLoading;
const error = teamError || membersError;
const retry = async () => {
await teamRetry();
await membersRetry();
};
// Determine admin status
const isAdmin = teamDetails?.isOwner ||
(memberships || []).some((m: any) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
const handleUpdate = () => {
retry();
};
const handleRemoveMember = async (driverId: string) => {
if (!confirm('Are you sure you want to remove this member?')) {
return;
}
try {
const performer = await teamService.getMembership(teamId, currentDriverId);
if (!performer || (performer.role !== 'owner' && performer.role !== 'manager')) {
throw new Error('Only owners or admins can remove members');
}
const membership = await teamService.getMembership(teamId, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot remove the team owner');
}
await teamService.removeMembership(teamId, driverId);
handleUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to remove member');
}
};
const handleChangeRole = async (driverId: string, newRole: 'owner' | 'admin' | 'member') => {
try {
const performer = await teamService.getMembership(teamId, currentDriverId);
if (!performer || (performer.role !== 'owner' && performer.role !== 'manager')) {
throw new Error('Only owners or admins can update roles');
}
const membership = await teamService.getMembership(teamId, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot change the owner role');
}
// Convert 'admin' to 'manager' for the service
const serviceRole = newRole === 'admin' ? 'manager' : newRole;
await teamService.updateMembership(teamId, driverId, serviceRole);
handleUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to change role');
}
};
const handleGoBack = () => {
window.history.back();
};
return (
<StateContainer
data={teamDetails}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading team details...' },
error: { variant: 'full-screen' },
empty: {
icon: Users,
title: 'Team not found',
description: 'The team may have been deleted or you may not have access',
action: { label: 'Back to Teams', onClick: () => router.push('/teams') }
}
}}
>
{(teamData) => (
<TeamDetailTemplate
team={teamData!}
memberships={memberships || []}
activeTab={activeTab}
loading={isLoading}
isAdmin={isAdmin}
onTabChange={setActiveTab}
onUpdate={handleUpdate}
onRemoveMember={handleRemoveMember}
onChangeRole={handleChangeRole}
onGoBack={handleGoBack}
/>
)}
</StateContainer>
);
}

View File

@@ -1,43 +0,0 @@
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
import type { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
import type { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
// This is a server component that can be used for static rendering
// It receives pre-fetched data and renders the template
interface TeamDetailStaticProps {
team: TeamDetailsViewModel | null;
memberships: TeamMemberViewModel[];
currentDriverId: string;
isLoading?: boolean;
}
export default function TeamDetailStatic({
team,
memberships,
currentDriverId,
isLoading = false
}: TeamDetailStaticProps) {
// Determine admin status
const isAdmin = team ? (
team.isOwner ||
memberships.some((m) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'))
) : false;
// For static rendering, we don't have interactive state
// So we pass empty values and handlers that won't be used
return (
<TeamDetailTemplate
team={team}
memberships={memberships}
activeTab="overview"
loading={isLoading}
isAdmin={isAdmin}
onTabChange={() => {}}
onUpdate={() => {}}
onRemoveMember={() => {}}
onChangeRole={() => {}}
onGoBack={() => {}}
/>
);
}

View File

@@ -1,3 +1,102 @@
import TeamDetailInteractive from './TeamDetailInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { TeamService } from '@/lib/services/teams/TeamService';
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { notFound } from 'next/navigation';
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
export default TeamDetailInteractive;
// Template wrapper to adapt TeamDetailTemplate for SSR
interface TeamDetailData {
team: TeamDetailsViewModel;
memberships: TeamMemberViewModel[];
isAdmin: boolean;
}
function TeamDetailTemplateWrapper({ data }: { data: TeamDetailData }) {
return (
<TeamDetailTemplate
team={data.team}
memberships={data.memberships}
activeTab="overview"
loading={false}
isAdmin={data.isAdmin}
// Event handlers are no-ops for SSR (client will handle real interactions)
onTabChange={() => {}}
onUpdate={() => {}}
onRemoveMember={() => {}}
onChangeRole={() => {}}
onGoBack={() => {}}
/>
);
}
export default async function Page({ params }: { params: { id: string } }) {
// Validate params
if (!params.id) {
notFound();
}
// Fetch data using PageDataFetcher.fetchManual
const data = await PageDataFetcher.fetchManual(async () => {
// Manual dependency creation
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Create API client
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new TeamService(teamsApiClient);
// For server-side, we need a current driver ID
// This would typically come from session, but for server components we'll use a placeholder
const currentDriverId = ''; // Placeholder - would need session handling
// Fetch team details
const teamData = await service.getTeamDetails(params.id, currentDriverId);
if (!teamData) {
return null;
}
// Fetch team members
const membersData = await service.getTeamMembers(params.id, currentDriverId, teamData.ownerId || '');
// Determine admin status
const isAdmin = teamData.isOwner ||
(membersData || []).some((m: any) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
return {
team: teamData,
memberships: membersData || [],
isAdmin,
};
});
if (!data) {
notFound();
}
return (
<PageWrapper
data={data}
Template={TeamDetailTemplateWrapper}
loading={{ variant: 'skeleton', message: 'Loading team details...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'Team not found',
description: 'The team you are looking for does not exist or has been removed.',
}}
/>
);
}

View File

@@ -1,45 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import TeamLeaderboardTemplate from '@/templates/TeamLeaderboardTemplate';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
interface TeamLeaderboardInteractiveProps {
teams: TeamSummaryViewModel[];
}
export default function TeamLeaderboardInteractive({ teams }: TeamLeaderboardInteractiveProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
const [sortBy, setSortBy] = useState<SortBy>('rating');
const handleTeamClick = (teamId: string) => {
if (teamId.startsWith('demo-team-')) {
return;
}
router.push(`/teams/${teamId}`);
};
const handleBackToTeams = () => {
router.push('/teams');
};
return (
<TeamLeaderboardTemplate
teams={teams}
searchQuery={searchQuery}
filterLevel={filterLevel}
sortBy={sortBy}
onSearchChange={setSearchQuery}
onFilterLevelChange={setFilterLevel}
onSortChange={setSortBy}
onTeamClick={handleTeamClick}
onBackToTeams={handleBackToTeams}
/>
);
}

View File

@@ -1,27 +0,0 @@
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import TeamLeaderboardInteractive from './TeamLeaderboardInteractive';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// ============================================================================
// SERVER COMPONENT - Fetches data and passes to Interactive wrapper
// ============================================================================
export default async function TeamLeaderboardStatic() {
// Create services for server-side data fetching
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
const teamService = serviceFactory.createTeamService();
// Fetch data server-side
let teams: TeamSummaryViewModel[] = [];
try {
teams = await teamService.getAllTeams();
} catch (error) {
console.error('Failed to load team leaderboard:', error);
teams = [];
}
// Pass data to Interactive wrapper which handles client-side interactions
return <TeamLeaderboardInteractive teams={teams} />;
}

View File

@@ -1,9 +1,97 @@
import TeamLeaderboardStatic from './TeamLeaderboardStatic';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import TeamLeaderboardTemplate from '@/templates/TeamLeaderboardTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { TeamService } from '@/lib/services/teams/TeamService';
import { Trophy } from 'lucide-react';
import { redirect , useRouter } from 'next/navigation';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { useState } from 'react';
// ============================================================================
// TYPES
// ============================================================================
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
// ============================================================================
// WRAPPER COMPONENT (Client-side state management)
// ============================================================================
function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewModel[] | null }) {
const router = useRouter();
// Client-side state for filtering and sorting
const [searchQuery, setSearchQuery] = useState('');
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
const [sortBy, setSortBy] = useState<SortBy>('rating');
if (!data || data.length === 0) {
return null;
}
const handleTeamClick = (teamId: string) => {
router.push(`/teams/${teamId}`);
};
const handleBackToTeams = () => {
router.push('/teams');
};
return (
<TeamLeaderboardTemplate
teams={data}
searchQuery={searchQuery}
filterLevel={filterLevel}
sortBy={sortBy}
onSearchChange={setSearchQuery}
onFilterLevelChange={setFilterLevel}
onSortChange={setSortBy}
onTeamClick={handleTeamClick}
onBackToTeams={handleBackToTeams}
/>
);
}
// ============================================================================
// MAIN PAGE COMPONENT
// ============================================================================
export default function TeamLeaderboardPage() {
return <TeamLeaderboardStatic />;
export default async function TeamLeaderboardPage() {
// Fetch data using PageDataFetcher
const teamsData = await PageDataFetcher.fetch<TeamService, 'getAllTeams'>(
TEAM_SERVICE_TOKEN,
'getAllTeams'
);
// Prepare data for template
const data: TeamSummaryViewModel[] | null = teamsData as TeamSummaryViewModel[] | null;
const hasData = (teamsData as any)?.length > 0;
// Handle loading state (should be fast since we're using async/await)
const isLoading = false;
const error = null;
const retry = async () => {
// In server components, we can't retry without a reload
redirect('/teams/leaderboard');
};
return (
<PageWrapper
data={hasData ? data : null}
isLoading={isLoading}
error={error}
retry={retry}
Template={TeamLeaderboardPageWrapper}
loading={{ variant: 'full-screen', message: 'Loading team leaderboard...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Trophy,
title: 'No teams found',
description: 'There are no teams in the system yet.',
}}
/>
);
}

View File

@@ -1,3 +1,97 @@
import TeamsInteractive from './TeamsInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import TeamsTemplate from '@/templates/TeamsTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { TeamService } from '@/lib/services/teams/TeamService';
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { notFound } from 'next/navigation';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
export default TeamsInteractive;
// Helper to compute derived data for SSR
function computeDerivedData(teams: TeamSummaryViewModel[]) {
// Group teams by performance level (skill level)
const teamsByLevel = teams.reduce((acc, team) => {
const level = team.performanceLevel || 'intermediate';
if (!acc[level]) {
acc[level] = [];
}
acc[level].push(team);
return acc;
}, {} as Record<string, TeamSummaryViewModel[]>);
// Get top teams (by rating, descending)
const topTeams = [...teams]
.filter(t => t.rating !== undefined)
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
.slice(0, 5);
// Count recruiting teams
const recruitingCount = teams.filter(t => t.isRecruiting).length;
// For SSR, filtered teams = all teams (no search filter applied server-side)
const filteredTeams = teams;
return {
teamsByLevel,
topTeams,
recruitingCount,
filteredTeams,
};
}
// Template wrapper for SSR
function TeamsTemplateWrapper({ data }: { data: TeamSummaryViewModel[] }) {
const derived = computeDerivedData(data);
// Provide default values for SSR
// The template will handle client-side state management
return (
<TeamsTemplate
teams={data}
isLoading={false}
searchQuery=""
showCreateForm={false}
teamsByLevel={derived.teamsByLevel}
topTeams={derived.topTeams}
recruitingCount={derived.recruitingCount}
filteredTeams={derived.filteredTeams}
// No-op handlers for SSR (client will override)
onSearchChange={() => {}}
onShowCreateForm={() => {}}
onHideCreateForm={() => {}}
onTeamClick={() => {}}
onCreateSuccess={() => {}}
onBrowseTeams={() => {}}
onSkillLevelClick={() => {}}
/>
);
}
export default async function Page() {
const data = await PageDataFetcher.fetchManual(async () => {
// Manual dependency creation
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Create API client
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new TeamService(teamsApiClient);
return await service.getAllTeams();
});
if (!data) {
notFound();
}
return <PageWrapper data={data} Template={TeamsTemplateWrapper} />;
}