page wrapper
This commit is contained in:
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
359
apps/website/app/leagues/[id]/stewarding/StewardingTemplate.tsx
Normal file
359
apps/website/app/leagues/[id]/stewarding/StewardingTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
300
apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx
Normal file
300
apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: () => {} }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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={[]}
|
||||
/>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user