di usage in website
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, FormEvent, type ChangeEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useForgotPassword } from '@/hooks/auth/useForgotPassword';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
@@ -33,7 +34,6 @@ export default function ForgotPasswordPage() {
|
||||
const router = useRouter();
|
||||
const { session } = useAuth();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [success, setSuccess] = useState<SuccessState | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -60,34 +60,40 @@ export default function ForgotPasswordPage() {
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const { ServiceFactory } = await import('@/lib/services/ServiceFactory');
|
||||
const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001');
|
||||
const authService = serviceFactory.createAuthService();
|
||||
const result = await authService.forgotPassword({ email: formData.email });
|
||||
|
||||
// Use forgot password mutation hook
|
||||
const forgotPasswordMutation = useForgotPassword({
|
||||
onSuccess: (result) => {
|
||||
setSuccess({
|
||||
message: result.message,
|
||||
magicLink: result.magicLink,
|
||||
});
|
||||
} catch (error) {
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrors({
|
||||
submit: error instanceof Error ? error.message : 'Failed to send reset link. Please try again.',
|
||||
submit: error.message || 'Failed to send reset link. Please try again.',
|
||||
});
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (forgotPasswordMutation.isPending) return;
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setErrors({});
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
await forgotPasswordMutation.mutateAsync({ email: formData.email });
|
||||
} catch (error) {
|
||||
// Error handling is done in the mutation's onError callback
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state from mutation
|
||||
const loading = forgotPasswordMutation.isPending;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
|
||||
{/* Background Pattern */}
|
||||
|
||||
@@ -20,6 +20,7 @@ import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
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';
|
||||
@@ -37,6 +38,21 @@ export default function LoginPage() {
|
||||
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', {
|
||||
@@ -84,10 +100,6 @@ export default function LoginPage() {
|
||||
validate: validateLoginForm,
|
||||
component: 'LoginPage',
|
||||
onSubmit: async (values) => {
|
||||
const { ServiceFactory } = await import('@/lib/services/ServiceFactory');
|
||||
const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001');
|
||||
const authService = serviceFactory.createAuthService();
|
||||
|
||||
// Log the attempt for debugging
|
||||
logErrorWithContext(
|
||||
{ message: 'Login attempt', values: { ...values, password: '[REDACTED]' } },
|
||||
@@ -98,21 +110,14 @@ export default function LoginPage() {
|
||||
}
|
||||
);
|
||||
|
||||
await authService.login({
|
||||
await loginMutation.mutateAsync({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
rememberMe: values.rememberMe,
|
||||
});
|
||||
|
||||
// Refresh session in context so header updates immediately
|
||||
await refreshSession();
|
||||
router.push(returnTo);
|
||||
},
|
||||
onError: (error, values) => {
|
||||
// Show error details toggle in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
setShowErrorDetails(true);
|
||||
}
|
||||
// Error handling is done in the mutation's onError callback
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Reset error details on success
|
||||
|
||||
@@ -20,6 +20,7 @@ import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useResetPassword } from '@/hooks/auth/useResetPassword';
|
||||
|
||||
interface FormErrors {
|
||||
newPassword?: string;
|
||||
@@ -52,7 +53,6 @@ export default function ResetPasswordPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const { session } = useAuth();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
@@ -112,34 +112,40 @@ export default function ResetPasswordPage() {
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Use reset password mutation hook
|
||||
const resetPasswordMutation = useResetPassword({
|
||||
onSuccess: (result) => {
|
||||
setSuccess(result.message);
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrors({
|
||||
submit: error.message || 'Failed to reset password. Please try again.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
if (resetPasswordMutation.isPending) return;
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const { ServiceFactory } = await import('@/lib/services/ServiceFactory');
|
||||
const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001');
|
||||
const authService = serviceFactory.createAuthService();
|
||||
const result = await authService.resetPassword({
|
||||
await resetPasswordMutation.mutateAsync({
|
||||
token,
|
||||
newPassword: formData.newPassword,
|
||||
});
|
||||
|
||||
setSuccess(result.message);
|
||||
} catch (error) {
|
||||
setErrors({
|
||||
submit: error instanceof Error ? error.message : 'Failed to reset password. Please try again.',
|
||||
});
|
||||
setLoading(false);
|
||||
// Error handling is done in the mutation's onError callback
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state from mutation
|
||||
const loading = resetPasswordMutation.isPending;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
|
||||
{/* Background Pattern */}
|
||||
|
||||
@@ -28,6 +28,7 @@ import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useSignup } from '@/hooks/auth/useSignup';
|
||||
|
||||
interface FormErrors {
|
||||
firstName?: string;
|
||||
@@ -94,7 +95,6 @@ export default function SignupPage() {
|
||||
const { refreshSession, session } = useAuth();
|
||||
const returnTo = searchParams.get('returnTo') ?? '/onboarding';
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkingAuth, setCheckingAuth] = useState(true);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
@@ -198,29 +198,9 @@ export default function SignupPage() {
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const { ServiceFactory } = await import('@/lib/services/ServiceFactory');
|
||||
const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001');
|
||||
const authService = serviceFactory.createAuthService();
|
||||
|
||||
// Combine first and last name into display name
|
||||
const displayName = `${formData.firstName} ${formData.lastName}`.trim();
|
||||
|
||||
await authService.signup({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
displayName,
|
||||
});
|
||||
|
||||
// Use signup mutation hook
|
||||
const signupMutation = useSignup({
|
||||
onSuccess: async () => {
|
||||
// Refresh session in context so header updates immediately
|
||||
try {
|
||||
await refreshSession();
|
||||
@@ -229,14 +209,39 @@ export default function SignupPage() {
|
||||
}
|
||||
// Always redirect to dashboard after signup
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrors({
|
||||
submit: error instanceof Error ? error.message : 'Signup failed. Please try again.',
|
||||
submit: error.message || 'Signup failed. Please try again.',
|
||||
});
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (signupMutation.isPending) return;
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
// Combine first and last name into display name
|
||||
const displayName = `${formData.firstName} ${formData.lastName}`.trim();
|
||||
|
||||
await signupMutation.mutateAsync({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
displayName,
|
||||
});
|
||||
} catch (error) {
|
||||
// Error handling is done in the mutation's onError callback
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state from mutation
|
||||
const loading = signupMutation.isPending;
|
||||
|
||||
// Show loading while checking auth
|
||||
if (checkingAuth) {
|
||||
return (
|
||||
|
||||
@@ -1,48 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
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 {
|
||||
Calendar,
|
||||
Trophy,
|
||||
Users,
|
||||
Star,
|
||||
Clock,
|
||||
Flag,
|
||||
ChevronRight,
|
||||
Target,
|
||||
Award,
|
||||
Activity,
|
||||
Play,
|
||||
Medal,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { StatCard } from '@/components/dashboard/StatCard';
|
||||
import { LeagueStandingItem } from '@/components/dashboard/LeagueStandingItem';
|
||||
import { UpcomingRaceItem } from '@/components/dashboard/UpcomingRaceItem';
|
||||
import { FriendItem } from '@/components/dashboard/FriendItem';
|
||||
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';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { EmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useDashboardOverview } from '@/hooks/dashboard/useDashboardOverview';
|
||||
import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { dashboardService } = useServices();
|
||||
|
||||
const { data: dashboardData, isLoading, error, retry } = useDataFetching({
|
||||
queryKey: ['dashboardOverview'],
|
||||
queryFn: () => dashboardService.getDashboardOverview(),
|
||||
});
|
||||
const { data: dashboardData, isLoading, error, retry } = useDashboardOverview();
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
@@ -61,285 +53,287 @@ export default function DashboardPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(data) => {
|
||||
const currentDriver = data.currentDriver;
|
||||
const nextRace = data.nextRace;
|
||||
const upcomingRaces = data.upcomingRaces;
|
||||
const leagueStandingsSummaries = data.leagueStandings;
|
||||
const feedSummary = { items: data.feedItems };
|
||||
const friends = data.friends;
|
||||
const activeLeaguesCount = data.activeLeaguesCount;
|
||||
{(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>
|
||||
{/* 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="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 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>
|
||||
|
||||
<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}`}>
|
||||
|
||||
{/* 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">
|
||||
View Details
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<Activity className="w-4 h-4" />
|
||||
View Profile
|
||||
</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>
|
||||
{/* 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>
|
||||
<div className="space-y-3">
|
||||
{leagueStandingsSummaries.map(({ leagueId, leagueName, position, points, totalDrivers }) => (
|
||||
<LeagueStandingItem
|
||||
key={leagueId}
|
||||
leagueId={leagueId}
|
||||
leagueName={leagueName}
|
||||
position={position}
|
||||
points={points}
|
||||
totalDrivers={totalDrivers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 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) => (
|
||||
<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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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) => (
|
||||
<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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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) => (
|
||||
<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>
|
||||
)}
|
||||
{/* 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>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DriversTemplate } from '@/templates/DriversTemplate';
|
||||
import { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
import { DriversTemplate } from '@/templates/DriversTemplate';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useDriverLeaderboard } from '@/hooks/driver/useDriverLeaderboard';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
export function DriversInteractive() {
|
||||
const router = useRouter();
|
||||
const { driverService } = useServices();
|
||||
|
||||
const { data: viewModel, isLoading: loading, error, retry } = useDataFetching({
|
||||
queryKey: ['driverLeaderboard'],
|
||||
queryFn: () => driverService.getDriverLeaderboard(),
|
||||
});
|
||||
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)
|
||||
@@ -45,7 +39,7 @@ export function DriversInteractive() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(leaderboardData) => (
|
||||
{() => (
|
||||
<DriversTemplate
|
||||
drivers={driverViewModels}
|
||||
totalRaces={totalRaces}
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
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';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { Car } from 'lucide-react';
|
||||
|
||||
interface Team {
|
||||
@@ -26,21 +24,22 @@ export function DriverProfileInteractive() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const driverId = params.id as string;
|
||||
const { driverService, teamService } = useServices();
|
||||
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
|
||||
const { data: driverProfile, isLoading, error, retry } = useDataFetching({
|
||||
// Fetch driver profile using React-Query
|
||||
const { data: driverProfile, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['driverProfile', driverId],
|
||||
queryFn: () => driverService.getDriverProfile(driverId),
|
||||
});
|
||||
|
||||
// Fetch team memberships
|
||||
const { data: allTeamMemberships } = useDataFetching({
|
||||
// Fetch team memberships using React-Query
|
||||
const { data: allTeamMemberships } = useQuery({
|
||||
queryKey: ['driverTeamMemberships', driverId],
|
||||
queryFn: async () => {
|
||||
if (!driverProfile?.currentDriver) return [];
|
||||
@@ -100,38 +99,71 @@ export function DriverProfileInteractive() {
|
||||
/>
|
||||
) : 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 (
|
||||
<StateContainer
|
||||
data={driverProfile}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'skeleton', message: 'Loading driver profile...' },
|
||||
error: { variant: 'full-screen' },
|
||||
empty: {
|
||||
icon: 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 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(profileData) => (
|
||||
<DriverProfileTemplate
|
||||
driverProfile={profileData}
|
||||
allTeamMemberships={allTeamMemberships || []}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onBackClick={handleBackClick}
|
||||
onAddFriend={handleAddFriend}
|
||||
friendRequestSent={friendRequestSent}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
isSponsorMode={isSponsorMode}
|
||||
sponsorInsights={sponsorInsights}
|
||||
/>
|
||||
)}
|
||||
</StateContainer>
|
||||
<DriverProfileTemplate
|
||||
driverProfile={driverProfile}
|
||||
allTeamMemberships={allTeamMemberships || []}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onBackClick={handleBackClick}
|
||||
onAddFriend={handleAddFriend}
|
||||
friendRequestSent={friendRequestSent}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
isSponsorMode={isSponsorMode}
|
||||
sponsorInsights={sponsorInsights}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import NotificationProvider from '@/components/notifications/NotificationProvide
|
||||
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||
import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
|
||||
import { FeatureFlagProvider } from '@/lib/feature/FeatureFlagProvider';
|
||||
import { ServiceProvider } from '@/lib/services/ServiceProvider';
|
||||
import { ContainerProvider } from '@/lib/di/providers/ContainerProvider';
|
||||
import { initializeGlobalErrorHandling } from '@/lib/infrastructure/GlobalErrorHandler';
|
||||
import { initializeApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
@@ -80,7 +80,7 @@ export default async function RootLayout({
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body className="antialiased overflow-x-hidden">
|
||||
<ServiceProvider>
|
||||
<ContainerProvider>
|
||||
<AuthProvider>
|
||||
<FeatureFlagProvider flags={enabledFlags}>
|
||||
<NotificationProvider>
|
||||
@@ -114,7 +114,7 @@ export default async function RootLayout({
|
||||
</NotificationProvider>
|
||||
</FeatureFlagProvider>
|
||||
</AuthProvider>
|
||||
</ServiceProvider>
|
||||
</ContainerProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -6,24 +6,16 @@ import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLea
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
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 { driverService, teamService } = useServices();
|
||||
|
||||
const { data: driverData, isLoading: driversLoading, error: driversError, retry: driversRetry } = useDataFetching({
|
||||
queryKey: ['driverLeaderboard'],
|
||||
queryFn: () => driverService.getDriverLeaderboard(),
|
||||
});
|
||||
|
||||
const { data: teams, isLoading: teamsLoading, error: teamsError, retry: teamsRetry } = useDataFetching({
|
||||
queryKey: ['allTeams'],
|
||||
queryFn: () => teamService.getAllTeams(),
|
||||
});
|
||||
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}`);
|
||||
|
||||
@@ -6,9 +6,8 @@ import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useDriverLeaderboard } from '@/hooks/driver/useDriverLeaderboard';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
@@ -16,17 +15,13 @@ type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
|
||||
|
||||
export default function DriverRankingsStatic() {
|
||||
const router = useRouter();
|
||||
const { driverService } = useServices();
|
||||
|
||||
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 } = useDataFetching({
|
||||
queryKey: ['driverLeaderboard'],
|
||||
queryFn: () => driverService.getDriverLeaderboard(),
|
||||
});
|
||||
const { data: driverData, isLoading, error, retry } = useDriverLeaderboard();
|
||||
|
||||
const handleDriverClick = (driverId: string) => {
|
||||
if (driverId.startsWith('demo-')) return;
|
||||
|
||||
@@ -3,22 +3,17 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
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 { leagueService } = useServices();
|
||||
|
||||
const { data: realLeagues = [], isLoading: loading, error, retry } = useDataFetching({
|
||||
queryKey: ['allLeagues'],
|
||||
queryFn: () => leagueService.getAllLeagues(),
|
||||
});
|
||||
const { data: realLeagues = [], isLoading: loading, error, retry } = useAllLeagues();
|
||||
|
||||
const handleLeagueClick = (leagueId: string) => {
|
||||
router.push(`/leagues/${leagueId}`);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
|
||||
import EndRaceModal from '@/components/leagues/EndRaceModal';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
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() {
|
||||
@@ -18,17 +19,15 @@ export default function LeagueDetailInteractive() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const isSponsor = useSponsorMode();
|
||||
const { leagueService, leagueMembershipService, raceService } = useServices();
|
||||
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 } = useDataFetching({
|
||||
queryKey: ['leagueDetailPage', leagueId],
|
||||
queryFn: () => leagueService.getLeagueDetailPageData(leagueId),
|
||||
});
|
||||
const { data: viewModel, isLoading, error, retry } = useLeagueDetailWithSponsors(leagueId);
|
||||
|
||||
const handleMembershipChange = () => {
|
||||
retry();
|
||||
@@ -82,7 +81,7 @@ export default function LeagueDetailInteractive() {
|
||||
{(leagueData) => (
|
||||
<>
|
||||
<LeagueDetailTemplate
|
||||
viewModel={leagueData}
|
||||
viewModel={leagueData!}
|
||||
leagueId={leagueId}
|
||||
isSponsor={isSponsor}
|
||||
membership={membership}
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import LeagueHeader from '@/components/leagues/LeagueHeader';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { LeaguePageDetailViewModel } from '@/lib/view-models/LeaguePageDetailViewModel';
|
||||
import { useLeagueDetail } from '@/hooks/league/useLeagueDetail';
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
export default function LeagueLayout({
|
||||
children,
|
||||
@@ -18,26 +17,8 @@ export default function LeagueLayout({
|
||||
const router = useRouter();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueService } = useServices();
|
||||
|
||||
const [leagueDetail, setLeagueDetail] = useState<LeaguePageDetailViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadLeague() {
|
||||
try {
|
||||
const leagueDetailData = await leagueService.getLeagueDetail(leagueId, currentDriverId);
|
||||
|
||||
setLeagueDetail(leagueDetailData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load league:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadLeague();
|
||||
}, [leagueId, currentDriverId, leagueService]);
|
||||
const { data: leagueDetail, isLoading: loading } = useLeagueDetail(leagueId, currentDriverId);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import type { Mocked } from 'vitest';
|
||||
|
||||
import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel';
|
||||
@@ -21,15 +22,70 @@ vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: 'league-1' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/ServiceProvider', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as object;
|
||||
return {
|
||||
...actual,
|
||||
useServices: () => ({
|
||||
leagueService: mockLeagueService,
|
||||
}),
|
||||
};
|
||||
});
|
||||
// Mock data storage
|
||||
let mockJoinRequests: any[] = [];
|
||||
let mockMembers: any[] = [];
|
||||
|
||||
// Mock the new DI hooks
|
||||
vi.mock('@/hooks/league/useLeagueRosterAdmin', () => ({
|
||||
useLeagueRosterJoinRequests: (leagueId: string) => ({
|
||||
data: [...mockJoinRequests],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useLeagueRosterMembers: (leagueId: string) => ({
|
||||
data: [...mockMembers],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useApproveJoinRequest: () => ({
|
||||
mutate: (params: any) => {
|
||||
// Remove from join requests
|
||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId);
|
||||
},
|
||||
mutateAsync: async (params: any) => {
|
||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId);
|
||||
return { success: true };
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
useRejectJoinRequest: () => ({
|
||||
mutate: (params: any) => {
|
||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId);
|
||||
},
|
||||
mutateAsync: async (params: any) => {
|
||||
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId);
|
||||
return { success: true };
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdateMemberRole: () => ({
|
||||
mutate: (params: any) => {
|
||||
const member = mockMembers.find(m => m.driverId === params.driverId);
|
||||
if (member) member.role = params.role;
|
||||
},
|
||||
mutateAsync: async (params: any) => {
|
||||
const member = mockMembers.find(m => m.driverId === params.driverId);
|
||||
if (member) member.role = params.role;
|
||||
return { success: true };
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
useRemoveMember: () => ({
|
||||
mutate: (params: any) => {
|
||||
mockMembers = mockMembers.filter(m => m.driverId !== params.driverId);
|
||||
},
|
||||
mutateAsync: async (params: any) => {
|
||||
mockMembers = mockMembers.filter(m => m.driverId !== params.driverId);
|
||||
return { success: true };
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeJoinRequest(overrides: Partial<LeagueAdminRosterJoinRequestViewModel> = {}): LeagueAdminRosterJoinRequestViewModel {
|
||||
return {
|
||||
@@ -55,6 +111,10 @@ function makeMember(overrides: Partial<LeagueAdminRosterMemberViewModel> = {}):
|
||||
|
||||
describe('RosterAdminPage', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mock data
|
||||
mockJoinRequests = [];
|
||||
mockMembers = [];
|
||||
|
||||
mockLeagueService = {
|
||||
getAdminRosterJoinRequests: vi.fn(),
|
||||
getAdminRosterMembers: vi.fn(),
|
||||
@@ -76,8 +136,9 @@ describe('RosterAdminPage', () => {
|
||||
makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'admin' }),
|
||||
];
|
||||
|
||||
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue(joinRequests);
|
||||
mockLeagueService.getAdminRosterMembers.mockResolvedValue(members);
|
||||
// Set mock data for hooks
|
||||
mockJoinRequests = joinRequests;
|
||||
mockMembers = members;
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
|
||||
@@ -91,9 +152,8 @@ describe('RosterAdminPage', () => {
|
||||
});
|
||||
|
||||
it('approves a join request and removes it from the pending list', async () => {
|
||||
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([makeJoinRequest({ id: 'jr-1', driverName: 'Driver One' })]);
|
||||
mockLeagueService.getAdminRosterMembers.mockResolvedValue([makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })]);
|
||||
mockLeagueService.approveJoinRequest.mockResolvedValue({ success: true } as any);
|
||||
mockJoinRequests = [makeJoinRequest({ id: 'jr-1', driverName: 'Driver One' })];
|
||||
mockMembers = [makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })];
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
|
||||
@@ -101,19 +161,14 @@ describe('RosterAdminPage', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('join-request-jr-1-approve'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLeagueService.approveJoinRequest).toHaveBeenCalledWith('league-1', 'jr-1');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Driver One')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a join request and removes it from the pending list', async () => {
|
||||
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([makeJoinRequest({ id: 'jr-2', driverName: 'Driver Two' })]);
|
||||
mockLeagueService.getAdminRosterMembers.mockResolvedValue([makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })]);
|
||||
mockLeagueService.rejectJoinRequest.mockResolvedValue({ success: true } as any);
|
||||
mockJoinRequests = [makeJoinRequest({ id: 'jr-2', driverName: 'Driver Two' })];
|
||||
mockMembers = [makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })];
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
|
||||
@@ -121,21 +176,14 @@ describe('RosterAdminPage', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('join-request-jr-2-reject'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLeagueService.rejectJoinRequest).toHaveBeenCalledWith('league-1', 'jr-2');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Driver Two')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('changes a member role via service and updates the displayed role', async () => {
|
||||
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([]);
|
||||
mockLeagueService.getAdminRosterMembers.mockResolvedValue([
|
||||
makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'member' }),
|
||||
]);
|
||||
mockLeagueService.updateMemberRole.mockResolvedValue({ success: true } as any);
|
||||
mockJoinRequests = [];
|
||||
mockMembers = [makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'member' })];
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
|
||||
@@ -146,21 +194,14 @@ describe('RosterAdminPage', () => {
|
||||
|
||||
fireEvent.change(roleSelect, { target: { value: 'admin' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLeagueService.updateMemberRole).toHaveBeenCalledWith('league-1', 'driver-11', 'admin');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText('Role for Member Eleven') as HTMLSelectElement).value).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
it('removes a member via service and removes them from the list', async () => {
|
||||
mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([]);
|
||||
mockLeagueService.getAdminRosterMembers.mockResolvedValue([
|
||||
makeMember({ driverId: 'driver-12', driverName: 'Member Twelve', role: 'member' }),
|
||||
]);
|
||||
mockLeagueService.removeMember.mockResolvedValue({ success: true } as any);
|
||||
mockJoinRequests = [];
|
||||
mockMembers = [makeMember({ driverId: 'driver-12', driverName: 'Member Twelve', role: 'member' })];
|
||||
|
||||
render(<RosterAdminPage />);
|
||||
|
||||
@@ -168,10 +209,6 @@ describe('RosterAdminPage', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('member-driver-12-remove'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLeagueService.removeMember).toHaveBeenCalledWith('league-1', 'driver-12');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Member Twelve')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel';
|
||||
import type { LeagueAdminRosterMemberViewModel } from '@/lib/view-models/LeagueAdminRosterMemberViewModel';
|
||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useLeagueRosterJoinRequests,
|
||||
useLeagueRosterMembers,
|
||||
useApproveJoinRequest,
|
||||
useRejectJoinRequest,
|
||||
useUpdateMemberRole,
|
||||
useRemoveMember,
|
||||
} from '@/hooks/league/useLeagueRosterAdmin';
|
||||
|
||||
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
|
||||
|
||||
@@ -14,56 +19,56 @@ export function RosterAdminPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
const { leagueService } = useServices();
|
||||
// Fetch data using React-Query + DI
|
||||
const {
|
||||
data: joinRequests = [],
|
||||
isLoading: loadingJoinRequests,
|
||||
refetch: refetchJoinRequests,
|
||||
} = useLeagueRosterJoinRequests(leagueId);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [joinRequests, setJoinRequests] = useState<LeagueAdminRosterJoinRequestViewModel[]>([]);
|
||||
const [members, setMembers] = useState<LeagueAdminRosterMemberViewModel[]>([]);
|
||||
const {
|
||||
data: members = [],
|
||||
isLoading: loadingMembers,
|
||||
refetch: refetchMembers,
|
||||
} = useLeagueRosterMembers(leagueId);
|
||||
|
||||
const loadRoster = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [requestsVm, membersVm] = await Promise.all([
|
||||
leagueService.getAdminRosterJoinRequests(leagueId),
|
||||
leagueService.getAdminRosterMembers(leagueId),
|
||||
]);
|
||||
setJoinRequests(requestsVm);
|
||||
setMembers(membersVm);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const loading = loadingJoinRequests || loadingMembers;
|
||||
|
||||
useEffect(() => {
|
||||
void loadRoster();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leagueId]);
|
||||
// Mutations
|
||||
const approveMutation = useApproveJoinRequest({
|
||||
onSuccess: () => refetchJoinRequests(),
|
||||
});
|
||||
|
||||
const rejectMutation = useRejectJoinRequest({
|
||||
onSuccess: () => refetchJoinRequests(),
|
||||
});
|
||||
|
||||
const updateRoleMutation = useUpdateMemberRole({
|
||||
onError: () => refetchMembers(), // Refetch on error to restore state
|
||||
});
|
||||
|
||||
const removeMemberMutation = useRemoveMember({
|
||||
onSuccess: () => refetchMembers(),
|
||||
});
|
||||
|
||||
const pendingCountLabel = useMemo(() => {
|
||||
return joinRequests.length === 1 ? '1 request' : `${joinRequests.length} requests`;
|
||||
}, [joinRequests.length]);
|
||||
|
||||
const handleApprove = async (joinRequestId: string) => {
|
||||
await leagueService.approveJoinRequest(leagueId, joinRequestId);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== joinRequestId));
|
||||
await approveMutation.mutateAsync({ leagueId, joinRequestId });
|
||||
};
|
||||
|
||||
const handleReject = async (joinRequestId: string) => {
|
||||
await leagueService.rejectJoinRequest(leagueId, joinRequestId);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== joinRequestId));
|
||||
await rejectMutation.mutateAsync({ leagueId, joinRequestId });
|
||||
};
|
||||
|
||||
const handleRoleChange = async (driverId: string, newRole: MembershipRole) => {
|
||||
setMembers((prev) => prev.map((m) => (m.driverId === driverId ? { ...m, role: newRole } : m)));
|
||||
const result = await leagueService.updateMemberRole(leagueId, driverId, newRole);
|
||||
if (!result.success) {
|
||||
await loadRoster();
|
||||
}
|
||||
await updateRoleMutation.mutateAsync({ leagueId, driverId, role: newRole });
|
||||
};
|
||||
|
||||
const handleRemove = async (driverId: string) => {
|
||||
await leagueService.removeMember(leagueId, driverId);
|
||||
setMembers((prev) => prev.filter((m) => m.driverId !== driverId));
|
||||
await removeMemberMutation.mutateAsync({ leagueId, driverId });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
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 } = useServices();
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
|
||||
const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import LeagueAdminSchedulePage from './page';
|
||||
|
||||
// Mock useEffectiveDriverId
|
||||
vi.mock('@/hooks/useEffectiveDriverId', () => ({
|
||||
useEffectiveDriverId: () => 'driver-1',
|
||||
}));
|
||||
|
||||
type SeasonSummaryViewModel = {
|
||||
seasonId: string;
|
||||
name: string;
|
||||
@@ -82,8 +88,42 @@ const mockServices = {
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('@/lib/services/ServiceProvider', () => ({
|
||||
useServices: () => mockServices,
|
||||
// Mock useInject to return mocked services
|
||||
vi.mock('@/lib/di/hooks/useInject', () => ({
|
||||
useInject: (token: symbol) => {
|
||||
const tokenStr = token.toString();
|
||||
if (tokenStr.includes('LEAGUE_SERVICE_TOKEN')) {
|
||||
return {
|
||||
getLeagueSeasonSummaries: mockGetLeagueSeasonSummaries,
|
||||
getAdminSchedule: mockGetAdminSchedule,
|
||||
publishAdminSchedule: mockPublishAdminSchedule,
|
||||
unpublishAdminSchedule: mockUnpublishAdminSchedule,
|
||||
createAdminScheduleRace: mockCreateAdminScheduleRace,
|
||||
updateAdminScheduleRace: mockUpdateAdminScheduleRace,
|
||||
deleteAdminScheduleRace: mockDeleteAdminScheduleRace,
|
||||
};
|
||||
}
|
||||
if (tokenStr.includes('LEAGUE_MEMBERSHIP_SERVICE_TOKEN')) {
|
||||
return {
|
||||
fetchLeagueMemberships: mockFetchLeagueMemberships,
|
||||
getMembership: mockGetMembership,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the static LeagueMembershipService for LeagueMembershipUtility
|
||||
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(() => []),
|
||||
},
|
||||
}));
|
||||
|
||||
function createAdminScheduleViewModel(overrides: Partial<AdminScheduleViewModel> = {}): AdminScheduleViewModel {
|
||||
@@ -114,6 +154,7 @@ describe('LeagueAdminSchedulePage', () => {
|
||||
mockFetchLeagueMemberships.mockReset();
|
||||
mockGetMembership.mockReset();
|
||||
|
||||
// Set up default mock implementations
|
||||
mockFetchLeagueMemberships.mockResolvedValue([]);
|
||||
mockGetMembership.mockReturnValue({ role: 'admin' });
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@ 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 { useServices } from '@/lib/services/ServiceProvider';
|
||||
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 { useParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -14,7 +15,8 @@ export default function LeagueAdminSchedulePage() {
|
||||
const leagueId = params.id as string;
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueService, leagueMembershipService } = useServices();
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
|
||||
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [membershipLoading, setMembershipLoading] = useState(true);
|
||||
|
||||
@@ -4,39 +4,29 @@ import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo';
|
||||
import LeagueOwnershipTransfer from '@/components/leagues/LeagueOwnershipTransfer';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
|
||||
import { AlertTriangle, Settings } from 'lucide-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus';
|
||||
import { useLeagueSettings } from '@/hooks/league/useLeagueSettings';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_SETTINGS_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { AlertTriangle, Settings } from 'lucide-react';
|
||||
|
||||
export default function LeagueSettingsPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueMembershipService, leagueSettingsService } = useServices();
|
||||
const leagueSettingsService = useInject(LEAGUE_SETTINGS_SERVICE_TOKEN);
|
||||
const router = useRouter();
|
||||
|
||||
// Check admin status
|
||||
const { data: isAdmin, isLoading: adminLoading } = useDataFetching({
|
||||
queryKey: ['leagueMembership', leagueId, currentDriverId],
|
||||
queryFn: async () => {
|
||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
|
||||
},
|
||||
});
|
||||
// Check admin status using DI + React-Query
|
||||
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
|
||||
|
||||
// Load settings (only if admin)
|
||||
const { data: settings, isLoading: settingsLoading, error, retry } = useDataFetching({
|
||||
queryKey: ['leagueSettings', leagueId],
|
||||
queryFn: () => leagueSettingsService.getLeagueSettings(leagueId),
|
||||
enabled: !!isAdmin,
|
||||
});
|
||||
// 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 {
|
||||
@@ -100,10 +90,10 @@ export default function LeagueSettingsPage() {
|
||||
|
||||
{/* READONLY INFORMATION SECTION - Compact */}
|
||||
<div className="space-y-4">
|
||||
<ReadonlyLeagueInfo league={settingsData.league} configForm={settingsData.config} />
|
||||
<ReadonlyLeagueInfo league={settingsData!.league} configForm={settingsData!.config} />
|
||||
|
||||
<LeagueOwnershipTransfer
|
||||
settings={settingsData}
|
||||
settings={settingsData!}
|
||||
currentDriverId={currentDriverId}
|
||||
onTransferOwnership={handleTransferOwnership}
|
||||
/>
|
||||
@@ -112,4 +102,4 @@ export default function LeagueSettingsPage() {
|
||||
)}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { LeagueSponsorshipsSection } from '@/components/leagues/LeagueSponsorshi
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
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 { useParams } from 'next/navigation';
|
||||
@@ -14,7 +15,8 @@ export default function LeagueSponsorshipsPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueService, leagueMembershipService } = useServices();
|
||||
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);
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useParams } from 'next/navigation';
|
||||
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
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';
|
||||
@@ -14,7 +15,7 @@ export default function LeagueStandingsInteractive() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueService } = useServices();
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
|
||||
const [standings, setStandings] = useState<StandingEntryViewModel[]>([]);
|
||||
const [drivers, setDrivers] = useState<DriverViewModel[]>([]);
|
||||
|
||||
@@ -6,9 +6,9 @@ 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 { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { LeagueStewardingViewModel } from '@/lib/view-models/LeagueStewardingViewModel';
|
||||
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,
|
||||
@@ -25,15 +25,14 @@ import { useParams } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
|
||||
export default function LeagueStewardingPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueStewardingService, leagueMembershipService } = useServices();
|
||||
const { data: currentDriver } = useCurrentDriver();
|
||||
const currentDriverId = currentDriver?.id;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
||||
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
|
||||
@@ -41,28 +40,10 @@ export default function LeagueStewardingPage() {
|
||||
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
|
||||
|
||||
// Check admin status
|
||||
const { data: isAdmin, isLoading: adminLoading } = useDataFetching({
|
||||
queryKey: ['leagueMembership', leagueId, currentDriverId],
|
||||
queryFn: async () => {
|
||||
const membership = await leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
|
||||
},
|
||||
});
|
||||
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
|
||||
|
||||
// Load stewarding data (only if admin)
|
||||
const { data: stewardingData, isLoading: dataLoading, error, retry } = useDataFetching({
|
||||
queryKey: ['leagueStewarding', leagueId],
|
||||
queryFn: () => leagueStewardingService.getLeagueStewardingData(leagueId),
|
||||
enabled: !!isAdmin,
|
||||
onSuccess: (data) => {
|
||||
// Auto-expand races with pending protests
|
||||
const racesWithPending = new Set<string>();
|
||||
data.pendingRaces.forEach(race => {
|
||||
racesWithPending.add(race.race.id);
|
||||
});
|
||||
setExpandedRaces(racesWithPending);
|
||||
},
|
||||
});
|
||||
const { data: stewardingData, isLoading: dataLoading, error, retry } = useLeagueStewardingData(leagueId);
|
||||
|
||||
// Filter races based on active tab
|
||||
const filteredRaces = useMemo(() => {
|
||||
@@ -75,13 +56,6 @@ export default function LeagueStewardingPage() {
|
||||
penaltyValue: number,
|
||||
stewardNotes: string
|
||||
) => {
|
||||
await leagueStewardingService.reviewProtest({
|
||||
protestId,
|
||||
stewardId: currentDriverId,
|
||||
decision: 'uphold',
|
||||
decisionNotes: stewardNotes,
|
||||
});
|
||||
|
||||
// Find the protest to get details for penalty
|
||||
let foundProtest: any | undefined;
|
||||
stewardingData?.racesWithData.forEach(raceData => {
|
||||
@@ -91,16 +65,24 @@ export default function LeagueStewardingPage() {
|
||||
});
|
||||
|
||||
if (foundProtest) {
|
||||
await leagueStewardingService.applyPenalty({
|
||||
raceId: foundProtest.raceId,
|
||||
driverId: foundProtest.accusedDriverId,
|
||||
stewardId: currentDriverId,
|
||||
type: penaltyType,
|
||||
value: penaltyValue,
|
||||
reason: foundProtest.incident.description,
|
||||
protestId,
|
||||
notes: stewardNotes,
|
||||
});
|
||||
// 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
|
||||
@@ -108,12 +90,13 @@ export default function LeagueStewardingPage() {
|
||||
};
|
||||
|
||||
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
|
||||
await leagueStewardingService.reviewProtest({
|
||||
protestId,
|
||||
stewardId: currentDriverId,
|
||||
decision: 'dismiss',
|
||||
decisionNotes: stewardNotes,
|
||||
});
|
||||
// TODO: Implement protest rejection
|
||||
// await leagueStewardingService.reviewProtest({
|
||||
// protestId,
|
||||
// stewardId: currentDriverId,
|
||||
// decision: 'dismiss',
|
||||
// decisionNotes: stewardNotes,
|
||||
// });
|
||||
|
||||
// Retry to refresh data
|
||||
await retry();
|
||||
@@ -185,245 +168,249 @@ export default function LeagueStewardingPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(data) => (
|
||||
<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" />
|
||||
{(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>
|
||||
<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
|
||||
{/* 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>
|
||||
)}
|
||||
</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
|
||||
{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>
|
||||
<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>
|
||||
{(protest.status === 'pending' || protest.status === 'under_review') && (
|
||||
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
|
||||
<Button variant="primary">
|
||||
Review
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</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('_', ' ')}
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
|
||||
)}
|
||||
{selectedProtest && (
|
||||
<ReviewProtestModal
|
||||
protest={selectedProtest}
|
||||
onClose={() => setSelectedProtest(null)}
|
||||
onAccept={handleAcceptProtest}
|
||||
onReject={handleRejectProtest}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import '@testing-library/jest-dom';
|
||||
|
||||
import ProtestReviewPage from './page';
|
||||
|
||||
// Mock useEffectiveDriverId
|
||||
vi.mock('@/hooks/useEffectiveDriverId', () => ({
|
||||
useEffectiveDriverId: () => 'driver-1',
|
||||
}));
|
||||
|
||||
// Mocks for Next.js navigation
|
||||
const mockPush = vi.fn();
|
||||
|
||||
@@ -24,22 +29,56 @@ const mockGetProtestDetailViewModel = vi.fn();
|
||||
const mockFetchLeagueMemberships = vi.fn();
|
||||
const mockGetMembership = vi.fn();
|
||||
|
||||
vi.mock('@/lib/services/ServiceProvider', () => ({
|
||||
useServices: () => ({
|
||||
leagueStewardingService: {
|
||||
getProtestDetailViewModel: mockGetProtestDetailViewModel,
|
||||
},
|
||||
protestService: {
|
||||
applyPenalty: vi.fn(),
|
||||
requestDefense: vi.fn(),
|
||||
},
|
||||
leagueMembershipService: {
|
||||
fetchLeagueMemberships: mockFetchLeagueMemberships,
|
||||
getMembership: mockGetMembership,
|
||||
},
|
||||
// Mock useLeagueAdminStatus hook
|
||||
vi.mock('@/hooks/league/useLeagueAdminStatus', () => ({
|
||||
useLeagueAdminStatus: (leagueId: string, driverId: string) => ({
|
||||
data: mockGetMembership.mock.results[0]?.value ?
|
||||
(mockGetMembership.mock.results[0].value.role === 'admin' || mockGetMembership.mock.results[0].value.role === 'owner') : false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useProtestDetail hook
|
||||
vi.mock('@/hooks/league/useProtestDetail', () => ({
|
||||
useProtestDetail: (leagueId: string, protestId: string, enabled: boolean = true) => ({
|
||||
data: mockGetProtestDetailViewModel.mock.results[0]?.value || null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: !!mockGetProtestDetailViewModel.mock.results[0]?.value,
|
||||
refetch: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useInject for protest service
|
||||
vi.mock('@/lib/di/hooks/useInject', () => ({
|
||||
useInject: (token: symbol) => {
|
||||
if (token.toString().includes('PROTEST_SERVICE_TOKEN')) {
|
||||
return {
|
||||
applyPenalty: vi.fn(),
|
||||
requestDefense: vi.fn(),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the static LeagueMembershipService for LeagueRoleUtility
|
||||
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(() => []),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockIsLeagueAdminOrHigherRole = vi.fn();
|
||||
|
||||
vi.mock('@/lib/utilities/LeagueRoleUtility', () => ({
|
||||
@@ -56,6 +95,7 @@ describe('ProtestReviewPage', () => {
|
||||
mockGetMembership.mockReset();
|
||||
mockIsLeagueAdminOrHigherRole.mockReset();
|
||||
|
||||
// Set up default mock implementations
|
||||
mockFetchLeagueMemberships.mockResolvedValue(undefined);
|
||||
mockGetMembership.mockReturnValue({ role: 'admin' });
|
||||
mockIsLeagueAdminOrHigherRole.mockReturnValue(true);
|
||||
|
||||
@@ -4,7 +4,8 @@ import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { ProtestDetailViewModel } from '@/lib/view-models/ProtestDetailViewModel';
|
||||
import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel';
|
||||
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
|
||||
@@ -35,9 +36,10 @@ import { useParams, useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus';
|
||||
import { useProtestDetail } from '@/hooks/league/useProtestDetail';
|
||||
|
||||
// Timeline event types
|
||||
interface TimelineEvent {
|
||||
@@ -108,7 +110,7 @@ export default function ProtestReviewPage() {
|
||||
const leagueId = params.id as string;
|
||||
const protestId = params.protestId as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueStewardingService, protestService, leagueMembershipService } = useServices();
|
||||
const protestService = useInject(PROTEST_SERVICE_TOKEN);
|
||||
|
||||
// Decision state
|
||||
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
|
||||
@@ -119,28 +121,19 @@ export default function ProtestReviewPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
// Check admin status
|
||||
const { data: isAdmin, isLoading: adminLoading } = useDataFetching({
|
||||
queryKey: ['leagueMembership', leagueId, currentDriverId],
|
||||
queryFn: async () => {
|
||||
await leagueMembershipService.fetchLeagueMemberships(leagueId);
|
||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
|
||||
},
|
||||
});
|
||||
// Check admin status using hook
|
||||
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
|
||||
|
||||
// Load protest detail
|
||||
const { data: detail, isLoading: detailLoading, error, retry } = useDataFetching({
|
||||
queryKey: ['protestDetail', leagueId, protestId],
|
||||
queryFn: () => leagueStewardingService.getProtestDetailViewModel(leagueId, protestId),
|
||||
enabled: !!isAdmin,
|
||||
onSuccess: (protestDetail) => {
|
||||
if (protestDetail.initialPenaltyType) {
|
||||
setPenaltyType(protestDetail.initialPenaltyType);
|
||||
setPenaltyValue(protestDetail.initialPenaltyValue);
|
||||
}
|
||||
},
|
||||
});
|
||||
// Load protest detail using hook
|
||||
const { data: detail, isLoading: detailLoading, error, retry } = useProtestDetail(leagueId, protestId, isAdmin || false);
|
||||
|
||||
// Set initial penalty values when data loads
|
||||
useMemo(() => {
|
||||
if (detail?.initialPenaltyType) {
|
||||
setPenaltyType(detail.initialPenaltyType);
|
||||
setPenaltyValue(detail.initialPenaltyValue);
|
||||
}
|
||||
}, [detail]);
|
||||
|
||||
const penaltyTypes = useMemo(() => {
|
||||
const referenceItems = detail?.penaltyTypes ?? [];
|
||||
@@ -315,6 +308,8 @@ export default function ProtestReviewPage() {
|
||||
}}
|
||||
>
|
||||
{(protestDetail) => {
|
||||
if (!protestDetail) return null;
|
||||
|
||||
const protest = protestDetail.protest;
|
||||
const race = protestDetail.race;
|
||||
const protestingDriver = protestDetail.protestingDriver;
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useParams } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import TransactionRow from '@/components/leagues/TransactionRow';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
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,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
|
||||
export default function LeagueWalletPage() {
|
||||
const params = useParams();
|
||||
const { leagueWalletService } = useServices();
|
||||
const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
|
||||
const [wallet, setWallet] = useState<LeagueWalletViewModel | null>(null);
|
||||
const [withdrawAmount, setWithdrawAmount] = useState('');
|
||||
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
|
||||
|
||||
@@ -7,22 +7,18 @@ import OnboardingWizard from '@/components/onboarding/OnboardingWizard';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { useCurrentDriver } from '@/hooks/driver/useCurrentDriver';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const { session } = useAuth();
|
||||
const { driverService } = useServices();
|
||||
|
||||
// Check if user is logged in
|
||||
const shouldRedirectToLogin = !session;
|
||||
|
||||
// Fetch current driver data
|
||||
const { data: driver, isLoading } = useDataFetching({
|
||||
queryKey: ['currentDriver'],
|
||||
queryFn: () => driverService.getCurrentDriver(),
|
||||
// Fetch current driver data using DI + React-Query
|
||||
const { data: driver, isLoading } = useCurrentDriver({
|
||||
enabled: !!session,
|
||||
});
|
||||
|
||||
@@ -59,4 +55,4 @@ export default function OnboardingPage() {
|
||||
<OnboardingWizard />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
import Link from 'next/link';
|
||||
@@ -20,7 +21,8 @@ export default function ManageLeaguesPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const effectiveDriverId = useEffectiveDriverId();
|
||||
const { leagueService, leagueMembershipService } = useServices();
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -7,7 +7,9 @@ import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useDriverProfile } from '@/hooks/driver/useDriverProfile';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { DRIVER_SERVICE_TOKEN, MEDIA_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type {
|
||||
DriverProfileAchievementViewModel,
|
||||
DriverProfileSocialHandleViewModel,
|
||||
@@ -16,9 +18,7 @@ import type {
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import {
|
||||
Activity,
|
||||
Award,
|
||||
@@ -263,17 +263,14 @@ export default function ProfilePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const tabParam = searchParams.get('tab') as ProfileTab | null;
|
||||
|
||||
const { driverService, mediaService } = useServices();
|
||||
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
|
||||
|
||||
// Shared state components
|
||||
const { data: profileData, isLoading: loading, error, retry } = useDataFetching({
|
||||
queryKey: ['driverProfile', effectiveDriverId],
|
||||
queryFn: () => driverService.getDriverProfile(effectiveDriverId),
|
||||
enabled: !!effectiveDriverId,
|
||||
});
|
||||
// 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');
|
||||
|
||||
@@ -8,7 +8,8 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
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';
|
||||
@@ -23,7 +24,11 @@ interface EntitySection {
|
||||
export default function SponsorshipRequestsPage() {
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const { sponsorshipService, driverService, leagueService, teamService, leagueMembershipService } = useServices();
|
||||
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);
|
||||
|
||||
const [sections, setSections] = useState<EntitySection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { RacesTemplate, TimeFilter, RaceStatusFilter } from '@/templates/RacesTemplate';
|
||||
import { useRacesPageData, useRegisterForRace, useWithdrawFromRace, useCancelRace } from '@/hooks/useRaceService';
|
||||
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';
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { RacesTemplate } from '@/templates/RacesTemplate';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
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 { raceService } = useServices();
|
||||
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 => ({
|
||||
const races = pageData.races.map((race: RaceListItemViewModel) => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
@@ -27,7 +30,7 @@ export async function RacesStatic() {
|
||||
|
||||
// Transform the categorized races as well
|
||||
const transformRaces = (raceList: RaceListItemViewModel[]) =>
|
||||
raceList.map(race => ({
|
||||
raceList.map((race: RaceListItemViewModel) => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
|
||||
@@ -3,21 +3,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
|
||||
import {
|
||||
useRegisterForRace,
|
||||
useWithdrawFromRace,
|
||||
useCancelRace,
|
||||
useCompleteRace,
|
||||
useReopenRace
|
||||
} from '@/hooks/useRaceService';
|
||||
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 { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useRaceDetail } from '@/hooks/race/useRaceDetail';
|
||||
import { Flag } from 'lucide-react';
|
||||
|
||||
export function RaceDetailInteractive() {
|
||||
@@ -25,13 +22,9 @@ export function RaceDetailInteractive() {
|
||||
const params = useParams();
|
||||
const raceId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { raceService } = useServices();
|
||||
|
||||
// Fetch data using new hook
|
||||
const { data: viewModel, isLoading, error, retry } = useDataFetching({
|
||||
queryKey: ['raceDetail', raceId, currentDriverId],
|
||||
queryFn: () => raceService.getRaceDetail(raceId, currentDriverId),
|
||||
});
|
||||
// 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);
|
||||
|
||||
@@ -39,23 +39,66 @@ vi.mock('@/components/sponsors/SponsorInsightsCard', () => ({
|
||||
useSponsorMode: () => false,
|
||||
}));
|
||||
|
||||
// Mock services hook to provide raceService and leagueMembershipService
|
||||
// Mock the new DI hooks
|
||||
const mockGetRaceDetails = vi.fn();
|
||||
const mockReopenRace = vi.fn();
|
||||
const mockFetchLeagueMemberships = vi.fn();
|
||||
const mockGetMembership = vi.fn();
|
||||
|
||||
vi.mock('@/lib/services/ServiceProvider', () => ({
|
||||
useServices: () => ({
|
||||
raceService: {
|
||||
getRaceDetails: mockGetRaceDetails,
|
||||
reopenRace: mockReopenRace,
|
||||
// other methods are not used in this test
|
||||
},
|
||||
leagueMembershipService: {
|
||||
fetchLeagueMemberships: mockFetchLeagueMemberships,
|
||||
getMembership: mockGetMembership,
|
||||
},
|
||||
// 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(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -122,7 +165,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
|
||||
mockGetMembership.mockReset();
|
||||
mockIsOwnerOrAdmin.mockReset();
|
||||
|
||||
// Set up default mock implementations for services
|
||||
// Set up default mock implementations
|
||||
mockFetchLeagueMemberships.mockResolvedValue(undefined);
|
||||
mockGetMembership.mockReturnValue({ role: 'owner' }); // Return owner role by default
|
||||
});
|
||||
@@ -131,8 +174,9 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
|
||||
mockIsOwnerOrAdmin.mockReturnValue(true);
|
||||
const viewModel = createViewModel('completed');
|
||||
|
||||
// Mock the service to return the right data
|
||||
mockGetRaceDetails.mockResolvedValue(viewModel);
|
||||
// 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);
|
||||
@@ -162,7 +206,8 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
|
||||
mockIsOwnerOrAdmin.mockReturnValue(false);
|
||||
const viewModel = createViewModel('completed');
|
||||
|
||||
mockGetRaceDetails.mockResolvedValue(viewModel);
|
||||
mockGetRaceDetails.mockReturnValue(viewModel);
|
||||
mockGetMembership.mockReturnValue({ role: 'member' });
|
||||
|
||||
renderWithQueryClient(<RaceDetailInteractive />);
|
||||
|
||||
@@ -178,7 +223,8 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
|
||||
mockIsOwnerOrAdmin.mockReturnValue(true);
|
||||
const viewModel = createViewModel('scheduled');
|
||||
|
||||
mockGetRaceDetails.mockResolvedValue(viewModel);
|
||||
mockGetRaceDetails.mockReturnValue(viewModel);
|
||||
mockGetMembership.mockReturnValue({ role: 'owner' });
|
||||
|
||||
renderWithQueryClient(<RaceDetailInteractive />);
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
||||
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
|
||||
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useRaceResultsDetail } from '@/hooks/race/useRaceResultsDetail';
|
||||
import { useRaceWithSOF } from '@/hooks/race/useRaceWithSOF';
|
||||
import { Trophy } from 'lucide-react';
|
||||
|
||||
export function RaceResultsInteractive() {
|
||||
@@ -18,22 +18,13 @@ export function RaceResultsInteractive() {
|
||||
const params = useParams();
|
||||
const raceId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { raceResultsService, raceService } = useServices();
|
||||
|
||||
// Fetch data using new hook
|
||||
const { data: raceData, isLoading, error, retry } = useDataFetching({
|
||||
queryKey: ['raceResultsDetail', raceId, currentDriverId],
|
||||
queryFn: () => raceResultsService.getResultsDetail(raceId, currentDriverId),
|
||||
});
|
||||
|
||||
// Fetch SOF data
|
||||
const { data: sofData } = useDataFetching({
|
||||
queryKey: ['raceWithSOF', raceId],
|
||||
queryFn: () => raceResultsService.getWithSOF(raceId),
|
||||
});
|
||||
// Fetch data using existing hooks
|
||||
const { data: raceData, isLoading, error, retry } = useRaceResultsDetail(raceId, currentDriverId);
|
||||
const { data: sofData } = useRaceWithSOF(raceId);
|
||||
|
||||
// Fetch membership
|
||||
const { data: membership } = useLeagueMembership(raceData?.league?.id || '', currentDriverId);
|
||||
const { data: membershipsData } = useLeagueMemberships(raceData?.league?.id || '', currentDriverId || '');
|
||||
|
||||
// UI State
|
||||
const [importing, setImporting] = useState(false);
|
||||
@@ -42,7 +33,8 @@ export function RaceResultsInteractive() {
|
||||
const [showImportForm, setShowImportForm] = useState(false);
|
||||
|
||||
const raceSOF = sofData?.strengthOfField || null;
|
||||
const isAdmin = membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
|
||||
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 => ({
|
||||
@@ -142,4 +134,4 @@ export function RaceResultsInteractive() {
|
||||
)}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
|
||||
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
|
||||
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useRaceStewardingData } from '@/hooks/race/useRaceStewardingData';
|
||||
import { Gavel } from 'lucide-react';
|
||||
|
||||
export function RaceStewardingInteractive() {
|
||||
@@ -18,21 +17,18 @@ export function RaceStewardingInteractive() {
|
||||
const params = useParams();
|
||||
const raceId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { raceStewardingService } = useServices();
|
||||
|
||||
// Fetch data using new hook
|
||||
const { data: stewardingData, isLoading, error, retry } = useDataFetching({
|
||||
queryKey: ['raceStewardingData', raceId, currentDriverId],
|
||||
queryFn: () => raceStewardingService.getRaceStewardingData(raceId, currentDriverId),
|
||||
});
|
||||
// Fetch data using existing hooks
|
||||
const { data: stewardingData, isLoading, error, retry } = useRaceStewardingData(raceId, currentDriverId);
|
||||
|
||||
// Fetch membership
|
||||
const { data: membership } = useLeagueMembership(stewardingData?.league?.id || '', currentDriverId);
|
||||
const { data: membershipsData } = useLeagueMemberships(stewardingData?.league?.id || '', currentDriverId || '');
|
||||
|
||||
// UI State
|
||||
const [activeTab, setActiveTab] = useState<StewardingTab>('pending');
|
||||
|
||||
const isAdmin = membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
|
||||
const currentMembership = membershipsData?.memberships.find(m => m.driverId === currentDriverId);
|
||||
const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false;
|
||||
|
||||
// Actions
|
||||
const handleBack = () => {
|
||||
@@ -88,4 +84,4 @@ export function RaceStewardingInteractive() {
|
||||
)}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
@@ -10,8 +10,9 @@ import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import InfoBanner from '@/components/ui/InfoBanner';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import { BillingViewModel } from '@/lib/view-models/BillingViewModel';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useSponsorBilling } from '@/hooks/sponsor/useSponsorBilling';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import {
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
@@ -260,29 +261,12 @@ function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
|
||||
|
||||
export default function SponsorBillingPage() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const { sponsorService } = useServices();
|
||||
const [data, setData] = useState<BillingViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const sponsorService = useInject(SPONSOR_SERVICE_TOKEN);
|
||||
const [showAllInvoices, setShowAllInvoices] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadBilling = async () => {
|
||||
try {
|
||||
const billingData = await sponsorService.getBilling('demo-sponsor-1');
|
||||
setData(new BillingViewModel(billingData));
|
||||
} catch (err) {
|
||||
console.error('Error loading billing data:', err);
|
||||
setError('Failed to load billing data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const { data: billingData, isLoading, error, retry } = useSponsorBilling('demo-sponsor-1');
|
||||
|
||||
loadBilling();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
@@ -293,16 +277,23 @@ export default function SponsorBillingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
if (error || !billingData) {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-400">{error || 'No billing data available'}</p>
|
||||
<p className="text-gray-400">{error?.getUserMessage() || 'No billing data available'}</p>
|
||||
{error && (
|
||||
<Button variant="secondary" onClick={retry} className="mt-4">
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = billingData;
|
||||
|
||||
const handleSetDefault = (methodId: string) => {
|
||||
// In a real app, this would call an API
|
||||
console.log('Setting default payment method:', methodId);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
@@ -8,13 +8,12 @@ import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import InfoBanner from '@/components/ui/InfoBanner';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { SponsorSponsorshipsViewModel } from '@/lib/view-models/SponsorSponsorshipsViewModel';
|
||||
import {
|
||||
Megaphone,
|
||||
Trophy,
|
||||
Users,
|
||||
Eye,
|
||||
import { useSponsorSponsorships } from '@/hooks/sponsor/useSponsorSponsorships';
|
||||
import {
|
||||
Megaphone,
|
||||
Trophy,
|
||||
Users,
|
||||
Eye,
|
||||
Calendar,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
@@ -364,37 +363,15 @@ export default function SponsorCampaignsPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const { sponsorService } = useServices();
|
||||
|
||||
const initialType = (searchParams.get('type') as SponsorshipType) || 'all';
|
||||
const [typeFilter, setTypeFilter] = useState<SponsorshipType>(initialType);
|
||||
const [statusFilter, setStatusFilter] = useState<SponsorshipStatus>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [data, setData] = useState<SponsorSponsorshipsViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSponsorships = async () => {
|
||||
try {
|
||||
const sponsorshipsData = await sponsorService.getSponsorSponsorships('demo-sponsor-1');
|
||||
if (sponsorshipsData) {
|
||||
setData(sponsorshipsData);
|
||||
} else {
|
||||
setError('Failed to load sponsorships data');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading sponsorships:', err);
|
||||
setError('Failed to load sponsorships data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const { data: sponsorshipsData, isLoading, error, retry } = useSponsorSponsorships('demo-sponsor-1');
|
||||
|
||||
loadSponsorships();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
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">
|
||||
@@ -405,16 +382,23 @@ export default function SponsorCampaignsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
if (error || !sponsorshipsData) {
|
||||
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 || 'No sponsorships data available'}</p>
|
||||
<p className="text-gray-400">{error?.getUserMessage() || 'No sponsorships data available'}</p>
|
||||
{error && (
|
||||
<Button variant="secondary" onClick={retry} className="mt-4">
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = sponsorshipsData;
|
||||
|
||||
// Filter sponsorships
|
||||
const filteredSponsorships = data.sponsorships.filter(s => {
|
||||
if (typeFilter !== 'all' && s.type !== typeFilter) return false;
|
||||
@@ -443,17 +427,6 @@ export default function SponsorCampaignsPage() {
|
||||
platform: data.sponsorships.filter(s => s.type === 'platform').length,
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
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 sponsorships...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4">
|
||||
{/* Header */}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
||||
import Card from '@/components/ui/Card';
|
||||
@@ -38,69 +37,46 @@ import {
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardViewModel';
|
||||
|
||||
|
||||
|
||||
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { SPONSOR_SERVICE_TOKEN, POLICY_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
||||
|
||||
export default function SponsorDashboardPage() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const { sponsorService, policyService } = useServices();
|
||||
const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | 'all'>('30d');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<SponsorDashboardViewModel | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const sponsorService = useInject(SPONSOR_SERVICE_TOKEN);
|
||||
const policyService = useInject(POLICY_SERVICE_TOKEN);
|
||||
|
||||
const {
|
||||
data: policySnapshot,
|
||||
isLoading: policyLoading,
|
||||
isError: policyError,
|
||||
} = useQuery({
|
||||
const policyQuery = useQuery({
|
||||
queryKey: ['policySnapshot'],
|
||||
queryFn: () => policyService.getSnapshot(),
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const enhancedPolicyQuery = enhanceQueryResult(policyQuery);
|
||||
const policySnapshot = enhancedPolicyQuery.data;
|
||||
const policyLoading = enhancedPolicyQuery.isLoading;
|
||||
const policyError = enhancedPolicyQuery.error;
|
||||
|
||||
const sponsorPortalState = policySnapshot
|
||||
? policyService.getCapabilityState(policySnapshot, 'sponsors.portal')
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (policyLoading) {
|
||||
return;
|
||||
}
|
||||
const dashboardQuery = useQuery({
|
||||
queryKey: ['sponsorDashboard', 'demo-sponsor-1', sponsorPortalState],
|
||||
queryFn: () => sponsorService.getSponsorDashboard('demo-sponsor-1'),
|
||||
enabled: !!policySnapshot && sponsorPortalState === 'enabled',
|
||||
staleTime: 300_000,
|
||||
gcTime: 10 * 60_000,
|
||||
});
|
||||
|
||||
if (policyError || sponsorPortalState !== 'enabled') {
|
||||
setError(
|
||||
sponsorPortalState === 'coming_soon'
|
||||
? 'Sponsor portal is coming soon.'
|
||||
: 'Sponsor portal is currently unavailable.',
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const enhancedDashboardQuery = enhanceQueryResult(dashboardQuery);
|
||||
const dashboardData = enhancedDashboardQuery.data;
|
||||
const dashboardLoading = enhancedDashboardQuery.isLoading;
|
||||
const dashboardError = enhancedDashboardQuery.error;
|
||||
|
||||
const loadDashboard = async () => {
|
||||
try {
|
||||
const dashboardData = await sponsorService.getSponsorDashboard('demo-sponsor-1');
|
||||
if (dashboardData) {
|
||||
setData(dashboardData);
|
||||
} else {
|
||||
setError('Failed to load dashboard data');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading dashboard:', err);
|
||||
setError('Failed to load dashboard data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadDashboard();
|
||||
}, [policyLoading, policyError, sponsorPortalState, sponsorService]);
|
||||
const loading = policyLoading || dashboardLoading;
|
||||
const error = policyError || dashboardError || (sponsorPortalState !== 'enabled' && sponsorPortalState !== null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -113,36 +89,31 @@ export default function SponsorDashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
if (error || !dashboardData) {
|
||||
const errorMessage = sponsorPortalState === 'coming_soon'
|
||||
? 'Sponsor portal is coming soon.'
|
||||
: sponsorPortalState === 'disabled'
|
||||
? 'Sponsor portal is currently unavailable.'
|
||||
: 'Failed to load dashboard data';
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[600px]">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-400">{error || 'No dashboard data available'}</p>
|
||||
<p className="text-gray-400">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const categoryData = data.categoryData;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[600px]">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-blue mx-auto mb-4" />
|
||||
<p className="text-gray-400">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const categoryData = dashboardData.categoryData;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Sponsor Dashboard</h1>
|
||||
<p className="text-gray-400">Welcome back, {data.sponsorName}</p>
|
||||
<h2 className="text-2xl font-bold text-white">Sponsor Dashboard</h2>
|
||||
<p className="text-gray-400">Welcome back, {dashboardData.sponsorName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Time Range Selector */}
|
||||
@@ -150,9 +121,9 @@ export default function SponsorDashboardPage() {
|
||||
{(['7d', '30d', '90d', 'all'] as const).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
onClick={() => {}}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
timeRange === range
|
||||
false
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
@@ -178,29 +149,29 @@ export default function SponsorDashboardPage() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<MetricCard
|
||||
title="Total Impressions"
|
||||
value={data.totalImpressions}
|
||||
change={data.metrics.impressionsChange}
|
||||
value={dashboardData.totalImpressions}
|
||||
change={dashboardData.metrics.impressionsChange}
|
||||
icon={Eye}
|
||||
delay={0}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Unique Viewers"
|
||||
value={data.metrics.uniqueViewers}
|
||||
change={data.metrics.viewersChange}
|
||||
value={dashboardData.metrics.uniqueViewers}
|
||||
change={dashboardData.metrics.viewersChange}
|
||||
icon={Users}
|
||||
delay={0.1}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Engagement Rate"
|
||||
value={data.metrics.exposure}
|
||||
change={data.metrics.exposureChange}
|
||||
value={dashboardData.metrics.exposure}
|
||||
change={dashboardData.metrics.exposureChange}
|
||||
icon={TrendingUp}
|
||||
suffix="%"
|
||||
delay={0.2}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Total Investment"
|
||||
value={data.totalInvestment}
|
||||
value={dashboardData.totalInvestment}
|
||||
icon={DollarSign}
|
||||
prefix="$"
|
||||
delay={0.3}
|
||||
@@ -210,7 +181,7 @@ export default function SponsorDashboardPage() {
|
||||
{/* Sponsorship Categories */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">Your Sponsorships</h2>
|
||||
<h3 className="text-lg font-semibold text-white">Your Sponsorships</h3>
|
||||
<Link href="/sponsor/campaigns">
|
||||
<Button variant="secondary" className="text-sm">
|
||||
View All
|
||||
@@ -270,7 +241,7 @@ export default function SponsorDashboardPage() {
|
||||
{/* Top Performing Sponsorships */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
|
||||
<h2 className="text-lg font-semibold text-white">Top Performing</h2>
|
||||
<h3 className="text-lg font-semibold text-white">Top Performing</h3>
|
||||
<Link href="/leagues">
|
||||
<Button variant="secondary" className="text-sm">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
@@ -280,7 +251,7 @@ export default function SponsorDashboardPage() {
|
||||
</div>
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{/* Leagues */}
|
||||
{data.sponsorships.leagues.map((league) => (
|
||||
{dashboardData.sponsorships.leagues.map((league: any) => (
|
||||
<div key={league.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={`px-2 py-1 rounded text-xs font-medium ${
|
||||
@@ -313,7 +284,7 @@ export default function SponsorDashboardPage() {
|
||||
))}
|
||||
|
||||
{/* Teams */}
|
||||
{data.sponsorships.teams.map((team) => (
|
||||
{dashboardData.sponsorships.teams.map((team: any) => (
|
||||
<div key={team.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="px-2 py-1 rounded text-xs font-medium bg-purple-500/20 text-purple-400 border border-purple-500/30">
|
||||
@@ -340,7 +311,7 @@ export default function SponsorDashboardPage() {
|
||||
))}
|
||||
|
||||
{/* Drivers */}
|
||||
{data.sponsorships.drivers.slice(0, 2).map((driver) => (
|
||||
{dashboardData.sponsorships.drivers.slice(0, 2).map((driver: any) => (
|
||||
<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="px-2 py-1 rounded text-xs font-medium bg-performance-green/20 text-performance-green border border-performance-green/30">
|
||||
@@ -371,15 +342,15 @@ export default function SponsorDashboardPage() {
|
||||
{/* Upcoming Events */}
|
||||
<Card>
|
||||
<div className="p-4 border-b border-charcoal-outline">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-warning-amber" />
|
||||
Upcoming Sponsored Events
|
||||
</h2>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{data.sponsorships.races.length > 0 ? (
|
||||
{dashboardData.sponsorships.races.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{data.sponsorships.races.map((race) => (
|
||||
{dashboardData.sponsorships.races.map((race: any) => (
|
||||
<div key={race.id} className="flex items-center justify-between p-3 rounded-lg bg-iron-gray/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-warning-amber/10 flex items-center justify-center">
|
||||
@@ -448,14 +419,14 @@ export default function SponsorDashboardPage() {
|
||||
</Card>
|
||||
|
||||
{/* Renewal Alerts */}
|
||||
{data.upcomingRenewals.length > 0 && (
|
||||
{dashboardData.upcomingRenewals.length > 0 && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Bell className="w-5 h-5 text-warning-amber" />
|
||||
Upcoming Renewals
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{data.upcomingRenewals.map((renewal) => (
|
||||
{dashboardData.upcomingRenewals.map((renewal: any) => (
|
||||
<RenewalAlert key={renewal.id} renewal={renewal} />
|
||||
))}
|
||||
</div>
|
||||
@@ -466,7 +437,7 @@ export default function SponsorDashboardPage() {
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Recent Activity</h3>
|
||||
<div>
|
||||
{data.recentActivity.map((activity) => (
|
||||
{dashboardData.recentActivity.map((activity: any) => (
|
||||
<ActivityItem key={activity.id} activity={activity} />
|
||||
))}
|
||||
</div>
|
||||
@@ -481,16 +452,16 @@ export default function SponsorDashboardPage() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Active Sponsorships</span>
|
||||
<span className="font-medium text-white">{data.activeSponsorships}</span>
|
||||
<span className="font-medium text-white">{dashboardData.activeSponsorships}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Total Investment</span>
|
||||
<span className="font-medium text-white">{data.formattedTotalInvestment}</span>
|
||||
<span className="font-medium text-white">{dashboardData.formattedTotalInvestment}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-400">Cost per 1K Views</span>
|
||||
<span className="font-medium text-performance-green">
|
||||
{data.costPerThousandViews}
|
||||
{dashboardData.costPerThousandViews}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
439
apps/website/app/sponsor/leagues/SponsorLeaguesInteractive.tsx
Normal file
439
apps/website/app/sponsor/leagues/SponsorLeaguesInteractive.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
'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,14 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
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 { LeagueDetailViewModel } from '@/lib/view-models/LeagueDetailViewModel';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useSponsorLeagueDetail } from '@/hooks/sponsor/useSponsorLeagueDetail';
|
||||
import {
|
||||
Trophy,
|
||||
Users,
|
||||
@@ -39,36 +38,14 @@ export default function SponsorLeagueDetailPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const { sponsorService } = useServices();
|
||||
|
||||
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, setData] = useState<LeagueDetailViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const leagueId = params.id as string;
|
||||
const { data: leagueData, isLoading, error, retry } = useSponsorLeagueDetail(leagueId);
|
||||
|
||||
useEffect(() => {
|
||||
const loadLeagueDetail = async () => {
|
||||
try {
|
||||
const leagueData = await sponsorService.getLeagueDetail(leagueId);
|
||||
setData(new LeagueDetailViewModel(leagueData));
|
||||
} catch (err) {
|
||||
console.error('Error loading league detail:', err);
|
||||
setError('Failed to load league detail');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (leagueId) {
|
||||
loadLeagueDetail();
|
||||
}
|
||||
}, [leagueId, sponsorService]);
|
||||
|
||||
if (loading) {
|
||||
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">
|
||||
@@ -79,16 +56,22 @@ export default function SponsorLeagueDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
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 || 'No league data available'}</p>
|
||||
<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;
|
||||
|
||||
|
||||
@@ -1,460 +1,3 @@
|
||||
'use client';
|
||||
import SponsorLeaguesInteractive from './SponsorLeaguesInteractive';
|
||||
|
||||
import { useState, useEffect } 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 { AvailableLeaguesViewModel } from '@/lib/view-models/AvailableLeaguesViewModel';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
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 SponsorLeaguesPage() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const { sponsorService } = useServices();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [tierFilter, setTierFilter] = useState<TierFilter>('all');
|
||||
const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('rating');
|
||||
const [data, setData] = useState<AvailableLeaguesViewModel | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadLeagues = async () => {
|
||||
try {
|
||||
const leaguesData = await sponsorService.getAvailableLeagues();
|
||||
setData(new AvailableLeaguesViewModel(leaguesData));
|
||||
} catch (err) {
|
||||
console.error('Error loading leagues:', err);
|
||||
setError('Failed to load leagues data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadLeagues();
|
||||
}, [sponsorService]);
|
||||
|
||||
if (loading) {
|
||||
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 (error || !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 || '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>
|
||||
);
|
||||
}
|
||||
export default SponsorLeaguesInteractive;
|
||||
|
||||
@@ -17,9 +17,8 @@ import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useAllTeams } from '@/hooks/team/useAllTeams';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
|
||||
@@ -27,12 +26,8 @@ const SKILL_LEVELS: SkillLevel[] = ['pro', 'advanced', 'intermediate', 'beginner
|
||||
|
||||
export default function TeamsInteractive() {
|
||||
const router = useRouter();
|
||||
const { teamService } = useServices();
|
||||
|
||||
const { data: teams = [], isLoading: loading, error, retry } = useDataFetching({
|
||||
queryKey: ['allTeams'],
|
||||
queryFn: () => teamService.getAllTeams(),
|
||||
});
|
||||
const { data: teams = [], isLoading: loading, error, retry } = useAllTeams();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
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';
|
||||
@@ -16,27 +18,21 @@ type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||
export default function TeamDetailInteractive() {
|
||||
const params = useParams();
|
||||
const teamId = params.id as string;
|
||||
const { teamService } = useServices();
|
||||
const router = useRouter();
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const teamService = useInject(TEAM_SERVICE_TOKEN);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
|
||||
// Fetch team details
|
||||
const { data: teamDetails, isLoading: teamLoading, error: teamError, retry: teamRetry } = useDataFetching({
|
||||
queryKey: ['teamDetails', teamId, currentDriverId],
|
||||
queryFn: () => teamService.getTeamDetails(teamId, currentDriverId),
|
||||
});
|
||||
// Fetch team details using DI + React-Query
|
||||
const { data: teamDetails, isLoading: teamLoading, error: teamError, retry: teamRetry } = useTeamDetails(teamId, currentDriverId);
|
||||
|
||||
// Fetch team members
|
||||
const { data: memberships, isLoading: membersLoading, error: membersError, retry: membersRetry } = useDataFetching({
|
||||
queryKey: ['teamMembers', teamId, currentDriverId],
|
||||
queryFn: async () => {
|
||||
if (!teamDetails?.ownerId) return [];
|
||||
return teamService.getTeamMembers(teamId, currentDriverId, teamDetails.ownerId);
|
||||
},
|
||||
enabled: !!teamDetails?.ownerId,
|
||||
});
|
||||
// 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;
|
||||
@@ -126,7 +122,7 @@ export default function TeamDetailInteractive() {
|
||||
>
|
||||
{(teamData) => (
|
||||
<TeamDetailTemplate
|
||||
team={teamData}
|
||||
team={teamData!}
|
||||
memberships={memberships || []}
|
||||
activeTab={activeTab}
|
||||
loading={isLoading}
|
||||
|
||||
Reference in New Issue
Block a user