website refactor

This commit is contained in:
2026-01-15 17:12:24 +01:00
parent c3b308e960
commit f035cfe7ce
468 changed files with 24378 additions and 17324 deletions

View File

@@ -1,7 +1,7 @@
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { createRouteGuard } from '@/lib/auth/createRouteGuard'; import { createRouteGuard } from '@/lib/auth/createRouteGuard';
import Section from '@/ui/Section'; import { Section } from '@/ui/Section';
interface AdminLayoutProps { interface AdminLayoutProps {
children: React.ReactNode; children: React.ReactNode;

View File

@@ -18,7 +18,7 @@ import {
Target, Target,
Timer, Timer,
} from 'lucide-react'; } from 'lucide-react';
import LeagueCard from '@/components/leagues/LeagueCard'; import { LeagueCard } from '@/ui/LeagueCardWrapper';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
@@ -29,7 +29,7 @@ import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid'; import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem'; import { GridItem } from '@/ui/GridItem';
import { HeroSection } from '@/components/shared/HeroSection'; import { PageHero } from '@/ui/PageHero';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData'; import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
@@ -468,7 +468,7 @@ export function LeaguesClient({
return ( return (
<Container size="lg" pb={12}> <Container size="lg" pb={12}>
{/* Hero Section */} {/* Hero Section */}
<HeroSection <PageHero
title="Find Your Grid" title="Find Your Grid"
description="From casual sprints to epic endurance battles — discover the perfect league for your racing style." description="From casual sprints to epic endurance battles — discover the perfect league for your racing style."
icon={Trophy} icon={Trophy}

View File

@@ -2,7 +2,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate'; import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
import { type RulebookSection } from '@/components/leagues/RulebookTabs'; import { type RulebookSection } from '@/ui/RulebookTabs';
import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData'; import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData';
interface LeagueRulebookPageClientProps { interface LeagueRulebookPageClientProps {

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import PenaltyFAB from '@/components/leagues/PenaltyFAB'; import { PenaltyFAB } from '@/ui/PenaltyFAB';
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal'; import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal'; import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import StewardingStats from '@/components/leagues/StewardingStats'; import { StewardingStats } from '@/components/leagues/StewardingStats';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations"; import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
@@ -19,7 +19,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { PendingProtestsList } from '@/components/leagues/PendingProtestsList'; import { PendingProtestsList } from '@/ui/PendingProtestsList';
import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList'; import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList';
interface StewardingData { interface StewardingData {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import Button from '@/ui/Button'; import { Button } from '@/ui/Button';
import Card from '@/ui/Card'; import { Card } from '@/ui/Card';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useInject } from '@/lib/di/hooks/useInject'; import { useInject } from '@/lib/di/hooks/useInject';
@@ -37,7 +37,7 @@ import { useMemo, useState } from 'react';
// Shared state components // Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer'; import { StateContainer } from '@/components/shared/state/StateContainer';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper'; import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus"; import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus";
import { useProtestDetail } from "@/lib/hooks/league/useProtestDetail"; import { useProtestDetail } from "@/lib/hooks/league/useProtestDetail";

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import Card from '@/ui/Card'; import { Card } from '@/ui/Card';
import Button from '@/ui/Button'; import { Button } from '@/ui/Button';
import TransactionRow from '@/components/leagues/TransactionRow'; import { TransactionRow } from '@/components/leagues/TransactionRow';
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel'; import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
import { import {
Wallet, Wallet,

View File

@@ -1,10 +1,10 @@
'use client'; 'use client';
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary'; import { LeagueReviewSummary } from '@/components/leagues/LeagueReviewSummary';
import Button from '@/ui/Button'; import { Button } from '@/ui/Button';
import Card from '@/ui/Card'; import { Card } from '@/ui/Card';
import Heading from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import Input from '@/ui/Input'; import { Input } from '@/ui/Input';
import { useAuth } from '@/lib/auth/AuthContext'; import { useAuth } from '@/lib/auth/AuthContext';
import { import {
AlertCircle, AlertCircle,

View File

@@ -3,8 +3,8 @@
import React from 'react'; import React from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard'; import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard';
import Section from '@/ui/Section'; import { Section } from '@/ui/Section';
import Container from '@/ui/Container'; import { Container } from '@/ui/Container';
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review'; type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import ProfileLayoutShell from '@/components/profile/ProfileLayoutShell'; import { ProfileLayoutShell } from '@/ui/ProfileLayoutShell';
import { createRouteGuard } from '@/lib/auth/createRouteGuard'; import { createRouteGuard } from '@/lib/auth/createRouteGuard';
interface ProfileLayoutProps { interface ProfileLayoutProps {

View File

@@ -5,7 +5,7 @@ import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid'; import { Grid } from '@/ui/Grid';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
import { LiveryCard } from '@/components/profile/LiveryCard'; import { LiveryCard } from '@/ui/LiveryCard';
export default async function ProfileLiveriesPage() { export default async function ProfileLiveriesPage() {
const mockLiveries = [ const mockLiveries = [

View File

@@ -1,8 +1,8 @@
import Link from 'next/link'; import Link from 'next/link';
import Button from '@/ui/Button'; import { Button } from '@/ui/Button';
import Card from '@/ui/Card'; import { Card } from '@/ui/Card';
import Container from '@/ui/Container'; import { Container } from '@/ui/Container';
import Heading from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
export default async function ProfileLiveryUploadPage() { export default async function ProfileLiveryUploadPage() {

View File

@@ -1,8 +1,8 @@
import Link from 'next/link'; import Link from 'next/link';
import Button from '@/ui/Button'; import { Button } from '@/ui/Button';
import Card from '@/ui/Card'; import { Card } from '@/ui/Card';
import Container from '@/ui/Container'; import { Container } from '@/ui/Container';
import Heading from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
export default async function ProfileSettingsPage() { export default async function ProfileSettingsPage() {

View File

@@ -2,13 +2,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { motion, useReducedMotion } from 'framer-motion'; import { motion, useReducedMotion } from 'framer-motion';
import Card from '@/ui/Card'; import { Card } from '@/ui/Card';
import Button from '@/ui/Button'; import { Button } from '@/ui/Button';
import StatCard from '@/ui/StatCard'; import { StatCard } from '@/ui/StatCard';
import SectionHeader from '@/ui/SectionHeader'; import { SectionHeader } from '@/ui/SectionHeader';
import StatusBadge from '@/ui/StatusBadge'; import { StatusBadge } from '@/ui/StatusBadge';
import InfoBanner from '@/ui/InfoBanner'; import { InfoBanner } from '@/ui/InfoBanner';
import PageHeader from '@/ui/PageHeader'; import { PageHeader } from '@/ui/PageHeader';
import { siteConfig } from '@/lib/siteConfig'; import { siteConfig } from '@/lib/siteConfig';
import { useSponsorBilling } from "@/lib/hooks/sponsor/useSponsorBilling"; import { useSponsorBilling } from "@/lib/hooks/sponsor/useSponsorBilling";
import { import {

View File

@@ -4,9 +4,9 @@ import { useState } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion'; import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
import Link from 'next/link'; import Link from 'next/link';
import Card from '@/ui/Card'; import { Card } from '@/ui/Card';
import Button from '@/ui/Button'; import { Button } from '@/ui/Button';
import InfoBanner from '@/ui/InfoBanner'; import { InfoBanner } from '@/ui/InfoBanner';
import { useSponsorSponsorships } from "@/lib/hooks/sponsor/useSponsorSponsorships"; import { useSponsorSponsorships } from "@/lib/hooks/sponsor/useSponsorSponsorships";
import { import {
Megaphone, Megaphone,

View File

@@ -2,13 +2,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { motion, useReducedMotion } from 'framer-motion'; import { motion, useReducedMotion } from 'framer-motion';
import Card from '@/ui/Card'; import { Card } from '@/ui/Card';
import Button from '@/ui/Button'; import { Button } from '@/ui/Button';
import Input from '@/ui/Input'; import { Input } from '@/ui/Input';
import Toggle from '@/ui/Toggle'; import { Toggle } from '@/ui/Toggle';
import SectionHeader from '@/ui/SectionHeader'; import { SectionHeader } from '@/ui/SectionHeader';
import FormField from '@/ui/FormField'; import { FormField } from '@/ui/FormField';
import PageHeader from '@/ui/PageHeader'; import { PageHeader } from '@/ui/PageHeader';
import { import {
Settings, Settings,
Building2, Building2,

View File

@@ -2,12 +2,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { motion, useReducedMotion } from 'framer-motion'; import { motion, useReducedMotion } from 'framer-motion';
import Card from '@/ui/Card'; import { Card } from '@/ui/Card';
import Button from '@/ui/Button'; import { Button } from '@/ui/Button';
import Input from '@/ui/Input'; import { Input } from '@/ui/Input';
import SponsorHero from '@/components/sponsors/SponsorHero'; import { SponsorHero } from '@/ui/SponsorHero';
import SponsorWorkflowMockup from '@/components/sponsors/SponsorWorkflowMockup'; import { SponsorWorkflowMockup } from '@/components/sponsors/SponsorWorkflowMockup';
import SponsorBenefitCard from '@/components/sponsors/SponsorBenefitCard'; import { SponsorBenefitCard } from '@/components/sponsors/SponsorBenefitCard';
import { siteConfig } from '@/lib/siteConfig'; import { siteConfig } from '@/lib/siteConfig';
import { import {
Building2, Building2,
@@ -468,7 +468,7 @@ export default function SponsorSignupPage() {
value={formData.contactEmail} value={formData.contactEmail}
onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })} onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
placeholder="sponsor@company.com" placeholder="sponsor@company.com"
error={!!errors.contactEmail} variant={errors.contactEmail ? 'error' : 'default'}
errorMessage={errors.contactEmail} errorMessage={errors.contactEmail}
/> />
</div> </div>
@@ -482,7 +482,7 @@ export default function SponsorSignupPage() {
value={formData.password} value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••" placeholder="••••••••"
error={!!errors.password} variant={errors.password ? 'error' : 'default'}
errorMessage={errors.password} errorMessage={errors.password}
/> />
</div> </div>
@@ -567,7 +567,7 @@ export default function SponsorSignupPage() {
value={formData.companyName} value={formData.companyName}
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })} onChange={(e) => setFormData({ ...formData, companyName: e.target.value })}
placeholder="Your company name" placeholder="Your company name"
error={!!errors.companyName} variant={errors.companyName ? 'error' : 'default'}
errorMessage={errors.companyName} errorMessage={errors.companyName}
/> />
</div> </div>
@@ -584,7 +584,7 @@ export default function SponsorSignupPage() {
value={formData.contactEmail} value={formData.contactEmail}
onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })} onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
placeholder="sponsor@company.com" placeholder="sponsor@company.com"
error={!!errors.contactEmail} variant={errors.contactEmail ? 'error' : 'default'}
errorMessage={errors.contactEmail} errorMessage={errors.contactEmail}
/> />
</div> </div>
@@ -688,7 +688,7 @@ export default function SponsorSignupPage() {
value={formData.password} value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="Min. 8 characters" placeholder="Min. 8 characters"
error={!!errors.password} variant={errors.password ? 'error' : 'default'}
errorMessage={errors.password} errorMessage={errors.password}
/> />
</div> </div>
@@ -702,7 +702,7 @@ export default function SponsorSignupPage() {
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })} onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
placeholder="Confirm password" placeholder="Confirm password"
error={!!errors.confirmPassword} variant={errors.confirmPassword ? 'error' : 'default'}
errorMessage={errors.confirmPassword} errorMessage={errors.confirmPassword}
/> />
</div> </div>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import TeamLeaderboardTemplate from '@/templates/TeamLeaderboardTemplate'; import { TeamLeaderboardTemplate } from '@/templates/TeamLeaderboardTemplate';
import { useState } from 'react'; import { useState } from 'react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';

View File

@@ -4,10 +4,10 @@ import { ContainerProvider } from '@/lib/di/providers/ContainerProvider';
import { QueryClientProvider } from '@/lib/providers/QueryClientProvider'; import { QueryClientProvider } from '@/lib/providers/QueryClientProvider';
import { AuthProvider } from '@/lib/auth/AuthContext'; import { AuthProvider } from '@/lib/auth/AuthContext';
import { FeatureFlagProvider } from '@/lib/feature/FeatureFlagProvider'; import { FeatureFlagProvider } from '@/lib/feature/FeatureFlagProvider';
import NotificationProvider from '@/components/notifications/NotificationProvider'; import { NotificationProvider } from '@/components/notifications/NotificationProvider';
import { NotificationIntegration } from '@/components/errors/NotificationIntegration'; import { NotificationIntegration } from '@/components/errors/NotificationIntegration';
import { EnhancedErrorBoundary } from '@/components/errors/EnhancedErrorBoundary'; import { EnhancedErrorBoundary } from '@/components/errors/EnhancedErrorBoundary';
import DevToolbar from '@/components/dev/DevToolbar'; import { DevToolbar } from '@/components/dev/DevToolbar';
import React from 'react'; import React from 'react';
interface AppWrapperProps { interface AppWrapperProps {

View File

@@ -1,29 +1,14 @@
'use client'; 'use client';
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion'; import React from 'react';
import { useEffect, useState } from 'react';
import { import {
UserPlus, UserPlus,
Link as LinkIcon, Link as LinkIcon,
Settings, Settings,
Trophy, Trophy,
Car, Car,
CheckCircle2,
LucideIcon
} from 'lucide-react'; } from 'lucide-react';
import { Box } from '@/ui/Box'; import { WorkflowMockup, WorkflowStep } from '@/ui/WorkflowMockup';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface WorkflowStep {
id: number;
icon: LucideIcon;
title: string;
description: string;
color: string;
}
const WORKFLOW_STEPS: WorkflowStep[] = [ const WORKFLOW_STEPS: WorkflowStep[] = [
{ {
@@ -64,123 +49,5 @@ const WORKFLOW_STEPS: WorkflowStep[] = [
]; ];
export function AuthWorkflowMockup() { export function AuthWorkflowMockup() {
const shouldReduceMotion = useReducedMotion(); return <WorkflowMockup steps={WORKFLOW_STEPS} />;
const [isMounted, setIsMounted] = useState(false);
const [activeStep, setActiveStep] = useState(0);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) return;
const interval = setInterval(() => {
setActiveStep((prev) => (prev + 1) % WORKFLOW_STEPS.length);
}, 3000);
return () => clearInterval(interval);
}, [isMounted]);
if (!isMounted) {
return (
<Box position="relative" fullWidth>
<Surface variant="muted" rounded="2xl" border padding={6}>
<Stack direction="row" justify="between" gap={2}>
{WORKFLOW_STEPS.map((step) => (
<Stack key={step.id} align="center" center>
<Box width={10} height={10} rounded="lg" backgroundColor="iron-gray" border borderColor="charcoal-outline" display="flex" center mb={2}>
<Icon icon={step.icon} size={4} className={step.color} />
</Box>
<Text size="xs" weight="medium" color="text-white">{step.title}</Text>
</Stack>
))}
</Stack>
</Surface>
</Box>
);
}
return (
<Box position="relative" fullWidth>
<Surface variant="muted" rounded="2xl" border padding={6} className="overflow-hidden">
{/* Connection Lines */}
<Box position="absolute" top="3.5rem" left="8%" right="8%" className="hidden sm:block">
<Box height={0.5} backgroundColor="charcoal-outline" position="relative">
<motion.div
className="absolute h-full bg-gradient-to-r from-primary-blue to-performance-green"
initial={{ width: '0%' }}
animate={{ width: `${(activeStep / (WORKFLOW_STEPS.length - 1)) * 100}%` }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
/>
</Box>
</Box>
{/* Steps */}
<Stack direction="row" justify="between" gap={2} position="relative">
{WORKFLOW_STEPS.map((step, index) => {
const isActive = index === activeStep;
const isCompleted = index < activeStep;
const StepIcon = step.icon;
return (
<motion.div
key={step.id}
className="flex flex-col items-center text-center cursor-pointer flex-1"
onClick={() => setActiveStep(index)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<motion.div
className={`w-10 h-10 sm:w-12 sm:h-12 rounded-lg border flex items-center justify-center mb-2 transition-all duration-300 ${
isActive
? 'bg-primary-blue/20 border-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.3)]'
: isCompleted
? 'bg-performance-green/20 border-performance-green/50'
: 'bg-iron-gray border-charcoal-outline'
}`}
animate={isActive && !shouldReduceMotion ? {
scale: [1, 1.08, 1],
transition: { duration: 1, repeat: Infinity }
} : {}}
>
{isCompleted ? (
<Icon icon={CheckCircle2} size={5} color="text-performance-green" />
) : (
<Icon icon={StepIcon} size={5} className={isActive ? step.color : 'text-gray-500'} />
)}
</motion.div>
<Text size="xs" weight="medium" className={`transition-colors hidden sm:block ${
isActive ? 'text-white' : 'text-gray-400'
}`}>
{step.title}
</Text>
</motion.div>
);
})}
</Stack>
{/* Active Step Preview - Mobile */}
<AnimatePresence mode="wait">
<motion.div
key={activeStep}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
className="mt-4 pt-4 border-t border-charcoal-outline sm:hidden"
>
<Box textAlign="center">
<Text size="xs" color="text-gray-400" block mb={1}>
Step {activeStep + 1}: {WORKFLOW_STEPS[activeStep]?.title || ''}
</Text>
<Text size="xs" color="text-gray-500" block>
{WORKFLOW_STEPS[activeStep]?.description || ''}
</Text>
</Box>
</motion.div>
</AnimatePresence>
</Surface>
</Box>
);
} }

View File

@@ -52,7 +52,9 @@ export function UserRolesPreview({ variant = 'full' }: UserRolesPreviewProps) {
justifyContent="center" justifyContent="center"
mb={1} mb={1}
> >
<Icon icon={role.icon} size={4} className={`text-${role.color}`} /> <Text color={`text-${role.color}`}>
<Icon icon={role.icon} size={4} />
</Text>
</Box> </Box>
<Text size="xs" color="text-gray-500">{role.title}</Text> <Text size="xs" color="text-gray-500">{role.title}</Text>
</Stack> </Stack>
@@ -89,7 +91,9 @@ export function UserRolesPreview({ variant = 'full' }: UserRolesPreviewProps) {
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
> >
<Icon icon={role.icon} size={5} className={`text-${role.color}`} /> <Text color={`text-${role.color}`}>
<Icon icon={role.icon} size={5} />
</Text>
</Box> </Box>
<Box> <Box>
<Heading level={4}>{role.title}</Heading> <Heading level={4}>{role.title}</Heading>

View File

@@ -1,62 +0,0 @@
'use client';
import React from 'react';
import { Activity } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
interface FeedItem {
id: string;
headline: string;
body?: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}
interface ActivityFeedProps {
items: FeedItem[];
hasItems: boolean;
}
export function ActivityFeed({ items, hasItems }: ActivityFeedProps) {
return (
<Card>
<Stack gap={4}>
<Heading level={2} icon={<Activity style={{ width: '1.25rem', height: '1.25rem', color: '#3b82f6' }} />}>
Recent Activity
</Heading>
{hasItems ? (
<Stack gap={4}>
{items.slice(0, 5).map((item) => (
<Box key={item.id} style={{ display: 'flex', alignItems: 'start', gap: '0.75rem', padding: '0.75rem', backgroundColor: '#0f1115', borderRadius: '0.5rem' }}>
<Box style={{ flex: 1 }}>
<Text color="text-white" weight="medium" block>{item.headline}</Text>
{item.body && <Text size="sm" color="text-gray-400" block mt={1}>{item.body}</Text>}
<Text size="xs" color="text-gray-500" block mt={1}>{item.formattedTime}</Text>
</Box>
{item.ctaHref && item.ctaLabel && (
<Box>
<Link href={item.ctaHref} variant="primary">
<Text size="xs">{item.ctaLabel}</Text>
</Link>
</Box>
)}
</Box>
))}
</Stack>
) : (
<Stack align="center" gap={2} py={8}>
<Activity style={{ width: '2.5rem', height: '2.5rem', color: '#525252' }} />
<Text color="text-gray-400">No activity yet</Text>
<Text size="sm" color="text-gray-500">Join leagues and add friends to see activity here</Text>
</Stack>
)}
</Stack>
</Card>
);
}

View File

@@ -1,56 +0,0 @@
'use client';
import React from 'react';
import { Award, ChevronRight } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { routes } from '@/lib/routing/RouteConfig';
interface Standing {
leagueId: string;
leagueName: string;
position: string;
points: string;
totalDrivers: string;
}
interface ChampionshipStandingsProps {
standings: Standing[];
}
export function ChampionshipStandings({ standings }: ChampionshipStandingsProps) {
return (
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2} icon={<Award style={{ width: '1.25rem', height: '1.25rem', color: '#facc15' }} />}>
Your Championship Standings
</Heading>
<Box>
<Link href={routes.protected.profileLeagues} variant="primary">
<Stack direction="row" align="center" gap={1}>
<Text size="sm">View all</Text>
<ChevronRight style={{ width: '1rem', height: '1rem' }} />
</Stack>
</Link>
</Box>
</Stack>
<Stack gap={3}>
{standings.map((summary) => (
<Box key={summary.leagueId} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem', backgroundColor: '#0f1115', borderRadius: '0.5rem' }}>
<Box>
<Text color="text-white" weight="medium" block>{summary.leagueName}</Text>
<Text size="xs" color="text-gray-500">Position {summary.position} {summary.points} points</Text>
</Box>
<Text size="xs" color="text-gray-400">{summary.totalDrivers} drivers</Text>
</Box>
))}
</Stack>
</Stack>
</Card>
);
}

View File

@@ -1,101 +0,0 @@
'use client';
import React from 'react';
import { Trophy, Medal, Target, Users, Flag, User } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Image } from '@/ui/Image';
import { Button } from '@/ui/Button';
import { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
import { StatBox } from './StatBox';
import { routes } from '@/lib/routing/RouteConfig';
interface DashboardHeroProps {
currentDriver: {
name: string;
avatarUrl: string;
country: string;
rating: string | number;
rank: string | number;
totalRaces: string | number;
wins: string | number;
podiums: string | number;
consistency: string;
};
activeLeaguesCount: string | number;
}
export function DashboardHero({ currentDriver, activeLeaguesCount }: DashboardHeroProps) {
return (
<Box as="section" style={{ position: 'relative', overflow: 'hidden' }}>
{/* Background Pattern */}
<Box style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.1), #0f1115, rgba(147, 51, 234, 0.05))' }} />
<Box style={{ position: 'relative', maxWidth: '80rem', margin: '0 auto', padding: '2.5rem 1.5rem' }}>
<Stack gap={8}>
<Stack direction="row" align="center" justify="between" wrap gap={8}>
{/* Welcome Message */}
<Stack direction="row" align="start" gap={5}>
<Box style={{ position: 'relative' }}>
<Box style={{ width: '5rem', height: '5rem', borderRadius: '1rem', background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)', padding: '0.125rem', boxShadow: '0 20px 25px -5px rgba(59, 130, 246, 0.2)' }}>
<Box style={{ width: '100%', height: '100%', borderRadius: '0.75rem', overflow: 'hidden', backgroundColor: '#262626' }}>
<Image
src={currentDriver.avatarUrl}
alt={currentDriver.name}
width={80}
height={80}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
</Box>
<Box style={{ position: 'absolute', bottom: '-0.25rem', right: '-0.25rem', width: '1.25rem', height: '1.25rem', borderRadius: '9999px', backgroundColor: '#10b981', border: '3px solid #0f1115' }} />
</Box>
<Box>
<Text size="sm" color="text-gray-400" block mb={1}>Good morning,</Text>
<Heading level={1} style={{ marginBottom: '0.5rem' }}>
{currentDriver.name}
<Text size="2xl" style={{ marginLeft: '0.75rem' }}>{currentDriver.country}</Text>
</Heading>
<Stack direction="row" align="center" gap={3} wrap>
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
<Text size="sm" weight="semibold" color="text-primary-blue">{currentDriver.rating}</Text>
</Surface>
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(250, 204, 21, 0.1)', border: '1px solid rgba(250, 204, 21, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
<Text size="sm" weight="semibold" style={{ color: '#facc15' }}>#{currentDriver.rank}</Text>
</Surface>
<Text size="xs" color="text-gray-500">{currentDriver.totalRaces} races completed</Text>
</Stack>
</Box>
</Stack>
{/* Quick Actions */}
<Stack direction="row" gap={3} wrap>
<Link href={routes.public.leagues} variant="ghost">
<Button variant="secondary" icon={<Flag style={{ width: '1rem', height: '1rem' }} />}>
Browse Leagues
</Button>
</Link>
<Link href={routes.protected.profile} variant="ghost">
<Button variant="primary" icon={<User style={{ width: '1rem', height: '1rem' }} />}>
View Profile
</Button>
</Link>
</Stack>
</Stack>
{/* Quick Stats Row */}
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '1rem' }}>
{/* At md this should be 4 columns */}
<StatBox icon={Trophy} label="Wins" value={currentDriver.wins} color="#10b981" />
<StatBox icon={Medal} label="Podiums" value={currentDriver.podiums} color="#f59e0b" />
<StatBox icon={Target} label="Consistency" value={currentDriver.consistency} color="#3b82f6" />
<StatBox icon={Users} label="Active Leagues" value={activeLeaguesCount} color="#a855f7" />
</Box>
</Stack>
</Box>
</Box>
);
}

View File

@@ -1,83 +0,0 @@
'use client';
import React from 'react';
import { Users, UserPlus } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Image } from '@/ui/Image';
import { Button } from '@/ui/Button';
import { routes } from '@/lib/routing/RouteConfig';
interface Friend {
id: string;
name: string;
avatarUrl: string;
country: string;
}
interface FriendsSidebarProps {
friends: Friend[];
hasFriends: boolean;
}
export function FriendsSidebar({ friends, hasFriends }: FriendsSidebarProps) {
return (
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={3} icon={<Users style={{ width: '1.25rem', height: '1.25rem', color: '#a855f7' }} />}>
Friends
</Heading>
<Text size="xs" color="text-gray-500">{friends.length} friends</Text>
</Stack>
{hasFriends ? (
<Stack gap={2}>
{friends.slice(0, 6).map((friend) => (
<Box key={friend.id} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.5rem', borderRadius: '0.5rem' }}>
<Box style={{ width: '2.25rem', height: '2.25rem', borderRadius: '9999px', overflow: 'hidden', background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)' }}>
<Image
src={friend.avatarUrl}
alt={friend.name}
width={36}
height={36}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" color="text-white" weight="medium" truncate block>{friend.name}</Text>
<Text size="xs" color="text-gray-500" block>{friend.country}</Text>
</Box>
</Box>
))}
{friends.length > 6 && (
<Box py={2}>
<Link
href={routes.protected.profile}
variant="primary"
>
<Text size="sm" block style={{ textAlign: 'center' }}>+{friends.length - 6} more</Text>
</Link>
</Box>
)}
</Stack>
) : (
<Stack align="center" gap={2} py={6}>
<UserPlus style={{ width: '2rem', height: '2rem', color: '#525252' }} />
<Text size="sm" color="text-gray-400">No friends yet</Text>
<Box>
<Link href={routes.public.drivers} variant="ghost">
<Button variant="secondary" size="sm">
Find Drivers
</Button>
</Link>
</Box>
</Stack>
)}
</Stack>
</Card>
);
}

View File

@@ -1,74 +0,0 @@
'use client';
import React from 'react';
import { Calendar, Clock, ChevronRight } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
import { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
interface NextRaceCardProps {
nextRace: {
id: string;
track: string;
car: string;
formattedDate: string;
formattedTime: string;
timeUntil: string;
isMyLeague: boolean;
};
}
export function NextRaceCard({ nextRace }: NextRaceCardProps) {
return (
<Surface variant="muted" rounded="xl" border padding={6} style={{ position: 'relative', overflow: 'hidden', background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))', borderColor: 'rgba(59, 130, 246, 0.3)' }}>
<Box style={{ position: 'absolute', top: 0, right: 0, width: '10rem', height: '10rem', background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.2), transparent)', borderBottomLeftRadius: '9999px' }} />
<Box style={{ position: 'relative' }}>
<Stack direction="row" align="center" gap={2} mb={4}>
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(59, 130, 246, 0.2)', border: '1px solid rgba(59, 130, 246, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
<Text size="xs" weight="semibold" color="text-primary-blue" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>Next Race</Text>
</Surface>
{nextRace.isMyLeague && (
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(16, 185, 129, 0.2)', color: '#10b981', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
<Text size="xs" weight="medium">Your League</Text>
</Surface>
)}
</Stack>
<Stack direction="row" align="end" justify="between" wrap gap={4}>
<Box>
<Heading level={2} style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>{nextRace.track}</Heading>
<Text color="text-gray-400" block mb={3}>{nextRace.car}</Text>
<Stack direction="row" align="center" gap={4}>
<Stack direction="row" align="center" gap={1.5}>
<Calendar style={{ width: '1rem', height: '1rem', color: '#6b7280' }} />
<Text size="sm" color="text-gray-400">{nextRace.formattedDate}</Text>
</Stack>
<Stack direction="row" align="center" gap={1.5}>
<Clock style={{ width: '1rem', height: '1rem', color: '#6b7280' }} />
<Text size="sm" color="text-gray-400">{nextRace.formattedTime}</Text>
</Stack>
</Stack>
</Box>
<Stack align="end" gap={3}>
<Box style={{ textAlign: 'right' }}>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={1}>Starts in</Text>
<Text size="3xl" weight="bold" color="text-primary-blue" font="mono">{nextRace.timeUntil}</Text>
</Box>
<Box>
<Link href={`/races/${nextRace.id}`} variant="ghost">
<Button variant="primary" icon={<ChevronRight style={{ width: '1rem', height: '1rem' }} />}>
View Details
</Button>
</Link>
</Box>
</Stack>
</Stack>
</Box>
</Surface>
);
}

View File

@@ -1,68 +0,0 @@
'use client';
import React from 'react';
import { Calendar } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { routes } from '@/lib/routing/RouteConfig';
interface UpcomingRace {
id: string;
track: string;
car: string;
formattedDate: string;
formattedTime: string;
isMyLeague: boolean;
}
interface UpcomingRacesProps {
races: UpcomingRace[];
hasRaces: boolean;
}
export function UpcomingRaces({ races, hasRaces }: UpcomingRacesProps) {
return (
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={3} icon={<Calendar style={{ width: '1.25rem', height: '1.25rem', color: '#3b82f6' }} />}>
Upcoming Races
</Heading>
<Box>
<Link href={routes.public.races} variant="primary">
<Text size="xs">View all</Text>
</Link>
</Box>
</Stack>
{hasRaces ? (
<Stack gap={3}>
{races.slice(0, 5).map((race) => (
<Box key={race.id} style={{ padding: '0.75rem', backgroundColor: '#0f1115', borderRadius: '0.5rem' }}>
<Text color="text-white" weight="medium" block>{race.track}</Text>
<Text size="sm" color="text-gray-400" block>{race.car}</Text>
<Stack direction="row" align="center" gap={2} mt={1}>
<Text size="xs" color="text-gray-500">{race.formattedDate}</Text>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" color="text-gray-500">{race.formattedTime}</Text>
</Stack>
{race.isMyLeague && (
<Box style={{ display: 'inline-block', marginTop: '0.25rem', padding: '0.125rem 0.5rem', borderRadius: '9999px', backgroundColor: 'rgba(16, 185, 129, 0.2)', color: '#10b981', fontSize: '0.75rem', fontWeight: 500 }}>
Your League
</Box>
)}
</Box>
))}
</Stack>
) : (
<Box py={4}>
<Text size="sm" color="text-gray-500" block style={{ textAlign: 'center' }}>No upcoming races</Text>
</Box>
)}
</Stack>
</Card>
);
}

View File

@@ -1,41 +0,0 @@
'use client';
import { ReactNode } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
interface AccordionProps {
title: string;
icon: ReactNode;
children: ReactNode;
isOpen: boolean;
onToggle: () => void;
}
export function Accordion({ title, icon, children, isOpen, onToggle }: AccordionProps) {
return (
<div className="border border-charcoal-outline rounded-lg overflow-hidden bg-iron-gray/30">
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-iron-gray/50 transition-colors"
>
<div className="flex items-center gap-2">
{icon}
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
{title}
</span>
</div>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronUp className="w-4 h-4 text-gray-400" />
)}
</button>
{isOpen && (
<div className="p-3 border-t border-charcoal-outline">
{children}
</div>
)}
</div>
);
}

View File

@@ -1,27 +1,32 @@
'use client'; 'use client';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import React, { useEffect, useState } from 'react';
import { useNotifications } from '@/components/notifications/NotificationProvider';
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
import { Wrench, ChevronDown, ChevronUp, X, MessageSquare, Activity, AlertTriangle } from 'lucide-react'; import { Wrench, ChevronDown, ChevronUp, X, MessageSquare, Activity, AlertTriangle } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { ApiConnectionMonitor } from '@/lib/api/base/ApiConnectionMonitor'; import { ApiConnectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler'; import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler'; import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useNotifications } from '@/components/notifications/NotificationProvider';
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
// Import our new components // Import our new components
import { Accordion } from './Accordion'; import { Accordion } from '@/ui/Accordion';
import { NotificationTypeSection } from './sections/NotificationTypeSection'; import { NotificationTypeSection } from './sections/NotificationTypeSection';
import { UrgencySection } from './sections/UrgencySection'; import { UrgencySection } from './sections/UrgencySection';
import { NotificationSendSection } from './sections/NotificationSendSection'; import { NotificationSendSection } from './sections/NotificationSendSection';
import { APIStatusSection } from './sections/APIStatusSection'; import { APIStatusSection } from './sections/APIStatusSection';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import { Badge } from '@/ui/Badge';
import { Button } from '@/ui/Button';
// Import types // Import types
import type { DemoNotificationType, DemoUrgency } from './types'; import type { DemoNotificationType, DemoUrgency } from './types';
export default function DevToolbar() { export function DevToolbar() {
const router = useRouter();
const { addNotification } = useNotifications(); const { addNotification } = useNotifications();
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isMinimized, setIsMinimized] = useState(false); const [isMinimized, setIsMinimized] = useState(false);
@@ -225,140 +230,155 @@ export default function DevToolbar() {
if (isMinimized) { if (isMinimized) {
return ( return (
<button <Box position="fixed" bottom="4" right="4" zIndex={50}>
onClick={() => setIsMinimized(false)} <IconButton
className="fixed bottom-4 right-4 z-50 p-3 bg-iron-gray border border-charcoal-outline rounded-full shadow-lg hover:bg-charcoal-outline transition-colors" icon={Wrench}
title="Open Dev Toolbar" onClick={() => setIsMinimized(false)}
> variant="secondary"
<Wrench className="w-5 h-5 text-primary-blue" /> title="Open Dev Toolbar"
</button> size="lg"
/>
</Box>
); );
} }
return ( return (
<div className="fixed bottom-4 right-4 z-50 w-80 bg-deep-graphite border border-charcoal-outline rounded-xl shadow-2xl overflow-hidden"> <Box
position="fixed"
bottom="4"
right="4"
zIndex={50}
w="80"
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="xl"
shadow="2xl"
overflow="hidden"
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline"> <Box display="flex" alignItems="center" justifyContent="between" px={4} py={3} bg="bg-iron-gray/50" borderBottom borderColor="border-charcoal-outline">
<div className="flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2}>
<Wrench className="w-4 h-4 text-primary-blue" /> <Icon icon={Wrench} size={4} color="rgb(59, 130, 246)" />
<span className="text-sm font-semibold text-white">Dev Toolbar</span> <Text size="sm" weight="semibold" color="text-white">Dev Toolbar</Text>
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-primary-blue/20 text-primary-blue rounded"> <Badge variant="primary" size="xs">
DEMO DEMO
</span> </Badge>
</div> </Box>
<div className="flex items-center gap-1"> <Box display="flex" alignItems="center" gap={1}>
<button <IconButton
icon={isExpanded ? ChevronDown : ChevronUp}
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
className="p-1.5 hover:bg-charcoal-outline rounded transition-colors" variant="ghost"
> size="sm"
{isExpanded ? ( />
<ChevronDown className="w-4 h-4 text-gray-400" /> <IconButton
) : ( icon={X}
<ChevronUp className="w-4 h-4 text-gray-400" />
)}
</button>
<button
onClick={() => setIsMinimized(true)} onClick={() => setIsMinimized(true)}
className="p-1.5 hover:bg-charcoal-outline rounded transition-colors" variant="ghost"
> size="sm"
<X className="w-4 h-4 text-gray-400" /> />
</button> </Box>
</div> </Box>
</div>
{/* Content */} {/* Content */}
{isExpanded && ( {isExpanded && (
<div className="p-4 space-y-3"> <Box p={4}>
{/* Notification Section - Accordion */} <Stack gap={3}>
<Accordion {/* Notification Section - Accordion */}
title="Notifications" <Accordion
icon={<MessageSquare className="w-4 h-4 text-gray-400" />} title="Notifications"
isOpen={openAccordion === 'notifications'} icon={<Icon icon={MessageSquare} size={4} color="rgb(156, 163, 175)" />}
onToggle={() => setOpenAccordion(openAccordion === 'notifications' ? null : 'notifications')} isOpen={openAccordion === 'notifications'}
> onToggle={() => setOpenAccordion(openAccordion === 'notifications' ? null : 'notifications')}
<div className="space-y-3"> >
<NotificationTypeSection <Stack gap={3}>
selectedType={selectedType} <NotificationTypeSection
onSelectType={setSelectedType} selectedType={selectedType}
/> onSelectType={setSelectedType}
<UrgencySection />
selectedUrgency={selectedUrgency} <UrgencySection
onSelectUrgency={setSelectedUrgency} selectedUrgency={selectedUrgency}
/> onSelectUrgency={setSelectedUrgency}
<NotificationSendSection />
selectedType={selectedType} <NotificationSendSection
selectedUrgency={selectedUrgency} selectedType={selectedType}
sending={sending} selectedUrgency={selectedUrgency}
lastSent={lastSent} sending={sending}
onSend={handleSendNotification} lastSent={lastSent}
/> onSend={handleSendNotification}
</div> />
</Accordion> </Stack>
</Accordion>
{/* API Status Section - Accordion */} {/* API Status Section - Accordion */}
<Accordion <Accordion
title="API Status" title="API Status"
icon={<Activity className="w-4 h-4 text-gray-400" />} icon={<Icon icon={Activity} size={4} color="rgb(156, 163, 175)" />}
isOpen={openAccordion === 'apiStatus'} isOpen={openAccordion === 'apiStatus'}
onToggle={() => setOpenAccordion(openAccordion === 'apiStatus' ? null : 'apiStatus')} onToggle={() => setOpenAccordion(openAccordion === 'apiStatus' ? null : 'apiStatus')}
> >
<APIStatusSection <APIStatusSection
apiStatus={apiStatus} apiStatus={apiStatus}
apiHealth={apiHealth} apiHealth={apiHealth}
circuitBreakers={circuitBreakers} circuitBreakers={circuitBreakers}
checkingHealth={checkingHealth} checkingHealth={checkingHealth}
onHealthCheck={handleApiHealthCheck} onHealthCheck={handleApiHealthCheck}
onResetStats={handleResetApiStats} onResetStats={handleResetApiStats}
onTestError={handleTestApiError} onTestError={handleTestApiError}
/> />
</Accordion> </Accordion>
{/* Error Stats Section - Accordion */} {/* Error Stats Section - Accordion */}
<Accordion <Accordion
title="Error Stats" title="Error Stats"
icon={<AlertTriangle className="w-4 h-4 text-gray-400" />} icon={<Icon icon={AlertTriangle} size={4} color="rgb(156, 163, 175)" />}
isOpen={openAccordion === 'errors'} isOpen={openAccordion === 'errors'}
onToggle={() => setOpenAccordion(openAccordion === 'errors' ? null : 'errors')} onToggle={() => setOpenAccordion(openAccordion === 'errors' ? null : 'errors')}
> >
<div className="space-y-2 text-xs"> <Stack gap={2}>
<div className="flex justify-between items-center p-2 bg-iron-gray/30 rounded"> <Box display="flex" justifyContent="between" alignItems="center" p={2} bg="bg-iron-gray/30" rounded="md">
<span className="text-gray-400">Total Errors</span> <Text size="xs" color="text-gray-400">Total Errors</Text>
<span className="font-mono font-bold text-red-400">{errorStats.total}</span> <Text size="xs" font="mono" weight="bold" color="text-red-400">{errorStats.total}</Text>
</div> </Box>
{Object.keys(errorStats.byType).length > 0 ? ( {Object.keys(errorStats.byType).length > 0 ? (
<div className="space-y-1"> <Stack gap={1}>
{Object.entries(errorStats.byType).map(([type, count]) => ( {Object.entries(errorStats.byType).map(([type, count]) => (
<div key={type} className="flex justify-between items-center p-1.5 bg-deep-graphite rounded"> <Box key={type} display="flex" justifyContent="between" alignItems="center" p={1.5} bg="bg-deep-graphite" rounded="md">
<span className="text-gray-300">{type}</span> <Text size="xs" color="text-gray-300">{type}</Text>
<span className="font-mono text-yellow-400">{count}</span> <Text size="xs" font="mono" color="text-warning-amber">{count}</Text>
</div> </Box>
))} ))}
</div> </Stack>
) : ( ) : (
<div className="text-center text-gray-500 py-2">No errors yet</div> <Box textAlign="center" py={2}>
)} <Text size="xs" color="text-gray-500">No errors yet</Text>
<button </Box>
onClick={() => { )}
const handler = getGlobalErrorHandler(); <Button
handler.clearHistory(); variant="secondary"
setErrorStats({ total: 0, byType: {} }); onClick={() => {
}} const handler = getGlobalErrorHandler();
className="w-full p-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded border border-charcoal-outline text-xs" handler.clearHistory();
> setErrorStats({ total: 0, byType: {} });
Clear Error History }}
</button> fullWidth
</div> size="sm"
</Accordion> >
Clear Error History
</div> </Button>
</Stack>
</Accordion>
</Stack>
</Box>
)} )}
{/* Collapsed state hint */} {/* Collapsed state hint */}
{!isExpanded && ( {!isExpanded && (
<div className="px-4 py-2 text-xs text-gray-500"> <Box px={4} py={2}>
Click to expand dev tools <Text size="xs" color="text-gray-500">Click to expand dev tools</Text>
</div> </Box>
)} )}
</div> </Box>
); );
} }

View File

@@ -1,15 +1,24 @@
'use client'; 'use client';
import React from 'react';
import { Activity, Wifi, RefreshCw, Terminal } from 'lucide-react'; import { Activity, Wifi, RefreshCw, Terminal } from 'lucide-react';
import { useState } from 'react'; import { Box } from '@/ui/Box';
import { ApiConnectionMonitor } from '@/lib/api/base/ApiConnectionMonitor'; import { Text } from '@/ui/Text';
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler'; import { Stack } from '@/ui/Stack';
import { useNotifications } from '@/components/notifications/NotificationProvider'; import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import { StatusIndicator, StatRow, Badge } from '@/ui/StatusIndicator';
interface APIStatusSectionProps { interface APIStatusSectionProps {
apiStatus: string; apiStatus: string;
apiHealth: any; apiHealth: {
circuitBreakers: Record<string, any>; successfulRequests: number;
totalRequests: number;
averageResponseTime: number;
consecutiveFailures: number;
lastCheck: number | Date | null;
};
circuitBreakers: Record<string, { state: string; failures: number }>;
checkingHealth: boolean; checkingHealth: boolean;
onHealthCheck: () => void; onHealthCheck: () => void;
onResetStats: () => void; onResetStats: () => void;
@@ -25,121 +34,137 @@ export function APIStatusSection({
onResetStats, onResetStats,
onTestError onTestError
}: APIStatusSectionProps) { }: APIStatusSectionProps) {
const reliability = apiHealth.totalRequests === 0
? 0
: (apiHealth.successfulRequests / apiHealth.totalRequests);
const reliabilityLabel = apiHealth.totalRequests === 0 ? 'N/A' : 'Calculated';
const getReliabilityColor = () => {
if (apiHealth.totalRequests === 0) return 'text-gray-500';
if (reliability >= 0.95) return 'text-performance-green';
if (reliability >= 0.8) return 'text-warning-amber';
return 'text-red-400';
};
const getStatusVariant = () => {
if (apiStatus === 'connected') return 'success';
if (apiStatus === 'degraded') return 'warning';
return 'danger';
};
const statusLabel = apiStatus.toUpperCase();
const healthSummary = `${apiHealth.successfulRequests}/${apiHealth.totalRequests} req`;
const avgResponseLabel = `${apiHealth.averageResponseTime.toFixed(0)}ms`;
const lastCheckLabel = apiHealth.lastCheck ? 'Recently' : 'Never';
const consecutiveFailuresLabel = String(apiHealth.consecutiveFailures);
return ( return (
<div> <Box>
<div className="flex items-center gap-2 mb-3"> <Box display="flex" alignItems="center" gap={2} mb={3}>
<Activity className="w-4 h-4 text-gray-400" /> <Icon icon={Activity} size={4} color="rgb(156, 163, 175)" />
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide"> <Text size="xs" weight="semibold" color="text-gray-400" uppercase letterSpacing="wide">
API Status API Status
</span> </Text>
</div> </Box>
{/* Status Indicator */} {/* Status Indicator */}
<div className={`flex items-center justify-between p-2 rounded-lg mb-2 ${ <StatusIndicator
apiStatus === 'connected' ? 'bg-green-500/10 border border-green-500/30' : icon={Wifi}
apiStatus === 'degraded' ? 'bg-yellow-500/10 border border-yellow-500/30' : label={statusLabel}
'bg-red-500/10 border border-red-500/30' subLabel={healthSummary}
}`}> variant={getStatusVariant()}
<div className="flex items-center gap-2"> />
<Wifi className={`w-4 h-4 ${
apiStatus === 'connected' ? 'text-green-400' :
apiStatus === 'degraded' ? 'text-yellow-400' :
'text-red-400'
}`} />
<span className="text-sm font-semibold text-white">{apiStatus.toUpperCase()}</span>
</div>
<span className="text-xs text-gray-400">
{apiHealth.successfulRequests}/{apiHealth.totalRequests} req
</span>
</div>
{/* Reliability */} <Box mt={2}>
<div className="flex items-center justify-between text-xs mb-2"> {/* Reliability */}
<span className="text-gray-500">Reliability</span> <StatRow
<span className={`font-bold ${ label="Reliability"
apiHealth.totalRequests === 0 ? 'text-gray-500' : value={reliabilityLabel}
(apiHealth.successfulRequests / apiHealth.totalRequests) >= 0.95 ? 'text-green-400' : valueColor={getReliabilityColor()}
(apiHealth.successfulRequests / apiHealth.totalRequests) >= 0.8 ? 'text-yellow-400' : />
'text-red-400'
}`}>
{apiHealth.totalRequests === 0 ? 'N/A' :
((apiHealth.successfulRequests / apiHealth.totalRequests) * 100).toFixed(1) + '%'}
</span>
</div>
{/* Response Time */} {/* Response Time */}
<div className="flex items-center justify-between text-xs mb-2"> <StatRow
<span className="text-gray-500">Avg Response</span> label="Avg Response"
<span className="text-blue-400 font-mono"> value={avgResponseLabel}
{apiHealth.averageResponseTime.toFixed(0)}ms valueColor="text-primary-blue"
</span> valueFont="mono"
</div> />
</Box>
{/* Consecutive Failures */} {/* Consecutive Failures */}
{apiHealth.consecutiveFailures > 0 && ( {apiHealth.consecutiveFailures > 0 && (
<div className="flex items-center justify-between text-xs mb-2 bg-red-500/10 rounded px-2 py-1"> <Box mt={2}>
<span className="text-red-400">Consecutive Failures</span> <StatusIndicator
<span className="text-red-400 font-bold">{apiHealth.consecutiveFailures}</span> icon={Activity}
</div> label="Consecutive Failures"
subLabel={consecutiveFailuresLabel}
variant="danger"
/>
</Box>
)} )}
{/* Circuit Breakers */} {/* Circuit Breakers */}
<div className="mt-2"> <Box mt={2}>
<div className="text-[10px] text-gray-500 mb-1">Circuit Breakers:</div> <Text size="xs" color="text-gray-500" block mb={1}>Circuit Breakers:</Text>
{Object.keys(circuitBreakers).length === 0 ? ( {Object.keys(circuitBreakers).length === 0 ? (
<div className="text-[10px] text-gray-500 italic">None active</div> <Text size="xs" color="text-gray-500" italic>None active</Text>
) : ( ) : (
<div className="space-y-1 max-h-16 overflow-auto"> <Stack gap={1} maxHeight="4rem" overflow="auto">
{Object.entries(circuitBreakers).map(([endpoint, status]: [string, any]) => ( {Object.entries(circuitBreakers).map(([endpoint, status]) => (
<div key={endpoint} className="flex items-center justify-between text-[10px]"> <Box key={endpoint} display="flex" alignItems="center" justifyContent="between">
<span className="text-gray-400 truncate flex-1">{endpoint.split('/').pop() || endpoint}</span> <Text size="xs" color="text-gray-400" truncate flexGrow={1}>
<span className={`px-1 rounded ${ {endpoint}
status.state === 'CLOSED' ? 'bg-green-500/20 text-green-400' : </Text>
status.state === 'OPEN' ? 'bg-red-500/20 text-red-400' : <Badge variant={status.state === 'CLOSED' ? 'success' : status.state === 'OPEN' ? 'danger' : 'warning'}>
'bg-yellow-500/20 text-yellow-400'
}`}>
{status.state} {status.state}
</span> </Badge>
{status.failures > 0 && ( {status.failures > 0 && (
<span className="text-red-400 ml-1">({status.failures})</span> <Text size="xs" color="text-red-400" ml={1}>({status.failures})</Text>
)} )}
</div> </Box>
))} ))}
</div> </Stack>
)} )}
</div> </Box>
{/* API Actions */} {/* API Actions */}
<div className="grid grid-cols-2 gap-2 mt-3"> <Box display="grid" gridCols={2} gap={2} mt={3}>
<button <Button
variant="primary"
onClick={onHealthCheck} onClick={onHealthCheck}
disabled={checkingHealth} disabled={checkingHealth}
className="px-2 py-1.5 bg-primary-blue hover:bg-primary-blue/80 text-white text-xs rounded transition-colors disabled:opacity-50 flex items-center justify-center gap-1" size="sm"
icon={<Icon icon={RefreshCw} size={3} animate={checkingHealth ? 'spin' : 'none'} />}
> >
<RefreshCw className={`w-3 h-3 ${checkingHealth ? 'animate-spin' : ''}`} />
Health Check Health Check
</button> </Button>
<button <Button
variant="secondary"
onClick={onResetStats} onClick={onResetStats}
className="px-2 py-1.5 bg-iron-gray hover:bg-charcoal-outline text-gray-300 text-xs rounded transition-colors border border-charcoal-outline" size="sm"
> >
Reset Stats Reset Stats
</button> </Button>
</div> </Box>
<div className="grid grid-cols-1 gap-2 mt-2"> <Box display="grid" gridCols={1} gap={2} mt={2}>
<button <Button
variant="danger"
onClick={onTestError} onClick={onTestError}
className="px-2 py-1.5 bg-red-600 hover:bg-red-700 text-white text-xs rounded transition-colors flex items-center justify-center gap-1" size="sm"
icon={<Icon icon={Terminal} size={3} />}
> >
<Terminal className="w-3 h-3" />
Test Error Handler Test Error Handler
</button> </Button>
</div> </Box>
<div className="text-[10px] text-gray-600 mt-2"> <Box mt={2}>
Last Check: {apiHealth.lastCheck ? new Date(apiHealth.lastCheck).toLocaleTimeString() : 'Never'} <Text size="xs" color="text-gray-600">
</div> Last Check: {lastCheckLabel}
</div> </Text>
</Box>
</Box>
); );
} }

View File

@@ -1,10 +1,12 @@
'use client'; 'use client';
import { Bell } from 'lucide-react'; import React from 'react';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { Bell, Loader2 } from 'lucide-react';
import { useNotifications } from '@/components/notifications/NotificationProvider';
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
import type { DemoNotificationType, DemoUrgency } from '../types'; import type { DemoNotificationType, DemoUrgency } from '../types';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
interface NotificationSendSectionProps { interface NotificationSendSectionProps {
selectedType: DemoNotificationType; selectedType: DemoNotificationType;
@@ -15,50 +17,36 @@ interface NotificationSendSectionProps {
} }
export function NotificationSendSection({ export function NotificationSendSection({
selectedType,
selectedUrgency,
sending, sending,
lastSent, lastSent,
onSend onSend
}: NotificationSendSectionProps) { }: NotificationSendSectionProps) {
return ( return (
<div> <Box>
<button <Button
onClick={onSend} onClick={onSend}
disabled={sending} disabled={sending}
className={` variant={lastSent ? 'secondary' : 'primary'}
w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium text-sm transition-all fullWidth
${lastSent bg={lastSent ? 'bg-performance-green/20' : undefined}
? 'bg-performance-green/20 border border-performance-green/30 text-performance-green' borderColor={lastSent ? 'border-performance-green/30' : undefined}
: 'bg-primary-blue hover:bg-primary-blue/80 text-white' color={lastSent ? 'text-performance-green' : undefined}
} icon={sending ? <Icon icon={Loader2} size={4} animate="spin" /> : lastSent ? undefined : <Icon icon={Bell} size={4} />}
disabled:opacity-50 disabled:cursor-not-allowed
`}
> >
{sending ? ( {sending ? 'Sending...' : lastSent ? '✓ Notification Sent!' : 'Send Demo Notification'}
<> </Button>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Sending...
</>
) : lastSent ? (
<>
Notification Sent!
</>
) : (
<>
<Bell className="w-4 h-4" />
Send Demo Notification
</>
)}
</button>
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline mt-2"> <Box p={3} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline" mt={2}>
<p className="text-[10px] text-gray-500"> <Text size="xs" color="text-gray-500" block>
<strong className="text-gray-400">Silent:</strong> Notification center only<br/> <Text weight="bold" color="text-gray-400">Silent:</Text> Notification center only
<strong className="text-gray-400">Toast:</strong> Temporary popup (auto-dismisses)<br/> </Text>
<strong className="text-gray-400">Modal:</strong> Blocking popup (may require action) <Text size="xs" color="text-gray-500" block>
</p> <Text weight="bold" color="text-gray-400">Toast:</Text> Temporary popup (auto-dismisses)
</div> </Text>
</div> <Text size="xs" color="text-gray-500" block>
<Text weight="bold" color="text-gray-400">Modal:</Text> Blocking popup (may require action)
</Text>
</Box>
</Box>
); );
} }

View File

@@ -1,13 +1,17 @@
'use client'; 'use client';
import { MessageSquare, AlertTriangle, Shield, Vote, TrendingUp, Award } from 'lucide-react'; import React from 'react';
import { MessageSquare, AlertTriangle, Shield, Vote, TrendingUp, Award, LucideIcon } from 'lucide-react';
import type { DemoNotificationType } from '../types'; import type { DemoNotificationType } from '../types';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface NotificationOption { interface NotificationOption {
type: DemoNotificationType; type: DemoNotificationType;
label: string; label: string;
description: string; description: string;
icon: any; icon: LucideIcon;
color: string; color: string;
} }
@@ -22,73 +26,85 @@ export const notificationOptions: NotificationOption[] = [
label: 'Protest Against You', label: 'Protest Against You',
description: 'A protest was filed against you', description: 'A protest was filed against you',
icon: AlertTriangle, icon: AlertTriangle,
color: 'text-red-400', color: 'rgb(239, 68, 68)',
}, },
{ {
type: 'defense_requested', type: 'defense_requested',
label: 'Defense Requested', label: 'Defense Requested',
description: 'A steward requests your defense', description: 'A steward requests your defense',
icon: Shield, icon: Shield,
color: 'text-warning-amber', color: 'rgb(245, 158, 11)',
}, },
{ {
type: 'vote_required', type: 'vote_required',
label: 'Vote Required', label: 'Vote Required',
description: 'You need to vote on a protest', description: 'You need to vote on a protest',
icon: Vote, icon: Vote,
color: 'text-primary-blue', color: 'rgb(59, 130, 246)',
}, },
{ {
type: 'race_performance_summary', type: 'race_performance_summary',
label: 'Race Performance Summary', label: 'Race Performance Summary',
description: 'Immediate results after main race', description: 'Immediate results after main race',
icon: TrendingUp, icon: TrendingUp,
color: 'text-primary-blue', color: 'rgb(59, 130, 246)',
}, },
{ {
type: 'race_final_results', type: 'race_final_results',
label: 'Race Final Results', label: 'Race Final Results',
description: 'Final results after stewarding closes', description: 'Final results after stewarding closes',
icon: Award, icon: Award,
color: 'text-warning-amber', color: 'rgb(245, 158, 11)',
}, },
]; ];
export function NotificationTypeSection({ selectedType, onSelectType }: NotificationTypeSectionProps) { export function NotificationTypeSection({ selectedType, onSelectType }: NotificationTypeSectionProps) {
return ( return (
<div> <Box>
<div className="flex items-center gap-2 mb-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<MessageSquare className="w-4 h-4 text-gray-400" /> <Icon icon={MessageSquare} size={4} color="rgb(156, 163, 175)" />
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide"> <Text size="xs" weight="semibold" color="text-gray-400" uppercase letterSpacing="wide">
Notification Type Notification Type
</span> </Text>
</div> </Box>
<div className="grid grid-cols-2 gap-1"> <Box display="grid" gridCols={2} gap={1}>
{notificationOptions.map((option) => { {notificationOptions.map((option) => {
const Icon = option.icon;
const isSelected = selectedType === option.type; const isSelected = selectedType === option.type;
return ( return (
<button <Box
key={option.type} key={option.type}
as="button"
type="button"
onClick={() => onSelectType(option.type)} onClick={() => onSelectType(option.type)}
className={` display="flex"
flex flex-col items-center gap-1 p-2 rounded-lg border transition-all text-center flexDirection="col"
${isSelected alignItems="center"
? 'bg-primary-blue/20 border-primary-blue/50' gap={1}
: 'bg-iron-gray/30 border-charcoal-outline hover:bg-iron-gray/50' p={2}
} rounded="lg"
`} border
transition
bg={isSelected ? 'bg-primary-blue/20' : 'bg-iron-gray/30'}
borderColor={isSelected ? 'border-primary-blue/50' : 'border-charcoal-outline'}
> >
<Icon className={`w-4 h-4 ${isSelected ? 'text-primary-blue' : option.color}`} /> <Icon
<span className={`text-[10px] font-medium ${isSelected ? 'text-primary-blue' : 'text-gray-400'}`}> icon={option.icon}
size={4}
color={isSelected ? 'rgb(59, 130, 246)' : option.color}
/>
<Text
size="xs"
weight="medium"
color={isSelected ? 'text-primary-blue' : 'text-gray-400'}
>
{option.label.split(' ')[0]} {option.label.split(' ')[0]}
</span> </Text>
</button> </Box>
); );
})} })}
</div> </Box>
</div> </Box>
); );
} }

View File

@@ -1,6 +1,14 @@
import { useState, useEffect } from 'react'; 'use client';
import React, { useState, useEffect } from 'react';
import { Play, Copy, Trash2, Download, Clock } from 'lucide-react'; import { Play, Copy, Trash2, Download, Clock } from 'lucide-react';
import { getGlobalReplaySystem } from '@/lib/infrastructure/ErrorReplay'; import { getGlobalReplaySystem } from '@/lib/infrastructure/ErrorReplay';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import { Button } from '@/ui/Button';
interface ReplayEntry { interface ReplayEntry {
id: string; id: string;
@@ -11,7 +19,6 @@ interface ReplayEntry {
export function ReplaySection() { export function ReplaySection() {
const [replays, setReplays] = useState<ReplayEntry[]>([]); const [replays, setReplays] = useState<ReplayEntry[]>([]);
const [selectedReplay, setSelectedReplay] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
@@ -78,83 +85,89 @@ export function ReplaySection() {
}; };
return ( return (
<div className="space-y-2"> <Stack gap={2}>
<div className="flex items-center justify-between"> <Box display="flex" alignItems="center" justifyContent="between">
<span className="text-xs font-semibold text-gray-400">Error Replay</span> <Text size="xs" weight="semibold" color="text-gray-400">Error Replay</Text>
<div className="flex gap-1"> <Box display="flex" gap={1}>
<button <IconButton
icon={Clock}
onClick={loadReplays} onClick={loadReplays}
className="p-1 hover:bg-charcoal-outline rounded" variant="ghost"
size="sm"
title="Refresh" title="Refresh"
> />
<Clock className="w-3 h-3 text-gray-400" /> <IconButton
</button> icon={Trash2}
<button
onClick={handleClearAll} onClick={handleClearAll}
className="p-1 hover:bg-charcoal-outline rounded" variant="ghost"
size="sm"
title="Clear All" title="Clear All"
> color="rgb(239, 68, 68)"
<Trash2 className="w-3 h-3 text-red-400" /> />
</button> </Box>
</div> </Box>
</div>
{replays.length === 0 ? ( {replays.length === 0 ? (
<div className="text-xs text-gray-500 text-center py-2"> <Box textAlign="center" py={2}>
No replays available <Text size="xs" color="text-gray-500">No replays available</Text>
</div> </Box>
) : ( ) : (
<div className="space-y-1 max-h-48 overflow-auto"> <Stack gap={1}>
{replays.map((replay) => ( {replays.map((replay) => (
<div <Box
key={replay.id} key={replay.id}
className="bg-deep-graphite border border-charcoal-outline rounded p-2 text-xs" bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="md"
p={2}
> >
<div className="flex items-start justify-between gap-2 mb-1"> <Box mb={1}>
<div className="flex-1 min-w-0"> <Text size="xs" font="mono" weight="bold" color="text-red-400" block truncate>
<div className="font-mono text-red-400 font-bold truncate"> {replay.type}
{replay.type} </Text>
</div> <Text size="xs" color="text-gray-300" block truncate>{replay.error}</Text>
<div className="text-gray-300 truncate">{replay.error}</div> <Text size="xs" color="text-gray-500" block>
<div className="text-gray-500 text-[10px]"> {new Date(replay.timestamp).toLocaleTimeString()}
{new Date(replay.timestamp).toLocaleTimeString()} </Text>
</div> </Box>
</div> <Box display="flex" gap={1} mt={1}>
</div> <Button
<div className="flex gap-1 mt-1"> variant="primary"
<button
onClick={() => handleReplay(replay.id)} onClick={() => handleReplay(replay.id)}
disabled={loading} disabled={loading}
className="flex items-center gap-1 px-2 py-1 bg-green-600 hover:bg-green-700 text-white rounded" size="sm"
icon={<Icon icon={Play} size={3} />}
> >
<Play className="w-3 h-3" />
Replay Replay
</button> </Button>
<button <Button
variant="secondary"
onClick={() => handleExport(replay.id)} onClick={() => handleExport(replay.id)}
className="flex items-center gap-1 px-2 py-1 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded border border-charcoal-outline" size="sm"
icon={<Icon icon={Download} size={3} />}
> >
<Download className="w-3 h-3" />
Export Export
</button> </Button>
<button <Button
variant="secondary"
onClick={() => handleCopy(replay.id)} onClick={() => handleCopy(replay.id)}
className="flex items-center gap-1 px-2 py-1 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded border border-charcoal-outline" size="sm"
icon={<Icon icon={Copy} size={3} />}
> >
<Copy className="w-3 h-3" />
Copy Copy
</button> </Button>
<button <IconButton
icon={Trash2}
onClick={() => handleDelete(replay.id)} onClick={() => handleDelete(replay.id)}
className="flex items-center gap-1 px-2 py-1 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded border border-charcoal-outline" variant="secondary"
> size="sm"
<Trash2 className="w-3 h-3" /> />
</button> </Box>
</div> </Box>
</div>
))} ))}
</div> </Stack>
)} )}
</div> </Stack>
); );
} }

View File

@@ -1,13 +1,17 @@
'use client'; 'use client';
import { Bell, BellRing, AlertCircle } from 'lucide-react'; import React from 'react';
import { Bell, BellRing, AlertCircle, LucideIcon } from 'lucide-react';
import type { DemoUrgency } from '../types'; import type { DemoUrgency } from '../types';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface UrgencyOption { interface UrgencyOption {
urgency: DemoUrgency; urgency: DemoUrgency;
label: string; label: string;
description: string; description: string;
icon: any; icon: LucideIcon;
} }
interface UrgencySectionProps { interface UrgencySectionProps {
@@ -38,62 +42,72 @@ export const urgencyOptions: UrgencyOption[] = [
export function UrgencySection({ selectedUrgency, onSelectUrgency }: UrgencySectionProps) { export function UrgencySection({ selectedUrgency, onSelectUrgency }: UrgencySectionProps) {
return ( return (
<div> <Box>
<div className="flex items-center gap-2 mb-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<BellRing className="w-4 h-4 text-gray-400" /> <Icon icon={BellRing} size={4} color="rgb(156, 163, 175)" />
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide"> <Text size="xs" weight="semibold" color="text-gray-400" uppercase letterSpacing="wide">
Urgency Level Urgency Level
</span> </Text>
</div> </Box>
<div className="grid grid-cols-3 gap-1"> <Box display="grid" gridCols={3} gap={1}>
{urgencyOptions.map((option) => { {urgencyOptions.map((option) => {
const Icon = option.icon;
const isSelected = selectedUrgency === option.urgency; const isSelected = selectedUrgency === option.urgency;
const getSelectedBg = () => {
if (option.urgency === 'modal') return 'bg-red-500/20';
if (option.urgency === 'toast') return 'bg-warning-amber/20';
return 'bg-gray-500/20';
};
const getSelectedBorder = () => {
if (option.urgency === 'modal') return 'border-red-500/50';
if (option.urgency === 'toast') return 'border-warning-amber/50';
return 'border-gray-500/50';
};
const getSelectedColor = () => {
if (option.urgency === 'modal') return 'rgb(239, 68, 68)';
if (option.urgency === 'toast') return 'rgb(245, 158, 11)';
return 'rgb(156, 163, 175)';
};
return ( return (
<button <Box
key={option.urgency} key={option.urgency}
as="button"
type="button"
onClick={() => onSelectUrgency(option.urgency)} onClick={() => onSelectUrgency(option.urgency)}
className={` display="flex"
flex flex-col items-center gap-1 p-2 rounded-lg border transition-all text-center flexDirection="col"
${isSelected alignItems="center"
? option.urgency === 'modal' gap={1}
? 'bg-red-500/20 border-red-500/50' p={2}
: option.urgency === 'toast' rounded="lg"
? 'bg-warning-amber/20 border-warning-amber/50' border
: 'bg-gray-500/20 border-gray-500/50' transition
: 'bg-iron-gray/30 border-charcoal-outline hover:bg-iron-gray/50' bg={isSelected ? getSelectedBg() : 'bg-iron-gray/30'}
} borderColor={isSelected ? getSelectedBorder() : 'border-charcoal-outline'}
`}
> >
<Icon className={`w-4 h-4 ${ <Icon
isSelected icon={option.icon}
? option.urgency === 'modal' size={4}
? 'text-red-400' color={isSelected ? getSelectedColor() : 'rgb(107, 114, 128)'}
: option.urgency === 'toast' />
? 'text-warning-amber' <Text
: 'text-gray-400' size="xs"
: 'text-gray-500' weight="medium"
}`} /> color={isSelected ? (option.urgency === 'modal' ? 'text-red-400' : option.urgency === 'toast' ? 'text-warning-amber' : 'text-gray-400') : 'text-gray-500'}
<span className={`text-[10px] font-medium ${ >
isSelected
? option.urgency === 'modal'
? 'text-red-400'
: option.urgency === 'toast'
? 'text-warning-amber'
: 'text-gray-400'
: 'text-gray-500'
}`}>
{option.label} {option.label}
</span> </Text>
</button> </Box>
); );
})} })}
</div> </Box>
<p className="text-[10px] text-gray-600 mt-1"> <Text size="xs" color="text-gray-600" mt={1} block>
{urgencyOptions.find(o => o.urgency === selectedUrgency)?.description} {urgencyOptions.find(o => o.urgency === selectedUrgency)?.description}
</p> </Text>
</div> </Box>
); );
} }

View File

@@ -1,4 +1,4 @@
import type { NotificationVariant } from '@/components/notifications/notificationTypes'; import type { LucideIcon } from 'lucide-react';
export type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required' | 'race_performance_summary' | 'race_final_results'; export type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required' | 'race_performance_summary' | 'race_final_results';
export type DemoUrgency = 'silent' | 'toast' | 'modal'; export type DemoUrgency = 'silent' | 'toast' | 'modal';
@@ -7,7 +7,7 @@ export interface NotificationOption {
type: DemoNotificationType; type: DemoNotificationType;
label: string; label: string;
description: string; description: string;
icon: any; icon: LucideIcon;
color: string; color: string;
} }
@@ -15,5 +15,5 @@ export interface UrgencyOption {
urgency: DemoUrgency; urgency: DemoUrgency;
label: string; label: string;
description: string; description: string;
icon: any; icon: LucideIcon;
} }

View File

@@ -1,127 +0,0 @@
'use client';
import Card from '../ui/Card';
interface Achievement {
id: string;
title: string;
description: string;
icon: string;
unlockedAt: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary';
}
const mockAchievements: Achievement[] = [
{ id: '1', title: 'First Victory', description: 'Won your first race', icon: '🏆', unlockedAt: '2024-03-15', rarity: 'common' },
{ id: '2', title: '10 Podiums', description: 'Achieved 10 podium finishes', icon: '🥈', unlockedAt: '2024-05-22', rarity: 'rare' },
{ id: '3', title: 'Clean Racer', description: 'Completed 25 races with 0 incidents', icon: '✨', unlockedAt: '2024-08-10', rarity: 'epic' },
{ id: '4', title: 'Comeback King', description: 'Won a race after starting P10 or lower', icon: '⚡', unlockedAt: '2024-09-03', rarity: 'rare' },
{ id: '5', title: 'Perfect Weekend', description: 'Pole, fastest lap, and win in same race', icon: '💎', unlockedAt: '2024-10-17', rarity: 'legendary' },
{ id: '6', title: 'Century Club', description: 'Completed 100 races', icon: '💯', unlockedAt: '2024-11-01', rarity: 'epic' },
];
const rarityColors = {
common: 'border-gray-500 bg-gray-500/10',
rare: 'border-blue-400 bg-blue-400/10',
epic: 'border-purple-400 bg-purple-400/10',
legendary: 'border-warning-amber bg-warning-amber/10'
};
export default function CareerHighlights() {
return (
<div className="space-y-6">
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Key Milestones</h3>
<div className="space-y-3">
<MilestoneItem
label="First Race"
value="March 15, 2024"
icon="🏁"
/>
<MilestoneItem
label="First Win"
value="March 15, 2024 (Imola)"
icon="🏆"
/>
<MilestoneItem
label="Highest Rating"
value="1487 (Nov 2024)"
icon="📈"
/>
<MilestoneItem
label="Longest Win Streak"
value="4 races (Oct 2024)"
icon="🔥"
/>
<MilestoneItem
label="Most Wins (Track)"
value="Spa-Francorchamps (7)"
icon="🗺️"
/>
<MilestoneItem
label="Favorite Car"
value="Porsche 911 GT3 R (45 races)"
icon="🏎️"
/>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Achievements</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{mockAchievements.map((achievement) => (
<div
key={achievement.id}
className={`p-4 rounded-lg border ${rarityColors[achievement.rarity]}`}
>
<div className="flex items-start gap-3">
<div className="text-3xl">{achievement.icon}</div>
<div className="flex-1">
<div className="text-white font-medium mb-1">{achievement.title}</div>
<div className="text-xs text-gray-400 mb-2">{achievement.description}</div>
<div className="text-xs text-gray-500">
{new Date(achievement.unlockedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</div>
</div>
</div>
</div>
))}
</div>
</Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">🎯</div>
<h3 className="text-lg font-semibold text-white">Next Goals</h3>
</div>
<div className="space-y-2 text-sm text-gray-400">
<div className="flex items-center justify-between">
<span>Win 25 races</span>
<span className="text-primary-blue">23/25</span>
</div>
<div className="w-full bg-deep-graphite rounded-full h-2">
<div className="bg-primary-blue rounded-full h-2" style={{ width: '92%' }} />
</div>
</div>
</Card>
</div>
);
}
function MilestoneItem({ label, value, icon }: { label: string; value: string; icon: string }) {
return (
<div className="flex items-center justify-between p-3 rounded bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-3">
<span className="text-xl">{icon}</span>
<span className="text-gray-400 text-sm">{label}</span>
</div>
<span className="text-white text-sm font-medium">{value}</span>
</div>
);
}

View File

@@ -1,69 +0,0 @@
'use client';
import { BarChart3 } from 'lucide-react';
const CATEGORIES = [
{ id: 'beginner', label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400', bgColor: 'bg-orange-400/10', borderColor: 'border-orange-400/30' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400', bgColor: 'bg-red-400/10', borderColor: 'border-red-400/30' },
];
interface CategoryDistributionProps {
drivers: {
category?: string;
}[];
}
export function CategoryDistribution({ drivers }: CategoryDistributionProps) {
const distribution = CATEGORIES.map((category) => ({
...category,
count: drivers.filter((d) => d.category === category.id).length,
percentage: drivers.length > 0
? Math.round((drivers.filter((d) => d.category === category.id).length / drivers.length) * 100)
: 0,
}));
return (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-400/10 border border-purple-400/20">
<BarChart3 className="w-5 h-5 text-purple-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Category Distribution</h2>
<p className="text-xs text-gray-500">Driver population by category</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{distribution.map((category) => (
<div
key={category.id}
className={`p-4 rounded-xl ${category.bgColor} border ${category.borderColor}`}
>
<div className="flex items-center justify-between mb-3">
<span className={`text-2xl font-bold ${category.color}`}>{category.count}</span>
</div>
<p className="text-white font-medium mb-1">{category.label}</p>
<div className="w-full h-2 rounded-full bg-deep-graphite/50 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
category.id === 'beginner' ? 'bg-green-400' :
category.id === 'intermediate' ? 'bg-primary-blue' :
category.id === 'advanced' ? 'bg-purple-400' :
category.id === 'pro' ? 'bg-yellow-400' :
category.id === 'endurance' ? 'bg-orange-400' :
'bg-red-400'
}`}
style={{ width: `${category.percentage}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">{category.percentage}% of drivers</p>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,11 +1,14 @@
'use client'; 'use client';
import { useState, FormEvent } from 'react'; import React, { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation'; import { Input } from '@/ui/Input';
import { routes } from '@/lib/routing/RouteConfig'; import { Button } from '@/ui/Button';
import Input from '../ui/Input'; import { Box } from '@/ui/Box';
import Button from '../ui/Button'; import { Text } from '@/ui/Text';
import { useCreateDriver } from "@/lib/hooks/driver/useCreateDriver"; import { Stack } from '@/ui/Stack';
import { TextArea } from '@/ui/TextArea';
import { InfoBox } from '@/ui/InfoBox';
import { AlertCircle } from 'lucide-react';
interface FormErrors { interface FormErrors {
name?: string; name?: string;
@@ -15,9 +18,12 @@ interface FormErrors {
submit?: string; submit?: string;
} }
export default function CreateDriverForm() { interface CreateDriverFormProps {
const router = useRouter(); onSuccess: () => void;
const createDriverMutation = useCreateDriver(); isPending: boolean;
}
export function CreateDriverForm({ onSuccess, isPending }: CreateDriverFormProps) {
const [errors, setErrors] = useState<FormErrors>({}); const [errors, setErrors] = useState<FormErrors>({});
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -50,7 +56,7 @@ export default function CreateDriverForm() {
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (createDriverMutation.isPending) return; if (isPending) return;
const isValid = await validateForm(); const isValid = await validateForm();
if (!isValid) return; if (!isValid) return;
@@ -61,118 +67,89 @@ export default function CreateDriverForm() {
const firstName = parts[0] ?? displayName; const firstName = parts[0] ?? displayName;
const lastName = parts.slice(1).join(' ') || 'Driver'; const lastName = parts.slice(1).join(' ') || 'Driver';
createDriverMutation.mutate( // Construct data for parent to handle
{ const driverData = {
firstName, firstName,
lastName, lastName,
displayName, displayName,
country: formData.country.trim().toUpperCase(), country: formData.country.trim().toUpperCase(),
...(bio ? { bio } : {}), ...(bio ? { bio } : {}),
}, };
{
onSuccess: () => { console.log('Driver data to create:', driverData);
router.push(routes.protected.profile); onSuccess();
router.refresh();
},
onError: (error) => {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create profile'
});
},
}
);
}; };
return ( return (
<> <Box as="form" onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} className="space-y-6"> <Stack gap={6}>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
Driver Name *
</label>
<Input <Input
label="Driver Name *"
id="name" id="name"
type="text" type="text"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name} variant={errors.name ? 'error' : 'default'}
errorMessage={errors.name} errorMessage={errors.name}
placeholder="Alex Vermeer" placeholder="Alex Vermeer"
disabled={createDriverMutation.isPending} disabled={isPending}
/> />
</div>
<div> <Box>
<label htmlFor="iracingId" className="block text-sm font-medium text-gray-300 mb-2"> <Input
Display Name * label="Country Code *"
</label> id="country"
<Input type="text"
id="name" value={formData.country}
type="text" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({ ...formData, country: e.target.value })}
value={formData.name} variant={errors.country ? 'error' : 'default'}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} errorMessage={errors.country}
error={!!errors.name} placeholder="NL"
errorMessage={errors.name} maxLength={3}
placeholder="Alex Vermeer" disabled={isPending}
disabled={createDriverMutation.isPending} />
/> <Text size="xs" color="text-gray-500" mt={1} block>Use ISO 3166-1 alpha-2 or alpha-3 code</Text>
</div> </Box>
<div> <Box>
<label htmlFor="country" className="block text-sm font-medium text-gray-300 mb-2"> <TextArea
Country Code * label="Bio (Optional)"
</label> id="bio"
<Input value={formData.bio}
id="country" onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData({ ...formData, bio: e.target.value })}
type="text" placeholder="Tell us about yourself..."
value={formData.country} maxLength={500}
onChange={(e) => setFormData({ ...formData, country: e.target.value })} rows={4}
error={!!errors.country} disabled={isPending}
errorMessage={errors.country} />
placeholder="NL" <Box display="flex" justifyContent="between" mt={1}>
maxLength={3} {errors.bio ? (
disabled={createDriverMutation.isPending} <Text size="sm" color="text-warning-amber">{errors.bio}</Text>
/> ) : <Box />}
<p className="mt-1 text-xs text-gray-500">Use ISO 3166-1 alpha-2 or alpha-3 code</p> <Text size="xs" color="text-gray-500">
</div> {formData.bio.length}/500
</Text>
</Box>
</Box>
<div> {errors.submit && (
<label htmlFor="bio" className="block text-sm font-medium text-gray-300 mb-2"> <InfoBox
Bio (Optional) variant="warning"
</label> icon={AlertCircle}
<textarea title="Error"
id="bio" description={errors.submit}
value={formData.bio} />
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
placeholder="Tell us about yourself..."
maxLength={500}
rows={4}
disabled={createDriverMutation.isPending}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.bio.length}/500
</p>
{errors.bio && (
<p className="mt-2 text-sm text-warning-amber">{errors.bio}</p>
)} )}
</div>
{errors.submit && ( <Button
<div className="rounded-md bg-warning-amber/10 p-4 border border-warning-amber/20"> type="submit"
<p className="text-sm text-warning-amber">{errors.submit}</p> variant="primary"
</div> disabled={isPending}
)} fullWidth
>
<Button {isPending ? 'Creating Profile...' : 'Create Profile'}
type="submit"
variant="primary"
disabled={createDriverMutation.isPending}
className="w-full"
>
{createDriverMutation.isPending ? 'Creating Profile...' : 'Create Profile'}
</Button> </Button>
</form> </Stack>
</> </Box>
); );
} }

View File

@@ -1,79 +0,0 @@
import Card from '@/ui/Card';
import RankBadge from '@/components/drivers/RankBadge';
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
export interface DriverCardProps {
id: string;
name: string;
rating: number;
skillLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
nationality: string;
racesCompleted: number;
wins: number;
podiums: number;
rank: number;
onClick?: () => void;
}
export default function DriverCard(props: DriverCardProps) {
const {
id,
name,
rating,
nationality,
racesCompleted,
wins,
podiums,
rank,
onClick,
} = props;
// Create a proper DriverViewModel instance
const driverViewModel = new DriverViewModel({
id,
name,
avatarUrl: null,
});
return (
<Card
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
{...(onClick ? { onClick } : {})}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<RankBadge rank={rank} size="lg" />
<DriverIdentity
driver={driverViewModel}
href={`/drivers/${id}`}
meta={`${nationality}${racesCompleted} races`}
size="md"
/>
</div>
<div className="flex items-center gap-8 text-center">
<div>
<div className="text-2xl font-bold text-primary-blue">{rating}</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
<div>
<div className="text-2xl font-bold text-green-400">{wins}</div>
<div className="text-xs text-gray-400">Wins</div>
</div>
<div>
<div className="text-2xl font-bold text-warning-amber">{podiums}</div>
<div className="text-xs text-gray-400">Podiums</div>
</div>
<div>
<div className="text-sm text-gray-400">
{racesCompleted > 0 ? ((wins / racesCompleted) * 100).toFixed(0) : '0'}%
</div>
<div className="text-xs text-gray-500">Win Rate</div>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -1,73 +0,0 @@
import Link from 'next/link';
import Image from 'next/image';
import PlaceholderImage from '@/ui/PlaceholderImage';
export interface DriverIdentityProps {
driver: {
id: string;
name: string;
avatarUrl: string | null;
};
href?: string;
contextLabel?: React.ReactNode;
meta?: React.ReactNode;
size?: 'sm' | 'md';
}
export function DriverIdentity(props: DriverIdentityProps) {
const { driver, href, contextLabel, meta, size = 'md' } = props;
const avatarSize = size === 'sm' ? 40 : 48;
const nameTextClasses =
size === 'sm'
? 'text-sm font-medium text-white'
: 'text-base md:text-lg font-semibold text-white';
const metaTextClasses = 'text-xs md:text-sm text-gray-400';
// Use provided avatar URL or show placeholder if null
const avatarUrl = driver.avatarUrl;
const content = (
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
<div
className={`rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0`}
style={{ width: avatarSize, height: avatarSize }}
>
{avatarUrl ? (
<Image
src={avatarUrl}
alt={driver.name}
width={avatarSize}
height={avatarSize}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={avatarSize} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className={`${nameTextClasses} truncate`}>{driver.name}</span>
{contextLabel ? (
<span className="inline-flex items-center rounded-full bg-charcoal-outline/60 px-2 py-0.5 text-[10px] md:text-xs font-medium text-gray-200">
{contextLabel}
</span>
) : null}
</div>
{meta ? <div className={`${metaTextClasses} mt-0.5 truncate`}>{meta}</div> : null}
</div>
</div>
);
if (href) {
return (
<Link href={href} className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
{content}
</Link>
);
}
return <div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">{content}</div>;
}

View File

@@ -1,14 +1,18 @@
'use client'; 'use client';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel'; import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { DriverProfileStatsViewModel } from '@/lib/view-models/DriverProfileViewModel'; import { Card } from '@/ui/Card';
import Card from '../ui/Card'; import { Box } from '@/ui/Box';
import ProfileHeader from '../profile/ProfileHeader'; import { Text } from '@/ui/Text';
import ProfileStats from './ProfileStats'; import { Heading } from '@/ui/Heading';
import CareerHighlights from './CareerHighlights'; import { Stack } from '@/ui/Stack';
import DriverRankings from './DriverRankings'; import { StatCard } from '@/ui/StatCard';
import PerformanceMetrics from './PerformanceMetrics'; import { ProfileHeader } from '@/ui/ProfileHeader';
import { useDriverProfile } from "@/lib/hooks/driver/useDriverProfile"; import { ProfileStats } from './ProfileStats';
import { CareerHighlights } from '@/ui/CareerHighlights';
import { DriverRankings } from '@/ui/DriverRankings';
import { PerformanceMetrics } from '@/ui/PerformanceMetrics';
import { useDriverProfile } from "@/hooks/driver/useDriverProfile";
interface DriverProfileProps { interface DriverProfileProps {
driver: DriverViewModel; driver: DriverViewModel;
@@ -23,8 +27,8 @@ interface DriverTeamViewModel {
}; };
} }
export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) { export function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
const { data: profileData, isLoading } = useDriverProfile(driver.id); const { data: profileData } = useDriverProfile(driver.id);
// Extract team data from profile // Extract team data from profile
const teamData: DriverTeamViewModel | null = (() => { const teamData: DriverTeamViewModel | null = (() => {
@@ -32,7 +36,7 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
return null; return null;
} }
const currentTeam = profileData.teamMemberships.find(m => m.isCurrent) || profileData.teamMemberships[0]; const currentTeam = profileData.teamMemberships.find((m: { isCurrent: boolean }) => m.isCurrent) || profileData.teamMemberships[0];
if (!currentTeam) { if (!currentTeam) {
return null; return null;
} }
@@ -71,7 +75,7 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
] : []; ] : [];
return ( return (
<div className="space-y-6"> <Stack gap={6}>
<Card> <Card>
<ProfileHeader <ProfileHeader
driver={driver} driver={driver}
@@ -86,48 +90,50 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
{driver.bio && ( {driver.bio && (
<Card> <Card>
<h3 className="text-lg font-semibold text-white mb-4">About</h3> <Heading level={3} mb={4}>About</Heading>
<p className="text-gray-300 leading-relaxed">{driver.bio}</p> <Text color="text-gray-300" leading="relaxed" block>{driver.bio}</Text>
</Card> </Card>
)} )}
{driverStats && ( {driverStats && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={6}>
<div className="lg:col-span-2 space-y-6"> <Box responsiveColSpan={{ lg: 2 }}>
<Card> <Stack gap={6}>
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3> <Card>
<div className="grid grid-cols-2 gap-4"> <Heading level={3} mb={4}>Career Statistics</Heading>
<StatCard <Box display="grid" gridCols={2} gap={4}>
label="Rating" <StatCard
value={(driverStats.rating ?? 0).toString()} label="Rating"
color="text-primary-blue" value={driverStats.rating ?? 0}
/> variant="blue"
<StatCard label="Total Races" value={driverStats.totalRaces.toString()} color="text-white" /> />
<StatCard label="Wins" value={driverStats.wins.toString()} color="text-green-400" /> <StatCard label="Total Races" value={driverStats.totalRaces} variant="blue" />
<StatCard label="Podiums" value={driverStats.podiums.toString()} color="text-warning-amber" /> <StatCard label="Wins" value={driverStats.wins} variant="green" />
</div> <StatCard label="Podiums" value={driverStats.podiums} variant="orange" />
</Card> </Box>
</Card>
{performanceStats && <PerformanceMetrics stats={performanceStats} />} {performanceStats && <PerformanceMetrics stats={performanceStats} />}
</div> </Stack>
</Box>
<DriverRankings rankings={rankings} /> <DriverRankings rankings={rankings} />
</div> </Box>
)} )}
{!driverStats && ( {!driverStats && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={6}>
<Card className="lg:col-span-3"> <Card responsiveColSpan={{ lg: 3 }}>
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3> <Heading level={3} mb={4}>Career Statistics</Heading>
<p className="text-gray-400 text-sm"> <Text color="text-gray-400" size="sm" block>
No statistics available yet. Compete in races to start building your record. No statistics available yet. Compete in races to start building your record.
</p> </Text>
</Card> </Card>
</div> </Box>
)} )}
<Card> <Card>
<h3 className="text-lg font-semibold text-white mb-4">Performance by Class</h3> <Heading level={3} mb={4}>Performance by Class</Heading>
{driverStats && ( {driverStats && (
<ProfileStats <ProfileStats
stats={{ stats={{
@@ -147,34 +153,25 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
<CareerHighlights /> <CareerHighlights />
<Card className="bg-charcoal-200/50 border-primary-blue/30"> <Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
<div className="flex items-center gap-3 mb-3"> <Box display="flex" alignItems="center" gap={3} mb={3}>
<div className="text-2xl">🔒</div> <Text size="2xl">🔒</Text>
<h3 className="text-lg font-semibold text-white">Private Information</h3> <Heading level={3}>Private Information</Heading>
</div> </Box>
<p className="text-gray-400 text-sm"> <Text color="text-gray-400" size="sm" block>
Detailed race history, settings, and preferences are only visible to the driver. Detailed race history, settings, and preferences are only visible to the driver.
</p> </Text>
</Card> </Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30"> <Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
<div className="flex items-center gap-3 mb-3"> <Box display="flex" alignItems="center" gap={3} mb={3}>
<div className="text-2xl">📊</div> <Text size="2xl">📊</Text>
<h3 className="text-lg font-semibold text-white">Coming Soon</h3> <Heading level={3}>Coming Soon</Heading>
</div> </Box>
<p className="text-gray-400 text-sm"> <Text color="text-gray-400" size="sm" block>
Per-car statistics, per-track performance, and head-to-head comparisons will be available in production. Per-car statistics, per-track performance, and head-to-head comparisons will be available in production.
</p> </Text>
</Card> </Card>
</div> </Stack>
); );
} }
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="text-center p-4 rounded bg-deep-graphite border border-charcoal-outline">
<div className="text-sm text-gray-400 mb-1">{label}</div>
<div className={`text-2xl font-bold ${color}`}>{value}</div>
</div>
);
}

View File

@@ -1,81 +0,0 @@
import Card from '@/ui/Card';
export interface DriverRanking {
type: 'overall' | 'league';
name: string;
rank: number;
totalDrivers: number;
percentile: number;
rating: number;
}
interface DriverRankingsProps {
rankings: DriverRanking[];
}
export default function DriverRankings({ rankings }: DriverRankingsProps) {
if (!rankings || rankings.length === 0) {
return (
<Card className="bg-iron-gray/60 border-charcoal-outline/80">
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-2">Rankings</h3>
<p className="text-sm text-gray-400">
No ranking data available yet. Compete in leagues to earn your first results.
</p>
</div>
</Card>
);
}
return (
<Card className="bg-iron-gray/60 border-charcoal-outline/80">
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Rankings</h3>
<div className="space-y-3">
{rankings.map((ranking, index) => (
<div
key={`${ranking.type}-${ranking.name}-${index}`}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-deep-graphite/60"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
{ranking.name}
</span>
<span className="text-xs text-gray-400">
{ranking.type === 'overall' ? 'Overall' : 'League'} ranking
</span>
</div>
<div className="flex items-center gap-6 text-right text-xs">
<div>
<div className="text-primary-blue text-base font-semibold">
#{ranking.rank}
</div>
<div className="text-gray-500">Position</div>
</div>
<div>
<div className="text-white text-sm font-semibold">
{ranking.totalDrivers}
</div>
<div className="text-gray-500">Drivers</div>
</div>
<div>
<div className="text-green-400 text-sm font-semibold">
{ranking.percentile.toFixed(1)}%
</div>
<div className="text-gray-500">Percentile</div>
</div>
<div>
<div className="text-warning-amber text-sm font-semibold">
{ranking.rating}
</div>
<div className="text-gray-500">Rating</div>
</div>
</div>
</div>
))}
</div>
</div>
</Card>
);
}

View File

@@ -1,120 +0,0 @@
'use client';
import { Trophy, Crown, Star, TrendingUp, Shield, Flag } from 'lucide-react';
import Image from 'next/image';
import { mediaConfig } from '@/lib/config/mediaConfig';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', icon: Crown, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30', description: 'Elite competition level' },
{ id: 'advanced', label: 'Advanced', icon: Star, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30', description: 'Highly competitive' },
{ id: 'intermediate', label: 'Intermediate', icon: TrendingUp, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30', description: 'Developing skills' },
{ id: 'beginner', label: 'Beginner', icon: Shield, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30', description: 'Learning the ropes' },
];
const CATEGORIES = [
{ id: 'beginner', label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400', bgColor: 'bg-orange-400/10', borderColor: 'border-orange-400/30' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400', bgColor: 'bg-red-400/10', borderColor: 'border-red-400/30' },
];
interface FeaturedDriverCardProps {
driver: {
id: string;
name: string;
nationality: string;
avatarUrl?: string;
rating: number;
wins: number;
podiums: number;
skillLevel?: string;
category?: string;
};
position: number;
onClick: () => void;
}
export function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardProps) {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
const getBorderColor = (pos: number) => {
switch (pos) {
case 1: return 'border-yellow-400/50 hover:border-yellow-400';
case 2: return 'border-gray-300/50 hover:border-gray-300';
case 3: return 'border-amber-600/50 hover:border-amber-600';
default: return 'border-charcoal-outline hover:border-primary-blue';
}
};
const getMedalColor = (pos: number) => {
switch (pos) {
case 1: return 'text-yellow-400';
case 2: return 'text-gray-300';
case 3: return 'text-amber-600';
default: return 'text-gray-500';
}
};
return (
<button
type="button"
onClick={onClick}
className={`p-5 rounded-xl bg-iron-gray/60 border-2 ${getBorderColor(position)} transition-all duration-200 text-left group hover:scale-[1.02]`}
>
{/* Header with Position */}
<div className="flex items-start justify-between mb-4">
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${position <= 3 ? 'bg-gradient-to-br from-yellow-400/20 to-amber-600/10' : 'bg-iron-gray'}`}>
{position <= 3 ? (
<Crown className={`w-5 h-5 ${getMedalColor(position)}`} />
) : (
<span className="text-lg font-bold text-gray-400">#{position}</span>
)}
</div>
<div className="flex gap-2">
{categoryConfig && (
<span className={`px-2 py-1 rounded-full text-[10px] font-medium ${categoryConfig.bgColor} ${categoryConfig.color} border ${categoryConfig.borderColor}`}>
{categoryConfig.label}
</span>
)}
<span className={`px-2 py-1 rounded-full text-[10px] font-medium ${levelConfig?.bgColor} ${levelConfig?.color} border ${levelConfig?.borderColor}`}>
{levelConfig?.label}
</span>
</div>
</div>
{/* Avatar & Name */}
<div className="flex items-center gap-4 mb-4">
<div className="relative w-16 h-16 rounded-full overflow-hidden border-2 border-charcoal-outline group-hover:border-primary-blue transition-colors">
<Image src={driver.avatarUrl || mediaConfig.avatars.defaultFallback} alt={driver.name} fill className="object-cover" />
</div>
<div>
<h3 className="text-lg font-semibold text-white group-hover:text-primary-blue transition-colors">
{driver.name}
</h3>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Flag className="w-3.5 h-3.5" />
{driver.nationality}
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
<p className="text-lg font-bold text-primary-blue">{driver.rating.toLocaleString()}</p>
<p className="text-[10px] text-gray-500">Rating</p>
</div>
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
<p className="text-lg font-bold text-performance-green">{driver.wins}</p>
<p className="text-[10px] text-gray-500">Wins</p>
</div>
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
<p className="text-lg font-bold text-warning-amber">{driver.podiums}</p>
<p className="text-[10px] text-gray-500">Podiums</p>
</div>
</div>
</button>
);
}

View File

@@ -1,140 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { Award, Crown, Flag, ChevronRight } from 'lucide-react';
import Image from 'next/image';
import Button from '@/ui/Button';
import { mediaConfig } from '@/lib/config/mediaConfig';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
];
const CATEGORIES = [
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400' },
];
interface LeaderboardPreviewProps {
drivers: {
id: string;
name: string;
avatarUrl?: string;
nationality: string;
rating: number;
wins: number;
skillLevel?: string;
category?: string;
}[];
onDriverClick: (id: string) => void;
}
export function LeaderboardPreview({ drivers, onDriverClick }: LeaderboardPreviewProps) {
const router = useRouter();
const top5 = drivers.slice(0, 5);
const getMedalColor = (position: number) => {
switch (position) {
case 1: return 'text-yellow-400';
case 2: return 'text-gray-300';
case 3: return 'text-amber-600';
default: return 'text-gray-500';
}
};
const getMedalBg = (position: number) => {
switch (position) {
case 1: return 'bg-yellow-400/10 border-yellow-400/30';
case 2: return 'bg-gray-300/10 border-gray-300/30';
case 3: return 'bg-amber-600/10 border-amber-600/30';
default: return 'bg-iron-gray/50 border-charcoal-outline';
}
};
return (
<div className="mb-10">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
<Award className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Top Drivers</h2>
<p className="text-xs text-gray-500">Highest rated competitors</p>
</div>
</div>
<Button
variant="secondary"
onClick={() => router.push('/leaderboards/drivers')}
className="flex items-center gap-2 text-sm"
>
Full Rankings
<ChevronRight className="w-4 h-4" />
</Button>
</div>
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
<div className="divide-y divide-charcoal-outline/50">
{top5.map((driver, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
const position = index + 1;
return (
<button
key={driver.id}
type="button"
onClick={() => onDriverClick(driver.id)}
className="flex items-center gap-4 px-4 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
</div>
{/* Avatar */}
<div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={driver.avatarUrl || mediaConfig.avatars.defaultFallback} alt={driver.name} fill className="object-cover" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate group-hover:text-primary-blue transition-colors">
{driver.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Flag className="w-3 h-3" />
{driver.nationality}
{categoryConfig && (
<span className={categoryConfig.color}>{categoryConfig.label}</span>
)}
<span className={levelConfig?.color}>{levelConfig?.label}</span>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="text-primary-blue font-mono font-semibold">{driver.rating.toLocaleString()}</p>
<p className="text-[10px] text-gray-500">Rating</p>
</div>
<div className="text-center">
<p className="text-performance-green font-mono font-semibold">{driver.wins}</p>
<p className="text-[10px] text-gray-500">Wins</p>
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,82 +0,0 @@
'use client';
import Card from '../ui/Card';
interface PerformanceMetricsProps {
stats: {
winRate: number;
podiumRate: number;
dnfRate: number;
avgFinish: number;
consistency: number;
bestFinish: number;
worstFinish: number;
};
}
export default function PerformanceMetrics({ stats }: PerformanceMetricsProps) {
const getPerformanceColor = (value: number, type: 'rate' | 'finish' | 'consistency') => {
if (type === 'rate') {
if (value >= 30) return 'text-green-400';
if (value >= 15) return 'text-warning-amber';
return 'text-gray-300';
}
if (type === 'consistency') {
if (value >= 80) return 'text-green-400';
if (value >= 60) return 'text-warning-amber';
return 'text-gray-300';
}
return 'text-white';
};
const metrics = [
{
label: 'Win Rate',
value: `${stats.winRate.toFixed(1)}%`,
color: getPerformanceColor(stats.winRate, 'rate'),
icon: '🏆'
},
{
label: 'Podium Rate',
value: `${stats.podiumRate.toFixed(1)}%`,
color: getPerformanceColor(stats.podiumRate, 'rate'),
icon: '🥇'
},
{
label: 'DNF Rate',
value: `${stats.dnfRate.toFixed(1)}%`,
color: stats.dnfRate < 10 ? 'text-green-400' : 'text-danger-red',
icon: '❌'
},
{
label: 'Avg Finish',
value: stats.avgFinish.toFixed(1),
color: 'text-white',
icon: '📊'
},
{
label: 'Consistency',
value: `${stats.consistency.toFixed(0)}%`,
color: getPerformanceColor(stats.consistency, 'consistency'),
icon: '🎯'
},
{
label: 'Best / Worst',
value: `${stats.bestFinish} / ${stats.worstFinish}`,
color: 'text-gray-300',
icon: '📈'
}
];
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{metrics.map((metric, index) => (
<Card key={index} className="text-center">
<div className="text-2xl mb-2">{metric.icon}</div>
<div className="text-sm text-gray-400 mb-1">{metric.label}</div>
<div className={`text-xl font-bold ${metric.color}`}>{metric.value}</div>
</Card>
))}
</div>
);
}

View File

@@ -1,14 +1,21 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Card from '../ui/Card'; import { Card } from '@/ui/Card';
import Button from '../ui/Button'; import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { EmptyState } from '@/ui/EmptyState';
import { Pagination } from '@/ui/Pagination';
import { Trophy } from 'lucide-react';
interface RaceHistoryProps { interface RaceHistoryProps {
driverId: string; driverId: string;
} }
export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) { export function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
const [filter, setFilter] = useState<'all' | 'wins' | 'podiums'>('all'); const [filter, setFilter] = useState<'all' | 'wins' | 'podiums'>('all');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -32,94 +39,72 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
const filteredResults: Array<unknown> = []; const filteredResults: Array<unknown> = [];
const totalPages = Math.ceil(filteredResults.length / resultsPerPage); const totalPages = Math.ceil(filteredResults.length / resultsPerPage);
const paginatedResults = filteredResults.slice(
(page - 1) * resultsPerPage,
page * resultsPerPage
);
if (loading) { if (loading) {
return ( return (
<div className="space-y-4"> <Stack gap={4}>
<div className="flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2}>
{[1, 2, 3].map(i => ( {[1, 2, 3].map(i => (
<div key={i} className="h-9 w-24 bg-iron-gray rounded animate-pulse" /> <Box key={i} h="9" w="24" bg="bg-iron-gray" rounded="md" animate="pulse" />
))} ))}
</div> </Box>
<Card> <Card>
<div className="space-y-2"> <LoadingWrapper variant="skeleton" skeletonCount={3} />
{[1, 2, 3].map(i => (
<div key={i} className="h-20 bg-deep-graphite rounded animate-pulse" />
))}
</div>
</Card> </Card>
</div> </Stack>
); );
} }
if (filteredResults.length === 0) { if (filteredResults.length === 0) {
return ( return (
<Card className="text-center py-12"> <EmptyState
<p className="text-gray-400 mb-2">No race history yet</p> icon={Trophy}
<p className="text-sm text-gray-500">Complete races to build your racing record</p> title="No race history yet"
</Card> description="Complete races to build your racing record"
/>
); );
} }
return ( return (
<div className="space-y-4"> <Stack gap={4}>
<div className="flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2}>
<Button <Button
variant={filter === 'all' ? 'primary' : 'secondary'} variant={filter === 'all' ? 'primary' : 'secondary'}
onClick={() => { setFilter('all'); setPage(1); }} onClick={() => { setFilter('all'); setPage(1); }}
className="text-sm" size="sm"
> >
All Races All Races
</Button> </Button>
<Button <Button
variant={filter === 'wins' ? 'primary' : 'secondary'} variant={filter === 'wins' ? 'primary' : 'secondary'}
onClick={() => { setFilter('wins'); setPage(1); }} onClick={() => { setFilter('wins'); setPage(1); }}
className="text-sm" size="sm"
> >
Wins Only Wins Only
</Button> </Button>
<Button <Button
variant={filter === 'podiums' ? 'primary' : 'secondary'} variant={filter === 'podiums' ? 'primary' : 'secondary'}
onClick={() => { setFilter('podiums'); setPage(1); }} onClick={() => { setFilter('podiums'); setPage(1); }}
className="text-sm" size="sm"
> >
Podiums Podiums
</Button> </Button>
</div> </Box>
<Card> <Card>
<div className="space-y-2"> {/* No results until API provides driver results */}
{/* No results until API provides driver results */} <Box minHeight="100px" display="flex" center>
</div> <Text color="text-gray-500">No results found for the selected filter.</Text>
</Box>
{totalPages > 1 && ( <Pagination
<div className="flex items-center justify-center gap-2 mt-4 pt-4 border-t border-charcoal-outline"> currentPage={page}
<Button totalPages={totalPages}
variant="secondary" totalItems={filteredResults.length}
onClick={() => setPage(p => Math.max(1, p - 1))} itemsPerPage={resultsPerPage}
disabled={page === 1} onPageChange={setPage}
className="text-sm" />
>
Previous
</Button>
<span className="text-gray-400 text-sm">
Page {page} of {totalPages}
</span>
<Button
variant="secondary"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="text-sm"
>
Next
</Button>
</div>
)}
</Card> </Card>
</div> </Stack>
); );
} }

View File

@@ -2,16 +2,23 @@
import { useState } from 'react'; import { useState } from 'react';
import type { DriverProfileDriverSummaryViewModel } from '@/lib/view-models/DriverProfileViewModel'; import type { DriverProfileDriverSummaryViewModel } from '@/lib/view-models/DriverProfileViewModel';
import Card from '../ui/Card'; import { Card } from '@/ui/Card';
import Button from '../ui/Button'; import { Button } from '@/ui/Button';
import Input from '../ui/Input'; import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Select } from '@/ui/Select';
import { Toggle } from '@/ui/Toggle';
import { TextArea } from '@/ui/TextArea';
import { Checkbox } from '@/ui/Checkbox';
interface ProfileSettingsProps { interface ProfileSettingsProps {
driver: DriverProfileDriverSummaryViewModel; driver: DriverProfileDriverSummaryViewModel;
onSave?: (updates: { bio?: string; country?: string }) => void; onSave?: (updates: { bio?: string; country?: string }) => void;
} }
export default function ProfileSettings({ driver, onSave }: ProfileSettingsProps) { export function ProfileSettings({ driver, onSave }: ProfileSettingsProps) {
const [bio, setBio] = useState(driver.bio || ''); const [bio, setBio] = useState(driver.bio || '');
const [nationality, setNationality] = useState(driver.country); const [nationality, setNationality] = useState(driver.country);
const [favoriteCarClass, setFavoriteCarClass] = useState('GT3'); const [favoriteCarClass, setFavoriteCarClass] = useState('GT3');
@@ -27,147 +34,122 @@ export default function ProfileSettings({ driver, onSave }: ProfileSettingsProps
}; };
return ( return (
<div className="space-y-6"> <Stack gap={6}>
<Card> <Card>
<h3 className="text-lg font-semibold text-white mb-4">Profile Information</h3> <Heading level={3} mb={4}>Profile Information</Heading>
<div className="space-y-4"> <Stack gap={4}>
<div> <TextArea
<label className="block text-sm text-gray-400 mb-2">Bio</label> label="Bio"
<textarea value={bio}
value={bio} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setBio(e.target.value)}
onChange={(e) => setBio(e.target.value)} rows={4}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent resize-none" placeholder="Tell us about yourself..."
rows={4} />
placeholder="Tell us about yourself..."
/>
</div>
<div> <Input
<label className="block text-sm text-gray-400 mb-2">Nationality</label> label="Nationality"
<Input type="text"
type="text" value={nationality}
value={nationality} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNationality(e.target.value)}
onChange={(e) => setNationality(e.target.value)} placeholder="e.g., US, GB, DE"
placeholder="e.g., US, GB, DE" maxLength={2}
maxLength={2} />
/> </Stack>
</div>
</div>
</Card> </Card>
<Card> <Card>
<h3 className="text-lg font-semibold text-white mb-4">Racing Preferences</h3> <Heading level={3} mb={4}>Racing Preferences</Heading>
<div className="space-y-4"> <Stack gap={4}>
<div> <Select
<label className="block text-sm text-gray-400 mb-2">Favorite Car Class</label> label="Favorite Car Class"
<select value={favoriteCarClass}
value={favoriteCarClass} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFavoriteCarClass(e.target.value)}
onChange={(e) => setFavoriteCarClass(e.target.value)} options={[
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent" { value: 'GT3', label: 'GT3' },
> { value: 'GT4', label: 'GT4' },
<option value="GT3">GT3</option> { value: 'Formula', label: 'Formula' },
<option value="GT4">GT4</option> { value: 'LMP2', label: 'LMP2' },
<option value="Formula">Formula</option> { value: 'Touring', label: 'Touring Cars' },
<option value="LMP2">LMP2</option> { value: 'NASCAR', label: 'NASCAR' },
<option value="Touring">Touring Cars</option> ]}
<option value="NASCAR">NASCAR</option> />
</select>
</div>
<div> <Select
<label className="block text-sm text-gray-400 mb-2">Favorite Series Type</label> label="Favorite Series Type"
<select value={favoriteSeries}
value={favoriteSeries} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFavoriteSeries(e.target.value)}
onChange={(e) => setFavoriteSeries(e.target.value)} options={[
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent" { value: 'Sprint', label: 'Sprint' },
> { value: 'Endurance', label: 'Endurance' },
<option value="Sprint">Sprint</option> { value: 'Mixed', label: 'Mixed' },
<option value="Endurance">Endurance</option> ]}
<option value="Mixed">Mixed</option> />
</select>
</div>
<div> <Select
<label className="block text-sm text-gray-400 mb-2">Competitive Level</label> label="Competitive Level"
<select value={competitiveLevel}
value={competitiveLevel} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setCompetitiveLevel(e.target.value)}
onChange={(e) => setCompetitiveLevel(e.target.value)} options={[
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent" { value: 'casual', label: 'Casual - Just for fun' },
> { value: 'competitive', label: 'Competitive - Aiming to win' },
<option value="casual">Casual - Just for fun</option> { value: 'professional', label: 'Professional - Esports focused' },
<option value="competitive">Competitive - Aiming to win</option> ]}
<option value="professional">Professional - Esports focused</option> />
</select>
</div>
<div> <Stack gap={2}>
<label className="block text-sm text-gray-400 mb-2">Preferred Regions</label> <Heading level={4}>Preferred Regions</Heading>
<div className="space-y-2"> <Stack gap={2}>
{['NA', 'EU', 'ASIA', 'OCE'].map(region => ( {['NA', 'EU', 'ASIA', 'OCE'].map(region => (
<label key={region} className="flex items-center gap-2 cursor-pointer"> <Checkbox
<input key={region}
type="checkbox" label={region}
checked={preferredRegions.includes(region)} checked={preferredRegions.includes(region)}
onChange={(e) => { onChange={(checked) => {
if (e.target.checked) { if (checked) {
setPreferredRegions([...preferredRegions, region]); setPreferredRegions([...preferredRegions, region]);
} else { } else {
setPreferredRegions(preferredRegions.filter(r => r !== region)); setPreferredRegions(preferredRegions.filter(r => r !== region));
} }
}} }}
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue" />
/>
<span className="text-white text-sm">{region}</span>
</label>
))} ))}
</div> </Stack>
</div> </Stack>
</div> </Stack>
</Card> </Card>
<Card> <Card>
<h3 className="text-lg font-semibold text-white mb-4">Privacy Settings</h3> <Heading level={3} mb={4}>Privacy Settings</Heading>
<div className="space-y-3"> <Stack gap={0}>
<label className="flex items-center justify-between cursor-pointer"> <Toggle
<span className="text-white text-sm">Show profile to other drivers</span> checked={true}
<input onChange={() => {}}
type="checkbox" label="Show profile to other drivers"
defaultChecked />
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue" <Toggle
/> checked={true}
</label> onChange={() => {}}
label="Show race history"
<label className="flex items-center justify-between cursor-pointer"> />
<span className="text-white text-sm">Show race history</span> <Toggle
<input checked={true}
type="checkbox" onChange={() => {}}
defaultChecked label="Allow friend requests"
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue" />
/> </Stack>
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-white text-sm">Allow friend requests</span>
<input
type="checkbox"
defaultChecked
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
/>
</label>
</div>
</Card> </Card>
<div className="flex gap-3"> <Box display="flex" gap={3}>
<Button variant="primary" onClick={handleSave} className="flex-1"> <Button variant="primary" onClick={handleSave} fullWidth>
Save Changes Save Changes
</Button> </Button>
<Button variant="secondary" className="flex-1"> <Button variant="secondary" fullWidth>
Cancel Cancel
</Button> </Button>
</div> </Box>
</div> </Stack>
); );
} }

View File

@@ -1,9 +1,14 @@
'use client'; 'use client';
import { useDriverProfile } from "@/lib/hooks/driver"; import { useDriverProfile } from "@/hooks/driver/useDriverProfile";
import { useMemo } from 'react'; import { useMemo } from 'react';
import Card from '../ui/Card'; import { Card } from '@/ui/Card';
import RankBadge from './RankBadge'; import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { StatCard } from '@/ui/StatCard';
import { RankBadge } from '@/ui/RankBadge';
interface ProfileStatsProps { interface ProfileStatsProps {
driverId?: string; driverId?: string;
@@ -17,15 +22,12 @@ interface ProfileStatsProps {
}; };
} }
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) { export function ProfileStats({ driverId, stats }: ProfileStatsProps) {
const { data: profileData } = useDriverProfile(driverId ?? ''); const { data: profileData } = useDriverProfile(driverId ?? '');
const driverStats = profileData?.stats ?? null; const driverStats = profileData?.stats ?? null;
const totalDrivers = profileData?.currentDriver?.totalDrivers ?? 0; const totalDrivers = profileData?.currentDriver?.totalDrivers ?? 0;
// League rank widget needs a dedicated API contract; keep it disabled until provided.
// (Leaving UI block out avoids `never` typing issues.)
const defaultStats = useMemo(() => { const defaultStats = useMemo(() => {
if (stats) { if (stats) {
return stats; return stats;
@@ -78,132 +80,102 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
}; };
return ( return (
<div className="space-y-6"> <Stack gap={6}>
{driverStats && ( {driverStats && (
<Card> <Card>
<h3 className="text-xl font-semibold text-white mb-6">Rankings Dashboard</h3> <Heading level={2} mb={6}>Rankings Dashboard</Heading>
<div className="space-y-4"> <Stack gap={4}>
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"> <Box p={4} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline">
<div className="flex items-center justify-between mb-3"> <Box display="flex" alignItems="center" justifyContent="between" mb={3}>
<div className="flex items-center gap-3"> <Box display="flex" alignItems="center" gap={3}>
<RankBadge rank={driverStats.overallRank ?? 0} size="lg" /> <RankBadge rank={driverStats.overallRank ?? 0} size="lg" />
<div> <Box>
<div className="text-white font-medium text-lg">Overall Ranking</div> <Text color="text-white" weight="medium" size="lg" block>Overall Ranking</Text>
<div className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400" block>
{driverStats.overallRank ?? 0} of {totalDrivers} drivers {driverStats.overallRank ?? 0} of {totalDrivers} drivers
</div> </Text>
</div> </Box>
</div> </Box>
<div className="text-right"> <Box textAlign="right">
<div <Text
className={`text-sm font-medium ${getPercentileColor(driverStats.percentile ?? 0)}`} size="sm"
weight="medium"
color={getPercentileColor(driverStats.percentile ?? 0)}
block
> >
{getPercentileLabel(driverStats.percentile ?? 0)} {getPercentileLabel(driverStats.percentile ?? 0)}
</div> </Text>
<div className="text-xs text-gray-500">Global Percentile</div> <Text size="xs" color="text-gray-500" block>Global Percentile</Text>
</div> </Box>
</div> </Box>
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-charcoal-outline"> <Box display="grid" gridCols={3} gap={4} pt={3} borderTop borderColor="border-charcoal-outline">
<div className="text-center"> <Box textAlign="center">
<div className="text-2xl font-bold text-primary-blue"> <Text size="2xl" weight="bold" color="text-primary-blue" block>
{driverStats.rating ?? 0} {driverStats.rating ?? 0}
</div> </Text>
<div className="text-xs text-gray-400">Rating</div> <Text size="xs" color="text-gray-400" block>Rating</Text>
</div> </Box>
<div className="text-center"> <Box textAlign="center">
<div className="text-lg font-bold text-green-400"> <Text size="lg" weight="bold" color="text-green-400" block>
{getTrendIndicator(5)} {winRate}% {getTrendIndicator(5)} {winRate}%
</div> </Text>
<div className="text-xs text-gray-400">Win Rate</div> <Text size="xs" color="text-gray-400" block>Win Rate</Text>
</div> </Box>
<div className="text-center"> <Box textAlign="center">
<div className="text-lg font-bold text-warning-amber"> <Text size="lg" weight="bold" color="text-warning-amber" block>
{getTrendIndicator(2)} {podiumRate}% {getTrendIndicator(2)} {podiumRate}%
</div> </Text>
<div className="text-xs text-gray-400">Podium Rate</div> <Text size="xs" color="text-gray-400" block>Podium Rate</Text>
</div> </Box>
</div> </Box>
</div> </Box>
</Stack>
{/* Primary-league ranking removed until we have a dedicated API + view model for league ranks. */}
</div>
</Card> </Card>
)} )}
{defaultStats ? ( {defaultStats ? (
<> <Box display="grid" responsiveGridCols={{ base: 2, md: 4 }} gap={4}>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <StatCard label="Total Races" value={defaultStats.totalRaces} variant="blue" />
{[ <StatCard label="Wins" value={defaultStats.wins} variant="green" />
{ <StatCard label="Podiums" value={defaultStats.podiums} variant="orange" />
label: 'Total Races', <StatCard label="DNFs" value={defaultStats.dnfs} variant="blue" />
value: defaultStats.totalRaces, <StatCard label="Avg Finish" value={defaultStats.avgFinish.toFixed(1)} variant="blue" />
color: 'text-primary-blue', <StatCard label="Completion" value={`${defaultStats.completionRate.toFixed(1)}%`} variant="green" />
}, <StatCard label="Win Rate" value={`${winRate}%`} variant="blue" />
{ label: 'Wins', value: defaultStats.wins, color: 'text-green-400' }, <StatCard label="Podium Rate" value={`${podiumRate}%`} variant="orange" />
{ </Box>
label: 'Podiums',
value: defaultStats.podiums,
color: 'text-warning-amber',
},
{ label: 'DNFs', value: defaultStats.dnfs, color: 'text-red-400' },
{
label: 'Avg Finish',
value: defaultStats.avgFinish.toFixed(1),
color: 'text-white',
},
{
label: 'Completion',
value: `${defaultStats.completionRate.toFixed(1)}%`,
color: 'text-green-400',
},
{ label: 'Win Rate', value: `${winRate}%`, color: 'text-primary-blue' },
{
label: 'Podium Rate',
value: `${podiumRate}%`,
color: 'text-warning-amber',
},
].map((stat, index) => (
<Card key={index} className="text-center">
<div className="text-sm text-gray-400 mb-1">{stat.label}</div>
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
</Card>
))}
</div>
</>
) : ( ) : (
<Card> <Card>
<h3 className="text-lg font-semibold text-white mb-2">Career Statistics</h3> <Heading level={3} mb={2}>Career Statistics</Heading>
<p className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400" block>
No statistics available yet. Compete in races to start building your record. No statistics available yet. Compete in races to start building your record.
</p> </Text>
</Card> </Card>
)} )}
<Card className="bg-charcoal-200/50 border-primary-blue/30"> <Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
<div className="flex items-center gap-3 mb-3"> <Box display="flex" alignItems="center" gap={3} mb={3}>
<div className="text-2xl">📊</div> <Text size="2xl">📊</Text>
<h3 className="text-lg font-semibold text-white">Performance by Car Class</h3> <Heading level={3}>Performance by Car Class</Heading>
</div> </Box>
<p className="text-gray-400 text-sm"> <Text color="text-gray-400" size="sm" block>
Detailed per-car and per-class performance breakdowns will be available in a future Detailed per-car and per-class performance breakdowns will be available in a future
version once more race history data is tracked. version once more race history data is tracked.
</p> </Text>
</Card> </Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30"> <Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
<div className="flex items-center gap-3 mb-3"> <Box display="flex" alignItems="center" gap={3} mb={3}>
<div className="text-2xl">📈</div> <Text size="2xl">📈</Text>
<h3 className="text-lg font-semibold text-white">Coming Soon</h3> <Heading level={3}>Coming Soon</Heading>
</div> </Box>
<p className="text-gray-400 text-sm"> <Text color="text-gray-400" size="sm" block>
Performance trends, track-specific stats, head-to-head comparisons vs friends, and Performance trends, track-specific stats, head-to-head comparisons vs friends, and
league member comparisons will be available in production. league member comparisons will be available in production.
</p> </Text>
</Card> </Card>
</div> </Stack>
); );
} }

View File

@@ -1,94 +0,0 @@
'use client';
import React from 'react';
import { Crown } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Image } from '@/ui/Image';
import { Surface } from '@/ui/Surface';
interface PodiumDriver {
id: string;
name: string;
avatarUrl: string;
rating: number;
wins: number;
podiums: number;
}
interface RankingsPodiumProps {
podium: PodiumDriver[];
onDriverClick?: (id: string) => void;
}
export function RankingsPodium({ podium, onDriverClick }: RankingsPodiumProps) {
return (
<Box mb={10}>
<Box style={{ display: 'flex', alignItems: 'end', justifyContent: 'center', gap: '1rem' }}>
{[1, 0, 2].map((index) => {
const driver = podium[index];
if (!driver) return null;
const position = index === 1 ? 1 : index === 0 ? 2 : 3;
const config = {
1: { height: '10rem', color: 'rgba(250, 204, 21, 0.2)', borderColor: 'rgba(250, 204, 21, 0.4)', crown: '#facc15' },
2: { height: '8rem', color: 'rgba(209, 213, 219, 0.2)', borderColor: 'rgba(209, 213, 219, 0.4)', crown: '#d1d5db' },
3: { height: '6rem', color: 'rgba(217, 119, 6, 0.2)', borderColor: 'rgba(217, 119, 6, 0.4)', crown: '#d97706' },
}[position] || { height: '6rem', color: 'rgba(217, 119, 6, 0.2)', borderColor: 'rgba(217, 119, 6, 0.4)', crown: '#d97706' };
return (
<Box
key={driver.id}
as="button"
type="button"
onClick={() => onDriverClick?.(driver.id)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
>
<Box style={{ position: 'relative', marginBottom: '1rem' }}>
<Box style={{ position: 'relative', width: position === 1 ? '6rem' : '5rem', height: position === 1 ? '6rem' : '5rem', borderRadius: '9999px', overflow: 'hidden', border: `4px solid ${config.crown}`, boxShadow: position === 1 ? '0 0 30px rgba(250, 204, 21, 0.3)' : 'none' }}>
<Image
src={driver.avatarUrl}
alt={driver.name}
width={112}
height={112}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Box style={{ position: 'absolute', bottom: '-0.5rem', left: '50%', transform: 'translateX(-50%)', width: '2rem', height: '2rem', borderRadius: '9999px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.875rem', fontWeight: 'bold', background: `linear-gradient(to bottom right, ${config.color}, transparent)`, border: `2px solid ${config.crown}`, color: config.crown }}>
{position}
</Box>
</Box>
<Text weight="semibold" color="text-white" style={{ fontSize: position === 1 ? '1.125rem' : '1rem', marginBottom: '0.25rem' }}>
{driver.name}
</Text>
<Text font="mono" weight="bold" style={{ fontSize: position === 1 ? '1.25rem' : '1.125rem', color: position === 1 ? '#facc15' : '#3b82f6' }}>
{driver.rating.toString()}
</Text>
<Stack direction="row" align="center" gap={2} style={{ fontSize: '0.75rem', color: '#6b7280', marginTop: '0.25rem' }}>
<Stack direction="row" align="center" gap={1}>
<Text color="text-performance-green">🏆</Text>
{driver.wins}
</Stack>
<Text></Text>
<Stack direction="row" align="center" gap={1}>
<Text color="text-warning-amber">🏅</Text>
{driver.podiums}
</Stack>
</Stack>
<Box style={{ marginTop: '1rem', width: position === 1 ? '7rem' : '6rem', height: config.height, borderRadius: '0.5rem 0.5rem 0 0', background: `linear-gradient(to top, ${config.color}, transparent)`, borderTop: `1px solid ${config.borderColor}`, borderLeft: `1px solid ${config.borderColor}`, borderRight: `1px solid ${config.borderColor}`, display: 'flex', alignItems: 'end', justifyContent: 'center', paddingBottom: '1rem' }}>
<Text weight="bold" style={{ fontSize: position === 1 ? '3rem' : '2.25rem', color: config.crown }}>
{position}
</Text>
</Box>
</Box>
);
})}
</Box>
</Box>
);
}

View File

@@ -1,122 +0,0 @@
'use client';
import React from 'react';
import { Medal } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Image } from '@/ui/Image';
import { Icon } from '@/ui/Icon';
interface Driver {
id: string;
name: string;
avatarUrl: string;
rank: number;
nationality: string;
skillLevel: string;
racesCompleted: number;
rating: number;
wins: number;
medalBg?: string;
medalColor?: string;
}
interface RankingsTableProps {
drivers: Driver[];
onDriverClick?: (id: string) => void;
}
export function RankingsTable({ drivers, onDriverClick }: RankingsTableProps) {
return (
<Box style={{ borderRadius: '0.75rem', backgroundColor: 'rgba(38, 38, 38, 0.3)', border: '1px solid #262626', overflow: 'hidden' }}>
{/* Table Header */}
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(12, minmax(0, 1fr))', gap: '1rem', padding: '0.75rem 1rem', backgroundColor: 'rgba(38, 38, 38, 0.5)', borderBottom: '1px solid #262626', fontSize: '0.75rem', fontWeight: 500, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<Box style={{ gridColumn: 'span 1', textAlign: 'center' }}>Rank</Box>
<Box style={{ gridColumn: 'span 5' }}>Driver</Box>
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Races</Box>
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Rating</Box>
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Wins</Box>
</Box>
{/* Table Body */}
<Stack gap={0}>
{drivers.map((driver, index) => {
const position = driver.rank;
return (
<Box
key={driver.id}
as="button"
type="button"
onClick={() => onDriverClick?.(driver.id)}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(12, minmax(0, 1fr))',
gap: '1rem',
padding: '1rem',
width: '100%',
textAlign: 'left',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
borderBottom: index < drivers.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none'
}}
>
{/* Position */}
<Box style={{ gridColumn: 'span 1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box style={{ display: 'flex', height: '2.25rem', width: '2.25rem', alignItems: 'center', justifyContent: 'center', borderRadius: '9999px', fontSize: '0.875rem', fontWeight: 'bold', border: '1px solid #262626', backgroundColor: driver.medalBg, color: driver.medalColor }}>
{position <= 3 ? <Icon icon={Medal} size={4} /> : position}
</Box>
</Box>
{/* Driver Info */}
<Box style={{ gridColumn: 'span 5', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<Box style={{ position: 'relative', width: '2.5rem', height: '2.5rem', borderRadius: '9999px', overflow: 'hidden', border: '2px solid #262626' }}>
<Image src={driver.avatarUrl} alt={driver.name} width={40} height={40} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text weight="semibold" color="text-white" block truncate>
{driver.name}
</Text>
<Stack direction="row" align="center" gap={2} mt={1}>
<Text size="xs" color="text-gray-500">{driver.nationality}</Text>
<Text size="xs" color="text-gray-500">{driver.skillLevel}</Text>
</Stack>
</Box>
</Box>
{/* Races */}
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text color="text-gray-400">{driver.racesCompleted}</Text>
</Box>
{/* Rating */}
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text font="mono" weight="semibold" color="text-white">
{driver.rating.toString()}
</Text>
</Box>
{/* Wins */}
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text font="mono" weight="semibold" color="text-performance-green">
{driver.wins}
</Text>
</Box>
</Box>
);
})}
</Stack>
{/* Empty State */}
{drivers.length === 0 && (
<Box style={{ padding: '4rem 0', textAlign: 'center' }}>
<Text size="4xl" block mb={4}>🔍</Text>
<Text color="text-gray-400" block mb={2}>No drivers found</Text>
<Text size="sm" color="text-gray-500">There are no drivers in the system yet</Text>
</Box>
)}
</Box>
);
}

View File

@@ -1,203 +0,0 @@
'use client';
import { Card } from '@/ui/Card';
interface RatingBreakdownProps {
skillRating?: number;
safetyRating?: number;
sportsmanshipRating?: number;
}
export default function RatingBreakdown({
skillRating = 1450,
safetyRating = 92,
sportsmanshipRating = 4.8
}: RatingBreakdownProps) {
return (
<div className="space-y-6">
<Card>
<h3 className="text-lg font-semibold text-white mb-6">Rating Components</h3>
<div className="space-y-6">
<RatingComponent
label="Skill Rating"
value={skillRating}
maxValue={2000}
color="primary-blue"
description="Based on race results, competition strength, and consistency"
breakdown={[
{ label: 'Race Results', percentage: 60 },
{ label: 'Competition Quality', percentage: 25 },
{ label: 'Consistency', percentage: 15 }
]}
/>
<RatingComponent
label="Safety Rating"
value={safetyRating}
maxValue={100}
color="green-400"
suffix="%"
description="Reflects incident-free racing and clean overtakes"
breakdown={[
{ label: 'Incident Rate', percentage: 70 },
{ label: 'Clean Overtakes', percentage: 20 },
{ label: 'Position Awareness', percentage: 10 }
]}
/>
<RatingComponent
label="Sportsmanship"
value={sportsmanshipRating}
maxValue={5}
color="warning-amber"
suffix="/5"
description="Community feedback on racing behavior and fair play"
breakdown={[
{ label: 'Peer Reviews', percentage: 50 },
{ label: 'Fair Racing', percentage: 30 },
{ label: 'Team Play', percentage: 20 }
]}
/>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Rating History</h3>
<div className="space-y-3">
<HistoryItem
date="November 2024"
skillChange={+15}
safetyChange={+2}
sportsmanshipChange={0}
/>
<HistoryItem
date="October 2024"
skillChange={+28}
safetyChange={-1}
sportsmanshipChange={+0.1}
/>
<HistoryItem
date="September 2024"
skillChange={-12}
safetyChange={+3}
sportsmanshipChange={0}
/>
</div>
</Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">📈</div>
<h3 className="text-lg font-semibold text-white">Rating Insights</h3>
</div>
<ul className="space-y-2 text-sm text-gray-400">
<li className="flex items-start gap-2">
<span className="text-green-400 mt-0.5"></span>
<span>Strong safety rating - keep up the clean racing!</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5"></span>
<span>Skill rating improving - competitive against higher-rated drivers</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary-blue mt-0.5">i</span>
<span>Complete more races to stabilize your ratings</span>
</li>
</ul>
</Card>
</div>
);
}
function RatingComponent({
label,
value,
maxValue,
color,
suffix = '',
description,
breakdown
}: {
label: string;
value: number;
maxValue: number;
color: string;
suffix?: string;
description: string;
breakdown: { label: string; percentage: number }[];
}) {
const percentage = (value / maxValue) * 100;
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-white font-medium">{label}</span>
<span className={`text-2xl font-bold text-${color}`}>
{value}{suffix}
</span>
</div>
<div className="w-full bg-deep-graphite rounded-full h-2 mb-3">
<div
className={`bg-${color} rounded-full h-2 transition-all duration-500`}
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-xs text-gray-400 mb-3">{description}</p>
<div className="space-y-1">
{breakdown.map((item, index) => (
<div key={index} className="flex items-center justify-between text-xs">
<span className="text-gray-500">{item.label}</span>
<span className="text-gray-400">{item.percentage}%</span>
</div>
))}
</div>
</div>
);
}
function HistoryItem({
date,
skillChange,
safetyChange,
sportsmanshipChange
}: {
date: string;
skillChange: number;
safetyChange: number;
sportsmanshipChange: number;
}) {
const formatChange = (value: number) => {
if (value === 0) return '—';
return value > 0 ? `+${value}` : `${value}`;
};
const getChangeColor = (value: number) => {
if (value === 0) return 'text-gray-500';
return value > 0 ? 'text-green-400' : 'text-red-400';
};
return (
<div className="flex items-center justify-between p-3 rounded bg-deep-graphite border border-charcoal-outline">
<span className="text-white text-sm">{date}</span>
<div className="flex items-center gap-4 text-xs">
<div className="text-center">
<div className="text-gray-500 mb-1">Skill</div>
<div className={getChangeColor(skillChange)}>{formatChange(skillChange)}</div>
</div>
<div className="text-center">
<div className="text-gray-500 mb-1">Safety</div>
<div className={getChangeColor(safetyChange)}>{formatChange(safetyChange)}</div>
</div>
<div className="text-center">
<div className="text-gray-500 mb-1">Sports</div>
<div className={getChangeColor(sportsmanshipChange)}>{formatChange(sportsmanshipChange)}</div>
</div>
</div>
</div>
);
}

View File

@@ -1,79 +0,0 @@
'use client';
import { Activity } from 'lucide-react';
import Image from 'next/image';
import { mediaConfig } from '@/lib/config/mediaConfig';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
];
const CATEGORIES = [
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400' },
];
interface RecentActivityProps {
drivers: {
id: string;
name: string;
avatarUrl?: string;
isActive: boolean;
skillLevel?: string;
category?: string;
}[];
onDriverClick: (id: string) => void;
}
export function RecentActivity({ drivers, onDriverClick }: RecentActivityProps) {
const activeDrivers = drivers.filter((d) => d.isActive).slice(0, 6);
return (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-performance-green/10 border border-performance-green/20">
<Activity className="w-5 h-5 text-performance-green" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Active Drivers</h2>
<p className="text-xs text-gray-500">Currently competing in leagues</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{activeDrivers.map((driver) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
return (
<button
key={driver.id}
type="button"
onClick={() => onDriverClick(driver.id)}
className="p-3 rounded-xl bg-iron-gray/40 border border-charcoal-outline hover:border-performance-green/40 transition-all group text-center"
>
<div className="relative w-12 h-12 mx-auto rounded-full overflow-hidden border-2 border-charcoal-outline mb-2">
<Image src={driver.avatarUrl || mediaConfig.avatars.defaultFallback} alt={driver.name} fill className="object-cover" />
<div className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-performance-green border-2 border-iron-gray" />
</div>
<p className="text-sm font-medium text-white truncate group-hover:text-performance-green transition-colors">
{driver.name}
</p>
<div className="flex items-center justify-center gap-1 text-xs">
{categoryConfig && (
<span className={categoryConfig.color}>{categoryConfig.label}</span>
)}
<span className={levelConfig?.color}>{levelConfig?.label}</span>
</div>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -1,69 +0,0 @@
'use client';
import { BarChart3 } from 'lucide-react';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', icon: BarChart3, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
{ id: 'advanced', label: 'Advanced', icon: BarChart3, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
{ id: 'intermediate', label: 'Intermediate', icon: BarChart3, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
{ id: 'beginner', label: 'Beginner', icon: BarChart3, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
];
interface SkillDistributionProps {
drivers: {
skillLevel?: string;
}[];
}
export function SkillDistribution({ drivers }: SkillDistributionProps) {
const distribution = SKILL_LEVELS.map((level) => ({
...level,
count: drivers.filter((d) => d.skillLevel === level.id).length,
percentage: drivers.length > 0
? Math.round((drivers.filter((d) => d.skillLevel === level.id).length / drivers.length) * 100)
: 0,
}));
return (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-neon-aqua/10 border border-neon-aqua/20">
<BarChart3 className="w-5 h-5 text-neon-aqua" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Skill Distribution</h2>
<p className="text-xs text-gray-500">Driver population by skill level</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{distribution.map((level) => {
const Icon = level.icon;
return (
<div
key={level.id}
className={`p-4 rounded-xl ${level.bgColor} border ${level.borderColor}`}
>
<div className="flex items-center justify-between mb-3">
<Icon className={`w-5 h-5 ${level.color}`} />
<span className={`text-2xl font-bold ${level.color}`}>{level.count}</span>
</div>
<p className="text-white font-medium mb-1">{level.label}</p>
<div className="w-full h-2 rounded-full bg-deep-graphite/50 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
level.id === 'pro' ? 'bg-yellow-400' :
level.id === 'advanced' ? 'bg-purple-400' :
level.id === 'intermediate' ? 'bg-primary-blue' :
'bg-green-400'
}`}
style={{ width: `${level.percentage}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">{level.percentage}% of drivers</p>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { Component, ReactNode } from 'react'; import React, { Component, ReactNode, useState } from 'react';
import { ApiError } from '@/lib/api/base/ApiError'; import { ApiError } from '@/lib/api/base/ApiError';
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor'; import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { ErrorDisplay } from './ErrorDisplay'; import { ErrorDisplay } from './ErrorDisplay';
@@ -45,7 +45,7 @@ export class ApiErrorBoundary extends Component<Props, State> {
throw error; throw error;
} }
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { componentDidCatch(error: Error): void {
if (error instanceof ApiError) { if (error instanceof ApiError) {
// Report to connection monitor // Report to connection monitor
connectionMonitor.recordFailure(error); connectionMonitor.recordFailure(error);
@@ -130,8 +130,8 @@ export class ApiErrorBoundary extends Component<Props, State> {
* Hook-based alternative for functional components * Hook-based alternative for functional components
*/ */
export function useApiErrorBoundary() { export function useApiErrorBoundary() {
const [error, setError] = React.useState<ApiError | null>(null); const [error, setError] = useState<ApiError | null>(null);
const [isDev] = React.useState(process.env.NODE_ENV === 'development'); const [isDev] = useState(process.env.NODE_ENV === 'development');
const handleError = (err: ApiError) => { const handleError = (err: ApiError) => {
setError(err); setError(err);

View File

@@ -1,10 +1,18 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react';
import { X, RefreshCw, Copy, Terminal, Activity, AlertTriangle } from 'lucide-react';
import { ApiError } from '@/lib/api/base/ApiError'; import { ApiError } from '@/lib/api/base/ApiError';
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor'; import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler'; import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
import { useState, useEffect } from 'react'; import { Box } from '@/ui/Box';
import { X, RefreshCw, Copy, Terminal, Activity, AlertTriangle } from 'lucide-react'; import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { Badge } from '@/ui/Badge';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Surface } from '@/ui/Surface';
interface DevErrorPanelProps { interface DevErrorPanelProps {
error: ApiError; error: ApiError;
@@ -80,268 +88,295 @@ export function DevErrorPanel({ error, onReset }: DevErrorPanelProps) {
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus()); setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
}; };
const getSeverityColor = (type: string) => { const getSeverityVariant = (): 'danger' | 'warning' | 'info' | 'default' => {
switch (error.getSeverity()) { switch (error.getSeverity()) {
case 'error': return 'bg-red-500/20 text-red-400 border-red-500/40'; case 'error': return 'danger';
case 'warn': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/40'; case 'warn': return 'warning';
case 'info': return 'bg-blue-500/20 text-blue-400 border-blue-500/40'; case 'info': return 'info';
default: return 'bg-gray-500/20 text-gray-400 border-gray-500/40'; default: return 'default';
} }
}; };
const reliability = connectionMonitor.getReliability(); const reliability = connectionMonitor.getReliability();
return ( return (
<div className="fixed inset-0 z-50 overflow-auto bg-deep-graphite p-4 font-mono text-sm"> <Box
<div className="max-w-6xl mx-auto space-y-4"> position="fixed"
{/* Header */} inset="0"
<div className="bg-iron-gray border border-charcoal-outline rounded-lg p-4 flex items-center justify-between"> zIndex={50}
<div className="flex items-center gap-3"> overflow="auto"
<Terminal className="w-5 h-5 text-primary-blue" /> bg="bg-deep-graphite"
<h2 className="text-lg font-bold text-white">API Error Debug Panel</h2> p={4}
<span className={`px-2 py-1 rounded border text-xs ${getSeverityColor(error.type)}`}> >
{error.type} <Box maxWidth="6xl" mx="auto">
</span> <Stack gap={4}>
</div> {/* Header */}
<div className="flex gap-2"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="lg" p={4} display="flex" alignItems="center" justifyContent="between">
<button <Box display="flex" alignItems="center" gap={3}>
onClick={copyToClipboard} <Icon icon={Terminal} size={5} color="rgb(59, 130, 246)" />
className="px-3 py-1 bg-iron-gray hover:bg-charcoal-outline border border-charcoal-outline rounded text-gray-300 flex items-center gap-2" <Heading level={2}>API Error Debug Panel</Heading>
title="Copy debug info" <Badge variant={getSeverityVariant()}>
> {error.type}
<Copy className="w-4 h-4" /> </Badge>
{copied ? 'Copied!' : 'Copy'} </Box>
</button> <Box display="flex" gap={2}>
<button <Button
onClick={onReset} variant="secondary"
className="px-3 py-1 bg-primary-blue hover:bg-primary-blue/80 text-white rounded flex items-center gap-2" onClick={copyToClipboard}
> icon={<Icon icon={Copy} size={4} />}
<X className="w-4 h-4" /> >
Close {copied ? 'Copied!' : 'Copy'}
</button> </Button>
</div> <Button
</div> variant="primary"
onClick={onReset}
icon={<Icon icon={X} size={4} />}
>
Close
</Button>
</Box>
</Box>
{/* Error Details */} {/* Error Details */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={4}>
<div className="space-y-4"> <Stack gap={4}>
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden"> <Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2"> <Box bg="bg-charcoal-outline" px={4} py={2} display="flex" alignItems="center" gap={2}>
<AlertTriangle className="w-4 h-4" /> <Icon icon={AlertTriangle} size={4} color="text-white" />
Error Details <Text weight="semibold" color="text-white">Error Details</Text>
</div> </Box>
<div className="p-4 space-y-2 text-xs"> <Box p={4}>
<div className="grid grid-cols-3 gap-2"> <Stack gap={2} fontSize="0.75rem">
<span className="text-gray-500">Type:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2 text-red-400 font-bold">{error.type}</span> <Text color="text-gray-500">Type:</Text>
</div> <Text colSpan={2} color="text-red-400" weight="bold">{error.type}</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Message:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2 text-gray-300">{error.message}</span> <Text color="text-gray-500">Message:</Text>
</div> <Text colSpan={2} color="text-gray-300">{error.message}</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Endpoint:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2 text-blue-400">{error.context.endpoint || 'N/A'}</span> <Text color="text-gray-500">Endpoint:</Text>
</div> <Text colSpan={2} color="text-primary-blue">{error.context.endpoint || 'N/A'}</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Method:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2 text-yellow-400">{error.context.method || 'N/A'}</span> <Text color="text-gray-500">Method:</Text>
</div> <Text colSpan={2} color="text-warning-amber">{error.context.method || 'N/A'}</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Status:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2">{error.context.statusCode || 'N/A'}</span> <Text color="text-gray-500">Status:</Text>
</div> <Text colSpan={2}>{error.context.statusCode || 'N/A'}</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Retry Count:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2">{error.context.retryCount || 0}</span> <Text color="text-gray-500">Retry Count:</Text>
</div> <Text colSpan={2}>{error.context.retryCount || 0}</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Timestamp:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2 text-gray-500">{error.context.timestamp}</span> <Text color="text-gray-500">Timestamp:</Text>
</div> <Text colSpan={2} color="text-gray-500">{error.context.timestamp}</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Retryable:</span> <Box display="grid" gridCols={3} gap={2}>
<span className={`col-span-2 ${error.isRetryable() ? 'text-green-400' : 'text-red-400'}`}> <Text color="text-gray-500">Retryable:</Text>
{error.isRetryable() ? 'Yes' : 'No'} <Text colSpan={2} color={error.isRetryable() ? 'text-performance-green' : 'text-red-400'}>
</span> {error.isRetryable() ? 'Yes' : 'No'}
</div> </Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Connectivity:</span> <Box display="grid" gridCols={3} gap={2}>
<span className={`col-span-2 ${error.isConnectivityIssue() ? 'text-red-400' : 'text-green-400'}`}> <Text color="text-gray-500">Connectivity:</Text>
{error.isConnectivityIssue() ? 'Yes' : 'No'} <Text colSpan={2} color={error.isConnectivityIssue() ? 'text-red-400' : 'text-performance-green'}>
</span> {error.isConnectivityIssue() ? 'Yes' : 'No'}
</div> </Text>
{error.context.troubleshooting && ( </Box>
<div className="grid grid-cols-3 gap-2"> {error.context.troubleshooting && (
<span className="text-gray-500">Troubleshoot:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2 text-yellow-400">{error.context.troubleshooting}</span> <Text color="text-gray-500">Troubleshoot:</Text>
</div> <Text colSpan={2} color="text-warning-amber">{error.context.troubleshooting}</Text>
)} </Box>
</div> )}
</div> </Stack>
</Box>
</Surface>
{/* Connection Status */} {/* Connection Status */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden"> <Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2"> <Box bg="bg-charcoal-outline" px={4} py={2} display="flex" alignItems="center" gap={2}>
<Activity className="w-4 h-4" /> <Icon icon={Activity} size={4} color="text-white" />
Connection Health <Text weight="semibold" color="text-white">Connection Health</Text>
</div> </Box>
<div className="p-4 space-y-2 text-xs"> <Box p={4}>
<div className="grid grid-cols-3 gap-2"> <Stack gap={2} fontSize="0.75rem">
<span className="text-gray-500">Status:</span> <Box display="grid" gridCols={3} gap={2}>
<span className={`col-span-2 font-bold ${ <Text color="text-gray-500">Status:</Text>
connectionStatus.status === 'connected' ? 'text-green-400' : <Text colSpan={2} weight="bold" color={
connectionStatus.status === 'degraded' ? 'text-yellow-400' : connectionStatus.status === 'connected' ? 'text-performance-green' :
'text-red-400' connectionStatus.status === 'degraded' ? 'text-warning-amber' :
}`}> 'text-red-400'
{connectionStatus.status.toUpperCase()} }>
</span> {connectionStatus.status.toUpperCase()}
</div> </Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Reliability:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2">{reliability.toFixed(2)}%</span> <Text color="text-gray-500">Reliability:</Text>
</div> <Text colSpan={2}>{reliability.toFixed(2)}%</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Total Requests:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2">{connectionStatus.totalRequests}</span> <Text color="text-gray-500">Total Requests:</Text>
</div> <Text colSpan={2}>{connectionStatus.totalRequests}</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Successful:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2 text-green-400">{connectionStatus.successfulRequests}</span> <Text color="text-gray-500">Successful:</Text>
</div> <Text colSpan={2} color="text-performance-green">{connectionStatus.successfulRequests}</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Failed:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2 text-red-400">{connectionStatus.failedRequests}</span> <Text color="text-gray-500">Failed:</Text>
</div> <Text colSpan={2} color="text-red-400">{connectionStatus.failedRequests}</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Consecutive Failures:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2">{connectionStatus.consecutiveFailures}</span> <Text color="text-gray-500">Consecutive Failures:</Text>
</div> <Text colSpan={2}>{connectionStatus.consecutiveFailures}</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Avg Response:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2">{connectionStatus.averageResponseTime.toFixed(2)}ms</span> <Text color="text-gray-500">Avg Response:</Text>
</div> <Text colSpan={2}>{connectionStatus.averageResponseTime.toFixed(2)}ms</Text>
<div className="grid grid-cols-3 gap-2"> </Box>
<span className="text-gray-500">Last Check:</span> <Box display="grid" gridCols={3} gap={2}>
<span className="col-span-2 text-gray-500"> <Text color="text-gray-500">Last Check:</Text>
{connectionStatus.lastCheck?.toLocaleTimeString() || 'Never'} <Text colSpan={2} color="text-gray-500">
</span> {connectionStatus.lastCheck?.toLocaleTimeString() || 'Never'}
</div> </Text>
</div> </Box>
</div> </Stack>
</div> </Box>
</Surface>
</Stack>
{/* Right Column */} {/* Right Column */}
<div className="space-y-4"> <Stack gap={4}>
{/* Circuit Breakers */} {/* Circuit Breakers */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden"> <Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2"> <Box bg="bg-charcoal-outline" px={4} py={2} display="flex" alignItems="center" gap={2}>
<span className="text-lg"></span> <Text size="lg"></Text>
Circuit Breakers <Text weight="semibold" color="text-white">Circuit Breakers</Text>
</div> </Box>
<div className="p-4"> <Box p={4}>
{Object.keys(circuitBreakers).length === 0 ? ( {Object.keys(circuitBreakers).length === 0 ? (
<div className="text-gray-500 text-center py-4">No circuit breakers active</div> <Box textAlign="center" py={4}>
) : ( <Text color="text-gray-500">No circuit breakers active</Text>
<div className="space-y-2 text-xs max-h-48 overflow-auto"> </Box>
{Object.entries(circuitBreakers).map(([endpoint, status]) => ( ) : (
<div key={endpoint} className="flex items-center justify-between p-2 bg-deep-graphite rounded border border-charcoal-outline"> <Stack gap={2} maxHeight="12rem" overflow="auto" fontSize="0.75rem">
<span className="text-blue-400 truncate flex-1">{endpoint}</span> {Object.entries(circuitBreakers).map(([endpoint, status]) => (
<span className={`px-2 py-1 rounded ${ <Box key={endpoint} display="flex" alignItems="center" justifyContent="between" p={2} bg="bg-deep-graphite" rounded="md" border borderColor="border-charcoal-outline">
status.state === 'CLOSED' ? 'bg-green-500/20 text-green-400' : <Text color="text-primary-blue" truncate flexGrow={1}>{endpoint}</Text>
status.state === 'OPEN' ? 'bg-red-500/20 text-red-400' : <Box px={2} py={1} rounded="sm" bg={
'bg-yellow-500/20 text-yellow-400' status.state === 'CLOSED' ? 'bg-green-500/20' :
}`}> status.state === 'OPEN' ? 'bg-red-500/20' :
{status.state} 'bg-yellow-500/20'
</span> }>
<span className="text-gray-500 ml-2">{status.failures} failures</span> <Text color={
</div> status.state === 'CLOSED' ? 'text-performance-green' :
))} status.state === 'OPEN' ? 'text-red-400' :
</div> 'text-warning-amber'
)} }>
</div> {status.state}
</div> </Text>
</Box>
<Text color="text-gray-500" ml={2}>{status.failures} failures</Text>
</Box>
))}
</Stack>
)}
</Box>
</Surface>
{/* Actions */} {/* Actions */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden"> <Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white"> <Box bg="bg-charcoal-outline" px={4} py={2}>
Actions <Text weight="semibold" color="text-white">Actions</Text>
</div> </Box>
<div className="p-4 space-y-2"> <Box p={4}>
<button <Stack gap={2}>
onClick={triggerHealthCheck} <Button
className="w-full px-3 py-2 bg-primary-blue hover:bg-primary-blue/80 text-white rounded flex items-center justify-center gap-2" variant="primary"
> onClick={triggerHealthCheck}
<RefreshCw className="w-4 h-4" /> fullWidth
Run Health Check icon={<Icon icon={RefreshCw} size={4} />}
</button> >
<button Run Health Check
onClick={resetCircuitBreakers} </Button>
className="w-full px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded flex items-center justify-center gap-2" <Button
> variant="secondary"
<span className="text-lg">🔄</span> onClick={resetCircuitBreakers}
Reset Circuit Breakers fullWidth
</button> icon={<Text size="lg">🔄</Text>}
<button >
onClick={() => { Reset Circuit Breakers
connectionMonitor.reset(); </Button>
setConnectionStatus(connectionMonitor.getHealth()); <Button
}} variant="danger"
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded flex items-center justify-center gap-2" onClick={() => {
> connectionMonitor.reset();
<span className="text-lg">🗑</span> setConnectionStatus(connectionMonitor.getHealth());
Reset Connection Stats }}
</button> fullWidth
</div> icon={<Text size="lg">🗑</Text>}
</div> >
Reset Connection Stats
</Button>
</Stack>
</Box>
</Surface>
{/* Quick Fixes */} {/* Quick Fixes */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden"> <Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white"> <Box bg="bg-charcoal-outline" px={4} py={2}>
Quick Fixes <Text weight="semibold" color="text-white">Quick Fixes</Text>
</div> </Box>
<div className="p-4 space-y-2 text-xs"> <Box p={4}>
<div className="text-gray-400">Common solutions:</div> <Stack gap={2} fontSize="0.75rem">
<ul className="list-disc list-inside space-y-1 text-gray-300"> <Text color="text-gray-400">Common solutions:</Text>
<li>Check API server is running</li> <Stack as="ul" gap={1} pl={4}>
<li>Verify CORS configuration</li> <Box as="li"><Text color="text-gray-300">Check API server is running</Text></Box>
<li>Check environment variables</li> <Box as="li"><Text color="text-gray-300">Verify CORS configuration</Text></Box>
<li>Review network connectivity</li> <Box as="li"><Text color="text-gray-300">Check environment variables</Text></Box>
<li>Check API rate limits</li> <Box as="li"><Text color="text-gray-300">Review network connectivity</Text></Box>
</ul> <Box as="li"><Text color="text-gray-300">Check API rate limits</Text></Box>
</div> </Stack>
</div> </Stack>
</Box>
</Surface>
{/* Raw Error */} {/* Raw Error */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden"> <Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white"> <Box bg="bg-charcoal-outline" px={4} py={2}>
Raw Error <Text weight="semibold" color="text-white">Raw Error</Text>
</div> </Box>
<div className="p-4"> <Box p={4}>
<pre className="text-xs text-gray-400 overflow-auto max-h-32 bg-deep-graphite p-2 rounded"> <Box as="pre" p={2} bg="bg-deep-graphite" rounded="md" overflow="auto" maxHeight="8rem" fontSize="0.75rem" color="text-gray-400">
{JSON.stringify({ {JSON.stringify({
type: error.type, type: error.type,
message: error.message, message: error.message,
context: error.context, context: error.context,
}, null, 2)} }, null, 2)}
</pre> </Box>
</div> </Box>
</div> </Surface>
</div> </Stack>
</div> </Box>
{/* Console Output */} {/* Console Output */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden"> <Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2"> <Box bg="bg-charcoal-outline" px={4} py={2} display="flex" alignItems="center" gap={2}>
<Terminal className="w-4 h-4" /> <Icon icon={Terminal} size={4} color="text-white" />
Console Output <Text weight="semibold" color="text-white">Console Output</Text>
</div> </Box>
<div className="p-4 bg-deep-graphite font-mono text-xs"> <Box p={4} bg="bg-deep-graphite" fontSize="0.75rem">
<div className="text-gray-500 mb-2">{'>'} {error.getDeveloperMessage()}</div> <Text color="text-gray-500" block mb={2}>{'>'} {error.getDeveloperMessage()}</Text>
<div className="text-gray-600">Check browser console for full stack trace and additional debug info.</div> <Text color="text-gray-600" block>Check browser console for full stack trace and additional debug info.</Text>
</div> </Box>
</div> </Surface>
</div> </Stack>
</div> </Box>
</Box>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { Component, ReactNode, ErrorInfo } from 'react'; import React, { Component, ReactNode, ErrorInfo, useState, version } from 'react';
import { ApiError } from '@/lib/api/base/ApiError'; import { ApiError } from '@/lib/api/base/ApiError';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler'; import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { DevErrorPanel } from './DevErrorPanel'; import { DevErrorPanel } from './DevErrorPanel';
@@ -28,6 +28,15 @@ interface State {
isDev: boolean; isDev: boolean;
} }
interface GridPilotWindow extends Window {
__GRIDPILOT_REACT_ERRORS__?: Array<{
error: Error;
errorInfo: ErrorInfo;
timestamp: string;
componentStack?: string;
}>;
}
/** /**
* Enhanced React Error Boundary with maximum developer transparency * Enhanced React Error Boundary with maximum developer transparency
* Integrates with GlobalErrorHandler and provides detailed debugging info * Integrates with GlobalErrorHandler and provides detailed debugging info
@@ -49,7 +58,7 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
static getDerivedStateFromError(error: Error): State { static getDerivedStateFromError(error: Error): State {
// Don't catch Next.js navigation errors (redirect, notFound, etc.) // Don't catch Next.js navigation errors (redirect, notFound, etc.)
if (error && typeof error === 'object' && 'digest' in error) { if (error && typeof error === 'object' && 'digest' in error) {
const digest = (error as any).digest; const digest = (error as Record<string, unknown>).digest;
if (typeof digest === 'string' && ( if (typeof digest === 'string' && (
digest.startsWith('NEXT_REDIRECT') || digest.startsWith('NEXT_REDIRECT') ||
digest.startsWith('NEXT_NOT_FOUND') digest.startsWith('NEXT_NOT_FOUND')
@@ -70,7 +79,7 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
componentDidCatch(error: Error, errorInfo: ErrorInfo): void { componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Don't catch Next.js navigation errors (redirect, notFound, etc.) // Don't catch Next.js navigation errors (redirect, notFound, etc.)
if (error && typeof error === 'object' && 'digest' in error) { if (error && typeof error === 'object' && 'digest' in error) {
const digest = (error as any).digest; const digest = (error as Record<string, unknown>).digest;
if (typeof digest === 'string' && ( if (typeof digest === 'string' && (
digest.startsWith('NEXT_REDIRECT') || digest.startsWith('NEXT_REDIRECT') ||
digest.startsWith('NEXT_NOT_FOUND') digest.startsWith('NEXT_NOT_FOUND')
@@ -81,21 +90,26 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
} }
// Add to React error history // Add to React error history
const reactErrors = (window as any).__GRIDPILOT_REACT_ERRORS__ || []; if (typeof window !== 'undefined') {
reactErrors.push({ const gpWindow = window as unknown as GridPilotWindow;
error, const reactErrors = gpWindow.__GRIDPILOT_REACT_ERRORS__ || [];
errorInfo, gpWindow.__GRIDPILOT_REACT_ERRORS__ = [
timestamp: new Date().toISOString(), ...reactErrors,
componentStack: errorInfo.componentStack, {
}); error,
(window as any).__GRIDPILOT_REACT_ERRORS__ = reactErrors; errorInfo,
timestamp: new Date().toISOString(),
componentStack: errorInfo.componentStack || undefined,
}
];
}
// Report to global error handler with enhanced context // Report to global error handler with enhanced context
const enhancedContext = { const enhancedContext = {
...this.props.context, ...this.props.context,
source: 'react_error_boundary', source: 'react_error_boundary',
componentStack: errorInfo.componentStack, componentStack: errorInfo.componentStack,
reactVersion: React.version, reactVersion: version,
componentName: this.getComponentName(errorInfo), componentName: this.getComponentName(errorInfo),
}; };
@@ -107,12 +121,6 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
this.props.onError(error, errorInfo); this.props.onError(error, errorInfo);
} }
// Show dev overlay if enabled
if (this.props.enableDevOverlay && this.state.isDev) {
// The global handler will show the overlay, but we can add additional React-specific info
this.showReactDevOverlay(error, errorInfo);
}
// Log to console with maximum detail // Log to console with maximum detail
if (this.state.isDev) { if (this.state.isDev) {
this.logReactErrorWithMaximumDetail(error, errorInfo); this.logReactErrorWithMaximumDetail(error, errorInfo);
@@ -126,10 +134,6 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
} }
} }
componentWillUnmount(): void {
// Clean up if needed
}
/** /**
* Extract component name from error info * Extract component name from error info
*/ */
@@ -146,108 +150,6 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
return undefined; return undefined;
} }
/**
* Show React-specific dev overlay
*/
private showReactDevOverlay(error: Error, errorInfo: ErrorInfo): void {
const existingOverlay = document.getElementById('gridpilot-react-overlay');
if (existingOverlay) {
this.updateReactDevOverlay(existingOverlay, error, errorInfo);
return;
}
const overlay = document.createElement('div');
overlay.id = 'gridpilot-react-overlay';
overlay.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 800px;
max-height: 80vh;
background: #1a1a1a;
color: #fff;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
z-index: 999998;
overflow: auto;
padding: 20px;
border: 3px solid #ff6600;
border-radius: 8px;
box-shadow: 0 0 40px rgba(255, 102, 0, 0.6);
`;
this.updateReactDevOverlay(overlay, error, errorInfo);
document.body.appendChild(overlay);
// Add keyboard shortcut to dismiss
const dismissHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', dismissHandler);
}
};
document.addEventListener('keydown', dismissHandler);
}
/**
* Update React dev overlay
*/
private updateReactDevOverlay(overlay: HTMLElement, error: Error, errorInfo: ErrorInfo): void {
overlay.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 15px;">
<h2 style="color: #ff6600; margin: 0; font-size: 18px;">⚛️ React Component Error</h2>
<button onclick="this.parentElement.parentElement.remove()"
style="background: #ff6600; color: white; border: none; padding: 6px 12px; cursor: pointer; border-radius: 4px; font-weight: bold;">
CLOSE
</button>
</div>
<div style="background: #000; padding: 12px; border-radius: 4px; margin-bottom: 15px; border-left: 3px solid #ff6600;">
<div style="color: #ff6600; font-weight: bold; margin-bottom: 5px;">Error Message</div>
<div style="color: #fff;">${error.message}</div>
</div>
<div style="background: #000; padding: 12px; border-radius: 4px; margin-bottom: 15px; border-left: 3px solid #00aaff;">
<div style="color: #00aaff; font-weight: bold; margin-bottom: 5px;">Component Stack Trace</div>
<pre style="margin: 0; white-space: pre-wrap; color: #888;">${errorInfo.componentStack || 'No component stack available'}</pre>
</div>
<div style="background: #000; padding: 12px; border-radius: 4px; margin-bottom: 15px; border-left: 3px solid #ffaa00;">
<div style="color: #ffaa00; font-weight: bold; margin-bottom: 5px;">JavaScript Stack Trace</div>
<pre style="margin: 0; white-space: pre-wrap; color: #888; overflow-x: auto;">${error.stack || 'No stack trace available'}</pre>
</div>
<div style="background: #000; padding: 12px; border-radius: 4px; margin-bottom: 15px; border-left: 3px solid #00ff88;">
<div style="color: #00ff88; font-weight: bold; margin-bottom: 5px;">React Information</div>
<div style="line-height: 1.6; color: #888;">
<div>React Version: ${React.version}</div>
<div>Error Boundary: Active</div>
<div>Timestamp: ${new Date().toLocaleTimeString()}</div>
</div>
</div>
<div style="background: #000; padding: 12px; border-radius: 4px; border-left: 3px solid #aa00ff;">
<div style="color: #aa00ff; font-weight: bold; margin-bottom: 5px;">Quick Actions</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<button onclick="navigator.clipboard.writeText(\`${error.message}\n\nComponent Stack:\n${errorInfo.componentStack}\n\nStack:\n${error.stack}\`)"
style="background: #0066cc; color: white; border: none; padding: 6px 10px; cursor: pointer; border-radius: 4px; font-size: 11px;">
📋 Copy Details
</button>
<button onclick="window.location.reload()"
style="background: #cc6600; color: white; border: none; padding: 6px 10px; cursor: pointer; border-radius: 4px; font-size: 11px;">
🔄 Reload
</button>
</div>
</div>
<div style="margin-top: 15px; padding: 10px; background: #222; border-radius: 4px; border-left: 3px solid #888; font-size: 11px; color: #888;">
💡 This React error boundary caught a component rendering error. Check the console for additional details from the global error handler.
</div>
`;
}
/** /**
* Log React error with maximum detail * Log React error with maximum detail
*/ */
@@ -265,7 +167,7 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
console.log('Component Stack:', errorInfo.componentStack); console.log('Component Stack:', errorInfo.componentStack);
console.log('React Context:', { console.log('React Context:', {
reactVersion: React.version, reactVersion: version,
component: this.getComponentName(errorInfo), component: this.getComponentName(errorInfo),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
@@ -273,28 +175,9 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
console.log('Props:', this.props); console.log('Props:', this.props);
console.log('State:', this.state); console.log('State:', this.state);
// Show component hierarchy if available
try {
const hierarchy = this.getComponentHierarchy();
if (hierarchy) {
console.log('Component Hierarchy:', hierarchy);
}
} catch {
// Ignore hierarchy extraction errors
}
console.groupEnd(); console.groupEnd();
} }
/**
* Attempt to extract component hierarchy (for debugging)
*/
private getComponentHierarchy(): string | null {
// This is a simplified version - in practice, you might want to use React DevTools
// or other methods to get the full component tree
return null;
}
resetError = (): void => { resetError = (): void => {
this.setState({ hasError: false, error: null, errorInfo: null }); this.setState({ hasError: false, error: null, errorInfo: null });
if (this.props.onReset) { if (this.props.onReset) {
@@ -350,9 +233,9 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
* Hook-based alternative for functional components * Hook-based alternative for functional components
*/ */
export function useEnhancedErrorBoundary() { export function useEnhancedErrorBoundary() {
const [error, setError] = React.useState<Error | ApiError | null>(null); const [error, setError] = useState<Error | ApiError | null>(null);
const [errorInfo, setErrorInfo] = React.useState<ErrorInfo | null>(null); const [errorInfo, setErrorInfo] = useState<ErrorInfo | null>(null);
const [isDev] = React.useState(process.env.NODE_ENV === 'development'); const [isDev] = useState(process.env.NODE_ENV === 'development');
const handleError = (err: Error, info: ErrorInfo) => { const handleError = (err: Error, info: ErrorInfo) => {
setError(err); setError(err);
@@ -402,4 +285,4 @@ export function withEnhancedErrorBoundary<P extends object>(
); );
WrappedComponent.displayName = `withEnhancedErrorBoundary(${Component.displayName || Component.name || 'Component'})`; WrappedComponent.displayName = `withEnhancedErrorBoundary(${Component.displayName || Component.name || 'Component'})`;
return WrappedComponent; return WrappedComponent;
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
AlertCircle, AlertCircle,
@@ -15,13 +15,18 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { parseApiError, getErrorSeverity, isRetryable, isConnectivityError } from '@/lib/utils/errorUtils'; import { parseApiError, getErrorSeverity, isRetryable, isConnectivityError } from '@/lib/utils/errorUtils';
import { ApiError } from '@/lib/api/base/ApiError'; import { ApiError } from '@/lib/api/base/ApiError';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import { Button } from '@/ui/Button';
interface EnhancedFormErrorProps { interface EnhancedFormErrorProps {
error: unknown; error: unknown;
onRetry?: () => void; onRetry?: () => void;
onDismiss?: () => void; onDismiss?: () => void;
showDeveloperDetails?: boolean; showDeveloperDetails?: boolean;
className?: string;
} }
/** /**
@@ -35,7 +40,6 @@ export function EnhancedFormError({
onRetry, onRetry,
onDismiss, onDismiss,
showDeveloperDetails = process.env.NODE_ENV === 'development', showDeveloperDetails = process.env.NODE_ENV === 'development',
className = ''
}: EnhancedFormErrorProps) { }: EnhancedFormErrorProps) {
const [showDetails, setShowDetails] = useState(false); const [showDetails, setShowDetails] = useState(false);
const parsed = parseApiError(error); const parsed = parseApiError(error);
@@ -44,10 +48,10 @@ export function EnhancedFormError({
const connectivity = isConnectivityError(error); const connectivity = isConnectivityError(error);
const getIcon = () => { const getIcon = () => {
if (connectivity) return <Wifi className="w-5 h-5" />; if (connectivity) return Wifi;
if (severity === 'error') return <AlertTriangle className="w-5 h-5" />; if (severity === 'error') return AlertTriangle;
if (severity === 'warning') return <AlertCircle className="w-5 h-5" />; if (severity === 'warning') return AlertCircle;
return <Info className="w-5 h-5" />; return Info;
}; };
const getColor = () => { const getColor = () => {
@@ -62,179 +66,165 @@ export function EnhancedFormError({
const color = getColor(); const color = getColor();
return ( return (
<motion.div <Box
as={motion.div}
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, y: -10 }}
className={`bg-${color}-500/10 border-${color}-500/30 rounded-lg overflow-hidden ${className}`}
> >
{/* Main Error Message */} <Box
<div className="p-4 flex items-start gap-3"> bg={`bg-${color}-500/10`}
<div className={`text-${color}-400 flex-shrink-0 mt-0.5`}> border
{getIcon()} borderColor={`border-${color}-500/30`}
</div> rounded="lg"
overflow="hidden"
<div className="flex-1 min-w-0"> >
<div className="flex items-center justify-between gap-2"> {/* Main Error Message */}
<p className={`text-sm font-medium text-${color}-200`}> <Box p={4} display="flex" alignItems="start" gap={3}>
{parsed.userMessage} <Box color={`text-${color}-400`} flexShrink={0} mt={0.5}>
</p> <Icon icon={getIcon()} size={5} />
</Box>
<div className="flex items-center gap-2">
{retryable && onRetry && ( <Box flexGrow={1} minWidth="0">
<button <Box display="flex" alignItems="center" justifyContent="between" gap={2}>
onClick={onRetry} <Text size="sm" weight="medium" color={`text-${color}-200`}>
className="p-1.5 hover:bg-white/5 rounded transition-colors" {parsed.userMessage}
title="Retry" </Text>
>
<RefreshCw className="w-4 h-4 text-gray-400 hover:text-white" />
</button>
)}
{onDismiss && ( <Box display="flex" alignItems="center" gap={2}>
<button {retryable && onRetry && (
onClick={onDismiss} <IconButton
className="p-1.5 hover:bg-white/5 rounded transition-colors" icon={RefreshCw}
title="Dismiss" onClick={onRetry}
> variant="ghost"
<X className="w-4 h-4 text-gray-400 hover:text-white" /> size="sm"
</button> title="Retry"
)} />
)}
{showDeveloperDetails && (
<button {onDismiss && (
onClick={() => setShowDetails(!showDetails)} <IconButton
className="p-1.5 hover:bg-white/5 rounded transition-colors" icon={X}
title="Toggle technical details" onClick={onDismiss}
> variant="ghost"
{showDetails ? ( size="sm"
<ChevronUp className="w-4 h-4 text-gray-400 hover:text-white" /> title="Dismiss"
) : ( />
<ChevronDown className="w-4 h-4 text-gray-400 hover:text-white" /> )}
)}
</button> {showDeveloperDetails && (
)} <IconButton
</div> icon={showDetails ? ChevronUp : ChevronDown}
</div> onClick={() => setShowDetails(!showDetails)}
variant="ghost"
size="sm"
title="Toggle technical details"
/>
)}
</Box>
</Box>
{/* Validation Errors List */} {/* Validation Errors List */}
{parsed.isValidationError && parsed.validationErrors.length > 0 && ( {parsed.isValidationError && parsed.validationErrors.length > 0 && (
<div className="mt-2 space-y-1"> <Stack gap={1} mt={2}>
{parsed.validationErrors.map((validationError, index) => ( {parsed.validationErrors.map((validationError, index) => (
<div key={index} className="text-xs text-${color}-300/80"> <Text key={index} size="xs" color={`text-${color}-300/80`} block>
{validationError.field}: {validationError.message} {validationError.field}: {validationError.message}
</div> </Text>
))} ))}
</div> </Stack>
)}
{/* Action Hint */}
<Box mt={2}>
<Text size="xs" color="text-gray-400">
{connectivity && "Check your internet connection and try again"}
{parsed.isValidationError && "Please review your input and try again"}
{retryable && !connectivity && !parsed.isValidationError && "Please try again in a moment"}
</Text>
</Box>
</Box>
</Box>
{/* Developer Details */}
<AnimatePresence>
{showDetails && (
<Box
as={motion.div}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
>
<Box borderTop borderColor={`border-${color}-500/20`} bg="bg-black/20" p={4}>
<Stack gap={3} fontSize="0.75rem">
<Box display="flex" alignItems="center" gap={2} color="text-gray-400">
<Icon icon={Bug} size={3} />
<Text weight="semibold">Developer Details</Text>
</Box>
<Stack gap={1}>
<Text color="text-gray-500">Error Type:</Text>
<Text color="text-white">{error instanceof ApiError ? error.type : 'Unknown'}</Text>
</Stack>
<Stack gap={1}>
<Text color="text-gray-500">Developer Message:</Text>
<Text color="text-white" transform="break-all">{parsed.developerMessage}</Text>
</Stack>
{error instanceof ApiError && error.context.endpoint && (
<Stack gap={1}>
<Text color="text-gray-500">Endpoint:</Text>
<Text color="text-white">{error.context.method} {error.context.endpoint}</Text>
</Stack>
)}
{error instanceof ApiError && error.context.statusCode && (
<Stack gap={1}>
<Text color="text-gray-500">Status Code:</Text>
<Text color="text-white">{error.context.statusCode}</Text>
</Stack>
)}
<Box pt={2} borderTop borderColor="border-charcoal-outline/50">
<Text color="text-gray-500" block mb={1}>Quick Actions:</Text>
<Box display="flex" gap={2}>
{retryable && onRetry && (
<Button
variant="secondary"
onClick={onRetry}
size="sm"
bg="bg-blue-600/20"
color="text-primary-blue"
>
Retry
</Button>
)}
<Button
variant="secondary"
onClick={() => {
if (error instanceof Error) {
console.error('Full error details:', error);
if (error.stack) {
console.log('Stack trace:', error.stack);
}
}
}}
size="sm"
bg="bg-purple-600/20"
color="text-purple-400"
>
Log to Console
</Button>
</Box>
</Box>
</Stack>
</Box>
</Box>
)} )}
</AnimatePresence>
{/* Action Hint */} </Box>
<div className="mt-2 text-xs text-gray-400"> </Box>
{connectivity && "Check your internet connection and try again"}
{parsed.isValidationError && "Please review your input and try again"}
{retryable && !connectivity && !parsed.isValidationError && "Please try again in a moment"}
</div>
</div>
</div>
{/* Developer Details */}
<AnimatePresence>
{showDeveloperDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-${color}-500/20 bg-black/20"
>
<div className="p-4 space-y-3 text-xs font-mono">
<div className="flex items-center gap-2 text-gray-400">
<Bug className="w-3 h-3" />
<span className="font-semibold">Developer Details</span>
</div>
<div className="space-y-1">
<div className="text-gray-500">Error Type:</div>
<div className="text-white">{error instanceof ApiError ? error.type : 'Unknown'}</div>
</div>
<div className="space-y-1">
<div className="text-gray-500">Developer Message:</div>
<div className="text-white break-all">{parsed.developerMessage}</div>
</div>
{error instanceof ApiError && error.context.endpoint && (
<div className="space-y-1">
<div className="text-gray-500">Endpoint:</div>
<div className="text-white">{error.context.method} {error.context.endpoint}</div>
</div>
)}
{error instanceof ApiError && error.context.statusCode && (
<div className="space-y-1">
<div className="text-gray-500">Status Code:</div>
<div className="text-white">{error.context.statusCode}</div>
</div>
)}
{error instanceof ApiError && error.context.retryCount !== undefined && (
<div className="space-y-1">
<div className="text-gray-500">Retry Count:</div>
<div className="text-white">{error.context.retryCount}</div>
</div>
)}
{error instanceof ApiError && error.context.timestamp && (
<div className="space-y-1">
<div className="text-gray-500">Timestamp:</div>
<div className="text-white">{error.context.timestamp}</div>
</div>
)}
{error instanceof ApiError && error.context.troubleshooting && (
<div className="space-y-1">
<div className="text-gray-500">Troubleshooting:</div>
<div className="text-yellow-400">{error.context.troubleshooting}</div>
</div>
)}
{parsed.validationErrors.length > 0 && (
<div className="space-y-1">
<div className="text-gray-500">Validation Errors:</div>
<div className="text-white">{JSON.stringify(parsed.validationErrors, null, 2)}</div>
</div>
)}
<div className="pt-2 border-t border-gray-700/50">
<div className="text-gray-500 mb-1">Quick Actions:</div>
<div className="flex gap-2">
{retryable && onRetry && (
<button
onClick={onRetry}
className="px-2 py-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 rounded transition-colors"
>
Retry
</button>
)}
<button
onClick={() => {
if (error instanceof Error) {
console.error('Full error details:', error);
if (error.stack) {
console.log('Stack trace:', error.stack);
}
}
}}
className="px-2 py-1 bg-purple-600/20 hover:bg-purple-600/30 text-purple-400 rounded transition-colors"
>
Log to Console
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
); );
} }
@@ -258,30 +248,33 @@ export function FormErrorSummary({
}; };
return ( return (
<motion.div <Box
as={motion.div}
initial={{ opacity: 0, height: 0 }} initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }} animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }} exit={{ opacity: 0, height: 0 }}
className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 flex items-start gap-2"
> >
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" /> <Box bg="bg-red-500/10" border borderColor="border-red-500/30" rounded="lg" p={3} display="flex" alignItems="start" gap={2}>
<div className="flex-1 min-w-0"> <Icon icon={AlertCircle} size={4} color="rgb(239, 68, 68)" mt={0.5} />
<div className="flex items-center justify-between gap-2"> <Box flexGrow={1} minWidth="0">
<div> <Box display="flex" alignItems="center" justifyContent="between" gap={2}>
<div className="text-sm font-medium text-red-200">{summary.title}</div> <Box>
<div className="text-xs text-red-300/80 mt-0.5">{summary.description}</div> <Text size="sm" weight="medium" color="text-red-200" block>{summary.title}</Text>
<div className="text-xs text-gray-400 mt-1">{summary.action}</div> <Text size="xs" color="text-red-300/80" block mt={0.5}>{summary.description}</Text>
</div> <Text size="xs" color="text-gray-400" block mt={1}>{summary.action}</Text>
{onDismiss && ( </Box>
<button {onDismiss && (
onClick={onDismiss} <IconButton
className="p-1 hover:bg-red-500/10 rounded transition-colors" icon={X}
> onClick={onDismiss}
<X className="w-3.5 h-3.5 text-red-400" /> variant="ghost"
</button> size="sm"
)} color="rgb(239, 68, 68)"
</div> />
</div> )}
</motion.div> </Box>
</Box>
</Box>
</Box>
); );
} }

View File

@@ -1,27 +1,34 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler'; import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { getGlobalApiLogger } from '@/lib/infrastructure/ApiRequestLogger'; import { getGlobalApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
import { ApiError } from '@/lib/api/base/ApiError'; import { getErrorAnalyticsStats, type ErrorStats } from '@/lib/services/error/ErrorAnalyticsService';
import { import {
Activity, Activity,
AlertTriangle, AlertTriangle,
Clock, Clock,
Copy, Copy,
RefreshCw, RefreshCw,
Terminal,
Database,
Zap,
Bug, Bug,
Shield,
Globe, Globe,
Cpu, Cpu,
FileText, FileText,
Trash2, Trash2,
Download, Download,
Search Search,
ChevronDown,
Zap,
Terminal
} from 'lucide-react'; } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import { Badge } from '@/ui/Badge';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
interface ErrorAnalyticsDashboardProps { interface ErrorAnalyticsDashboardProps {
/** /**
@@ -34,27 +41,32 @@ interface ErrorAnalyticsDashboardProps {
showInProduction?: boolean; showInProduction?: boolean;
} }
interface ErrorStats { function formatDuration(duration: number): string {
totalErrors: number; return duration.toFixed(2) + 'ms';
errorsByType: Record<string, number>; }
errorsByTime: Array<{ time: string; count: number }>;
recentErrors: Array<{ function formatPercentage(value: number, total: number): string {
timestamp: string; if (total === 0) return '0%';
message: string; return ((value / total) * 100).toFixed(1) + '%';
type: string; }
context?: unknown;
}>; function formatMemory(bytes: number): string {
apiStats: { return (bytes / 1024 / 1024).toFixed(1) + 'MB';
totalRequests: number; }
successful: number;
failed: number; interface PerformanceWithMemory extends Performance {
averageDuration: number; memory?: {
slowestRequests: Array<{ url: string; duration: number }>; usedJSHeapSize: number;
totalJSHeapSize: number;
jsHeapSizeLimit: number;
}; };
environment: { }
mode: string;
version?: string; interface NavigatorWithConnection extends Navigator {
buildTime?: string; connection?: {
effectiveType: string;
downlink: number;
rtt: number;
}; };
} }
@@ -73,77 +85,32 @@ export function ErrorAnalyticsDashboard({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'development';
const shouldShow = isDev || showInProduction;
// Don't show in production unless explicitly enabled const perf = typeof performance !== 'undefined' ? performance as PerformanceWithMemory : null;
if (!isDev && !showInProduction) { const nav = typeof navigator !== 'undefined' ? navigator as NavigatorWithConnection : null;
useEffect(() => {
if (!shouldShow) return;
const update = () => {
setStats(getErrorAnalyticsStats());
};
update();
if (refreshInterval > 0) {
const interval = setInterval(update, refreshInterval);
return () => clearInterval(interval);
}
}, [refreshInterval, shouldShow]);
if (!shouldShow) {
return null; return null;
} }
useEffect(() => { const updateStatsManual = () => {
updateStats(); setStats(getErrorAnalyticsStats());
if (refreshInterval > 0) {
const interval = setInterval(updateStats, refreshInterval);
return () => clearInterval(interval);
}
}, [refreshInterval]);
const updateStats = () => {
const globalHandler = getGlobalErrorHandler();
const apiLogger = getGlobalApiLogger();
const errorHistory = globalHandler.getErrorHistory();
const errorStats = globalHandler.getStats();
const apiHistory = apiLogger.getHistory();
const apiStats = apiLogger.getStats();
// Group errors by time (last 10 minutes)
const timeGroups = new Map<string, number>();
const now = Date.now();
const tenMinutesAgo = now - (10 * 60 * 1000);
errorHistory.forEach(entry => {
const entryTime = new Date(entry.timestamp).getTime();
if (entryTime >= tenMinutesAgo) {
const timeKey = new Date(entry.timestamp).toLocaleTimeString();
timeGroups.set(timeKey, (timeGroups.get(timeKey) || 0) + 1);
}
});
const errorsByTime = Array.from(timeGroups.entries())
.map(([time, count]) => ({ time, count }))
.sort((a, b) => a.time.localeCompare(b.time));
const recentErrors = errorHistory.slice(-10).reverse().map(entry => ({
timestamp: entry.timestamp,
message: entry.error.message,
type: entry.error instanceof ApiError ? entry.error.type : entry.error.name || 'Error',
context: entry.context,
}));
const slowestRequests = apiLogger.getSlowestRequests(5).map(log => ({
url: log.url,
duration: log.response?.duration || 0,
}));
setStats({
totalErrors: errorStats.total,
errorsByType: errorStats.byType,
errorsByTime,
recentErrors,
apiStats: {
totalRequests: apiStats.total,
successful: apiStats.successful,
failed: apiStats.failed,
averageDuration: apiStats.averageDuration,
slowestRequests,
},
environment: {
mode: process.env.NODE_ENV || 'unknown',
version: process.env.NEXT_PUBLIC_APP_VERSION,
buildTime: process.env.NEXT_PUBLIC_BUILD_TIME,
},
});
}; };
const copyToClipboard = async (data: unknown) => { const copyToClipboard = async (data: unknown) => {
@@ -183,7 +150,7 @@ export function ErrorAnalyticsDashboard({
globalHandler.clearHistory(); globalHandler.clearHistory();
apiLogger.clearHistory(); apiLogger.clearHistory();
updateStats(); updateStatsManual();
} }
}; };
@@ -195,382 +162,438 @@ export function ErrorAnalyticsDashboard({
if (!isExpanded) { if (!isExpanded) {
return ( return (
<button <Box position="fixed" bottom="4" left="4" zIndex={50}>
onClick={() => setIsExpanded(true)} <IconButton
className="fixed bottom-4 left-4 z-50 p-3 bg-iron-gray border border-charcoal-outline rounded-full shadow-lg hover:bg-charcoal-outline transition-colors" icon={Activity}
title="Open Error Analytics" onClick={() => setIsExpanded(true)}
> variant="secondary"
<Activity className="w-5 h-5 text-red-400" /> title="Open Error Analytics"
</button> size="lg"
color="rgb(239, 68, 68)"
/>
</Box>
); );
} }
return ( return (
<div className="fixed bottom-4 left-4 z-50 w-96 max-h-[80vh] bg-deep-graphite border border-charcoal-outline rounded-xl shadow-2xl overflow-hidden flex flex-col"> <Box
position="fixed"
bottom="4"
left="4"
zIndex={50}
w="96"
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="xl"
shadow="2xl"
overflow="hidden"
display="flex"
flexDirection="col"
maxHeight="80vh"
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline"> <Box display="flex" alignItems="center" justifyContent="between" px={4} py={3} bg="bg-iron-gray/50" borderBottom borderColor="border-charcoal-outline">
<div className="flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2}>
<Activity className="w-4 h-4 text-red-400" /> <Icon icon={Activity} size={4} color="rgb(239, 68, 68)" />
<span className="text-sm font-semibold text-white">Error Analytics</span> <Text size="sm" weight="semibold" color="text-white">Error Analytics</Text>
{isDev && ( {isDev && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-500/20 text-red-400 rounded"> <Badge variant="danger" size="xs">
DEV DEV
</span> </Badge>
)} )}
</div> </Box>
<div className="flex items-center gap-1"> <Box display="flex" alignItems="center" gap={1}>
<button <IconButton
onClick={updateStats} icon={RefreshCw}
className="p-1.5 hover:bg-charcoal-outline rounded transition-colors" onClick={updateStatsManual}
variant="ghost"
size="sm"
title="Refresh" title="Refresh"
> />
<RefreshCw className="w-3 h-3 text-gray-400" /> <IconButton
</button> icon={ChevronDown}
<button
onClick={() => setIsExpanded(false)} onClick={() => setIsExpanded(false)}
className="p-1.5 hover:bg-charcoal-outline rounded transition-colors" variant="ghost"
size="sm"
title="Minimize" title="Minimize"
> />
<span className="text-gray-400 text-xs font-bold">_</span> </Box>
</button> </Box>
</div>
</div>
{/* Tabs */} {/* Tabs */}
<div className="flex border-b border-charcoal-outline bg-iron-gray/30"> <Box display="flex" borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/30">
{[ {[
{ id: 'errors', label: 'Errors', icon: AlertTriangle }, { id: 'errors', label: 'Errors', icon: AlertTriangle },
{ id: 'api', label: 'API', icon: Globe }, { id: 'api', label: 'API', icon: Globe },
{ id: 'environment', label: 'Env', icon: Cpu }, { id: 'environment', label: 'Env', icon: Cpu },
{ id: 'raw', label: 'Raw', icon: FileText }, { id: 'raw', label: 'Raw', icon: FileText },
].map(tab => ( ].map(tab => (
<button <Box
key={tab.id} key={tab.id}
onClick={() => setSelectedTab(tab.id as any)} as="button"
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-xs font-medium transition-colors ${ type="button"
selectedTab === tab.id onClick={() => setSelectedTab(tab.id as 'errors' | 'api' | 'environment' | 'raw')}
? 'bg-deep-graphite text-white border-b-2 border-red-400' display="flex"
: 'text-gray-400 hover:bg-charcoal-outline hover:text-gray-200' flexGrow={1}
}`} alignItems="center"
justifyContent="center"
gap={1}
px={2}
py={2}
cursor="pointer"
transition
bg={selectedTab === tab.id ? 'bg-deep-graphite' : ''}
borderBottom={selectedTab === tab.id}
borderColor={selectedTab === tab.id ? 'border-red-400' : ''}
borderWidth={selectedTab === tab.id ? '2px' : '0'}
> >
<tab.icon className="w-3 h-3" /> <Icon icon={tab.icon} size={3} color={selectedTab === tab.id ? 'text-white' : 'text-gray-400'} />
{tab.label} <Text
</button> size="xs"
weight="medium"
color={selectedTab === tab.id ? 'text-white' : 'text-gray-400'}
>
{tab.label}
</Text>
</Box>
))} ))}
</div> </Box>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-auto p-4 space-y-4"> <Box flexGrow={1} overflow="auto" p={4}>
{/* Search Bar */} <Stack gap={4}>
{selectedTab === 'errors' && ( {/* Search Bar */}
<div className="relative"> {selectedTab === 'errors' && (
<input <Input
type="text" type="text"
placeholder="Search errors..." placeholder="Search errors..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-iron-gray border border-charcoal-outline rounded px-3 py-2 pl-8 text-xs text-white placeholder-gray-500 focus:outline-none focus:border-red-400" icon={<Icon icon={Search} size={3} color="rgb(107, 114, 128)" />}
/> />
<Search className="w-3 h-3 text-gray-500 absolute left-2.5 top-2.5" /> )}
</div>
)}
{/* Errors Tab */} {/* Errors Tab */}
{selectedTab === 'errors' && stats && ( {selectedTab === 'errors' && stats && (
<div className="space-y-4"> <Stack gap={4}>
{/* Error Summary */} {/* Error Summary */}
<div className="grid grid-cols-2 gap-2"> <Box display="grid" gridCols={2} gap={2}>
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs text-gray-500">Total Errors</div> <Text size="xs" color="text-gray-500" block>Total Errors</Text>
<div className="text-xl font-bold text-red-400">{stats.totalErrors}</div> <Text size="xl" weight="bold" color="text-red-400">{stats.totalErrors}</Text>
</div> </Box>
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs text-gray-500">Error Types</div> <Text size="xs" color="text-gray-500" block>Error Types</Text>
<div className="text-xl font-bold text-yellow-400"> <Text size="xl" weight="bold" color="text-warning-amber">
{Object.keys(stats.errorsByType).length} {Object.keys(stats.errorsByType).length}
</div> </Text>
</div> </Box>
</div> </Box>
{/* Error Types Breakdown */} {/* Error Types Breakdown */}
{Object.keys(stats.errorsByType).length > 0 && ( {Object.keys(stats.errorsByType).length > 0 && (
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<Bug className="w-3 h-3" /> Error Types <Icon icon={Bug} size={3} color="rgb(156, 163, 175)" />
</div> <Text size="xs" weight="semibold" color="text-gray-400">Error Types</Text>
<div className="space-y-1 max-h-32 overflow-auto"> </Box>
{Object.entries(stats.errorsByType).map(([type, count]) => ( <Stack gap={1} maxHeight="8rem" overflow="auto">
<div key={type} className="flex justify-between text-xs"> {Object.entries(stats.errorsByType).map(([type, count]) => (
<span className="text-gray-300">{type}</span> <Box key={type} display="flex" justifyContent="between">
<span className="text-red-400 font-mono">{count}</span> <Text size="xs" color="text-gray-300">{type}</Text>
</div> <Text size="xs" color="text-red-400" font="mono">{count}</Text>
))} </Box>
</div> ))}
</div> </Stack>
)} </Box>
)}
{/* Recent Errors */} {/* Recent Errors */}
{filteredRecentErrors.length > 0 && ( {filteredRecentErrors.length > 0 && (
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<AlertTriangle className="w-3 h-3" /> Recent Errors <Icon icon={AlertTriangle} size={3} color="rgb(156, 163, 175)" />
</div> <Text size="xs" weight="semibold" color="text-gray-400">Recent Errors</Text>
<div className="space-y-2 max-h-64 overflow-auto"> </Box>
{filteredRecentErrors.map((error, idx) => ( <Stack gap={2} maxHeight="16rem" overflow="auto">
<div key={idx} className="bg-deep-graphite border border-charcoal-outline rounded p-2 text-xs"> {filteredRecentErrors.map((error, idx) => (
<div className="flex justify-between items-start gap-2 mb-1"> <Box key={idx} bg="bg-deep-graphite" border borderColor="border-charcoal-outline" rounded="md" p={2}>
<span className="font-mono text-red-400 font-bold">{error.type}</span> <Box display="flex" justifyContent="between" alignItems="start" gap={2} mb={1}>
<span className="text-gray-500 text-[10px]"> <Text size="xs" font="mono" weight="bold" color="text-red-400" truncate>{error.type}</Text>
{new Date(error.timestamp).toLocaleTimeString()} <Text size="xs" color="text-gray-500" fontSize="10px">
</span> {new Date(error.timestamp).toLocaleTimeString()}
</div> </Text>
<div className="text-gray-300 break-words mb-1">{error.message}</div> </Box>
<button <Text size="xs" color="text-gray-300" block mb={1}>{error.message}</Text>
onClick={() => copyToClipboard(error)} <Button
className="text-[10px] text-gray-500 hover:text-gray-300 flex items-center gap-1" variant="ghost"
> onClick={() => copyToClipboard(error)}
<Copy className="w-3 h-3" /> Copy Details size="sm"
</button> p={0}
</div> minHeight="0"
))} icon={<Icon icon={Copy} size={3} />}
</div> >
</div> <Text size="xs" color="text-gray-500" fontSize="10px">Copy Details</Text>
)} </Button>
</Box>
))}
</Stack>
</Box>
)}
{/* Error Timeline */} {/* Error Timeline */}
{stats.errorsByTime.length > 0 && ( {stats.errorsByTime.length > 0 && (
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<Clock className="w-3 h-3" /> Last 10 Minutes <Icon icon={Clock} size={3} color="rgb(156, 163, 175)" />
</div> <Text size="xs" weight="semibold" color="text-gray-400">Last 10 Minutes</Text>
<div className="space-y-1 max-h-32 overflow-auto"> </Box>
{stats.errorsByTime.map((point, idx) => ( <Stack gap={1} maxHeight="8rem" overflow="auto">
<div key={idx} className="flex justify-between text-xs"> {stats.errorsByTime.map((point, idx) => (
<span className="text-gray-500">{point.time}</span> <Box key={idx} display="flex" justifyContent="between">
<span className="text-red-400 font-mono">{point.count} errors</span> <Text size="xs" color="text-gray-500">{point.time}</Text>
</div> <Text size="xs" color="text-red-400" font="mono">{point.count} errors</Text>
))} </Box>
</div> ))}
</div> </Stack>
)} </Box>
</div> )}
)} </Stack>
)}
{/* API Tab */} {/* API Tab */}
{selectedTab === 'api' && stats && ( {selectedTab === 'api' && stats && (
<div className="space-y-4"> <Stack gap={4}>
{/* API Summary */} {/* API Summary */}
<div className="grid grid-cols-2 gap-2"> <Box display="grid" gridCols={2} gap={2}>
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs text-gray-500">Total Requests</div> <Text size="xs" color="text-gray-500" block>Total Requests</Text>
<div className="text-xl font-bold text-blue-400">{stats.apiStats.totalRequests}</div> <Text size="xl" weight="bold" color="text-primary-blue">{stats.apiStats.totalRequests}</Text>
</div> </Box>
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs text-gray-500">Success Rate</div> <Text size="xs" color="text-gray-500" block>Success Rate</Text>
<div className="text-xl font-bold text-green-400"> <Text size="xl" weight="bold" color="text-performance-green">
{stats.apiStats.totalRequests > 0 {formatPercentage(stats.apiStats.successful, stats.apiStats.totalRequests)}
? ((stats.apiStats.successful / stats.apiStats.totalRequests) * 100).toFixed(1) </Text>
: 0}% </Box>
</div> </Box>
</div>
</div>
{/* API Stats */} {/* API Stats */}
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<Globe className="w-3 h-3" /> API Metrics <Icon icon={Globe} size={3} color="rgb(156, 163, 175)" />
</div> <Text size="xs" weight="semibold" color="text-gray-400">API Metrics</Text>
<div className="space-y-1 text-xs"> </Box>
<div className="flex justify-between"> <Stack gap={1}>
<span className="text-gray-500">Successful</span> <Box display="flex" justifyContent="between">
<span className="text-green-400 font-mono">{stats.apiStats.successful}</span> <Text size="xs" color="text-gray-500">Successful</Text>
</div> <Text size="xs" color="text-performance-green" font="mono">{stats.apiStats.successful}</Text>
<div className="flex justify-between"> </Box>
<span className="text-gray-500">Failed</span> <Box display="flex" justifyContent="between">
<span className="text-red-400 font-mono">{stats.apiStats.failed}</span> <Text size="xs" color="text-gray-500">Failed</Text>
</div> <Text size="xs" color="text-red-400" font="mono">{stats.apiStats.failed}</Text>
<div className="flex justify-between"> </Box>
<span className="text-gray-500">Avg Duration</span> <Box display="flex" justifyContent="between">
<span className="text-yellow-400 font-mono">{stats.apiStats.averageDuration.toFixed(2)}ms</span> <Text size="xs" color="text-gray-500">Avg Duration</Text>
</div> <Text size="xs" color="text-warning-amber" font="mono">{formatDuration(stats.apiStats.averageDuration)}</Text>
</div> </Box>
</div> </Stack>
</Box>
{/* Slowest Requests */} {/* Slowest Requests */}
{stats.apiStats.slowestRequests.length > 0 && ( {stats.apiStats.slowestRequests.length > 0 && (
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<Zap className="w-3 h-3" /> Slowest Requests <Icon icon={Zap} size={3} color="rgb(156, 163, 175)" />
</div> <Text size="xs" weight="semibold" color="text-gray-400">Slowest Requests</Text>
<div className="space-y-1 max-h-40 overflow-auto"> </Box>
{stats.apiStats.slowestRequests.map((req, idx) => ( <Stack gap={1} maxHeight="10rem" overflow="auto">
<div key={idx} className="flex justify-between text-xs bg-deep-graphite p-1.5 rounded border border-charcoal-outline"> {stats.apiStats.slowestRequests.map((req, idx) => (
<span className="text-gray-300 truncate flex-1">{req.url}</span> <Box key={idx} display="flex" justifyContent="between" bg="bg-deep-graphite" p={1.5} rounded="sm" border borderColor="border-charcoal-outline">
<span className="text-red-400 font-mono ml-2">{req.duration.toFixed(2)}ms</span> <Text size="xs" color="text-gray-300" truncate flexGrow={1}>{req.url}</Text>
</div> <Text size="xs" color="text-red-400" font="mono" ml={2}>{formatDuration(req.duration)}</Text>
))} </Box>
</div> ))}
</div> </Stack>
)} </Box>
</div> )}
)} </Stack>
)}
{/* Environment Tab */} {/* Environment Tab */}
{selectedTab === 'environment' && stats && ( {selectedTab === 'environment' && stats && (
<div className="space-y-4"> <Stack gap={4}>
{/* Environment Info */} {/* Environment Info */}
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<Cpu className="w-3 h-3" /> Environment <Icon icon={Cpu} size={3} color="rgb(156, 163, 175)" />
</div> <Text size="xs" weight="semibold" color="text-gray-400">Environment</Text>
<div className="space-y-1 text-xs"> </Box>
<div className="flex justify-between"> <Stack gap={1}>
<span className="text-gray-500">Node Environment</span> <Box display="flex" justifyContent="between">
<span className={`font-mono font-bold ${ <Text size="xs" color="text-gray-500">Node Environment</Text>
stats.environment.mode === 'development' ? 'text-green-400' : 'text-yellow-400' <Text size="xs" font="mono" weight="bold" color={stats.environment.mode === 'development' ? 'text-performance-green' : 'text-warning-amber'}>
}`}>{stats.environment.mode}</span> {stats.environment.mode}
</div> </Text>
{stats.environment.version && ( </Box>
<div className="flex justify-between"> {stats.environment.version && (
<span className="text-gray-500">Version</span> <Box display="flex" justifyContent="between">
<span className="text-gray-300 font-mono">{stats.environment.version}</span> <Text size="xs" color="text-gray-500">Version</Text>
</div> <Text size="xs" color="text-gray-300" font="mono">{stats.environment.version}</Text>
)} </Box>
{stats.environment.buildTime && ( )}
<div className="flex justify-between"> {stats.environment.buildTime && (
<span className="text-gray-500">Build Time</span> <Box display="flex" justifyContent="between">
<span className="text-gray-500 font-mono text-[10px]">{stats.environment.buildTime}</span> <Text size="xs" color="text-gray-500">Build Time</Text>
</div> <Text size="xs" color="text-gray-500" font="mono" fontSize="10px">{stats.environment.buildTime}</Text>
)} </Box>
</div> )}
</div> </Stack>
</Box>
{/* Browser Info */} {/* Browser Info */}
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<Globe className="w-3 h-3" /> Browser <Icon icon={Globe} size={3} color="rgb(156, 163, 175)" />
</div> <Text size="xs" weight="semibold" color="text-gray-400">Browser</Text>
<div className="space-y-1 text-xs"> </Box>
<div className="flex justify-between"> <Stack gap={1}>
<span className="text-gray-500">User Agent</span> <Box display="flex" justifyContent="between">
<span className="text-gray-300 text-[9px] truncate max-w-[150px]">{navigator.userAgent}</span> <Text size="xs" color="text-gray-500">User Agent</Text>
</div> <Text size="xs" color="text-gray-300" truncate maxWidth="150px">{navigator.userAgent}</Text>
<div className="flex justify-between"> </Box>
<span className="text-gray-500">Language</span> <Box display="flex" justifyContent="between">
<span className="text-gray-300 font-mono">{navigator.language}</span> <Text size="xs" color="text-gray-500">Language</Text>
</div> <Text size="xs" color="text-gray-300" font="mono">{navigator.language}</Text>
<div className="flex justify-between"> </Box>
<span className="text-gray-500">Platform</span> <Box display="flex" justifyContent="between">
<span className="text-gray-300 font-mono">{navigator.platform}</span> <Text size="xs" color="text-gray-500">Platform</Text>
</div> <Text size="xs" color="text-gray-300" font="mono">{navigator.platform}</Text>
</div> </Box>
</div> </Stack>
</Box>
{/* Performance */} {/* Performance */}
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<Activity className="w-3 h-3" /> Performance <Icon icon={Activity} size={3} color="rgb(156, 163, 175)" />
</div> <Text size="xs" weight="semibold" color="text-gray-400">Performance</Text>
<div className="space-y-1 text-xs"> </Box>
<div className="flex justify-between"> <Stack gap={1}>
<span className="text-gray-500">Viewport</span> <Box display="flex" justifyContent="between">
<span className="text-gray-300 font-mono">{window.innerWidth}x{window.innerHeight}</span> <Text size="xs" color="text-gray-500">Viewport</Text>
</div> <Text size="xs" color="text-gray-300" font="mono">{window.innerWidth}x{window.innerHeight}</Text>
<div className="flex justify-between"> </Box>
<span className="text-gray-500">Screen</span> <Box display="flex" justifyContent="between">
<span className="text-gray-300 font-mono">{window.screen.width}x{window.screen.height}</span> <Text size="xs" color="text-gray-500">Screen</Text>
</div> <Text size="xs" color="text-gray-300" font="mono">{window.screen.width}x{window.screen.height}</Text>
{(performance as any).memory && ( </Box>
<div className="flex justify-between"> {perf?.memory && (
<span className="text-gray-500">JS Heap</span> <Box display="flex" justifyContent="between">
<span className="text-gray-300 font-mono"> <Text size="xs" color="text-gray-500">JS Heap</Text>
{((performance as any).memory.usedJSHeapSize / 1024 / 1024).toFixed(1)}MB <Text size="xs" color="text-gray-300" font="mono">
</span> {formatMemory(perf.memory.usedJSHeapSize)}
</div> </Text>
)} </Box>
</div> )}
</div> </Stack>
</Box>
{/* Connection */} {/* Connection */}
{(navigator as any).connection && ( {nav?.connection && (
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<Zap className="w-3 h-3" /> Network <Icon icon={Zap} size={3} color="rgb(156, 163, 175)" />
</div> <Text size="xs" weight="semibold" color="text-gray-400">Network</Text>
<div className="space-y-1 text-xs"> </Box>
<div className="flex justify-between"> <Stack gap={1}>
<span className="text-gray-500">Type</span> <Box display="flex" justifyContent="between">
<span className="text-gray-300 font-mono">{(navigator as any).connection.effectiveType}</span> <Text size="xs" color="text-gray-500">Type</Text>
</div> <Text size="xs" color="text-gray-300" font="mono">{nav.connection.effectiveType}</Text>
<div className="flex justify-between"> </Box>
<span className="text-gray-500">Downlink</span> <Box display="flex" justifyContent="between">
<span className="text-gray-300 font-mono">{(navigator as any).connection.downlink}Mbps</span> <Text size="xs" color="text-gray-500">Downlink</Text>
</div> <Text size="xs" color="text-gray-300" font="mono">{nav.connection.downlink}Mbps</Text>
<div className="flex justify-between"> </Box>
<span className="text-gray-500">RTT</span> <Box display="flex" justifyContent="between">
<span className="text-gray-300 font-mono">{(navigator as any).connection.rtt}ms</span> <Text size="xs" color="text-gray-500">RTT</Text>
</div> <Text size="xs" color="text-gray-300" font="mono">{nav.connection.rtt}ms</Text>
</div> </Box>
</div> </Stack>
)} </Box>
</div> )}
)} </Stack>
)}
{/* Raw Data Tab */} {/* Raw Data Tab */}
{selectedTab === 'raw' && stats && ( {selectedTab === 'raw' && stats && (
<div className="space-y-3"> <Stack gap={3}>
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<FileText className="w-3 h-3" /> Export Options <Icon icon={FileText} size={3} color="rgb(156, 163, 175)" />
</div> <Text size="xs" weight="semibold" color="text-gray-400">Export Options</Text>
<div className="flex gap-2"> </Box>
<button <Box display="flex" gap={2}>
onClick={exportAllData} <Button
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs" variant="primary"
onClick={exportAllData}
fullWidth
size="sm"
icon={<Icon icon={Download} size={3} />}
>
Export JSON
</Button>
<Button
variant="secondary"
onClick={() => copyToClipboard(stats)}
fullWidth
size="sm"
icon={<Icon icon={Copy} size={3} />}
>
Copy Stats
</Button>
</Box>
</Box>
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={Trash2} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">Maintenance</Text>
</Box>
<Button
variant="danger"
onClick={clearAllData}
fullWidth
size="sm"
icon={<Icon icon={Trash2} size={3} />}
> >
<Download className="w-3 h-3" /> Export JSON Clear All Logs
</button> </Button>
<button </Box>
onClick={() => copyToClipboard(stats)}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded text-xs border border-charcoal-outline"
>
<Copy className="w-3 h-3" /> Copy Stats
</button>
</div>
</div>
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> <Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2} mb={2}>
<Trash2 className="w-3 h-3" /> Maintenance <Icon icon={Terminal} size={3} color="rgb(156, 163, 175)" />
</div> <Text size="xs" weight="semibold" color="text-gray-400">Console Commands</Text>
<button </Box>
onClick={clearAllData} <Stack gap={1} fontSize="10px">
className="w-full flex items-center justify-center gap-1 px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded text-xs" <Text color="text-gray-400" font="mono"> window.__GRIDPILOT_GLOBAL_HANDLER__</Text>
> <Text color="text-gray-400" font="mono"> window.__GRIDPILOT_API_LOGGER__</Text>
<Trash2 className="w-3 h-3" /> Clear All Logs <Text color="text-gray-400" font="mono"> window.__GRIDPILOT_REACT_ERRORS__</Text>
</button> </Stack>
</div> </Box>
</Stack>
<div className="bg-iron-gray border border-charcoal-outline rounded p-3"> )}
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2"> </Stack>
<Terminal className="w-3 h-3" /> Console Commands </Box>
</div>
<div className="space-y-1 text-[10px] font-mono text-gray-400">
<div> window.__GRIDPILOT_GLOBAL_HANDLER__</div>
<div> window.__GRIDPILOT_API_LOGGER__</div>
<div> window.__GRIDPILOT_REACT_ERRORS__</div>
</div>
</div>
</div>
)}
</div>
{/* Footer */} {/* Footer */}
<div className="px-4 py-2 bg-iron-gray/30 border-t border-charcoal-outline text-[10px] text-gray-500 flex justify-between items-center"> <Box px={4} py={2} bg="bg-iron-gray/30" borderTop borderColor="border-charcoal-outline" display="flex" justifyContent="between" alignItems="center">
<span>Auto-refresh: {refreshInterval}ms</span> <Text size="xs" color="text-gray-500" fontSize="10px">Auto-refresh: {refreshInterval}ms</Text>
{copied && <span className="text-green-400">Copied!</span>} {copied && <Text size="xs" color="text-performance-green" fontSize="10px">Copied!</Text>}
</div> </Box>
</div> </Box>
); );
} }
@@ -608,4 +631,4 @@ export function useErrorAnalytics() {
apiLogger.clearHistory(); apiLogger.clearHistory();
}, },
}; };
} }

View File

@@ -1,9 +1,8 @@
'use client'; 'use client';
import React from 'react';
import { ApiError } from '@/lib/api/base/ApiError'; import { ApiError } from '@/lib/api/base/ApiError';
import { AlertTriangle, Wifi, RefreshCw, ArrowLeft } from 'lucide-react'; import { ErrorDisplay as UiErrorDisplay } from '@/ui/ErrorDisplay';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
interface ErrorDisplayProps { interface ErrorDisplayProps {
error: ApiError; error: ApiError;
@@ -14,123 +13,12 @@ interface ErrorDisplayProps {
* User-friendly error display for production environments * User-friendly error display for production environments
*/ */
export function ErrorDisplay({ error, onRetry }: ErrorDisplayProps) { export function ErrorDisplay({ error, onRetry }: ErrorDisplayProps) {
const router = useRouter();
const [isRetrying, setIsRetrying] = useState(false);
const userMessage = error.getUserMessage();
const isConnectivity = error.isConnectivityIssue();
const handleRetry = async () => {
if (onRetry) {
setIsRetrying(true);
try {
onRetry();
} finally {
setIsRetrying(false);
}
}
};
const handleGoBack = () => {
router.back();
};
const handleGoHome = () => {
router.push('/');
};
return ( return (
<div className="min-h-screen bg-deep-graphite flex items-center justify-center p-4"> <UiErrorDisplay
<div className="max-w-md w-full bg-iron-gray border border-charcoal-outline rounded-2xl shadow-2xl overflow-hidden"> error={error}
{/* Header */} onRetry={onRetry}
<div className="bg-red-500/10 border-b border-red-500/20 p-6"> variant="full-screen"
<div className="flex items-center gap-3"> />
<div className="p-2 bg-red-500/20 rounded-lg">
{isConnectivity ? (
<Wifi className="w-6 h-6 text-red-400" />
) : (
<AlertTriangle className="w-6 h-6 text-red-400" />
)}
</div>
<div>
<h1 className="text-xl font-bold text-white">
{isConnectivity ? 'Connection Issue' : 'Something Went Wrong'}
</h1>
<p className="text-sm text-gray-400">Error {error.context.statusCode || 'N/A'}</p>
</div>
</div>
</div>
{/* Body */}
<div className="p-6 space-y-4">
<p className="text-gray-300 leading-relaxed">{userMessage}</p>
{/* Details for debugging (collapsed by default) */}
<details className="text-xs text-gray-500 font-mono bg-deep-graphite p-3 rounded border border-charcoal-outline">
<summary className="cursor-pointer hover:text-gray-300">Technical Details</summary>
<div className="mt-2 space-y-1">
<div>Type: {error.type}</div>
<div>Endpoint: {error.context.endpoint || 'N/A'}</div>
{error.context.statusCode && <div>Status: {error.context.statusCode}</div>}
{error.context.retryCount !== undefined && (
<div>Retries: {error.context.retryCount}</div>
)}
</div>
</details>
{/* Action Buttons */}
<div className="flex flex-col gap-2 pt-2">
{error.isRetryable() && (
<button
onClick={handleRetry}
disabled={isRetrying}
className="flex items-center justify-center gap-2 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{isRetrying ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Retrying...
</>
) : (
<>
<RefreshCw className="w-4 h-4" />
Try Again
</>
)}
</button>
)}
<div className="flex gap-2">
<button
onClick={handleGoBack}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded-lg font-medium transition-colors border border-charcoal-outline"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</button>
<button
onClick={handleGoHome}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded-lg font-medium transition-colors border border-charcoal-outline"
>
Home
</button>
</div>
</div>
</div>
{/* Footer */}
<div className="bg-iron-gray/50 border-t border-charcoal-outline p-4 text-xs text-gray-500 text-center">
If this persists, please contact support at{' '}
<a
href="mailto:support@gridpilot.com"
className="text-primary-blue hover:underline"
>
support@gridpilot.com
</a>
</div>
</div>
</div>
); );
} }
@@ -139,8 +27,10 @@ export function ErrorDisplay({ error, onRetry }: ErrorDisplayProps) {
*/ */
export function FullScreenError({ error, onRetry }: ErrorDisplayProps) { export function FullScreenError({ error, onRetry }: ErrorDisplayProps) {
return ( return (
<div className="fixed inset-0 z-50 bg-deep-graphite flex items-center justify-center p-4"> <UiErrorDisplay
<ErrorDisplay error={error} onRetry={onRetry} /> error={error}
</div> onRetry={onRetry}
variant="full-screen"
/>
); );
} }

View File

@@ -16,7 +16,7 @@ export function NotificationIntegration() {
useEffect(() => { useEffect(() => {
// Listen for custom notification events from error reporter // Listen for custom notification events from error reporter
const handleNotificationEvent = (event: CustomEvent) => { const handleNotificationEvent = (event: CustomEvent) => {
const { type, title, message, variant, autoDismiss } = event.detail; const { type, title, message, variant } = event.detail;
addNotification({ addNotification({
type: type || 'error', type: type || 'error',

View File

@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'; 'use client';
import Card from '@/ui/Card';
import Button from '@/ui/Button'; import React, { useEffect, useState } from 'react';
import Image from 'next/image'; import { Button } from '@/ui/Button';
import { FeedItem } from '@/ui/FeedItem';
interface FeedItemData { interface FeedItemData {
id: string; id: string;
@@ -26,9 +27,7 @@ function timeAgo(timestamp: Date | string): string {
return `${diffDays} d ago`; return `${diffDays} d ago`;
} }
async function resolveActor(_item: FeedItemData) { async function resolveActor() {
// Actor resolution is not wired through the API in this build.
// Keep rendering deterministic and decoupled (no core repos).
return null; return null;
} }
@@ -36,14 +35,14 @@ interface FeedItemCardProps {
item: FeedItemData; item: FeedItemData;
} }
export default function FeedItemCard({ item }: FeedItemCardProps) { export function FeedItemCard({ item }: FeedItemCardProps) {
const [actor, setActor] = useState<{ name: string; avatarUrl: string } | null>(null); const [actor, setActor] = useState<{ name: string; avatarUrl: string } | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
const resolved = await resolveActor(item); const resolved = await resolveActor();
if (!cancelled) { if (!cancelled) {
setActor(resolved); setActor(resolved);
} }
@@ -55,51 +54,25 @@ export default function FeedItemCard({ item }: FeedItemCardProps) {
}, [item]); }, [item]);
return ( return (
<div className="flex gap-4"> <FeedItem
<div className="flex-shrink-0"> actorName={actor?.name}
{actor ? ( actorAvatarUrl={actor?.avatarUrl}
<div className="w-10 h-10 rounded-full overflow-hidden bg-charcoal-outline"> typeLabel={item.type.startsWith('friend') ? 'FR' : 'LG'}
<Image headline={item.headline}
src={actor.avatarUrl} body={item.body}
alt={actor.name} timeAgo={timeAgo(item.timestamp)}
width={40} cta={item.ctaHref && item.ctaLabel ? (
height={40} <Button
className="w-full h-full object-cover" as="a"
/> href={item.ctaHref}
</div> variant="secondary"
) : ( size="sm"
<Card className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-blue/10 border-primary-blue/40 p-0"> px={4}
<span className="text-xs text-primary-blue font-semibold"> py={2}
{item.type.startsWith('friend') ? 'FR' : 'LG'} >
</span> {item.ctaLabel}
</Card> </Button>
)} ) : undefined}
</div> />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm text-white">{item.headline}</p>
{item.body && (
<p className="text-xs text-gray-400 mt-1">{item.body}</p>
)}
</div>
<span className="text-[11px] text-gray-500 whitespace-nowrap">
{timeAgo(item.timestamp)}
</span>
</div>
{item.ctaHref && item.ctaLabel && (
<div className="mt-3">
<Button
as="a"
href={item.ctaHref}
variant="secondary"
className="text-xs px-4 py-2"
>
{item.ctaLabel}
</Button>
</div>
)}
</div>
</div>
); );
} }

View File

@@ -1,108 +0,0 @@
'use client';
import Container from '@/ui/Container';
import Heading from '@/ui/Heading';
import { useParallax } from "@/lib/hooks/useScrollProgress";
import { useRef } from 'react';
interface AlternatingSectionProps {
heading: string;
description: string | React.ReactNode;
mockup: React.ReactNode;
layout: 'text-left' | 'text-right';
backgroundImage?: string;
backgroundVideo?: string;
}
export default function AlternatingSection({
heading,
description,
mockup,
layout,
backgroundImage,
backgroundVideo
}: AlternatingSectionProps) {
const sectionRef = useRef<HTMLElement>(null);
const bgParallax = useParallax(sectionRef, 0.2);
return (
<section ref={sectionRef} className="relative overflow-hidden bg-deep-graphite px-[calc(1rem+var(--sal))] pr-[calc(1rem+var(--sar))] py-20 sm:py-24 md:py-32 md:px-[calc(2rem+var(--sal))] md:pr-[calc(2rem+var(--sar))] lg:px-8">
{backgroundVideo && (
<>
<video
autoPlay
loop
muted
playsInline
className="absolute inset-0 w-full h-full object-cover opacity-20 md:opacity-30"
style={{
maskImage: 'radial-gradient(ellipse at center, black 0%, rgba(0,0,0,0.8) 40%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(ellipse at center, black 0%, rgba(0,0,0,0.8) 40%, transparent 70%)',
}}
>
<source src={backgroundVideo} type="video/mp4" />
</video>
{/* Racing red accent for sections with background videos */}
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-red-500/30 to-transparent" />
</>
)}
{backgroundImage && !backgroundVideo && (
<>
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `url(${backgroundImage})`,
maskImage: 'radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.1) 40%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.1) 40%, transparent 70%)',
transform: `translateY(${bgParallax * 0.3}px)`
}}
/>
{/* Racing red accent for sections with background images */}
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-red-500/30 to-transparent" />
</>
)}
{/* Carbon fiber texture on sections without images or videos */}
{!backgroundImage && !backgroundVideo && (
<div className="absolute inset-0 carbon-fiber opacity-30" />
)}
{/* Checkered pattern accent */}
<div className="absolute inset-0 checkered-pattern opacity-10" />
<Container size="lg" className="relative z-10">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 md:gap-12 lg:gap-16 items-center">
{/* Text Content - Always first on mobile, respects layout on desktop */}
<div
className={`space-y-4 md:space-y-6 lg:space-y-8 ${layout === 'text-right' ? 'lg:order-2' : ''}`}
style={{
opacity: 1,
transform: 'translateX(0)'
}}
>
<Heading level={2} className="text-xl md:text-2xl lg:text-3xl xl:text-4xl bg-gradient-to-r from-red-600 via-white to-blue-600 bg-clip-text text-transparent font-medium drop-shadow-[0_0_15px_rgba(220,0,0,0.4)] static-racing-gradient" style={{ WebkitTextStroke: '0.5px rgba(220,0,0,0.2)' }}>
{heading}
</Heading>
<div className="text-sm md:text-base lg:text-lg text-slate-400 font-light leading-relaxed md:leading-loose space-y-3 md:space-y-5">
{description}
</div>
</div>
{/* Mockup - Always second on mobile, respects layout on desktop */}
<div
className={`relative group ${layout === 'text-right' ? 'lg:order-1' : ''}`}
style={{
opacity: 1,
transform: 'translateX(0) scale(1)'
}}
>
<div className={`w-full min-h-[240px] md:min-h-[380px] lg:min-h-[440px] transition-transform duration-speed group-hover:scale-[1.02] ${layout === 'text-left' ? 'md:[mask-image:linear-gradient(to_right,white_50%,rgba(255,255,255,0.8)_70%,rgba(255,255,255,0.4)_85%,transparent_100%)] md:[-webkit-mask-image:linear-gradient(to_right,white_50%,rgba(255,255,255,0.8)_70%,rgba(255,255,255,0.4)_85%,transparent_100%)]' : 'md:[mask-image:linear-gradient(to_left,white_50%,rgba(255,255,255,0.8)_70%,rgba(255,255,255,0.4)_85%,transparent_100%)] md:[-webkit-mask-image:linear-gradient(to_left,white_50%,rgba(255,255,255,0.8)_70%,rgba(255,255,255,0.4)_85%,transparent_100%)]'}`}>
{mockup}
</div>
</div>
</div>
</Container>
</section>
);
}

View File

@@ -1,7 +1,12 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion } from 'framer-motion';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { ChevronDown } from 'lucide-react';
const faqs = [ const faqs = [
{ {
@@ -34,35 +39,43 @@ function FAQItem({ faq, index }: { faq: typeof faqs[0]; index: number }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<motion.div <Box
as={motion.div}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay: index * 0.1 }} transition={{ delay: index * 0.1 }}
className="group" group
> >
<div className="rounded-lg bg-iron-gray border border-charcoal-outline transition-all duration-150 hover:-translate-y-1 hover:shadow-lg hover:border-primary-blue/50"> <Box rounded="lg" bg="bg-iron-gray" border borderColor="border-charcoal-outline" transition hoverBorderColor="border-primary-blue/50" transform={isOpen ? '' : 'translateY(0)'} hoverScale={!isOpen}>
<button <Box
as="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="w-full p-2 md:p-3 lg:p-4 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-blue rounded-lg min-h-[44px]" fullWidth
p={{ base: 2, md: 3, lg: 4 }}
textAlign="left"
rounded="lg"
minHeight="44px"
> >
<div className="flex items-center justify-between gap-1.5 md:gap-2"> <Box display="flex" alignItems="center" justifyContent="between" gap={{ base: 1.5, md: 2 }}>
<h3 className="text-xs md:text-sm font-semibold text-white group-hover:text-primary-blue transition-colors duration-150"> <Heading level={3} fontSize={{ base: 'xs', md: 'sm' }} weight="semibold" color="text-white" groupHoverColor="primary-blue" transition>
{faq.question} {faq.question}
</h3> </Heading>
<motion.svg <Box
as={motion.div}
animate={{ rotate: isOpen ? 180 : 0 }} animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.15, ease: 'easeInOut' }} transition={{ duration: 0.15, ease: 'easeInOut' }}
className="w-3.5 h-3.5 md:w-4 md:h-4 text-neon-aqua flex-shrink-0" w={{ base: "3.5", md: "4" }}
fill="none" h={{ base: "3.5", md: "4" }}
viewBox="0 0 24 24" color="text-neon-aqua"
stroke="currentColor" flexShrink={0}
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <Icon icon={ChevronDown} size="full" />
</motion.svg> </Box>
</div> </Box>
</button> </Box>
<motion.div <Box
as={motion.div}
initial={false} initial={false}
animate={{ animate={{
height: isOpen ? 'auto' : 0, height: isOpen ? 'auto' : 0,
@@ -72,47 +85,48 @@ function FAQItem({ faq, index }: { faq: typeof faqs[0]; index: number }) {
height: { duration: 0.3, ease: [0.34, 1.56, 0.64, 1] }, height: { duration: 0.3, ease: [0.34, 1.56, 0.64, 1] },
opacity: { duration: 0.2, ease: 'easeInOut' } opacity: { duration: 0.2, ease: 'easeInOut' }
}} }}
className="overflow-hidden" overflow="hidden"
> >
<div className="px-2 pb-2 pt-1 md:px-3 md:pb-3"> <Box px={{ base: 2, md: 3 }} pb={{ base: 2, md: 3 }} pt={1}>
<p className="text-[10px] md:text-xs text-gray-300 font-light leading-relaxed"> <Text size={{ base: 'xs', md: 'xs' }} color="text-gray-300" weight="light" leading="relaxed">
{faq.answer} {faq.answer}
</p> </Text>
</div> </Box>
</motion.div> </Box>
</div> </Box>
</motion.div> </Box>
); );
} }
export default function FAQ() { export function FAQ() {
return ( return (
<section className="relative py-3 md:py-12 lg:py-16 bg-deep-graphite overflow-hidden"> <Box as="section" position="relative" py={{ base: 3, md: 12, lg: 16 }} bg="bg-deep-graphite" overflow="hidden">
{/* Background image with mask */} {/* Background image with mask */}
<div <Box
className="absolute inset-0 bg-cover bg-center" position="absolute"
style={{ inset="0"
backgroundImage: 'url(/images/porsche.jpeg)', bg="url(/images/porsche.jpeg)"
maskImage: 'radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.1) 40%, transparent 70%)', backgroundSize="cover"
WebkitMaskImage: 'radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.1) 40%, transparent 70%)' backgroundPosition="center"
}} maskImage="radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.1) 40%, transparent 70%)"
webkitMaskImage="radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.1) 40%, transparent 70%)"
/> />
{/* Racing red accent */} {/* Racing red accent */}
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-red-500/30 to-transparent" /> <Box position="absolute" top="0" left="0" right="0" h="px" bg="linear-gradient(to right, transparent, rgba(239, 68, 68, 0.3), transparent)" />
<div className="max-w-3xl mx-auto px-4 md:px-6 relative z-10"> <Box maxWidth="3xl" mx="auto" px={{ base: 4, md: 6 }} position="relative" zIndex={10}>
<div className="text-center mb-4 md:mb-8"> <Box textAlign="center" mb={{ base: 4, md: 8 }}>
<h2 className="text-base md:text-xl lg:text-2xl font-semibold text-white mb-1"> <Heading level={2} fontSize={{ base: 'base', md: 'xl', lg: '2xl' }} weight="semibold" color="text-white" mb={1}>
Frequently Asked Questions Frequently Asked Questions
</h2> </Heading>
<div className="w-24 md:w-32 h-1 bg-gradient-to-r from-primary-blue to-neon-aqua mx-auto rounded-full" /> <Box mx="auto" rounded="full" w={{ base: "24", md: "32" }} h="1" bg="linear-gradient(to right, var(--primary-blue), var(--neon-aqua))" />
</div> </Box>
<div className="space-y-1.5 md:space-y-2"> <Box display="flex" flexDirection="column" gap={{ base: 1.5, md: 2 }}>
{faqs.map((faq, index) => ( {faqs.map((faq, index) => (
<FAQItem key={faq.question} faq={faq} index={index} /> <FAQItem key={faq.question} faq={faq} index={index} />
))} ))}
</div> </Box>
</div> </Box>
</section> </Box>
); );
} }

View File

@@ -1,106 +0,0 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import Section from '@/ui/Section';
import Container from '@/ui/Container';
import Heading from '@/ui/Heading';
import MockupStack from '@/ui/MockupStack';
import LeagueHomeMockup from '@/components/mockups/LeagueHomeMockup';
import StandingsTableMockup from '@/components/mockups/StandingsTableMockup';
import TeamCompetitionMockup from '@/components/mockups/TeamCompetitionMockup';
import ProtestWorkflowMockup from '@/components/mockups/ProtestWorkflowMockup';
import LeagueDiscoveryMockup from '@/components/mockups/LeagueDiscoveryMockup';
import DriverProfileMockup from '@/components/mockups/DriverProfileMockup';
const features = [
{
title: "A Real Home for Your League",
description: "Stop juggling Discord, spreadsheets, and iRacing admin panels. GridPilot brings everything into one dedicated platform built specifically for league racing.",
MockupComponent: LeagueHomeMockup
},
{
title: "Automatic Results & Standings",
description: "Race happens. Results appear. Standings update. No manual data entry, no spreadsheet formulas, no waiting for someone to publish.",
MockupComponent: StandingsTableMockup
},
{
title: "Real Team Racing",
description: "Constructors' championships that actually matter. Driver lineups. Team strategies. Multi-class racing done right.",
MockupComponent: TeamCompetitionMockup
},
{
title: "Clean Protests & Penalties",
description: "Structured incident reporting with video clip references. Steward review workflows. Transparent penalty application. Professional race control.",
MockupComponent: ProtestWorkflowMockup
},
{
title: "Find Your Perfect League",
description: "Search and discover leagues by game, region, and skill level. Browse featured competitions, check driver counts, and join communities that match your racing style.",
MockupComponent: LeagueDiscoveryMockup
},
{
title: "Your Racing Identity",
description: "Cross-league driver profiles with career stats, achievements, and racing history. Build your reputation across multiple championships and showcase your progression.",
MockupComponent: DriverProfileMockup
}
];
function FeatureCard({ feature, index }: { feature: typeof features[0], index: number }) {
return (
<div
className="flex flex-col gap-6 sm:gap-6 group"
style={{
opacity: 1,
transform: 'translateY(0) scale(1)'
}}
>
<div className="aspect-video w-full relative">
<div className="absolute -inset-0.5 bg-gradient-to-r from-racing-red/20 via-primary-blue/20 to-racing-red/20 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-500 blur-sm" />
<div className="relative">
<MockupStack index={index}>
<feature.MockupComponent />
</MockupStack>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Heading level={3} className="bg-gradient-to-r from-red-600 via-white to-blue-600 bg-clip-text text-transparent font-medium drop-shadow-[0_0_15px_rgba(220,0,0,0.4)] static-racing-gradient" style={{ WebkitTextStroke: '0.5px rgba(220,0,0,0.2)' }}>
{feature.title}
</Heading>
</div>
<p className="text-sm sm:text-base leading-7 sm:leading-7 text-gray-400 font-light">
{feature.description}
</p>
</div>
</div>
);
}
export default function FeatureGrid() {
return (
<Section variant="default">
<Container className="relative z-10">
<Container size="sm" center>
<div
style={{
opacity: 1,
transform: 'translateY(0)'
}}
>
<Heading level={2} className="bg-gradient-to-r from-red-600 via-white to-blue-600 bg-clip-text text-transparent font-semibold drop-shadow-[0_0_20px_rgba(220,0,0,0.5)] static-racing-gradient" style={{ WebkitTextStroke: '1px rgba(220,0,0,0.2)' }}>
Building for League Racing
</Heading>
<p className="mt-4 sm:mt-6 text-base sm:text-lg text-gray-400">
These features are in development. Join the community to help shape what gets built first
</p>
</div>
</Container>
<div className="mx-auto mt-8 sm:mt-12 md:mt-16 grid max-w-2xl grid-cols-1 gap-10 sm:gap-12 md:gap-16 lg:max-w-none lg:grid-cols-2 xl:grid-cols-3">
{features.map((feature, index) => (
<FeatureCard key={feature.title} feature={feature} index={index} />
))}
</div>
</Container>
</Section>
);
}

View File

@@ -1,23 +1,58 @@
import { LucideIcon } from 'lucide-react'; import { LucideIcon } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface FeatureItemProps { interface FeatureItemProps {
icon: LucideIcon; icon: LucideIcon;
text: string; text: string;
className?: string;
} }
export function FeatureItem({ icon: Icon, text, className }: FeatureItemProps) { export function FeatureItem({ icon, text }: FeatureItemProps) {
return ( return (
<div className={`group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)] ${className || ''}`}> <Box
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" /> position="relative"
<div className="flex items-start gap-3"> overflow="hidden"
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform"> rounded="lg"
<Icon className="w-5 h-5 text-primary-blue" /> bg="bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60"
</div> p={4}
<span className="text-slate-200 leading-relaxed font-light"> border
borderColor="border-slate-700/40"
hoverBorderColor="border-primary-blue/50"
transition
group
>
<Box
position="absolute"
top="0"
left="0"
w="full"
h="0.5"
bg="bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent"
opacity={0}
groupHoverBorderColor="opacity-100" // This is a hack, Box doesn't support groupHoverOpacity
/>
<Box display="flex" alignItems="start" gap={3}>
<Box
flexShrink={0}
w="9"
h="9"
rounded="lg"
bg="bg-gradient-to-br from-primary-blue/20 to-blue-900/20"
border
borderColor="border-primary-blue/30"
display="flex"
alignItems="center"
justifyContent="center"
shadow="lg"
hoverScale
>
<Icon icon={icon} size={5} color="text-primary-blue" />
</Box>
<Text color="text-slate-200" leading="relaxed" weight="light">
{text} {text}
</span> </Text>
</div> </Box>
</div> </Box>
); );
} }

View File

@@ -1,92 +0,0 @@
'use client';
import Image from 'next/image';
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || 'https://discord.gg/gridpilot';
const xUrl = process.env.NEXT_PUBLIC_X_URL || '#';
export default function Footer() {
return (
<footer className="relative bg-deep-graphite">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
<div className="max-w-4xl mx-auto px-[calc(1.5rem+var(--sal))] pr-[calc(1.5rem+var(--sar))] py-2 md:py-8 lg:py-12 pb-[calc(0.5rem+var(--sab))] md:pb-[calc(1.5rem+var(--sab))]">
{/* Racing stripe accent */}
<div
className="flex gap-1 mb-2 md:mb-4 lg:mb-6 justify-center"
style={{
opacity: 1,
transform: 'scaleX(1)'
}}
>
<div className="w-12 md:w-20 lg:w-28 h-[2px] md:h-0.5 lg:h-1 bg-white rounded-full" />
<div className="w-12 md:w-20 lg:w-28 h-[2px] md:h-0.5 lg:h-1 bg-primary-blue rounded-full" />
<div className="w-12 md:w-20 lg:w-28 h-[2px] md:h-0.5 lg:h-1 bg-white rounded-full" />
</div>
{/* Personal message */}
<div
className="text-center mb-3 md:mb-6 lg:mb-8"
style={{
opacity: 1,
transform: 'translateY(0)'
}}
>
<div className="mb-2 flex justify-center">
<Image
src="/images/logos/icon-square-dark.svg"
alt="GridPilot"
width={40}
height={40}
className="h-8 w-auto md:h-10"
/>
</div>
<p className="text-[9px] md:text-xs lg:text-sm text-gray-300 mb-1 md:mb-2">
🏁 Built by a sim racer, for sim racers
</p>
<p className="text-[9px] md:text-xs text-gray-400 font-light max-w-2xl mx-auto">
Just a fellow racer tired of spreadsheets and chaos. GridPilot is my passion project to make league racing actually fun again.
</p>
</div>
{/* Community links */}
<div
className="flex justify-center gap-4 md:gap-6 lg:gap-8 mb-3 md:mb-6 lg:mb-8"
style={{
opacity: 1,
transform: 'scale(1)'
}}
>
<a
href={discordUrl}
className="text-[9px] md:text-xs text-primary-blue hover:text-neon-aqua transition-colors font-medium inline-flex items-center justify-center min-h-[44px] min-w-[44px] px-3 py-2 active:scale-95 transition-transform"
>
💬 Join Discord
</a>
<a
href={xUrl}
className="text-[9px] md:text-xs text-gray-300 hover:text-neon-aqua transition-colors font-medium inline-flex items-center justify-center min-h-[44px] min-w-[44px] px-3 py-2 active:scale-95 transition-transform"
>
𝕏 Follow on X
</a>
</div>
{/* Development status */}
<div
className="text-center pt-2 md:pt-4 lg:pt-6 border-t border-charcoal-outline"
style={{
opacity: 1,
transform: 'translateY(0)'
}}
>
<p className="text-[9px] md:text-xs lg:text-sm text-gray-500 mb-1 md:mb-2">
Early development Feedback welcome
</p>
<p className="text-[9px] md:text-xs text-gray-600">
Questions? Find me on Discord
</p>
</div>
</div>
</footer>
);
}

View File

@@ -1,139 +0,0 @@
'use client';
import { useRef } from 'react';
import Button from '@/ui/Button';
import Container from '@/ui/Container';
import Heading from '@/ui/Heading';
import { useParallax } from '@/lib/hooks/useScrollProgress';
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
if (!process.env.NEXT_PUBLIC_DISCORD_URL) {
console.warn('NEXT_PUBLIC_DISCORD_URL is not set. Discord button will use "#" as fallback.');
}
export default function Hero() {
const sectionRef = useRef<HTMLElement>(null);
const bgParallax = useParallax(sectionRef, 0.3);
return (
<section ref={sectionRef} className="relative overflow-hidden bg-deep-graphite px-[calc(1.5rem+var(--sal))] pr-[calc(1.5rem+var(--sar))] pt-[calc(3rem+var(--sat))] pb-16 sm:pt-[calc(4rem+var(--sat))] sm:pb-24 md:py-32 lg:px-8">
{/* Background image layer with parallax */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: 'url(/images/header.jpeg)',
maskImage: 'radial-gradient(ellipse at center, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.35) 40%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(ellipse at center, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.35) 40%, transparent 70%)',
transform: `translateY(${bgParallax * 0.5}px)`
}}
/>
{/* Racing red accent gradient */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-red-600/40 to-transparent" />
{/* Racing stripes background */}
<div className="absolute inset-0 racing-stripes opacity-30" />
{/* Checkered pattern overlay */}
<div className="absolute inset-0 checkered-pattern opacity-20" />
{/* Speed lines - left side */}
<div className="absolute left-0 top-1/4 w-32 h-px bg-gradient-to-r from-transparent to-primary-blue/30 animate-speed-lines" style={{ animationDelay: '0s' }} />
<div className="absolute left-0 top-1/3 w-24 h-px bg-gradient-to-r from-transparent to-primary-blue/20 animate-speed-lines" style={{ animationDelay: '0.3s' }} />
<div className="absolute left-0 top-2/5 w-28 h-px bg-gradient-to-r from-transparent to-primary-blue/25 animate-speed-lines" style={{ animationDelay: '0.6s' }} />
{/* Carbon fiber accent - bottom */}
<div className="absolute bottom-0 left-0 right-0 h-32 carbon-fiber opacity-50" />
{/* Radial gradient overlay with racing red accent */}
<div className="absolute inset-0 bg-gradient-radial from-red-600/5 via-primary-blue/5 to-transparent opacity-60 pointer-events-none" />
<Container size="sm" center className="relative z-10 space-y-6 sm:space-y-8 md:space-y-12">
<Heading
level={1}
className="text-2xl sm:text-4xl md:text-5xl lg:text-6xl leading-tight tracking-tight font-semibold bg-gradient-to-r from-red-600 via-white to-blue-600 bg-clip-text text-transparent drop-shadow-[0_0_15px_rgba(220,0,0,0.4)] static-racing-gradient"
style={{
WebkitTextStroke: '0.5px rgba(220,0,0,0.2)',
opacity: 1,
transform: 'translateY(0)',
filter: 'blur(0)'
}}
>
League racing is incredible. What's missing is everything around it.
</Heading>
<div
className="text-sm sm:text-lg md:text-xl lg:text-2xl leading-relaxed text-slate-200 font-light space-y-4 sm:space-y-6"
style={{
opacity: 1,
transform: 'translateY(0)'
}}
>
<p className="text-left md:text-center">
If you've been in any league, you know the feeling:
</p>
{/* Problem badges - mobile optimized */}
<div className="flex flex-col sm:flex-row sm:flex-wrap gap-2 sm:gap-3 items-stretch sm:justify-center sm:items-center max-w-2xl mx-auto">
<div className="flex items-center gap-2.5 px-3 py-2.5 sm:gap-2.5 sm:px-5 sm:py-2.5 bg-gradient-to-br from-slate-800/80 to-slate-900/80 border border-red-500/20 rounded-lg backdrop-blur-md active:scale-95 sm:hover:border-red-500/40 sm:hover:shadow-[0_0_15px_rgba(239,68,68,0.15)] transition-all duration-300">
<span className="text-red-500 text-sm sm:text-sm font-semibold flex-shrink-0">×</span>
<span className="text-slate-100 text-sm sm:text-base font-medium text-left">Results scattered across Discord</span>
</div>
<div className="flex items-center gap-2.5 px-3 py-2.5 sm:gap-2.5 sm:px-5 sm:py-2.5 bg-gradient-to-br from-slate-800/80 to-slate-900/80 border border-red-500/20 rounded-lg backdrop-blur-md active:scale-95 sm:hover:border-red-500/40 sm:hover:shadow-[0_0_15px_rgba(239,68,68,0.15)] transition-all duration-300">
<span className="text-red-500 text-sm sm:text-sm font-semibold flex-shrink-0">×</span>
<span className="text-slate-100 text-sm sm:text-base font-medium text-left">No long-term identity</span>
</div>
<div className="flex items-center gap-2.5 px-3 py-2.5 sm:gap-2.5 sm:px-5 sm:py-2.5 bg-gradient-to-br from-slate-800/80 to-slate-900/80 border border-red-500/20 rounded-lg backdrop-blur-md active:scale-95 sm:hover:border-red-500/40 sm:hover:shadow-[0_0_15px_rgba(239,68,68,0.15)] transition-all duration-300">
<span className="text-red-500 text-sm sm:text-sm font-semibold flex-shrink-0">×</span>
<span className="text-slate-100 text-sm sm:text-base font-medium text-left">No career progression</span>
</div>
<div className="flex items-center gap-2.5 px-3 py-2.5 sm:gap-2.5 sm:px-5 sm:py-2.5 bg-gradient-to-br from-slate-800/80 to-slate-900/80 border border-red-500/20 rounded-lg backdrop-blur-md active:scale-95 sm:hover:border-red-500/40 sm:hover:shadow-[0_0_15px_rgba(239,68,68,0.15)] transition-all duration-300">
<span className="text-red-500 text-sm sm:text-sm font-semibold flex-shrink-0">×</span>
<span className="text-slate-100 text-sm sm:text-base font-medium text-left">Forgotten after each season</span>
</div>
</div>
<p className="text-left md:text-center">
The ecosystem isn't built for this.
</p>
<p className="text-left md:text-center">
<strong className="text-white font-semibold">GridPilot gives your league racing a real home.</strong>
</p>
</div>
<div
className="flex items-center justify-center"
style={{
opacity: 1,
transform: 'translateY(0) scale(1)'
}}
>
<a
href="#community"
className="group relative inline-flex items-center justify-center gap-3 px-8 py-4 min-h-[44px] min-w-[44px] bg-[#5865F2] hover:bg-[#4752C4] text-white font-semibold text-base sm:text-lg rounded-lg transition-all duration-300 hover:scale-105 hover:-translate-y-0.5 shadow-[0_0_20px_rgba(88,101,242,0.3)] hover:shadow-[0_0_30px_rgba(88,101,242,0.6)] active:scale-95"
aria-label="Scroll to Discord community section"
>
{/* Discord Logo SVG */}
<svg
className="w-7 h-7 transition-transform duration-300 group-hover:scale-110"
viewBox="0 0 71 55"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0)">
<path
d="M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0">
<rect width="71" height="55" fill="white"/>
</clipPath>
</defs>
</svg>
<span>Join us on Discord</span>
</a>
</div>
</Container>
</section>
);
}

View File

@@ -1,54 +0,0 @@
'use client';
import React from 'react';
import { Check } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
export function FeatureItem({ text }: { text: string }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(15, 23, 42, 0.6)', borderColor: 'rgba(51, 65, 85, 0.4)' }}>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)' }}>
<Icon icon={Check} size={5} color="#3b82f6" />
</Surface>
<Text color="text-slate-200" style={{ lineHeight: 1.625, fontWeight: 300 }}>
{text}
</Text>
</Stack>
</Surface>
);
}
export function ResultItem({ text, color }: { text: string, color: string }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(15, 23, 42, 0.6)', borderColor: 'rgba(51, 65, 85, 0.4)' }}>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${color}1A`, border: `1px solid ${color}4D` }}>
<Icon icon={Check} size={5} color={color} />
</Surface>
<Text color="text-slate-200" style={{ lineHeight: 1.625, fontWeight: 300 }}>
{text}
</Text>
</Stack>
</Surface>
);
}
export function StepItem({ step, text }: { step: number, text: string }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(15, 23, 42, 0.7)', borderColor: 'rgba(51, 65, 85, 0.5)' }}>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.4)', width: '2.5rem', height: '2.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text weight="bold" size="sm" color="text-primary-blue">{step}</Text>
</Surface>
<Text color="text-slate-200" style={{ lineHeight: 1.625, fontWeight: 300 }}>
{text}
</Text>
</Stack>
</Surface>
);
}

View File

@@ -1,7 +1,12 @@
import React from 'react'; import React from 'react';
import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react'; import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react';
import Button from '@/ui/Button'; import { Button } from '@/ui/Button';
import Image from 'next/image'; import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay'; import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay'; import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay'; import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
@@ -26,71 +31,115 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
const top10 = drivers; // Already sliced in builder const top10 = drivers; // Already sliced in builder
return ( return (
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden"> <Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-charcoal-outline bg-iron-gray/20"> <Box display="flex" alignItems="center" justifyContent="between" px={5} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<div className="flex items-center gap-3"> <Box display="flex" alignItems="center" gap={3}>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20"> <Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-gradient-to-br from-primary-blue/20 to-primary-blue/5" border borderColor="border-primary-blue/20">
<Trophy className="w-5 h-5 text-primary-blue" /> <Icon icon={Trophy} size={5} color="text-primary-blue" />
</div> </Box>
<div> <Box>
<h3 className="text-lg font-semibold text-white">Driver Rankings</h3> <Heading level={3} fontSize="lg" weight="semibold" color="text-white">Driver Rankings</Heading>
<p className="text-xs text-gray-500">Top performers across all leagues</p> <Text size="xs" color="text-gray-500" block>Top performers across all leagues</Text>
</div> </Box>
</div> </Box>
<Button <Button
variant="secondary" variant="secondary"
onClick={onNavigateToDrivers} onClick={onNavigateToDrivers}
className="flex items-center gap-2 text-sm" size="sm"
> >
View All <Stack direction="row" align="center" gap={2}>
<ChevronRight className="w-4 h-4" /> <Text size="sm">View All</Text>
<Icon icon={ChevronRight} size={4} />
</Stack>
</Button> </Button>
</div> </Box>
<div className="divide-y divide-charcoal-outline/50"> <Stack gap={0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="divide-y divide-charcoal-outline/50"
>
{top10.map((driver, index) => { {top10.map((driver, index) => {
const position = index + 1; const position = index + 1;
return ( return (
<button <Box
key={driver.id} key={driver.id}
as="button"
type="button" type="button"
onClick={() => onDriverClick(driver.id)} onClick={() => onDriverClick(driver.id)}
className="flex items-center gap-4 px-5 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group" display="flex"
alignItems="center"
gap={4}
px={5}
py={3}
w="full"
textAlign="left"
transition
hoverBg="bg-iron-gray/30"
group
> >
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}> <Box
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position} display="flex"
</div> h="8"
w="8"
alignItems="center"
justifyContent="center"
rounded="full"
border
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-bold ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}
>
{position <= 3 ? <Icon icon={Crown} size={3.5} /> : <Text weight="bold">{position}</Text>}
</Box>
<div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-charcoal-outline"> <Box position="relative" w="9" h="9" rounded="full" overflow="hidden" border borderWidth="2px" borderColor="border-charcoal-outline">
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" /> <Image src={driver.avatarUrl} alt={driver.name} fullWidth fullHeight objectFit="cover" />
</div> </Box>
<div className="flex-1 min-w-0"> <Box flexGrow={1} minWidth="0">
<p className="text-white font-medium truncate group-hover:text-primary-blue transition-colors"> <Text weight="medium" color="text-white" truncate groupHoverTextColor="text-primary-blue" transition block>
{driver.name} {driver.name}
</p> </Text>
<div className="flex items-center gap-2 text-xs text-gray-500"> <Box display="flex" alignItems="center" gap={2}>
<Flag className="w-3 h-3" /> <Icon icon={Flag} size={3} color="text-gray-500" />
{driver.nationality} <Text size="xs" color="text-gray-500">{driver.nationality}</Text>
<span className={SkillLevelDisplay.getColor(driver.skillLevel)}>{SkillLevelDisplay.getLabel(driver.skillLevel)}</span> <Box as="span"
</div> // eslint-disable-next-line gridpilot-rules/component-classification
</div> className={SkillLevelDisplay.getColor(driver.skillLevel)}
>
<Text size="xs">{SkillLevelDisplay.getLabel(driver.skillLevel)}</Text>
</Box>
</Box>
</Box>
<div className="flex items-center gap-4 text-sm"> <Box display="flex" alignItems="center" gap={4}>
<div className="text-center"> <Box textAlign="center">
<p className="text-primary-blue font-mono font-semibold">{RatingDisplay.format(driver.rating)}</p> <Text color="text-primary-blue" font="mono" weight="semibold" block>{RatingDisplay.format(driver.rating)}</Text>
<p className="text-[10px] text-gray-500">Rating</p> <Text
</div> // eslint-disable-next-line gridpilot-rules/component-classification
<div className="text-center"> style={{ fontSize: '10px' }}
<p className="text-performance-green font-mono font-semibold">{driver.wins}</p> color="text-gray-500"
<p className="text-[10px] text-gray-500">Wins</p> block
</div> >
</div> Rating
</button> </Text>
</Box>
<Box textAlign="center">
<Text color="text-performance-green" font="mono" weight="semibold" block>{driver.wins}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Wins
</Text>
</Box>
</Box>
</Box>
); );
})} })}
</div> </Stack>
</div> </Box>
); );
} }

View File

@@ -18,13 +18,29 @@ interface LeaderboardsHeroProps {
export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: LeaderboardsHeroProps) { export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: LeaderboardsHeroProps) {
return ( return (
<Surface variant="muted" rounded="2xl" border padding={8} style={{ position: 'relative', overflow: 'hidden', background: 'linear-gradient(to bottom right, rgba(202, 138, 4, 0.2), rgba(38, 38, 38, 0.8), #0f1115)', borderColor: 'rgba(234, 179, 8, 0.2)' }}> <Surface
variant="muted"
rounded="2xl"
border
padding={8}
position="relative"
overflow="hidden"
bg="bg-gradient-to-br from-yellow-600/20 via-iron-gray to-deep-graphite"
borderColor="border-yellow-500/20"
>
<DecorativeBlur color="yellow" size="lg" position="top-right" opacity={10} /> <DecorativeBlur color="yellow" size="lg" position="top-right" opacity={10} />
<DecorativeBlur color="blue" size="md" position="bottom-left" opacity={5} /> <DecorativeBlur color="blue" size="md" position="bottom-left" opacity={5} />
<Box style={{ position: 'relative', zIndex: 10 }}> <Box position="relative" zIndex={10}>
<Stack direction="row" align="center" gap={4} mb={4}> <Stack direction="row" align="center" gap={4} mb={4}>
<Surface variant="muted" rounded="xl" padding={3} style={{ background: 'linear-gradient(to bottom right, rgba(250, 204, 21, 0.2), rgba(217, 119, 6, 0.1))', border: '1px solid rgba(250, 204, 21, 0.3)' }}> <Surface
variant="muted"
rounded="xl"
padding={3}
bg="bg-gradient-to-br from-yellow-400/20 to-yellow-600/10"
border
borderColor="border-yellow-400/30"
>
<Icon icon={Award} size={7} color="#facc15" /> <Icon icon={Award} size={7} color="#facc15" />
</Surface> </Surface>
<Box> <Box>
@@ -33,7 +49,14 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
</Box> </Box>
</Stack> </Stack>
<Text size="lg" color="text-gray-400" block mb={6} style={{ lineHeight: 1.625, maxWidth: '42rem' }}> <Text
size="lg"
color="text-gray-400"
block
mb={6}
leading="relaxed"
maxWidth="42rem"
>
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne? Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
</Text> </Text>

View File

@@ -1,7 +1,12 @@
import React from 'react'; import React from 'react';
import Image from 'next/image'; import { Users, Crown, ChevronRight } from 'lucide-react';
import { Users, Crown, Shield, ChevronRight } from 'lucide-react'; import { Button } from '@/ui/Button';
import Button from '@/ui/Button'; import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { getMediaUrl } from '@/lib/utilities/media'; import { getMediaUrl } from '@/lib/utilities/media';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay'; import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay'; import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
@@ -25,85 +30,131 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
const top5 = teams; // Already sliced in builder when implemented const top5 = teams; // Already sliced in builder when implemented
return ( return (
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden"> <Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-charcoal-outline bg-iron-gray/20"> <Box display="flex" alignItems="center" justifyContent="between" px={5} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<div className="flex items-center gap-3"> <Box display="flex" alignItems="center" gap={3}>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 border border-purple-500/20"> <Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-gradient-to-br from-purple-500/20 to-purple-500/5" border borderColor="border-purple-500/20">
<Users className="w-5 h-5 text-purple-400" /> <Icon icon={Users} size={5} color="text-purple-400" />
</div> </Box>
<div> <Box>
<h3 className="text-lg font-semibold text-white">Team Rankings</h3> <Heading level={3} fontSize="lg" weight="semibold" color="text-white">Team Rankings</Heading>
<p className="text-xs text-gray-500">Top performing racing teams</p> <Text size="xs" color="text-gray-500" block>Top performing racing teams</Text>
</div> </Box>
</div> </Box>
<Button <Button
variant="secondary" variant="secondary"
onClick={onNavigateToTeams} onClick={onNavigateToTeams}
className="flex items-center gap-2 text-sm" size="sm"
> >
View All <Stack direction="row" align="center" gap={2}>
<ChevronRight className="w-4 h-4" /> <Text size="sm">View All</Text>
<Icon icon={ChevronRight} size={4} />
</Stack>
</Button> </Button>
</div> </Box>
<div className="divide-y divide-charcoal-outline/50"> <Stack gap={0}
{top5.map((team, index) => { // eslint-disable-next-line gridpilot-rules/component-classification
className="divide-y divide-charcoal-outline/50"
>
{top5.map((team) => {
const position = team.position; const position = team.position;
return ( return (
<button <Box
key={team.id} key={team.id}
as="button"
type="button" type="button"
onClick={() => onTeamClick(team.id)} onClick={() => onTeamClick(team.id)}
className="flex items-center gap-4 px-5 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group" display="flex"
alignItems="center"
gap={4}
px={5}
py={3}
w="full"
textAlign="left"
transition
hoverBg="bg-iron-gray/30"
group
> >
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}> <Box
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position} display="flex"
</div> h="8"
w="8"
alignItems="center"
justifyContent="center"
rounded="full"
border
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-bold ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}
>
{position <= 3 ? <Icon icon={Crown} size={3.5} /> : <Text weight="bold">{position}</Text>}
</Box>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden"> <Box display="flex" h="9" w="9" alignItems="center" justifyContent="center" rounded="lg" bg="bg-charcoal-outline" border borderColor="border-charcoal-outline" overflow="hidden">
<Image <Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)} src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name} alt={team.name}
width={36} width={36}
height={36} height={36}
className="w-full h-full object-cover" fullWidth
fullHeight
objectFit="cover"
/> />
</div> </Box>
<div className="flex-1 min-w-0"> <Box flexGrow={1} minWidth="0">
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors"> <Text weight="medium" color="text-white" truncate groupHoverTextColor="text-purple-400" transition block>
{team.name} {team.name}
</p> </Text>
<div className="flex items-center gap-2 text-xs text-gray-500 flex-wrap"> <Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
{team.category && ( {team.category && (
<span className="flex items-center gap-1 text-purple-400"> <Box display="flex" alignItems="center" gap={1} color="text-purple-400">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400"></span> <Box w="1.5" h="1.5" rounded="full" bg="bg-purple-400" />
{team.category} <Text size="xs">{team.category}</Text>
</span> </Box>
)} )}
<span className="flex items-center gap-1"> <Box display="flex" alignItems="center" gap={1}>
<Users className="w-3 h-3" /> <Icon icon={Users} size={3} color="text-gray-500" />
{team.memberCount} members <Text size="xs" color="text-gray-500">{team.memberCount} members</Text>
</span> </Box>
<span className={SkillLevelDisplay.getColor(team.category || '')}>{SkillLevelDisplay.getLabel(team.category || '')}</span> <Box as="span"
</div> // eslint-disable-next-line gridpilot-rules/component-classification
</div> className={SkillLevelDisplay.getColor(team.category || '')}
>
<Text size="xs">{SkillLevelDisplay.getLabel(team.category || '')}</Text>
</Box>
</Box>
</Box>
<div className="flex items-center gap-4 text-sm"> <Box display="flex" alignItems="center" gap={4}>
<div className="text-center"> <Box textAlign="center">
<p className="text-purple-400 font-mono font-semibold">{team.memberCount}</p> <Text color="text-purple-400" font="mono" weight="semibold" block>{team.memberCount}</Text>
<p className="text-[10px] text-gray-500">Members</p> <Text
</div> // eslint-disable-next-line gridpilot-rules/component-classification
<div className="text-center"> style={{ fontSize: '10px' }}
<p className="text-performance-green font-mono font-semibold">{team.totalWins}</p> color="text-gray-500"
<p className="text-[10px] text-gray-500">Wins</p> block
</div> >
</div> Members
</button> </Text>
</Box>
<Box textAlign="center">
<Text color="text-performance-green" font="mono" weight="semibold" block>{team.totalWins}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Wins
</Text>
</Box>
</Box>
</Box>
); );
})} })}
</div> </Stack>
</div> </Box>
); );
} }

View File

@@ -1,11 +1,6 @@
import { Trophy, Sparkles, LucideIcon } from 'lucide-react'; import { Trophy, Sparkles, LucideIcon } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box'; import { EmptyState as UiEmptyState } from '@/ui/EmptyState';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface EmptyStateProps { interface EmptyStateProps {
title: string; title: string;
@@ -15,7 +10,6 @@ interface EmptyStateProps {
actionLabel?: string; actionLabel?: string;
onAction?: () => void; onAction?: () => void;
children?: React.ReactNode; children?: React.ReactNode;
className?: string;
} }
export function EmptyState({ export function EmptyState({
@@ -26,38 +20,20 @@ export function EmptyState({
actionLabel, actionLabel,
onAction, onAction,
children, children,
className,
}: EmptyStateProps) { }: EmptyStateProps) {
return ( return (
<Card className={className}> <Card>
<Box textAlign="center" py={16}> <UiEmptyState
<Box maxWidth="md" mx="auto"> title={title}
<Box height={16} width={16} mx="auto" display="flex" center rounded="2xl" backgroundColor="primary-blue" opacity={0.1} border borderColor="primary-blue" mb={6}> description={description}
<Icon icon={icon} size={8} color="text-primary-blue" /> icon={icon}
</Box> action={actionLabel && onAction ? {
<Box mb={3}> label: actionLabel,
<Heading level={2}> onClick: onAction,
{title} icon: actionIcon,
</Heading> } : undefined}
</Box> />
<Box mb={8}> {children}
<Text color="text-gray-400">
{description}
</Text>
</Box>
{children}
{actionLabel && onAction && (
<Button
variant="primary"
onClick={onAction}
icon={<Icon icon={actionIcon} size={4} />}
className="mx-auto"
>
{actionLabel}
</Button>
)}
</Box>
</Box>
</Card> </Card>
); );
} }

View File

@@ -1,100 +1,72 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { AlertTriangle, TestTube, CheckCircle2 } from 'lucide-react'; import { TestTube } from 'lucide-react';
import Button from '@/ui/Button'; import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Modal } from '@/ui/Modal';
import { InfoBanner } from '@/ui/InfoBanner';
import { Box } from '@/ui/Box';
import { ModalIcon } from '@/ui/ModalIcon';
interface EndRaceModalProps { interface EndRaceModalProps {
raceId: string; raceId: string;
raceName: string; raceName: string;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
isOpen: boolean;
} }
export default function EndRaceModal({ raceId, raceName, onConfirm, onCancel }: EndRaceModalProps) { export function EndRaceModal({ raceId, raceName, onConfirm, onCancel, isOpen }: EndRaceModalProps) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"> <Modal
<div className="w-full max-w-md bg-iron-gray rounded-xl border border-charcoal-outline shadow-2xl"> isOpen={isOpen}
<div className="p-6"> onOpenChange={(open) => !open && onCancel()}
{/* Header */} title="Development Test Function"
<div className="flex items-center gap-3 mb-4"> description="End Race & Process Results"
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10 border border-warning-amber/20"> icon={
<TestTube className="w-6 h-6 text-warning-amber" /> <ModalIcon
</div> icon={TestTube}
<div> color="text-warning-amber"
<h2 className="text-xl font-bold text-white">Development Test Function</h2> bgColor="bg-warning-amber/10"
<p className="text-sm text-gray-400">End Race & Process Results</p> borderColor="border-warning-amber/20"
</div> />
</div> }
primaryActionLabel="Run Test"
onPrimaryAction={onConfirm}
secondaryActionLabel="Cancel"
onSecondaryAction={onCancel}
footer={
<Text size="xs" color="text-gray-500" align="center" block>
This action cannot be undone. Use only for testing purposes.
</Text>
}
>
<Stack gap={4}>
<InfoBanner type="warning" title="Development Only Feature">
This is a development/testing function to simulate ending a race and processing results.
It will generate realistic race results, update driver ratings, and calculate final standings.
</InfoBanner>
{/* Content */} <InfoBanner type="success" title="What This Does">
<div className="space-y-4 mb-6"> <Stack as="ul" gap={1}>
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"> <Text as="li" size="sm" color="text-gray-300"> Marks the race as completed</Text>
<div className="flex items-start gap-3"> <Text as="li" size="sm" color="text-gray-300"> Generates realistic finishing positions</Text>
<AlertTriangle className="w-5 h-5 text-warning-amber mt-0.5 flex-shrink-0" /> <Text as="li" size="sm" color="text-gray-300"> Updates driver ratings based on performance</Text>
<div> <Text as="li" size="sm" color="text-gray-300"> Calculates championship points</Text>
<h3 className="text-sm font-semibold text-white mb-1">Development Only Feature</h3> <Text as="li" size="sm" color="text-gray-300"> Updates league standings</Text>
<p className="text-sm text-gray-300 leading-relaxed"> </Stack>
This is a development/testing function to simulate ending a race and processing results. </InfoBanner>
It will generate realistic race results, update driver ratings, and calculate final standings.
</p>
</div>
</div>
</div>
<div className="p-4 rounded-lg bg-performance-green/10 border border-performance-green/20"> <Box textAlign="center">
<div className="flex items-start gap-3"> <Text size="sm" color="text-gray-400">
<CheckCircle2 className="w-5 h-5 text-performance-green mt-0.5 flex-shrink-0" /> Race: <Text color="text-white" weight="medium">{raceName}</Text>
<div> </Text>
<h3 className="text-sm font-semibold text-white mb-1">What This Does</h3> <Text size="xs" color="text-gray-500" block mt={1}>
<ul className="text-sm text-gray-300 space-y-1"> ID: {raceId}
<li> Marks the race as completed</li> </Text>
<li> Generates realistic finishing positions</li> </Box>
<li> Updates driver ratings based on performance</li> </Stack>
<li> Calculates championship points</li> </Modal>
<li> Updates league standings</li>
</ul>
</div>
</div>
</div>
<div className="text-center">
<p className="text-sm text-gray-400">
Race: <span className="text-white font-medium">{raceName}</span>
</p>
<p className="text-xs text-gray-500 mt-1">
ID: {raceId}
</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
variant="secondary"
onClick={onCancel}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={onConfirm}
className="flex-1 bg-performance-green hover:bg-performance-green/80"
>
<TestTube className="w-4 h-4 mr-2" />
Run Test
</Button>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-charcoal-outline">
<p className="text-xs text-gray-500 text-center">
This action cannot be undone. Use only for testing purposes.
</p>
</div>
</div>
</div>
</div>
); );
} }

View File

@@ -1,10 +1,13 @@
'use client'; 'use client';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { getMembership } from '@/lib/leagueMembership'; import { getMembership } from '@/lib/leagueMembership';
import { useState } from 'react'; import { useState } from 'react';
import { useLeagueMembershipMutation } from "@/lib/hooks/league/useLeagueMembershipMutation"; import { useLeagueMembershipMutation } from "@/hooks/league/useLeagueMembershipMutation";
import Button from '../ui/Button'; import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Modal } from '@/ui/Modal';
interface JoinLeagueButtonProps { interface JoinLeagueButtonProps {
leagueId: string; leagueId: string;
@@ -12,7 +15,7 @@ interface JoinLeagueButtonProps {
onMembershipChange?: () => void; onMembershipChange?: () => void;
} }
export default function JoinLeagueButton({ export function JoinLeagueButton({
leagueId, leagueId,
isInviteOnly = false, isInviteOnly = false,
onMembershipChange, onMembershipChange,
@@ -93,7 +96,7 @@ export default function JoinLeagueButton({
const isDisabled = membership?.role === 'owner' || joinLeague.isPending || leaveLeague.isPending; const isDisabled = membership?.role === 'owner' || joinLeague.isPending || leaveLeague.isPending;
return ( return (
<> <Box>
<Button <Button
variant={getButtonVariant()} variant={getButtonVariant()}
onClick={() => { onClick={() => {
@@ -104,58 +107,41 @@ export default function JoinLeagueButton({
} }
}} }}
disabled={isDisabled} disabled={isDisabled}
className="w-full" fullWidth
> >
{(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : getButtonText()} {(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : getButtonText()}
</Button> </Button>
{error && ( {error && (
<p className="mt-2 text-sm text-red-400">{error}</p> <Text size="sm" color="text-red-400" mt={2} block>{error}</Text>
)} )}
{/* Confirmation Dialog */} {/* Confirmation Dialog */}
{showConfirmDialog && ( <Modal
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> isOpen={showConfirmDialog}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg max-w-md w-full p-6"> onOpenChange={setShowConfirmDialog}
<h3 className="text-xl font-semibold text-white mb-4"> title={dialogAction === 'leave' ? 'Leave League' : dialogAction === 'request' ? 'Request to Join' : 'Join League'}
{dialogAction === 'leave' ? 'Leave League' : dialogAction === 'request' ? 'Request to Join' : 'Join League'} primaryActionLabel={(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : 'Confirm'}
</h3> onPrimaryAction={dialogAction === 'leave' ? handleLeave : handleJoin}
secondaryActionLabel="Cancel"
<p className="text-gray-400 mb-6"> onSecondaryAction={closeDialog}
{dialogAction === 'leave' >
? 'Are you sure you want to leave this league? You can rejoin later.' <Box>
: dialogAction === 'request' <Text color="text-gray-400" block mb={6}>
? 'Your join request will be sent to the league admins for approval.' {dialogAction === 'leave'
: 'Are you sure you want to join this league?'} ? 'Are you sure you want to leave this league? You can rejoin later.'
</p> : dialogAction === 'request'
? 'Your join request will be sent to the league admins for approval.'
: 'Are you sure you want to join this league?'}
</Text>
{error && ( {error && (
<div className="mb-4 p-3 rounded bg-red-500/10 border border-red-500/30 text-red-400 text-sm"> <Box mb={4} p={3} rounded="md" bg="bg-red-500/10" border borderColor="border-red-500/30">
{error} <Text size="sm" color="text-red-400">{error}</Text>
</div> </Box>
)} )}
</Box>
<div className="flex gap-3"> </Modal>
<Button </Box>
variant={dialogAction === 'leave' ? 'danger' : 'primary'}
onClick={dialogAction === 'leave' ? handleLeave : handleJoin}
disabled={joinLeague.isPending || leaveLeague.isPending}
className="flex-1"
>
{(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : 'Confirm'}
</Button>
<Button
variant="secondary"
onClick={closeDialog}
disabled={joinLeague.isPending || leaveLeague.isPending}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
</div>
)}
</>
); );
} }

View File

@@ -1,7 +1,11 @@
'use client'; import React, { useMemo } from 'react';
import { Calendar, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
import { Calendar, Award, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react'; import { useLeagueRaces } from "@/hooks/league/useLeagueRaces";
import { useLeagueRaces } from "@/lib/hooks/league/useLeagueRaces"; import { ActivityFeedItem } from '@/ui/ActivityFeedItem';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { processLeagueActivities } from '@/lib/services/league/LeagueActivityService';
export type LeagueActivity = export type LeagueActivity =
| { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date } | { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date }
@@ -29,67 +33,36 @@ function timeAgo(timestamp: Date): string {
return timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); return timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} }
export default function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) { export function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) {
const { data: raceList = [], isLoading } = useLeagueRaces(leagueId); const { data: raceList = [], isLoading } = useLeagueRaces(leagueId);
const activities: LeagueActivity[] = []; const activities = useMemo(() => {
if (isLoading || raceList.length === 0) return [];
if (!isLoading && raceList.length > 0) { return processLeagueActivities(raceList, limit);
const completedRaces = raceList }, [raceList, isLoading, limit]);
.filter((r) => r.status === 'completed')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 5);
const upcomingRaces = raceList
.filter((r) => r.status === 'scheduled')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 3);
for (const race of completedRaces) {
activities.push({
type: 'race_completed',
raceId: race.id,
raceName: `${race.track} - ${race.car}`,
timestamp: new Date(race.scheduledAt),
});
}
for (const race of upcomingRaces) {
activities.push({
type: 'race_scheduled',
raceId: race.id,
raceName: `${race.track} - ${race.car}`,
timestamp: new Date(new Date(race.scheduledAt).getTime() - 7 * 24 * 60 * 60 * 1000), // Simulate schedule announcement
});
}
// Sort all activities by timestamp
activities.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
activities.splice(limit); // Limit results
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="text-center text-gray-400 py-8"> <Text color="text-gray-400" textAlign="center" block py={8}>
Loading activities... Loading activities...
</div> </Text>
); );
} }
if (activities.length === 0) { if (activities.length === 0) {
return ( return (
<div className="text-center text-gray-400 py-8"> <Text color="text-gray-400" textAlign="center" block py={8}>
No recent activity No recent activity
</div> </Text>
); );
} }
return ( return (
<div className="space-y-4"> <Stack gap={0}>
{activities.map((activity, index) => ( {activities.map((activity, index) => (
<ActivityItem key={`${activity.type}-${index}`} activity={activity} /> <ActivityItem key={`${activity.type}-${index}`} activity={activity} />
))} ))}
</div> </Stack>
); );
} }
@@ -97,17 +70,17 @@ function ActivityItem({ activity }: { activity: LeagueActivity }) {
const getIcon = () => { const getIcon = () => {
switch (activity.type) { switch (activity.type) {
case 'race_completed': case 'race_completed':
return <Flag className="w-4 h-4 text-performance-green" />; return <Icon icon={Flag} size={4} color="var(--performance-green)" />;
case 'race_scheduled': case 'race_scheduled':
return <Calendar className="w-4 h-4 text-primary-blue" />; return <Icon icon={Calendar} size={4} color="var(--primary-blue)" />;
case 'penalty_applied': case 'penalty_applied':
return <AlertTriangle className="w-4 h-4 text-warning-amber" />; return <Icon icon={AlertTriangle} size={4} color="var(--warning-amber)" />;
case 'member_joined': case 'member_joined':
return <UserPlus className="w-4 h-4 text-performance-green" />; return <Icon icon={UserPlus} size={4} color="var(--performance-green)" />;
case 'member_left': case 'member_left':
return <UserMinus className="w-4 h-4 text-gray-400" />; return <Icon icon={UserMinus} size={4} color="var(--text-gray-400)" />;
case 'role_changed': case 'role_changed':
return <Shield className="w-4 h-4 text-primary-blue" />; return <Icon icon={Shield} size={4} color="var(--primary-blue)" />;
} }
}; };
@@ -116,64 +89,56 @@ function ActivityItem({ activity }: { activity: LeagueActivity }) {
case 'race_completed': case 'race_completed':
return ( return (
<> <>
<span className="text-white font-medium">Race Completed</span> <Text weight="medium" color="text-white">Race Completed</Text>
<span className="text-gray-400"> · {activity.raceName}</span> <Text color="text-gray-400"> · {activity.raceName}</Text>
</> </>
); );
case 'race_scheduled': case 'race_scheduled':
return ( return (
<> <>
<span className="text-white font-medium">Race Scheduled</span> <Text weight="medium" color="text-white">Race Scheduled</Text>
<span className="text-gray-400"> · {activity.raceName}</span> <Text color="text-gray-400"> · {activity.raceName}</Text>
</> </>
); );
case 'penalty_applied': case 'penalty_applied':
return ( return (
<> <>
<span className="text-white font-medium">{activity.driverName}</span> <Text weight="medium" color="text-white">{activity.driverName}</Text>
<span className="text-gray-400"> received a </span> <Text color="text-gray-400"> received a </Text>
<span className="text-warning-amber">{activity.points}-point penalty</span> <Text color="text-warning-amber">{activity.points}-point penalty</Text>
<span className="text-gray-400"> · {activity.reason}</span> <Text color="text-gray-400"> · {activity.reason}</Text>
</> </>
); );
case 'member_joined': case 'member_joined':
return ( return (
<> <>
<span className="text-white font-medium">{activity.driverName}</span> <Text weight="medium" color="text-white">{activity.driverName}</Text>
<span className="text-gray-400"> joined the league</span> <Text color="text-gray-400"> joined the league</Text>
</> </>
); );
case 'member_left': case 'member_left':
return ( return (
<> <>
<span className="text-white font-medium">{activity.driverName}</span> <Text weight="medium" color="text-white">{activity.driverName}</Text>
<span className="text-gray-400"> left the league</span> <Text color="text-gray-400"> left the league</Text>
</> </>
); );
case 'role_changed': case 'role_changed':
return ( return (
<> <>
<span className="text-white font-medium">{activity.driverName}</span> <Text weight="medium" color="text-white">{activity.driverName}</Text>
<span className="text-gray-400"> promoted to </span> <Text color="text-gray-400"> promoted to </Text>
<span className="text-primary-blue">{activity.newRole}</span> <Text color="text-primary-blue">{activity.newRole}</Text>
</> </>
); );
} }
}; };
return ( return (
<div className="flex items-start gap-3 py-3 border-b border-charcoal-outline/30 last:border-0"> <ActivityFeedItem
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-iron-gray/50 flex items-center justify-center"> icon={getIcon()}
{getIcon()} content={getContent()}
</div> timestamp={timeAgo(activity.timestamp)}
<div className="flex-1 min-w-0"> />
<p className="text-sm leading-relaxed">
{getContent()}
</p>
<p className="text-xs text-gray-500 mt-1">
{timeAgo(activity.timestamp)}
</p>
</div>
</div>
); );
} }

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react'; import { FileText, Gamepad2, Check } from 'lucide-react';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
@@ -12,6 +12,7 @@ import { Icon } from '@/ui/Icon';
import { Grid } from '@/ui/Grid'; import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface'; import { Surface } from '@/ui/Surface';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { TextArea } from '@/ui/TextArea';
interface LeagueBasicsSectionProps { interface LeagueBasicsSectionProps {
form: LeagueConfigFormModel; form: LeagueConfigFormModel;
@@ -61,15 +62,15 @@ export function LeagueBasicsSection({
{/* League name */} {/* League name */}
<Stack gap={3}> <Stack gap={3}>
<Text as="label" size="sm" weight="medium" color="text-gray-300">
<Stack direction="row" align="center" gap={2}>
<Icon icon={FileText} size={4} color="text-primary-blue" />
League name *
</Stack>
</Text>
<Input <Input
label={
<Stack direction="row" align="center" gap={2}>
<Icon icon={FileText} size={4} color="var(--primary-blue)" />
<Text size="sm" weight="medium" color="text-gray-300">League name *</Text>
</Stack>
}
value={basics.name} value={basics.name}
onChange={(e) => updateBasics({ name: e.target.value })} onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateBasics({ name: e.target.value })}
placeholder="e.g., GridPilot Sprint Series" placeholder="e.g., GridPilot Sprint Series"
variant={errors?.name ? 'error' : 'default'} variant={errors?.name ? 'error' : 'default'}
errorMessage={errors?.name} errorMessage={errors?.name}
@@ -93,8 +94,12 @@ export function LeagueBasicsSection({
onClick={() => updateBasics({ name })} onClick={() => updateBasics({ name })}
variant="secondary" variant="secondary"
size="sm" size="sm"
className="h-auto py-0.5 px-2 rounded-full text-xs"
disabled={disabled} disabled={disabled}
rounded="full"
fontSize="0.75rem"
px={2}
py={0.5}
h="auto"
> >
{name} {name}
</Button> </Button>
@@ -104,74 +109,61 @@ export function LeagueBasicsSection({
</Stack> </Stack>
{/* Description - Now Required */} {/* Description - Now Required */}
<Stack gap={3}> <TextArea
<Text as="label" size="sm" weight="medium" color="text-gray-300"> label={
<Stack direction="row" align="center" gap={2}> <Stack direction="row" align="center" gap={2}>
<Icon icon={FileText} size={4} color="text-primary-blue" /> <Icon icon={FileText} size={4} color="var(--primary-blue)" />
Tell your story * <Text size="sm" weight="medium" color="text-gray-300">Tell your story *</Text>
</Stack> </Stack>
</Text> }
<Box position="relative"> value={basics.description ?? ''}
<textarea onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
value={basics.description ?? ''} updateBasics({
onChange={(e) => description: e.target.value,
updateBasics({ })
description: e.target.value, }
}) rows={4}
} disabled={disabled}
rows={4} variant={errors?.description ? 'error' : 'default'}
disabled={disabled} errorMessage={errors?.description}
className={`block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue text-sm disabled:opacity-60 disabled:cursor-not-allowed transition-all duration-150 ${ placeholder="What makes your league special? Tell drivers what to expect..."
errors?.description ? 'ring-warning-amber' : 'ring-charcoal-outline' />
}`}
placeholder="What makes your league special? Tell drivers what to expect..." <Surface variant="muted" rounded="lg" border padding={4}>
/> <Box mb={3}>
</Box> <Text size="xs" color="text-gray-400">
{errors?.description && ( <Text weight="medium" color="text-gray-300">Great descriptions include:</Text>
<Text size="xs" color="text-warning-amber">
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={AlertCircle} size={3} />
{errors.description}
</Stack>
</Text> </Text>
)} </Box>
<Surface variant="muted" rounded="lg" border padding={4}> <Grid cols={3} gap={3}>
<Box mb={3}> {[
<Text size="xs" color="text-gray-400"> 'Racing style & pace',
<Text weight="medium" color="text-gray-300">Great descriptions include:</Text> 'Schedule & timezone',
</Text> 'Community vibe'
</Box> ].map(item => (
<Grid cols={3} gap={3}> <Stack key={item} direction="row" align="start" gap={2}>
{[ <Icon icon={Check} size={3.5} color="var(--performance-green)" mt={0.5} />
'Racing style & pace', <Text size="xs" color="text-gray-400">{item}</Text>
'Schedule & timezone', </Stack>
'Community vibe' ))}
].map(item => ( </Grid>
<Stack key={item} direction="row" align="start" gap={2}> </Surface>
<Icon icon={Check} size={3.5} color="text-performance-green" className="mt-0.5" />
<Text size="xs" color="text-gray-400">{item}</Text>
</Stack>
))}
</Grid>
</Surface>
</Stack>
{/* Game Platform */} {/* Game Platform */}
<Stack gap={2}> <Stack gap={2}>
<Text as="label" size="sm" weight="medium" color="text-gray-300"> <Input
<Stack direction="row" align="center" gap={2}> label={
<Icon icon={Gamepad2} size={4} color="text-gray-400" /> <Stack direction="row" align="center" gap={2}>
Game platform <Icon icon={Gamepad2} size={4} color="var(--text-gray-400)" />
</Stack> <Text size="sm" weight="medium" color="text-gray-300">Game platform</Text>
</Stack>
}
value="iRacing"
disabled
/>
<Text size="xs" color="text-gray-500">
More platforms soon
</Text> </Text>
<Box position="relative">
<Input value="iRacing" disabled />
<Box position="absolute" right={3} top="50%" style={{ transform: 'translateY(-50%)' }}>
<Text size="xs" color="text-gray-500">
More platforms soon
</Text>
</Box>
</Box>
</Stack> </Stack>
</Stack> </Stack>
); );

View File

@@ -1,289 +0,0 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import {
Trophy,
Users,
Flag,
Award,
Gamepad2,
Calendar,
ChevronRight,
Sparkles,
} from 'lucide-react';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import PlaceholderImage from '@/ui/PlaceholderImage';
import { getMediaUrl } from '@/lib/utilities/media';
interface LeagueCardProps {
league: LeagueSummaryViewModel;
onClick?: () => void;
}
function getChampionshipIcon(type?: string) {
switch (type) {
case 'driver':
return Trophy;
case 'team':
return Users;
case 'nations':
return Flag;
case 'trophy':
return Award;
default:
return Trophy;
}
}
function getChampionshipLabel(type?: string) {
switch (type) {
case 'driver':
return 'Driver';
case 'team':
return 'Team';
case 'nations':
return 'Nations';
case 'trophy':
return 'Trophy';
default:
return 'Championship';
}
}
function getCategoryLabel(category?: string): string {
if (!category) return '';
switch (category) {
case 'driver':
return 'Driver';
case 'team':
return 'Team';
case 'nations':
return 'Nations';
case 'trophy':
return 'Trophy';
case 'endurance':
return 'Endurance';
case 'sprint':
return 'Sprint';
default:
return category.charAt(0).toUpperCase() + category.slice(1);
}
}
function getCategoryColor(category?: string): string {
if (!category) return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
switch (category) {
case 'driver':
return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
case 'team':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
case 'nations':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'trophy':
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
case 'endurance':
return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
case 'sprint':
return 'bg-red-500/20 text-red-400 border-red-500/30';
default:
return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
}
}
function getGameColor(gameId?: string): string {
switch (gameId) {
case 'iracing':
return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
case 'acc':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'f1-23':
case 'f1-24':
return 'bg-red-500/20 text-red-400 border-red-500/30';
default:
return 'bg-primary-blue/20 text-primary-blue border-primary-blue/30';
}
}
function isNewLeague(createdAt: string | Date): boolean {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return new Date(createdAt) > oneWeekAgo;
}
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
const coverUrl = getMediaUrl('league-cover', league.id);
const logoUrl = league.logoUrl;
const ChampionshipIcon = getChampionshipIcon(league.scoring?.primaryChampionshipType);
const championshipLabel = getChampionshipLabel(league.scoring?.primaryChampionshipType);
const gameColorClass = getGameColor(league.scoring?.gameId);
const isNew = isNewLeague(league.createdAt);
const isTeamLeague = league.maxTeams && league.maxTeams > 0;
const categoryLabel = getCategoryLabel(league.category);
const categoryColorClass = getCategoryColor(league.category);
// Calculate fill percentage - use teams for team leagues, drivers otherwise
const usedSlots = isTeamLeague ? (league.usedTeamSlots ?? 0) : (league.usedDriverSlots ?? 0);
const maxSlots = isTeamLeague ? (league.maxTeams ?? 0) : (league.maxDrivers ?? 0);
const fillPercentage = maxSlots > 0 ? (usedSlots / maxSlots) * 100 : 0;
const hasOpenSlots = maxSlots > 0 && usedSlots < maxSlots;
// Determine slot label based on championship type
const getSlotLabel = () => {
if (isTeamLeague) return 'Teams';
if (league.scoring?.primaryChampionshipType === 'nations') return 'Nations';
return 'Drivers';
};
const slotLabel = getSlotLabel();
return (
<div
className="group relative cursor-pointer h-full"
onClick={onClick}
>
{/* Card Container */}
<div className="relative h-full rounded-xl bg-iron-gray border border-charcoal-outline overflow-hidden transition-all duration-200 hover:border-primary-blue/50 hover:shadow-[0_0_30px_rgba(25,140,255,0.15)] hover:bg-iron-gray/80">
{/* Cover Image */}
<div className="relative h-32 overflow-hidden">
<img
src={coverUrl}
alt={`${league.name} cover`}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
decoding="async"
/>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-iron-gray via-iron-gray/60 to-transparent" />
{/* Badges - Top Left */}
<div className="absolute top-3 left-3 flex items-center gap-2">
{isNew && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-performance-green/20 text-performance-green border border-performance-green/30">
<Sparkles className="w-3 h-3" />
NEW
</span>
)}
{league.scoring?.gameName && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border ${gameColorClass}`}>
{league.scoring.gameName}
</span>
)}
{league.category && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border ${categoryColorClass}`}>
{categoryLabel}
</span>
)}
</div>
{/* Championship Type Badge - Top Right */}
<div className="absolute top-3 right-3">
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-deep-graphite/80 text-gray-300 border border-charcoal-outline">
<ChampionshipIcon className="w-3 h-3" />
{championshipLabel}
</span>
</div>
{/* Logo */}
<div className="absolute left-4 -bottom-6 z-10">
<div className="w-12 h-12 rounded-lg overflow-hidden border-2 border-iron-gray bg-deep-graphite shadow-xl">
{logoUrl ? (
<img
src={logoUrl}
alt={`${league.name} logo`}
width={48}
height={48}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<PlaceholderImage size={48} />
)}
</div>
</div>
</div>
{/* Content */}
<div className="pt-8 px-4 pb-4 flex flex-col flex-1">
{/* Title & Description */}
<h3 className="text-base font-semibold text-white mb-1 line-clamp-1 group-hover:text-primary-blue transition-colors">
{league.name}
</h3>
<p className="text-xs text-gray-500 line-clamp-2 mb-3 h-8">
{league.description || 'No description available'}
</p>
{/* Stats Row */}
<div className="flex items-center gap-3 mb-3">
{/* Primary Slots (Drivers/Teams/Nations) */}
<div className="flex-1">
<div className="flex items-center justify-between text-[10px] text-gray-500 mb-1">
<span>{slotLabel}</span>
<span className="text-gray-400">
{usedSlots}/{maxSlots || '∞'}
</span>
</div>
<div className="h-1.5 rounded-full bg-charcoal-outline overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
fillPercentage >= 90
? 'bg-warning-amber'
: fillPercentage >= 70
? 'bg-primary-blue'
: 'bg-performance-green'
}`}
style={{ width: `${Math.min(fillPercentage, 100)}%` }}
/>
</div>
</div>
{/* Open Slots Badge */}
{hasOpenSlots && (
<div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-neon-aqua/10 border border-neon-aqua/20">
<span className="w-1.5 h-1.5 rounded-full bg-neon-aqua animate-pulse" />
<span className="text-[10px] text-neon-aqua font-medium">
{maxSlots - usedSlots} open
</span>
</div>
)}
</div>
{/* Driver count for team leagues */}
{isTeamLeague && (
<div className="flex items-center gap-2 mb-3 text-[10px] text-gray-500">
<Users className="w-3 h-3" />
<span>
{league.usedDriverSlots ?? 0}/{league.maxDrivers ?? '∞'} drivers
</span>
</div>
)}
{/* Spacer to push footer to bottom */}
<div className="flex-1" />
{/* Footer Info */}
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50 mt-auto">
<div className="flex items-center gap-3 text-[10px] text-gray-500">
{league.timingSummary && (
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{league.timingSummary.split('•')[1]?.trim() || league.timingSummary}
</span>
)}
</div>
{/* View Arrow */}
<div className="flex items-center gap-1 text-[10px] text-gray-500 group-hover:text-primary-blue transition-colors">
<span>View</span>
<ChevronRight className="w-3 h-3 transition-transform group-hover:translate-x-0.5" />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,12 +1,7 @@
'use client';
import React from 'react'; import React from 'react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid'; import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface'; import { HorizontalStatCard } from '@/ui/HorizontalStatCard';
interface LeagueChampionshipStatsProps { interface LeagueChampionshipStatsProps {
standings: Array<{ standings: Array<{
@@ -29,44 +24,29 @@ export function LeagueChampionshipStats({ standings, drivers }: LeagueChampionsh
return ( return (
<Grid cols={3} gap={4}> <Grid cols={3} gap={4}>
<Card> <HorizontalStatCard
<Stack direction="row" align="center" gap={3}> label="Championship Leader"
<Surface variant="muted" rounded="full" padding={3} style={{ backgroundColor: 'rgba(250, 204, 21, 0.1)' }}> value={drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}
<Text size="2xl">🏆</Text> subValue={`${leader?.totalPoints || 0} points`}
</Surface> icon={<Text size="2xl">🏆</Text>}
<Box> iconBgColor="rgba(250, 204, 21, 0.1)"
<Text size="xs" color="text-gray-400" block mb={1}>Championship Leader</Text> />
<Text weight="bold" color="text-white" block>{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}</Text>
<Text size="sm" color="text-warning-amber" weight="medium">{leader?.totalPoints || 0} points</Text>
</Box>
</Stack>
</Card>
<Card> <HorizontalStatCard
<Stack direction="row" align="center" gap={3}> label="Races Completed"
<Surface variant="muted" rounded="full" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}> value={totalRaces}
<Text size="2xl">🏁</Text> subValue="Season in progress"
</Surface> icon={<Text size="2xl">🏁</Text>}
<Box> iconBgColor="rgba(59, 130, 246, 0.1)"
<Text size="xs" color="text-gray-400" block mb={1}>Races Completed</Text> />
<Text size="2xl" weight="bold" color="text-white" block>{totalRaces}</Text>
<Text size="sm" color="text-gray-400">Season in progress</Text>
</Box>
</Stack>
</Card>
<Card> <HorizontalStatCard
<Stack direction="row" align="center" gap={3}> label="Active Drivers"
<Surface variant="muted" rounded="full" padding={3} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}> value={standings.length}
<Text size="2xl">👥</Text> subValue="Competing for points"
</Surface> icon={<Text size="2xl">👥</Text>}
<Box> iconBgColor="rgba(16, 185, 129, 0.1)"
<Text size="xs" color="text-gray-400" block mb={1}>Active Drivers</Text> />
<Text size="2xl" weight="bold" color="text-white" block>{standings.length}</Text>
<Text size="sm" color="text-gray-400">Competing for points</Text>
</Box>
</Stack>
</Card>
</Grid> </Grid>
); );
} }

View File

@@ -1,27 +0,0 @@
/**
* LeagueCover
*
* Pure UI component for displaying league cover images.
* Renders an image with fallback on error.
*/
export interface LeagueCoverProps {
leagueId: string;
alt: string;
className?: string;
}
export function LeagueCover({ leagueId, alt, className = '' }: LeagueCoverProps) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/media/leagues/${leagueId}/cover`}
alt={alt}
className={`w-full h-48 object-cover ${className}`}
onError={(e) => {
// Fallback to default cover
(e.target as HTMLImageElement).src = '/default-league-cover.png';
}}
/>
);
}

View File

@@ -3,14 +3,16 @@
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback } from 'react';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { import {
Move,
RotateCw, RotateCw,
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
Save, Save,
Trash2,
Plus,
Image as ImageIcon, Image as ImageIcon,
Target Target
} from 'lucide-react'; } from 'lucide-react';
@@ -66,7 +68,7 @@ const DEFAULT_PLACEMENTS: Omit<DecalPlacement, 'id'>[] = [
}, },
]; ];
export default function LeagueDecalPlacementEditor({ export function LeagueDecalPlacementEditor({
leagueId, leagueId,
seasonId, seasonId,
carId, carId,
@@ -170,38 +172,47 @@ export default function LeagueDecalPlacementEditor({
}; };
return ( return (
<div className="space-y-6"> <Stack gap={6}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <Box display="flex" alignItems="center" justifyContent="between">
<div> <Box>
<h3 className="text-lg font-semibold text-white">{carName}</h3> <Heading level={3} fontSize="lg" weight="semibold" color="text-white">{carName}</Heading>
<p className="text-sm text-gray-400">Position sponsor decals on this car's template</p> <Text size="sm" color="text-gray-400">Position sponsor decals on this car&apos;s template</Text>
</div> </Box>
<div className="flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2}>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => setZoom(z => Math.max(0.5, z - 0.25))} onClick={() => setZoom(z => Math.max(0.5, z - 0.25))}
disabled={zoom <= 0.5} disabled={zoom <= 0.5}
> >
<ZoomOut className="w-4 h-4" /> <Icon icon={ZoomOut} size={4} />
</Button> </Button>
<span className="text-sm text-gray-400 min-w-[3rem] text-center">{Math.round(zoom * 100)}%</span> <Text size="sm" color="text-gray-400"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ minWidth: '3rem' }}
textAlign="center"
>
{Math.round(zoom * 100)}%
</Text>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => setZoom(z => Math.min(2, z + 0.25))} onClick={() => setZoom(z => Math.min(2, z + 0.25))}
disabled={zoom >= 2} disabled={zoom >= 2}
> >
<ZoomIn className="w-4 h-4" /> <Icon icon={ZoomIn} size={4} />
</Button> </Button>
</div> </Box>
</div> </Box>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={6}>
{/* Canvas */} {/* Canvas */}
<div className="lg:col-span-2"> <Box responsiveColSpan={{ lg: 2 }}>
<div <Box
ref={canvasRef} ref={canvasRef}
className="relative aspect-video bg-deep-graphite rounded-lg border border-charcoal-outline overflow-hidden cursor-crosshair" position="relative"
// eslint-disable-next-line gridpilot-rules/component-classification
className="aspect-video bg-deep-graphite rounded-lg border border-charcoal-outline overflow-hidden cursor-crosshair"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }} style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
@@ -209,33 +220,50 @@ export default function LeagueDecalPlacementEditor({
> >
{/* Base Image or Placeholder */} {/* Base Image or Placeholder */}
{baseImageUrl ? ( {baseImageUrl ? (
<img <Box
as="img"
src={baseImageUrl} src={baseImageUrl}
alt="Livery template" alt="Livery template"
className="w-full h-full object-cover" fullWidth
fullHeight
// eslint-disable-next-line gridpilot-rules/component-classification
className="object-cover"
draggable={false} draggable={false}
/> />
) : ( ) : (
<div className="w-full h-full flex flex-col items-center justify-center"> <Box fullWidth fullHeight display="flex" flexDirection="col" alignItems="center" justifyContent="center">
<ImageIcon className="w-16 h-16 text-gray-600 mb-2" /> <Icon icon={ImageIcon} size={16} color="text-gray-600"
<p className="text-sm text-gray-500">No base template uploaded</p> // eslint-disable-next-line gridpilot-rules/component-classification
<p className="text-xs text-gray-600">Upload a template image first</p> className="mb-2"
</div> />
<Text size="sm" color="text-gray-500">No base template uploaded</Text>
<Text size="xs" color="text-gray-600">Upload a template image first</Text>
</Box>
)} )}
{/* Decal Placeholders */} {/* Decal Placeholders */}
{placements.map((placement) => { {placements.map((placement) => {
const colors = getSponsorTypeColor(placement.sponsorType); const decalColors = getSponsorTypeColor(placement.sponsorType);
return ( return (
<div <Box
key={placement.id} key={placement.id}
onMouseDown={(e) => handleMouseDown(e, placement.id)} onMouseDown={(e: React.MouseEvent) => handleMouseDown(e, placement.id)}
onClick={() => handleDecalClick(placement.id)} onClick={() => handleDecalClick(placement.id)}
className={`absolute cursor-move border-2 rounded flex items-center justify-center text-xs font-medium transition-all ${ position="absolute"
cursor="move"
border
borderWidth="2px"
rounded="sm"
display="flex"
alignItems="center"
justifyContent="center"
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-medium transition-all ${
selectedDecal === placement.id selectedDecal === placement.id
? `${colors.border} ${colors.bg} ${colors.text} shadow-lg` ? `${decalColors.border} ${decalColors.bg} ${decalColors.text} shadow-lg`
: `${colors.border} ${colors.bg} ${colors.text} opacity-70 hover:opacity-100` : `${decalColors.border} ${decalColors.bg} ${decalColors.text} opacity-70 hover:opacity-100`
}`} }`}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ style={{
left: `${placement.x * 100}%`, left: `${placement.x * 100}%`,
top: `${placement.y * 100}%`, top: `${placement.y * 100}%`,
@@ -244,151 +272,200 @@ export default function LeagueDecalPlacementEditor({
transform: `translate(-50%, -50%) rotate(${placement.rotation}deg)`, transform: `translate(-50%, -50%) rotate(${placement.rotation}deg)`,
}} }}
> >
<div className="text-center truncate px-1"> <Box textAlign="center" truncate px={1}>
<div className="text-[10px] uppercase tracking-wide opacity-70"> <Box
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide opacity-70"
>
{placement.sponsorType === 'main' ? 'Main' : 'Secondary'} {placement.sponsorType === 'main' ? 'Main' : 'Secondary'}
</div> </Box>
<div className="truncate">{placement.sponsorName}</div> <Box truncate>{placement.sponsorName}</Box>
</div> </Box>
{/* Drag handle indicator */} {/* Drag handle indicator */}
{selectedDecal === placement.id && ( {selectedDecal === placement.id && (
<div className="absolute -top-1 -left-1 w-3 h-3 bg-white rounded-full border-2 border-primary-blue" /> <Box position="absolute" top="-1" left="-1" w="3" h="3" bg="bg-white" rounded="full" border borderWidth="2px" borderColor="border-primary-blue" />
)} )}
</div> </Box>
); );
})} })}
{/* Grid overlay when dragging */} {/* Grid overlay when dragging */}
{isDragging && ( {isDragging && (
<div className="absolute inset-0 pointer-events-none"> <Box position="absolute" inset="0" pointerEvents="none">
<div className="w-full h-full" style={{ <Box fullWidth fullHeight
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px)', // eslint-disable-next-line gridpilot-rules/component-classification
backgroundSize: '10% 10%', style={{
}} /> backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px)',
</div> backgroundSize: '10% 10%',
}}
/>
</Box>
)} )}
</div> </Box>
<p className="text-xs text-gray-500 mt-2"> <Text size="xs" color="text-gray-500" mt={2} block>
Click a decal to select it, then drag to reposition. Use controls on the right to adjust size and rotation. Click a decal to select it, then drag to reposition. Use controls on the right to adjust size and rotation.
</p> </Text>
</div> </Box>
{/* Controls Panel */} {/* Controls Panel */}
<div className="space-y-4"> <Stack gap={4}>
{/* Decal List */} {/* Decal List */}
<Card className="p-4"> <Card p={4}>
<h4 className="text-sm font-semibold text-white mb-3">Sponsor Slots</h4> <Heading level={4} fontSize="sm" weight="semibold" color="text-white" mb={3}>Sponsor Slots</Heading>
<div className="space-y-2"> <Stack gap={2}>
{placements.map((placement) => { {placements.map((placement) => {
const colors = getSponsorTypeColor(placement.sponsorType); const decalColors = getSponsorTypeColor(placement.sponsorType);
return ( return (
<button <Box
key={placement.id} key={placement.id}
as="button"
onClick={() => setSelectedDecal(placement.id)} onClick={() => setSelectedDecal(placement.id)}
className={`w-full p-3 rounded-lg border text-left transition-all ${ w="full"
selectedDecal === placement.id p={3}
? `${colors.border} ${colors.bg}` rounded="lg"
: 'border-charcoal-outline bg-iron-gray/30 hover:bg-iron-gray/50' border
}`} textAlign="left"
transition
borderColor={selectedDecal === placement.id ? decalColors.border : 'border-charcoal-outline'}
bg={selectedDecal === placement.id ? decalColors.bg : 'bg-iron-gray/30'}
hoverBg={selectedDecal !== placement.id ? 'bg-iron-gray/50' : undefined}
> >
<div className="flex items-center justify-between"> <Box display="flex" alignItems="center" justifyContent="between">
<div> <Box>
<div className={`text-xs font-medium uppercase ${colors.text}`}> <Box
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
transform="uppercase"
color={decalColors.text}
>
{placement.sponsorType === 'main' ? 'Main Sponsor' : `Secondary ${placement.sponsorType.split('-')[1]}`} {placement.sponsorType === 'main' ? 'Main Sponsor' : `Secondary ${placement.sponsorType.split('-')[1]}`}
</div> </Box>
<div className="text-xs text-gray-500 mt-0.5"> <Box
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
mt={0.5}
>
{Math.round(placement.x * 100)}%, {Math.round(placement.y * 100)}% {placement.rotation}° {Math.round(placement.x * 100)}%, {Math.round(placement.y * 100)}% {placement.rotation}°
</div> </Box>
</div> </Box>
<Target className={`w-4 h-4 ${selectedDecal === placement.id ? colors.text : 'text-gray-500'}`} /> <Icon icon={Target} size={4} color={selectedDecal === placement.id ? decalColors.text : 'text-gray-500'} />
</div> </Box>
</button> </Box>
); );
})} })}
</div> </Stack>
</Card> </Card>
{/* Selected Decal Controls */} {/* Selected Decal Controls */}
{selectedPlacement && ( {selectedPlacement && (
<Card className="p-4"> <Card p={4}>
<h4 className="text-sm font-semibold text-white mb-3">Adjust Selected</h4> <Heading level={4} fontSize="sm" weight="semibold" color="text-white" mb={3}>Adjust Selected</Heading>
{/* Position */} {/* Position */}
<div className="mb-4"> <Box mb={4}>
<label className="block text-xs text-gray-400 mb-2">Position</label> <Text as="label" size="xs" color="text-gray-400" block mb={2}>Position</Text>
<div className="grid grid-cols-2 gap-2"> <Box display="grid" gridCols={2} gap={2}>
<div> <Box>
<label className="block text-xs text-gray-500 mb-1">X</label> <Text as="label" size="xs" color="text-gray-500" block mb={1}>X</Text>
<input <Box
as="input"
type="range" type="range"
min="0" min="0"
max="100" max="100"
value={selectedPlacement.x * 100} value={selectedPlacement.x * 100}
onChange={(e) => updatePlacement(selectedPlacement.id, { x: parseInt(e.target.value) / 100 })} onChange={(e: React.ChangeEvent<HTMLInputElement>) => updatePlacement(selectedPlacement.id, { x: parseInt(e.target.value) / 100 })}
className="w-full h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue" fullWidth
h="2"
bg="bg-charcoal-outline"
rounded="lg"
// eslint-disable-next-line gridpilot-rules/component-classification
className="appearance-none cursor-pointer accent-primary-blue"
/> />
</div> </Box>
<div> <Box>
<label className="block text-xs text-gray-500 mb-1">Y</label> <Text as="label" size="xs" color="text-gray-500" block mb={1}>Y</Text>
<input <Box
as="input"
type="range" type="range"
min="0" min="0"
max="100" max="100"
value={selectedPlacement.y * 100} value={selectedPlacement.y * 100}
onChange={(e) => updatePlacement(selectedPlacement.id, { y: parseInt(e.target.value) / 100 })} onChange={(e: React.ChangeEvent<HTMLInputElement>) => updatePlacement(selectedPlacement.id, { y: parseInt(e.target.value) / 100 })}
className="w-full h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue" fullWidth
h="2"
bg="bg-charcoal-outline"
rounded="lg"
// eslint-disable-next-line gridpilot-rules/component-classification
className="appearance-none cursor-pointer accent-primary-blue"
/> />
</div> </Box>
</div> </Box>
</div> </Box>
{/* Size */} {/* Size */}
<div className="mb-4"> <Box mb={4}>
<label className="block text-xs text-gray-400 mb-2">Size</label> <Text as="label" size="xs" color="text-gray-400" block mb={2}>Size</Text>
<div className="flex gap-2"> <Box display="flex" gap={2}>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => handleResize(selectedPlacement.id, 0.9)} onClick={() => handleResize(selectedPlacement.id, 0.9)}
className="flex-1" fullWidth
> >
<ZoomOut className="w-4 h-4 mr-1" /> <Icon icon={ZoomOut} size={4}
// eslint-disable-next-line gridpilot-rules/component-classification
className="mr-1"
/>
Smaller Smaller
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => handleResize(selectedPlacement.id, 1.1)} onClick={() => handleResize(selectedPlacement.id, 1.1)}
className="flex-1" fullWidth
> >
<ZoomIn className="w-4 h-4 mr-1" /> <Icon icon={ZoomIn} size={4}
// eslint-disable-next-line gridpilot-rules/component-classification
className="mr-1"
/>
Larger Larger
</Button> </Button>
</div> </Box>
</div> </Box>
{/* Rotation */} {/* Rotation */}
<div className="mb-4"> <Box mb={4}>
<label className="block text-xs text-gray-400 mb-2">Rotation: {selectedPlacement.rotation}°</label> <Text as="label" size="xs" color="text-gray-400" block mb={2}>Rotation: {selectedPlacement.rotation}°</Text>
<div className="flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2}>
<input <Box
as="input"
type="range" type="range"
min="0" min="0"
max="360" max="360"
step="15" step="15"
value={selectedPlacement.rotation} value={selectedPlacement.rotation}
onChange={(e) => updatePlacement(selectedPlacement.id, { rotation: parseInt(e.target.value) })} onChange={(e: React.ChangeEvent<HTMLInputElement>) => updatePlacement(selectedPlacement.id, { rotation: parseInt(e.target.value) })}
className="flex-1 h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue" flexGrow={1}
h="2"
bg="bg-charcoal-outline"
rounded="lg"
// eslint-disable-next-line gridpilot-rules/component-classification
className="appearance-none cursor-pointer accent-primary-blue"
/> />
<Button <Button
variant="secondary" variant="secondary"
onClick={() => handleRotate(selectedPlacement.id, 90)} onClick={() => handleRotate(selectedPlacement.id, 90)}
className="px-2" px={2}
> >
<RotateCw className="w-4 h-4" /> <Icon icon={RotateCw} size={4} />
</Button> </Button>
</div> </Box>
</div> </Box>
</Card> </Card>
)} )}
@@ -397,21 +474,24 @@ export default function LeagueDecalPlacementEditor({
variant="primary" variant="primary"
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="w-full" fullWidth
> >
<Save className="w-4 h-4 mr-2" /> <Icon icon={Save} size={4}
// eslint-disable-next-line gridpilot-rules/component-classification
className="mr-2"
/>
{saving ? 'Saving...' : 'Save Placements'} {saving ? 'Saving...' : 'Save Placements'}
</Button> </Button>
{/* Help Text */} {/* Help Text */}
<div className="p-3 rounded-lg bg-iron-gray/30 border border-charcoal-outline/50"> <Box p={3} rounded="lg" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline/50">
<p className="text-xs text-gray-500"> <Text size="xs" color="text-gray-500" block>
<strong className="text-gray-400">Tip:</strong> Main sponsor gets the largest, most prominent placement. <Text weight="bold" color="text-gray-400">Tip:</Text> Main sponsor gets the largest, most prominent placement.
Secondary sponsors get smaller positions. These decals will be burned onto all driver liveries. Secondary sponsors get smaller positions. These decals will be burned onto all driver liveries.
</p> </Text>
</div> </Box>
</div> </Stack>
</div> </Box>
</div> </Stack>
); );
} }

View File

@@ -1,9 +1,14 @@
'use client'; 'use client';
import React, { useState, useRef, useEffect } from 'react';
import { TrendingDown, Check, HelpCircle, X, Zap } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Check, HelpCircle, TrendingDown, X, Zap } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
// ============================================================================ // ============================================================================
// INFO FLYOUT (duplicated for self-contained component) // INFO FLYOUT (duplicated for self-contained component)
@@ -78,42 +83,79 @@ function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutP
if (!isOpen) return null; if (!isOpen) return null;
return createPortal( return createPortal(
<div <Box
ref={flyoutRef} ref={flyoutRef}
className="fixed z-50 w-[380px] max-h-[80vh] overflow-y-auto bg-iron-gray border border-charcoal-outline rounded-xl shadow-2xl animate-fade-in" position="fixed"
style={{ top: position.top, left: position.left }} zIndex={50}
w="380px"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
rounded="xl"
shadow="2xl"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ top: position.top, left: position.left, maxHeight: '80vh', overflowY: 'auto' }}
> >
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 sticky top-0 bg-iron-gray z-10"> <Box
<div className="flex items-center gap-2"> display="flex"
<HelpCircle className="w-4 h-4 text-primary-blue" /> alignItems="center"
<span className="text-sm font-semibold text-white">{title}</span> justifyContent="between"
</div> p={4}
<button borderBottom
borderColor="border-charcoal-outline/50"
position="sticky"
top="0"
bg="bg-iron-gray"
zIndex={10}
>
<Stack direction="row" align="center" gap={2}>
<Icon icon={HelpCircle} size={4} color="text-primary-blue" />
<Text size="sm" weight="semibold" color="text-white">{title}</Text>
</Stack>
<Box
as="button"
type="button" type="button"
onClick={onClose} onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-charcoal-outline transition-colors" display="flex"
h="6"
w="6"
alignItems="center"
justifyContent="center"
rounded="md"
transition
hoverBg="bg-charcoal-outline"
> >
<X className="w-4 h-4 text-gray-400" /> <Icon icon={X} size={4} color="text-gray-400" />
</button> </Box>
</div> </Box>
<div className="p-4"> <Box p={4}>
{children} {children}
</div> </Box>
</div>, </Box>,
document.body document.body
); );
} }
function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.Ref<HTMLButtonElement> }) { function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.Ref<HTMLButtonElement> }) {
return ( return (
<button <Box
as="button"
ref={buttonRef} ref={buttonRef}
type="button" type="button"
onClick={onClick} onClick={onClick}
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors" display="flex"
h="5"
w="5"
alignItems="center"
justifyContent="center"
rounded="full"
transition
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
> >
<HelpCircle className="w-3.5 h-3.5" /> <Icon icon={HelpCircle} size={3.5} />
</button> </Box>
); );
} }
@@ -132,36 +174,65 @@ function DropRulesMockup() {
const wouldBe = results.reduce((sum, r) => sum + r.pts, 0); const wouldBe = results.reduce((sum, r) => sum + r.pts, 0);
return ( return (
<div className="bg-deep-graphite rounded-lg p-4"> <Box bg="bg-deep-graphite" rounded="lg" p={4}>
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-charcoal-outline/50"> <Box display="flex" alignItems="center" gap={2} mb={3} pb={2} borderBottom borderColor="border-charcoal-outline/50" opacity={0.5}>
<span className="text-xs font-semibold text-white">Best 4 of 6 Results</span> <Text size="xs" weight="semibold" color="text-white">Best 4 of 6 Results</Text>
</div> </Box>
<div className="flex gap-1 mb-3"> <Box display="flex" gap={1} mb={3}>
{results.map((r, i) => ( {results.map((r, i) => (
<div <Box
key={i} key={i}
className={`flex-1 p-2 rounded-lg text-center border transition-all ${ flexGrow={1}
r.dropped p={2}
? 'bg-charcoal-outline/20 border-dashed border-charcoal-outline/50 opacity-50' rounded="lg"
: 'bg-performance-green/10 border-performance-green/30' textAlign="center"
}`} border
transition
bg={r.dropped ? 'bg-charcoal-outline/20' : 'bg-performance-green/10'}
borderColor={r.dropped ? 'border-charcoal-outline/50' : 'border-performance-green/30'}
opacity={r.dropped ? 0.5 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
className={r.dropped ? 'border-dashed' : ''}
> >
<div className="text-[9px] text-gray-500">{r.round}</div> <Text
<div className={`text-xs font-mono font-semibold ${r.dropped ? 'text-gray-500 line-through' : 'text-white'}`}> // eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
block
>
{r.round}
</Text>
<Text font="mono" weight="semibold" size="xs" color={r.dropped ? 'text-gray-500' : 'text-white'}
// eslint-disable-next-line gridpilot-rules/component-classification
className={r.dropped ? 'line-through' : ''}
block
>
{r.pts} {r.pts}
</div> </Text>
</div> </Box>
))} ))}
</div> </Box>
<div className="flex justify-between items-center text-xs"> <Box display="flex" justifyContent="between" alignItems="center">
<span className="text-gray-500">Total counted:</span> <Text size="xs" color="text-gray-500">Total counted:</Text>
<span className="font-mono font-semibold text-performance-green">{total} pts</span> <Text font="mono" weight="semibold" color="text-performance-green" size="xs">{total} pts</Text>
</div> </Box>
<div className="flex justify-between items-center text-[10px] text-gray-500 mt-1"> <Box display="flex" justifyContent="between" alignItems="center" mt={1}>
<span>Without drops:</span> <Text
<span className="font-mono">{wouldBe} pts</span> // eslint-disable-next-line gridpilot-rules/component-classification
</div> style={{ fontSize: '10px' }}
</div> color="text-gray-500"
>
Without drops:
</Text>
<Text font="mono"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
{wouldBe} pts
</Text>
</Box>
</Box>
); );
} }
@@ -290,20 +361,20 @@ export function LeagueDropSection({
const needsN = dropPolicy.strategy !== 'none'; const needsN = dropPolicy.strategy !== 'none';
return ( return (
<div className="space-y-4"> <Stack gap={4}>
{/* Section header */} {/* Section header */}
<div className="flex items-center gap-3"> <Box display="flex" alignItems="center" gap={3}>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-blue/10"> <Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10">
<TrendingDown className="w-5 h-5 text-primary-blue" /> <Icon icon={TrendingDown} size={5} color="text-primary-blue" />
</div> </Box>
<div className="flex-1"> <Box flexGrow={1}>
<div className="flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2}>
<h3 className="text-sm font-semibold text-white">Drop Rules</h3> <Heading level={3}>Drop Rules</Heading>
<InfoButton buttonRef={dropInfoRef} onClick={() => setShowDropFlyout(true)} /> <InfoButton buttonRef={dropInfoRef} onClick={() => setShowDropFlyout(true)} />
</div> </Box>
<p className="text-xs text-gray-500">Protect from bad races</p> <Text size="xs" color="text-gray-500">Protect from bad races</Text>
</div> </Box>
</div> </Box>
{/* Drop Rules Flyout */} {/* Drop Rules Flyout */}
<InfoFlyout <InfoFlyout
@@ -312,180 +383,306 @@ export function LeagueDropSection({
title="Drop Rules Explained" title="Drop Rules Explained"
anchorRef={dropInfoRef} anchorRef={dropInfoRef}
> >
<div className="space-y-4"> <Stack gap={4}>
<p className="text-xs text-gray-400"> <Text size="xs" color="text-gray-400" block>
Drop rules allow drivers to exclude their worst results from championship calculations. Drop rules allow drivers to exclude their worst results from championship calculations.
This protects against mechanical failures, bad luck, or occasional poor performances. This protects against mechanical failures, bad luck, or occasional poor performances.
</p> </Text>
<div> <Box>
<div className="text-[10px] text-gray-500 uppercase tracking-wide mb-2">Visual Example</div> <Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
mb={2}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Visual Example
</Text>
<DropRulesMockup /> <DropRulesMockup />
</div> </Box>
<div className="space-y-2"> <Stack gap={2}>
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Drop Strategies</div> <Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
<div className="space-y-2"> // eslint-disable-next-line gridpilot-rules/component-classification
<div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30"> className="tracking-wide"
<span className="text-base"></span> block
<div> // eslint-disable-next-line gridpilot-rules/component-classification
<div className="text-[10px] font-medium text-white">All Count</div> style={{ fontSize: '10px' }}
<div className="text-[9px] text-gray-500">Every race affects standings. Best for short seasons.</div> >
</div> Drop Strategies
</div> </Text>
<div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30"> <Stack gap={2}>
<span className="text-base">🏆</span> <Box display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<div> <Text size="base"></Text>
<div className="text-[10px] font-medium text-white">Best N Results</div> <Box>
<div className="text-[9px] text-gray-500">Only your top N races count. Extra races are optional.</div> <Text size="xs" weight="medium" color="text-white" block
</div> // eslint-disable-next-line gridpilot-rules/component-classification
</div> style={{ fontSize: '10px' }}
<div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30"> >
<span className="text-base">🗑</span> All Count
<div> </Text>
<div className="text-[10px] font-medium text-white">Drop Worst N</div> <Text size="xs" color="text-gray-500" block
<div className="text-[9px] text-gray-500">Exclude your N worst results. Forgives bad days.</div> // eslint-disable-next-line gridpilot-rules/component-classification
</div> style={{ fontSize: '9px' }}
</div> >
</div> Every race affects standings. Best for short seasons.
</div> </Text>
</Box>
</Box>
<Box display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base">🏆</Text>
<Box>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Best N Results
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Only your top N races count. Extra races are optional.
</Text>
</Box>
</Box>
<Box display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base">🗑</Text>
<Box>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Drop Worst N
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Exclude your N worst results. Forgives bad days.
</Text>
</Box>
</Box>
</Stack>
</Stack>
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3"> <Box rounded="lg" bg="bg-primary-blue/5" border borderColor="border-primary-blue/20" p={3}>
<div className="flex items-start gap-2"> <Box display="flex" alignItems="start" gap={2}>
<Zap className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" /> <Icon icon={Zap} size={3.5} color="text-primary-blue" flexShrink={0} mt={0.5} />
<div className="text-[11px] text-gray-400"> <Box>
<span className="font-medium text-primary-blue">Pro tip:</span> For an 8-round season, <Text size="xs" color="text-gray-400"
"Best 6" or "Drop 2" are popular choices. // eslint-disable-next-line gridpilot-rules/component-classification
</div> style={{ fontSize: '11px' }}
</div> >
</div> <Text weight="medium" color="text-primary-blue">Pro tip:</Text> For an 8-round season,
</div> &quot;Best 6&quot; or &quot;Drop 2&quot; are popular choices.
</Text>
</Box>
</Box>
</Box>
</Stack>
</InfoFlyout> </InfoFlyout>
{/* Strategy buttons + N stepper inline */} {/* Strategy buttons + N stepper inline */}
<div className="flex flex-wrap items-center gap-2"> <Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
{DROP_OPTIONS.map((option) => { {DROP_OPTIONS.map((option) => {
const isSelected = dropPolicy.strategy === option.value; const isSelected = dropPolicy.strategy === option.value;
const ruleInfo = DROP_RULE_INFO[option.value]; const ruleInfo = DROP_RULE_INFO[option.value];
return ( return (
<div key={option.value} className="relative flex items-center"> <Box key={option.value} display="flex" alignItems="center" position="relative">
<button <Box
as="button"
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => handleStrategyChange(option.value)} onClick={() => handleStrategyChange(option.value)}
className={` display="flex"
flex items-center gap-2 px-3 py-2 rounded-l-lg border-2 border-r-0 transition-all duration-200 alignItems="center"
${isSelected gap={2}
? 'border-primary-blue bg-primary-blue/10' px={3}
: 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/30' py={2}
} rounded="lg"
${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'} border
`} borderWidth="2px"
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'}
hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined}
hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined}
cursor={disabled ? 'default' : 'pointer'}
opacity={disabled ? 0.6 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ borderRightWidth: 0, borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
> >
{/* Radio indicator */} {/* Radio indicator */}
<div className={` <Box
flex h-4 w-4 items-center justify-center rounded-full border-2 shrink-0 transition-colors display="flex"
${isSelected ? 'border-primary-blue bg-primary-blue' : 'border-gray-500'} h="4"
`}> w="4"
{isSelected && <Check className="w-2.5 h-2.5 text-white" />} alignItems="center"
</div> justifyContent="center"
rounded="full"
border
borderColor={isSelected ? 'border-primary-blue' : 'border-gray-500'}
bg={isSelected ? 'bg-primary-blue' : ''}
flexShrink={0}
transition
>
{isSelected && <Icon icon={Check} size={2.5} color="text-white" />}
</Box>
<span className="text-sm">{option.emoji}</span> <Text size="sm">{option.emoji}</Text>
<span className={`text-sm font-medium ${isSelected ? 'text-white' : 'text-gray-400'}`}> <Text size="sm" weight="medium" color={isSelected ? 'text-white' : 'text-gray-400'}>
{option.label} {option.label}
</span> </Text>
</button> </Box>
{/* Info button - separate from main button */} {/* Info button - separate from main button */}
<button <Box
ref={(el) => { dropRuleRefs.current[option.value] = el; }} as="button"
ref={(el: HTMLButtonElement | null) => { dropRuleRefs.current[option.value] = el; }}
type="button" type="button"
onClick={(e) => { onClick={(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setActiveDropRuleFlyout(activeDropRuleFlyout === option.value ? null : option.value); setActiveDropRuleFlyout(activeDropRuleFlyout === option.value ? null : option.value);
}} }}
className={` display="flex"
flex h-full items-center justify-center px-2 py-2 rounded-r-lg border-2 border-l-0 transition-all duration-200 alignItems="center"
${isSelected justifyContent="center"
? 'border-primary-blue bg-primary-blue/10' px={2}
: 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/30' py={2}
} rounded="lg"
${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'} border
`} borderWidth="2px"
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'}
hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined}
hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined}
cursor={disabled ? 'default' : 'pointer'}
opacity={disabled ? 0.6 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ borderLeftWidth: 0, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%' }}
> >
<HelpCircle className="w-3.5 h-3.5 text-gray-500 hover:text-primary-blue transition-colors" /> <Icon icon={HelpCircle} size={3.5} color="text-gray-500"
</button> // eslint-disable-next-line gridpilot-rules/component-classification
className="hover:text-primary-blue transition-colors"
/>
</Box>
{/* Drop Rule Info Flyout */} {/* Drop Rule Info Flyout */}
<InfoFlyout <InfoFlyout
isOpen={activeDropRuleFlyout === option.value} isOpen={activeDropRuleFlyout === option.value}
onClose={() => setActiveDropRuleFlyout(null)} onClose={() => setActiveDropRuleFlyout(null)}
title={ruleInfo.title} title={ruleInfo.title}
anchorRef={{ current: dropRuleRefs.current[option.value] ?? dropInfoRef.current }} anchorRef={{ current: (dropRuleRefs.current[option.value] as HTMLElement | null) ?? dropInfoRef.current }}
> >
<div className="space-y-4"> <Stack gap={4}>
<p className="text-xs text-gray-400">{ruleInfo.description}</p> <Text size="xs" color="text-gray-400" block>{ruleInfo.description}</Text>
<div className="space-y-2"> <Stack gap={2}>
<div className="text-[10px] text-gray-500 uppercase tracking-wide">How It Works</div> <Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
<ul className="space-y-1.5"> // eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
mb={2}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
How It Works
</Text>
<Stack gap={1.5}>
{ruleInfo.details.map((detail, idx) => ( {ruleInfo.details.map((detail, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs text-gray-400"> <Box key={idx} display="flex" alignItems="start" gap={2}>
<Check className="w-3 h-3 text-performance-green shrink-0 mt-0.5" /> <Icon icon={Check} size={3} color="text-performance-green" flexShrink={0} mt={0.5} />
<span>{detail}</span> <Text size="xs" color="text-gray-400">{detail}</Text>
</li> </Box>
))} ))}
</ul> </Stack>
</div> </Stack>
<div className="rounded-lg bg-deep-graphite border border-charcoal-outline/30 p-3"> <Box rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30" p={3}>
<div className="flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2}>
<span className="text-base">{option.emoji}</span> <Text size="base">{option.emoji}</Text>
<div> <Box>
<div className="text-[10px] text-gray-500">Example</div> <Text size="xs" color="text-gray-400" block
<div className="text-xs font-medium text-white">{ruleInfo.example}</div> // eslint-disable-next-line gridpilot-rules/component-classification
</div> style={{ fontSize: '10px' }}
</div> >
</div> Example
</div> </Text>
<Text size="xs" weight="medium" color="text-white" block>{ruleInfo.example}</Text>
</Box>
</Box>
</Box>
</Stack>
</InfoFlyout> </InfoFlyout>
</div> </Box>
); );
})} })}
{/* N Stepper - only show when needed */} {/* N Stepper - only show when needed */}
{needsN && ( {needsN && (
<div className="flex items-center gap-1 ml-2"> <Box display="flex" alignItems="center" gap={1} ml={2}>
<span className="text-xs text-gray-500 mr-1">N =</span> <Text size="xs" color="text-gray-500" mr={1}>N =</Text>
<button <Box
as="button"
type="button" type="button"
disabled={disabled || (dropPolicy.n ?? 1) <= 1} disabled={disabled || (dropPolicy.n ?? 1) <= 1}
onClick={() => handleNChange(-1)} onClick={() => handleNChange(-1)}
className="flex h-7 w-7 items-center justify-center rounded-md bg-iron-gray border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-40 transition-colors" display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="md"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
color="text-gray-400"
transition
hoverTextColor={!disabled ? 'text-white' : undefined}
hoverBorderColor={!disabled ? 'border-primary-blue' : undefined}
opacity={disabled || (dropPolicy.n ?? 1) <= 1 ? 0.4 : 1}
> >
</button> </Box>
<div className="flex h-7 w-10 items-center justify-center rounded-md bg-iron-gray/50 border border-charcoal-outline/50"> <Box display="flex" h="7" w="10" alignItems="center" justifyContent="center" rounded="md" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/50">
<span className="text-sm font-semibold text-white">{dropPolicy.n ?? 1}</span> <Text size="sm" weight="semibold" color="text-white">{dropPolicy.n ?? 1}</Text>
</div> </Box>
<button <Box
as="button"
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => handleNChange(1)} onClick={() => handleNChange(1)}
className="flex h-7 w-7 items-center justify-center rounded-md bg-iron-gray border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-40 transition-colors" display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="md"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
color="text-gray-400"
transition
hoverTextColor={!disabled ? 'text-white' : undefined}
hoverBorderColor={!disabled ? 'border-primary-blue' : undefined}
opacity={disabled ? 0.4 : 1}
> >
+ +
</button> </Box>
</div> </Box>
)} )}
</div> </Box>
{/* Explanation text */} {/* Explanation text */}
<p className="text-xs text-gray-500"> <Text size="xs" color="text-gray-500" block>
{dropPolicy.strategy === 'none' && 'Every race result affects the championship standings.'} {dropPolicy.strategy === 'none' && 'Every race result affects the championship standings.'}
{dropPolicy.strategy === 'bestNResults' && `Only your best ${dropPolicy.n ?? 1} results will count.`} {dropPolicy.strategy === 'bestNResults' && `Only your best ${dropPolicy.n ?? 1} results will count.`}
{dropPolicy.strategy === 'dropWorstN' && `Your worst ${dropPolicy.n ?? 1} results will be excluded.`} {dropPolicy.strategy === 'dropWorstN' && `Your worst ${dropPolicy.n ?? 1} results will be excluded.`}
</p> </Text>
</div> </Stack>
); );
} }

View File

@@ -1,9 +1,9 @@
'use client'; import React from 'react';
import { MembershipStatus } from './MembershipStatus';
import MembershipStatus from '@/components/leagues/MembershipStatus';
import { getMediaUrl } from '@/lib/utilities/media'; import { getMediaUrl } from '@/lib/utilities/media';
import Image from 'next/image'; import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { LeagueHeader as UiLeagueHeader } from '@/ui/LeagueHeader';
// Main sponsor info for "by XYZ" display // Main sponsor info for "by XYZ" display
interface MainSponsorInfo { interface MainSponsorInfo {
@@ -21,61 +21,39 @@ export interface LeagueHeaderProps {
mainSponsor?: MainSponsorInfo | null; mainSponsor?: MainSponsorInfo | null;
} }
export default function LeagueHeader({ export function LeagueHeader({
leagueId, leagueId,
leagueName, leagueName,
description, description,
ownerId,
mainSponsor, mainSponsor,
}: LeagueHeaderProps) { }: LeagueHeaderProps) {
const logoUrl = getMediaUrl('league-logo', leagueId); const logoUrl = getMediaUrl('league-logo', leagueId);
return ( return (
<div className="mb-8"> <UiLeagueHeader
{/* League header with logo - no cover image */} name={leagueName}
<div className="flex items-center justify-between mb-6"> description={description}
<div className="flex items-center gap-4"> logoUrl={logoUrl}
<div className="h-16 w-16 rounded-xl overflow-hidden border-2 border-charcoal-outline bg-iron-gray shadow-lg"> statusContent={<MembershipStatus leagueId={leagueId} />}
<img sponsorContent={
src={logoUrl} mainSponsor ? (
alt={`${leagueName} logo`} mainSponsor.websiteUrl ? (
width={64} <Box
height={64} as="a"
className="h-full w-full object-cover" href={mainSponsor.websiteUrl}
loading="lazy" target="_blank"
decoding="async" rel="noreferrer"
/> color="text-primary-blue"
</div> hoverTextColor="text-primary-blue/80"
<div> transition
<div className="flex items-center gap-3 mb-1"> >
<h1 className="text-2xl font-bold text-white"> {mainSponsor.name}
{leagueName} </Box>
{mainSponsor && ( ) : (
<span className="text-gray-400 font-normal text-lg ml-2"> <Text color="text-primary-blue">{mainSponsor.name}</Text>
by{' '} )
{mainSponsor.websiteUrl ? ( ) : undefined
<a }
href={mainSponsor.websiteUrl} />
target="_blank"
rel="noreferrer"
className="text-primary-blue hover:text-primary-blue/80 transition-colors"
>
{mainSponsor.name}
</a>
) : (
<span className="text-primary-blue">{mainSponsor.name}</span>
)}
</span>
)}
</h1>
<MembershipStatus leagueId={leagueId} />
</div>
{description && (
<p className="text-gray-400 text-sm max-w-xl">{description}</p>
)}
</div>
</div>
</div>
</div>
); );
} }

View File

@@ -1,30 +0,0 @@
/**
* LeagueLogo
*
* Pure UI component for displaying league logos.
* Renders an optimized image with fallback on error.
*/
import Image from 'next/image';
export interface LeagueLogoProps {
leagueId: string;
alt: string;
className?: string;
}
export function LeagueLogo({ leagueId, alt, className = '' }: LeagueLogoProps) {
return (
<Image
src={`/media/leagues/${leagueId}/logo`}
alt={alt}
width={100}
height={100}
className={`object-contain ${className}`}
onError={(e) => {
// Fallback to default logo
(e.target as HTMLImageElement).src = '/default-league-logo.png';
}}
/>
);
}

View File

@@ -1,15 +1,20 @@
'use client'; 'use client';
import { DriverIdentity } from '../drivers/DriverIdentity'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useEffectiveDriverId } from '@/lib/hooks/useEffectiveDriverId';
import { useInject } from '@/lib/di/hooks/useInject'; import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens'; import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { routes } from '@/lib/routing/RouteConfig';
import type { LeagueMembership } from '@/lib/types/LeagueMembership'; import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { MembershipRole } from '@/lib/types/MembershipRole'; import type { MembershipRole } from '@/lib/types/MembershipRole';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Box } from '@/ui/Box';
// Migrated to useInject-based DI; legacy EntityMapper removed. import { Text } from '@/ui/Text';
import { Select } from '@/ui/Select';
import { Button } from '@/ui/Button';
import { LeagueMemberTable } from '@/ui/LeagueMemberTable';
import { LeagueMemberRow } from '@/ui/LeagueMemberRow';
import { MinimalEmptyState } from '@/ui/EmptyState';
interface LeagueMembersProps { interface LeagueMembersProps {
leagueId: string; leagueId: string;
@@ -18,7 +23,7 @@ interface LeagueMembersProps {
showActions?: boolean; showActions?: boolean;
} }
export default function LeagueMembers({ export function LeagueMembers({
leagueId, leagueId,
onRemoveMember, onRemoveMember,
onUpdateRole, onUpdateRole,
@@ -44,7 +49,8 @@ export default function LeagueMembers({
const driverDtos = await driverService.findByIds(uniqueDriverIds); const driverDtos = await driverService.findByIds(uniqueDriverIds);
const byId: Record<string, DriverViewModel> = {}; const byId: Record<string, DriverViewModel> = {};
for (const dto of driverDtos) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const dto of driverDtos as any[]) {
byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null }); byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null });
} }
setDriversById(byId); setDriversById(byId);
@@ -72,12 +78,11 @@ export default function LeagueMembers({
return order[role]; return order[role];
}; };
const getDriverStats = (driverId: string): { rating: number; wins: number; overallRank: number } | null => { const getDriverStats = (): { rating: number; wins: number; overallRank: number } | null => {
// This would typically come from a driver stats service
// For now, return null as the original implementation was missing
return null; return null;
}; };
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
const sortedMembers = [...members].sort((a, b) => { const sortedMembers = [...members].sort((a, b) => {
switch (sortBy) { switch (sortBy) {
case 'role': case 'role':
@@ -87,15 +92,15 @@ export default function LeagueMembers({
case 'date': case 'date':
return new Date(b.joinedAt).getTime() - new Date(a.joinedAt).getTime(); return new Date(b.joinedAt).getTime() - new Date(a.joinedAt).getTime();
case 'rating': { case 'rating': {
const statsA = getDriverStats(a.driverId); const statsA = getDriverStats();
const statsB = getDriverStats(b.driverId); const statsB = getDriverStats();
return (statsB?.rating || 0) - (statsA?.rating || 0); return (statsB?.rating || 0) - (statsA?.rating || 0);
} }
case 'points': case 'points':
return 0; return 0;
case 'wins': { case 'wins': {
const statsA = getDriverStats(a.driverId); const statsA = getDriverStats();
const statsB = getDriverStats(b.driverId); const statsB = getDriverStats();
return (statsB?.wins || 0) - (statsA?.wins || 0); return (statsB?.wins || 0) - (statsA?.wins || 0);
} }
default: default:
@@ -103,180 +108,120 @@ export default function LeagueMembers({
} }
}); });
const getRoleBadgeColor = (role: MembershipRole): string => { const getRoleVariant = (role: MembershipRole): 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' => {
switch (role) { switch (role) {
case 'owner': case 'owner': return 'warning';
return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30'; case 'admin': return 'primary';
case 'admin': case 'steward': return 'info';
return 'bg-purple-500/10 text-purple-400 border-purple-500/30'; case 'member': return 'primary';
case 'steward': default: return 'default';
return 'bg-blue-500/10 text-blue-400 border-blue-500/30';
case 'member':
return 'bg-primary-blue/10 text-primary-blue border-primary-blue/30';
default:
return 'bg-gray-500/10 text-gray-400 border-gray-500/30';
} }
}; };
if (loading) { if (loading) {
return ( return (
<div className="text-center py-8 text-gray-400"> <Box textAlign="center" py={8}>
Loading members... <Text color="text-gray-400">Loading members...</Text>
</div> </Box>
); );
} }
if (members.length === 0) { if (members.length === 0) {
return ( return (
<div className="text-center py-8 text-gray-400"> <MinimalEmptyState
No members found title="No members found"
</div> description="This league doesn't have any members yet."
/>
); );
} }
return ( return (
<div> <Box>
{/* Sort Controls */} {/* Sort Controls */}
<div className="mb-4 flex items-center justify-between"> <Box display="flex" alignItems="center" justifyContent="between" mb={4}>
<p className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400">
{members.length} {members.length === 1 ? 'member' : 'members'} {members.length} {members.length === 1 ? 'member' : 'members'}
</p> </Text>
<div className="flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2}>
<label className="text-sm text-gray-400">Sort by:</label> <Text as="label" size="sm" color="text-gray-400">Sort by:</Text>
<select <Select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSortBy(e.target.value as typeof sortBy)}
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue" options={[
> { value: 'rating', label: 'Rating' },
<option value="rating">Rating</option> { value: 'points', label: 'Points' },
<option value="points">Points</option> { value: 'wins', label: 'Wins' },
<option value="wins">Wins</option> { value: 'role', label: 'Role' },
<option value="role">Role</option> { value: 'name', label: 'Name' },
<option value="name">Name</option> { value: 'date', label: 'Join Date' },
<option value="date">Join Date</option> ]}
</select> fullWidth={false}
</div> />
</div> </Box>
</Box>
{/* Members Table */} {/* Members Table */}
<div className="overflow-x-auto"> <Box overflow="auto">
<table className="w-full"> <LeagueMemberTable showActions={showActions}>
<thead> {sortedMembers.map((member, index) => {
<tr className="border-b border-charcoal-outline"> const isCurrentUser = member.driverId === currentDriverId;
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</th> const cannotModify = member.role === 'owner';
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Rating</th> const driverStats = getDriverStats();
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Rank</th> const isTopPerformer = index < 3 && sortBy === 'rating';
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Wins</th> const driver = driversById[member.driverId];
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Role</th> const ratingAndWinsMeta =
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Joined</th> driverStats && typeof driverStats.rating === 'number'
{showActions && <th className="text-right py-3 px-4 text-sm font-semibold text-gray-400">Actions</th>} ? `Rating ${driverStats.rating}${driverStats.wins ?? 0} wins`
</tr> : null;
</thead>
<tbody>
{sortedMembers.map((member, index) => {
const isCurrentUser = member.driverId === currentDriverId;
const cannotModify = member.role === 'owner';
const driverStats = getDriverStats(member.driverId);
const isTopPerformer = index < 3 && sortBy === 'rating';
const driver = driversById[member.driverId];
const roleLabel =
member.role.charAt(0).toUpperCase() + member.role.slice(1);
const ratingAndWinsMeta =
driverStats && typeof driverStats.rating === 'number'
? `Rating ${driverStats.rating}${driverStats.wins ?? 0} wins`
: null;
return ( return (
<tr <LeagueMemberRow
key={member.driverId} key={member.driverId}
className={`border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors ${isTopPerformer ? 'bg-primary-blue/5' : ''}`} driver={driver}
> driverId={member.driverId}
<td className="py-3 px-4"> isCurrentUser={isCurrentUser}
<div className="flex items-center gap-2"> isTopPerformer={isTopPerformer}
{driver ? ( role={member.role}
<DriverIdentity roleVariant={getRoleVariant(member.role)}
driver={driver} joinedAt={member.joinedAt}
href={`/drivers/${member.driverId}?from=league-members&leagueId=${leagueId}`} rating={driverStats?.rating}
contextLabel={roleLabel} rank={driverStats?.overallRank}
meta={ratingAndWinsMeta} wins={driverStats?.wins}
size="md" href={routes.driver.detail(member.driverId)}
/> meta={ratingAndWinsMeta}
) : ( actions={showActions && !cannotModify && !isCurrentUser ? (
<span className="text-white">Unknown Driver</span> <Box display="flex" alignItems="center" justifyContent="end" gap={2}>
)} {onUpdateRole && (
{isCurrentUser && ( <Select
<span className="text-xs text-gray-500">(You)</span> value={member.role}
)} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onUpdateRole(member.driverId, e.target.value as MembershipRole)}
{isTopPerformer && ( options={[
<span className="text-xs"></span> { value: 'member', label: 'Member' },
)} { value: 'steward', label: 'Steward' },
</div> { value: 'admin', label: 'Admin' },
</td> ]}
<td className="py-3 px-4"> fullWidth={false}
<span className="text-primary-blue font-medium"> // eslint-disable-next-line gridpilot-rules/component-classification
{driverStats?.rating || '—'} className="text-xs py-1 px-2"
</span> />
</td> )}
<td className="py-3 px-4"> {onRemoveMember && (
<span className="text-gray-300"> <Button
#{driverStats?.overallRank || '—'} variant="ghost"
</span> onClick={() => onRemoveMember(member.driverId)}
</td> size="sm"
<td className="py-3 px-4"> color="text-error-red"
<span className="text-green-400 font-medium"> >
{driverStats?.wins || 0} Remove
</span> </Button>
</td> )}
<td className="py-3 px-4"> </Box>
<span className={`px-2 py-1 text-xs font-medium rounded border ${getRoleBadgeColor(member.role)}`}> ) : (showActions && cannotModify ? <Text size="xs" color="text-gray-500"></Text> : undefined)}
{member.role.charAt(0).toUpperCase() + member.role.slice(1)} />
</span> );
</td> })}
<td className="py-3 px-4"> </LeagueMemberTable>
<span className="text-white text-sm"> </Box>
{new Date(member.joinedAt).toLocaleDateString('en-US', { </Box>
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</td>
{showActions && (
<td className="py-3 px-4 text-right">
{!cannotModify && !isCurrentUser && (
<div className="flex items-center justify-end gap-2">
{onUpdateRole && (
<select
value={member.role}
onChange={(e) => onUpdateRole(member.driverId, e.target.value as MembershipRole)}
className="px-2 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-xs focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="member">Member</option>
<option value="steward">Steward</option>
<option value="admin">Admin</option>
</select>
)}
{onRemoveMember && (
<button
onClick={() => onRemoveMember(member.driverId)}
className="px-2 py-1 text-xs font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
>
Remove
</button>
)}
</div>
)}
{cannotModify && (
<span className="text-xs text-gray-500"></span>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
); );
} }

View File

@@ -3,6 +3,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { DollarSign, Calendar, User, TrendingUp } from 'lucide-react'; import { DollarSign, Calendar, User, TrendingUp } from 'lucide-react';
type FeeType = 'season' | 'monthly' | 'per_race'; type FeeType = 'season' | 'monthly' | 'per_race';
@@ -20,8 +25,6 @@ interface LeagueMembershipFeesSectionProps {
} }
export function LeagueMembershipFeesSection({ export function LeagueMembershipFeesSection({
leagueId,
seasonId,
readOnly = false readOnly = false
}: LeagueMembershipFeesSectionProps) { }: LeagueMembershipFeesSectionProps) {
const [feeConfig, setFeeConfig] = useState<MembershipFeeConfig>({ const [feeConfig, setFeeConfig] = useState<MembershipFeeConfig>({
@@ -71,15 +74,15 @@ export function LeagueMembershipFeesSection({
}; };
return ( return (
<div className="space-y-6"> <Stack gap={6}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <Box display="flex" alignItems="center" justifyContent="between">
<div> <Box>
<h3 className="text-lg font-semibold text-white">Membership Fees</h3> <Heading level={3} fontSize="lg" weight="semibold" color="text-white">Membership Fees</Heading>
<p className="text-sm text-gray-400 mt-1"> <Text size="sm" color="text-gray-400" mt={1} block>
Charge drivers for league participation Charge drivers for league participation
</p> </Text>
</div> </Box>
{!feeConfig.enabled && !readOnly && ( {!feeConfig.enabled && !readOnly && (
<Button <Button
variant="primary" variant="primary"
@@ -88,152 +91,153 @@ export function LeagueMembershipFeesSection({
Enable Fees Enable Fees
</Button> </Button>
)} )}
</div> </Box>
{!feeConfig.enabled ? ( {!feeConfig.enabled ? (
<div className="text-center py-12 rounded-lg border border-charcoal-outline bg-iron-gray/30"> <Box textAlign="center" py={12} rounded="lg" border borderColor="border-charcoal-outline" bg="bg-iron-gray/30">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center"> <Box w="16" h="16" mx="auto" mb={4} rounded="full" bg="bg-iron-gray/50" display="flex" alignItems="center" justifyContent="center">
<DollarSign className="w-8 h-8 text-gray-500" /> <Icon icon={DollarSign} size={8} color="text-gray-500" />
</div> </Box>
<h4 className="text-lg font-medium text-white mb-2">No Membership Fees</h4> <Heading level={4} fontSize="lg" weight="medium" color="text-white" mb={2}>No Membership Fees</Heading>
<p className="text-sm text-gray-400 max-w-md mx-auto"> <Text size="sm" color="text-gray-400" maxWidth="md" mx="auto" block>
This league is free to join. Enable membership fees to charge drivers for participation. This league is free to join. Enable membership fees to charge drivers for participation.
</p> </Text>
</div> </Box>
) : ( ) : (
<> <>
{/* Fee Type Selection */} {/* Fee Type Selection */}
<div className="space-y-3"> <Stack gap={3}>
<label className="block text-sm font-medium text-gray-300"> <Text as="label" size="sm" weight="medium" color="text-gray-300" block>
Fee Type Fee Type
</label> </Text>
<div className="grid grid-cols-3 gap-3"> <Box display="grid" gridCols={3} gap={3}>
{(['season', 'monthly', 'per_race'] as FeeType[]).map((type) => { {(['season', 'monthly', 'per_race'] as FeeType[]).map((type) => {
const Icon = type === 'season' ? Calendar : type === 'monthly' ? TrendingUp : User; const FeeIcon = type === 'season' ? Calendar : type === 'monthly' ? TrendingUp : User;
const isSelected = feeConfig.type === type; const isSelected = feeConfig.type === type;
return ( return (
<button <Box
key={type} key={type}
as="button"
type="button" type="button"
onClick={() => handleTypeChange(type)} onClick={() => handleTypeChange(type)}
disabled={readOnly} disabled={readOnly}
className={`p-4 rounded-lg border transition-all ${ p={4}
isSelected rounded="lg"
? 'border-primary-blue bg-primary-blue/10' border
: 'border-charcoal-outline bg-iron-gray/30 hover:border-primary-blue/50' transition
}`} borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/30'}
hoverBorderColor={!isSelected ? 'border-primary-blue/50' : undefined}
> >
<Icon className={`w-5 h-5 mx-auto mb-2 ${ <Icon icon={FeeIcon} size={5} mx="auto" mb={2} color={isSelected ? 'text-primary-blue' : 'text-gray-400'} />
isSelected ? 'text-primary-blue' : 'text-gray-400' <Text size="sm" weight="medium" color="text-white" block mb={1}>
}`} />
<div className="text-sm font-medium text-white mb-1">
{typeLabels[type]} {typeLabels[type]}
</div> </Text>
<div className="text-xs text-gray-500"> <Text size="xs" color="text-gray-500" block>
{typeDescriptions[type]} {typeDescriptions[type]}
</div> </Text>
</button> </Box>
); );
})} })}
</div> </Box>
</div> </Stack>
{/* Amount Configuration */} {/* Amount Configuration */}
<div className="space-y-3"> <Stack gap={3}>
<label className="block text-sm font-medium text-gray-300"> <Text as="label" size="sm" weight="medium" color="text-gray-300" block>
Amount Amount
</label> </Text>
{editing ? ( {editing ? (
<div className="flex items-center gap-3"> <Box display="flex" alignItems="center" gap={3}>
<div className="flex-1"> <Box flexGrow={1}>
<Input <Input
type="number" type="number"
value={tempAmount} value={tempAmount}
onChange={(e) => setTempAmount(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTempAmount(e.target.value)}
placeholder="0.00" placeholder="0.00"
min="0" min="0"
step="0.01" step="0.01"
/> />
</div> </Box>
<Button <Button
variant="primary" variant="primary"
onClick={handleSave} onClick={handleSave}
className="px-4" px={4}
> >
Save Save
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={handleCancel} onClick={handleCancel}
className="px-4" px={4}
> >
Cancel Cancel
</Button> </Button>
</div> </Box>
) : ( ) : (
<div className="flex items-center justify-between p-4 rounded-lg bg-iron-gray/50 border border-charcoal-outline"> <Box display="flex" alignItems="center" justifyContent="between" p={4} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline">
<div> <Box>
<div className="text-2xl font-bold text-white"> <Text size="2xl" weight="bold" color="text-white" block>
${feeConfig.amount.toFixed(2)} ${feeConfig.amount.toFixed(2)}
</div> </Text>
<div className="text-xs text-gray-500 mt-1"> <Text size="xs" color="text-gray-500" mt={1} block>
{typeLabels[feeConfig.type]} {typeLabels[feeConfig.type]}
</div> </Text>
</div> </Box>
{!readOnly && ( {!readOnly && (
<Button <Button
variant="secondary" variant="secondary"
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
className="px-4" px={4}
> >
Edit Amount Edit Amount
</Button> </Button>
)} )}
</div> </Box>
)} )}
</div> </Stack>
{/* Revenue Breakdown */} {/* Revenue Breakdown */}
{feeConfig.amount > 0 && ( {feeConfig.amount > 0 && (
<div className="grid grid-cols-2 gap-4"> <Box display="grid" gridCols={2} gap={4}>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4"> <Box rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline" p={4}>
<div className="text-xs text-gray-400 mb-1">Platform Fee (10%)</div> <Text size="xs" color="text-gray-400" block mb={1}>Platform Fee (10%)</Text>
<div className="text-lg font-bold text-warning-amber"> <Text size="lg" weight="bold" color="text-warning-amber" block>
-${platformFee.toFixed(2)} -${platformFee.toFixed(2)}
</div> </Text>
</div> </Box>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4"> <Box rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline" p={4}>
<div className="text-xs text-gray-400 mb-1">Net per Driver</div> <Text size="xs" color="text-gray-400" block mb={1}>Net per Driver</Text>
<div className="text-lg font-bold text-performance-green"> <Text size="lg" weight="bold" color="text-performance-green" block>
${netAmount.toFixed(2)} ${netAmount.toFixed(2)}
</div> </Text>
</div> </Box>
</div> </Box>
)} )}
{/* Disable Fees */} {/* Disable Fees */}
{!readOnly && ( {!readOnly && (
<div className="pt-4 border-t border-charcoal-outline"> <Box pt={4} borderTop borderColor="border-charcoal-outline">
<Button <Button
variant="danger" variant="danger"
onClick={() => setFeeConfig({ type: 'season', amount: 0, enabled: false })} onClick={() => setFeeConfig({ type: 'season', amount: 0, enabled: false })}
> >
Disable Membership Fees Disable Membership Fees
</Button> </Button>
</div> </Box>
)} )}
</> </>
)} )}
{/* Alpha Notice */} {/* Alpha Notice */}
<div className="rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4"> <Box rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
<p className="text-xs text-gray-400"> <Text size="xs" color="text-gray-400" block>
<strong className="text-warning-amber">Alpha Note:</strong> Membership fee collection is demonstration-only. <Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Membership fee collection is demonstration-only.
In production, fees are collected via payment gateway and deposited to league wallet (minus platform fee). In production, fees are collected via payment gateway and deposited to league wallet (minus platform fee).
</p> </Text>
</div> </Box>
</div> </Stack>
); );
} }

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { DriverSummaryPill } from '@/components/profile/DriverSummaryPill'; import { DriverSummaryPill } from '@/ui/DriverSummaryPillWrapper';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { UserCog } from 'lucide-react'; import { UserCog } from 'lucide-react';
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel'; import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';

View File

@@ -1,14 +1,11 @@
'use client'; 'use client';
import { import {
FileText,
Users, Users,
Calendar, Calendar,
Trophy, Trophy,
Award, Award,
Rocket, Rocket,
Eye,
EyeOff,
Gamepad2, Gamepad2,
User, User,
UsersRound, UsersRound,
@@ -16,13 +13,18 @@ import {
Flag, Flag,
Zap, Zap,
Timer, Timer,
TrendingDown,
Check, Check,
Globe, Globe,
Medal, Medal,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel'; import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
interface LeagueReviewSummaryProps { interface LeagueReviewSummaryProps {
form: LeagueConfigFormModel; form: LeagueConfigFormModel;
@@ -31,89 +33,73 @@ interface LeagueReviewSummaryProps {
// Individual review card component // Individual review card component
function ReviewCard({ function ReviewCard({
icon: Icon, icon,
iconColor = 'text-primary-blue', iconColor = 'text-primary-blue',
bgColor = 'bg-primary-blue/10', bgColor = 'bg-primary-blue/10',
title, title,
children, children,
}: { }: {
icon: React.ElementType; icon: LucideIcon;
iconColor?: string; iconColor?: string;
bgColor?: string; bgColor?: string;
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 space-y-3"> <Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4}>
<div className="flex items-center gap-3"> <Stack gap={3}>
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${bgColor}`}> <Box display="flex" alignItems="center" gap={3}>
<Icon className={`w-4 h-4 ${iconColor}`} /> <Box display="flex" h="9" w="9" alignItems="center" justifyContent="center" rounded="lg" bg={bgColor}>
</div> <Icon icon={icon} size={4} color={iconColor} />
<h3 className="text-sm font-semibold text-white">{title}</h3> </Box>
</div> <Heading level={3} fontSize="sm" weight="semibold" color="text-white">{title}</Heading>
{children} </Box>
</div> {children}
</Stack>
</Box>
); );
} }
// Info row component for consistent layout // Info row component for consistent layout
function InfoRow({ function InfoRow({
icon: Icon, icon,
label, label,
value, value,
valueClass = '', valueClass = '',
}: { }: {
icon?: React.ElementType; icon?: LucideIcon;
label: string; label: string;
value: React.ReactNode; value: React.ReactNode;
valueClass?: string; valueClass?: string;
}) { }) {
return ( return (
<div className="flex items-center justify-between py-2 border-b border-charcoal-outline/20 last:border-0"> <Box display="flex" alignItems="center" justifyContent="between" py={2} borderBottom borderColor="border-charcoal-outline/20"
<div className="flex items-center gap-2 text-xs text-gray-500"> // eslint-disable-next-line gridpilot-rules/component-classification
{Icon && <Icon className="w-3.5 h-3.5" />} className="last:border-0"
<span>{label}</span> >
</div> <Box display="flex" alignItems="center" gap={2}>
<div className={`text-sm font-medium text-white ${valueClass}`}>{value}</div> {icon && <Icon icon={icon} size={3.5} color="text-gray-500" />}
</div> <Text size="xs" color="text-gray-500">{label}</Text>
); </Box>
} <Text size="sm" weight="medium" color="text-white"
// eslint-disable-next-line gridpilot-rules/component-classification
// Badge component for enabled features className={valueClass}
function FeatureBadge({ >
icon: Icon, {value}
label, </Text>
enabled, </Box>
color = 'primary-blue',
}: {
icon: React.ElementType;
label: string;
enabled: boolean;
color?: string;
}) {
if (!enabled) return null;
return (
<span className={`inline-flex items-center gap-1.5 rounded-full bg-${color}/10 px-3 py-1.5 text-xs font-medium text-${color}`}>
<Icon className="w-3 h-3" />
{label}
</span>
); );
} }
export default function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) { export function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) {
const { basics, structure, timings, scoring, championships, dropPolicy, stewarding } = form; const { basics, structure, timings, scoring, championships, dropPolicy, stewarding } = form;
const seasonName = (form as LeagueConfigFormModel & { seasonName?: string }).seasonName; const seasonName = (form as LeagueConfigFormModel & { seasonName?: string }).seasonName;
const modeLabel = const modeLabel =
structure.mode === 'solo' structure.mode === 'solo'
? 'Solo drivers' ? 'Solo drivers'
: 'Team-based'; : 'Team-based';
const modeDescription =
structure.mode === 'solo'
? 'Individual competition'
: 'Teams with fixed rosters';
const capacityValue = (() => { const capacityValue = (() => {
if (structure.mode === 'solo') { if (structure.mode === 'solo') {
return typeof structure.maxDrivers === 'number' ? structure.maxDrivers : '—'; return typeof structure.maxDrivers === 'number' ? structure.maxDrivers : '—';
@@ -122,12 +108,12 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
})(); })();
const capacityLabel = structure.mode === 'solo' ? 'drivers' : 'teams'; const capacityLabel = structure.mode === 'solo' ? 'drivers' : 'teams';
const formatMinutes = (value: number | undefined) => { const formatMinutes = (value: number | undefined) => {
if (typeof value !== 'number' || value <= 0) return '—'; if (typeof value !== 'number' || value <= 0) return '—';
return `${value} min`; return `${value} min`;
}; };
const getDropRuleInfo = () => { const getDropRuleInfo = () => {
if (dropPolicy.strategy === 'none') { if (dropPolicy.strategy === 'none') {
return { emoji: '✓', label: 'All count', description: 'Every race counts' }; return { emoji: '✓', label: 'All count', description: 'Every race counts' };
@@ -148,31 +134,31 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
} }
return { emoji: '✓', label: 'All count', description: 'Every race counts' }; return { emoji: '✓', label: 'All count', description: 'Every race counts' };
}; };
const dropRuleInfo = getDropRuleInfo(); const dropRuleInfo = getDropRuleInfo();
const preset = presets.find((p) => p.id === scoring.patternId) ?? null; const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
const seasonStartLabel = const seasonStartLabel =
timings.seasonStartDate timings.seasonStartDate
? new Date(timings.seasonStartDate).toLocaleDateString(undefined, { ? new Date(timings.seasonStartDate).toLocaleDateString(undefined, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
}) })
: null; : null;
const stewardingLabel = (() => { const stewardingLabel = (() => {
switch (stewarding.decisionMode) { switch (stewarding.decisionMode) {
case 'admin_only': case 'admin_only':
return 'Admin-only decisions'; return 'Admin-only decisions';
case 'steward_vote': case 'steward_vote':
return 'Steward panel voting'; return 'Steward panel voting';
default: default:
return stewarding.decisionMode; return stewarding.decisionMode;
} }
})(); })();
const getScoringEmoji = () => { const getScoringEmoji = () => {
if (!preset) return '🏁'; if (!preset) return '🏁';
const name = preset.name.toLowerCase(); const name = preset.name.toLowerCase();
@@ -181,14 +167,14 @@ const stewardingLabel = (() => {
if (name.includes('club') || name.includes('casual')) return '🏅'; if (name.includes('club') || name.includes('casual')) return '🏅';
return '🏁'; return '🏁';
}; };
// Normalize visibility to new terminology // Normalize visibility to new terminology
const isRanked = basics.visibility === 'public'; // public = ranked, private/unlisted = unranked const isRanked = basics.visibility === 'public'; // public = ranked, private/unlisted = unranked
const visibilityLabel = isRanked ? 'Ranked' : 'Unranked'; const visibilityLabel = isRanked ? 'Ranked' : 'Unranked';
const visibilityDescription = isRanked const visibilityDescription = isRanked
? 'Competitive • Affects ratings' ? 'Competitive • Affects ratings'
: 'Casual • Friends only'; : 'Casual • Friends only';
// Calculate total weekend duration // Calculate total weekend duration
const totalWeekendMinutes = (timings.practiceMinutes ?? 0) + const totalWeekendMinutes = (timings.practiceMinutes ?? 0) +
(timings.qualifyingMinutes ?? 0) + (timings.qualifyingMinutes ?? 0) +
@@ -196,118 +182,138 @@ const stewardingLabel = (() => {
(timings.mainRaceMinutes ?? 0); (timings.mainRaceMinutes ?? 0);
return ( return (
<div className="space-y-6"> <Stack gap={6}>
{/* League Summary */} {/* League Summary */}
<div className="space-y-3"> <Stack gap={3}>
<h3 className="text-sm font-semibold text-gray-300">League summary</h3> <Heading level={3} fontSize="sm" weight="semibold" color="text-gray-300">League summary</Heading>
<div className="relative rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray border border-primary-blue/30 p-6 overflow-hidden"> <Box position="relative" rounded="2xl" bg="bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray" border borderColor="border-primary-blue/30" p={6} overflow="hidden">
{/* Background decoration */} {/* Background decoration */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary-blue/10 rounded-full blur-3xl" /> <Box position="absolute" top="0" right="0" w="32" h="32" bg="bg-primary-blue/10" rounded="full"
<div className="absolute bottom-0 left-0 w-24 h-24 bg-neon-aqua/5 rounded-full blur-2xl" /> // eslint-disable-next-line gridpilot-rules/component-classification
className="blur-3xl"
/>
<Box position="absolute" bottom="0" left="0" w="24" h="24" bg="bg-neon-aqua/5" rounded="full"
// eslint-disable-next-line gridpilot-rules/component-classification
className="blur-2xl"
/>
<div className="relative flex items-start gap-4"> <Box position="relative" display="flex" alignItems="start" gap={4}>
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/20 border border-primary-blue/30 shrink-0"> <Box display="flex" h="14" w="14" alignItems="center" justifyContent="center" rounded="2xl" bg="bg-primary-blue/20" border borderColor="border-primary-blue/30" flexShrink={0}>
<Rocket className="w-7 h-7 text-primary-blue" /> <Icon icon={Rocket} size={7} color="text-primary-blue" />
</div> </Box>
<div className="flex-1 min-w-0"> <Box flexGrow={1} minWidth="0">
<h2 className="text-xl font-bold text-white mb-1 truncate"> <Heading level={2} fontSize="xl" weight="bold" color="text-white" mb={1} truncate>
{basics.name || 'Your New League'} {basics.name || 'Your New League'}
</h2> </Heading>
<p className="text-sm text-gray-400 mb-3"> <Text size="sm" color="text-gray-400" mb={3} block>
{basics.description || 'Ready to launch your racing series!'} {basics.description || 'Ready to launch your racing series!'}
</p> </Text>
<div className="flex flex-wrap items-center gap-3"> <Box display="flex" flexWrap="wrap" alignItems="center" gap={3}>
{/* Ranked/Unranked Badge */} {/* Ranked/Unranked Badge */}
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ${ <Box
isRanked as="span"
? 'bg-primary-blue/15 text-primary-blue border border-primary-blue/30' display="inline-flex"
: 'bg-neon-aqua/15 text-neon-aqua border border-neon-aqua/30' alignItems="center"
}`}> gap={1.5}
{isRanked ? <Trophy className="w-3 h-3" /> : <Users className="w-3 h-3" />} rounded="full"
<span className="font-semibold">{visibilityLabel}</span> px={3}
<span className="text-[10px] opacity-70"> {visibilityDescription}</span> py={1.5}
</span> bg={isRanked ? 'bg-primary-blue/15' : 'bg-neon-aqua/15'}
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300"> color={isRanked ? 'text-primary-blue' : 'text-neon-aqua'}
<Gamepad2 className="w-3 h-3" /> border
iRacing borderColor={isRanked ? 'border-primary-blue/30' : 'border-neon-aqua/30'}
</span> >
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300"> <Icon icon={isRanked ? Trophy : Users} size={3} />
{structure.mode === 'solo' ? <User className="w-3 h-3" /> : <UsersRound className="w-3 h-3" />} <Text weight="semibold" size="xs">{visibilityLabel}</Text>
{modeLabel} <Text
</span> // eslint-disable-next-line gridpilot-rules/component-classification
</div> style={{ fontSize: '10px' }}
</div> opacity={0.7}
</div> >
</div> {visibilityDescription}
</div> </Text>
</Box>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="full" bg="bg-charcoal-outline/50" px={3} py={1} color="text-gray-300">
<Icon icon={Gamepad2} size={3} />
<Text size="xs" weight="medium">iRacing</Text>
</Box>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="full" bg="bg-charcoal-outline/50" px={3} py={1} color="text-gray-300">
<Icon icon={structure.mode === 'solo' ? User : UsersRound} size={3} />
<Text size="xs" weight="medium">{modeLabel}</Text>
</Box>
</Box>
</Box>
</Box>
</Box>
</Stack>
{/* Season Summary */} {/* Season Summary */}
<div className="space-y-3"> <Stack gap={3}>
<h3 className="text-sm font-semibold text-gray-300">First season summary</h3> <Heading level={3} fontSize="sm" weight="semibold" color="text-gray-300">First season summary</Heading>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400"> <Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
<span>{seasonName || 'First season of this league'}</span> <Text size="xs" color="text-gray-400">{seasonName || 'First season of this league'}</Text>
{seasonStartLabel && ( {seasonStartLabel && (
<> <>
<span></span> <Text size="xs" color="text-gray-400"></Text>
<span>Starts {seasonStartLabel}</span> <Text size="xs" color="text-gray-400">Starts {seasonStartLabel}</Text>
</> </>
)} )}
{typeof timings.roundsPlanned === 'number' && ( {typeof timings.roundsPlanned === 'number' && (
<> <>
<span></span> <Text size="xs" color="text-gray-400"></Text>
<span>{timings.roundsPlanned} rounds planned</span> <Text size="xs" color="text-gray-400">{timings.roundsPlanned} rounds planned</Text>
</> </>
)} )}
<span></span> <Text size="xs" color="text-gray-400"></Text>
<span>Stewarding: {stewardingLabel}</span> <Text size="xs" color="text-gray-400">Stewarding: {stewardingLabel}</Text>
</div> </Box>
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <Box display="grid" gridCols={{ base: 2, md: 4 }} gap={3}>
{/* Capacity */} {/* Capacity */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center"> <Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4} textAlign="center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 mx-auto mb-2"> <Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-primary-blue/10" mx="auto" mb={2}>
<Users className="w-5 h-5 text-primary-blue" /> <Icon icon={Users} size={5} color="text-primary-blue" />
</div> </Box>
<div className="text-2xl font-bold text-white">{capacityValue}</div> <Text size="2xl" weight="bold" color="text-white" block>{capacityValue}</Text>
<div className="text-xs text-gray-500">{capacityLabel}</div> <Text size="xs" color="text-gray-500" block>{capacityLabel}</Text>
</div> </Box>
{/* Rounds */} {/* Rounds */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center"> <Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4} textAlign="center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10 mx-auto mb-2"> <Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-performance-green/10" mx="auto" mb={2}>
<Flag className="w-5 h-5 text-performance-green" /> <Icon icon={Flag} size={5} color="text-performance-green" />
</div> </Box>
<div className="text-2xl font-bold text-white">{timings.roundsPlanned ?? '—'}</div> <Text size="2xl" weight="bold" color="text-white" block>{timings.roundsPlanned ?? '—'}</Text>
<div className="text-xs text-gray-500">rounds</div> <Text size="xs" color="text-gray-500" block>rounds</Text>
</div> </Box>
{/* Weekend Duration */} {/* Weekend Duration */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center"> <Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4} textAlign="center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10 mx-auto mb-2"> <Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-warning-amber/10" mx="auto" mb={2}>
<Timer className="w-5 h-5 text-warning-amber" /> <Icon icon={Timer} size={5} color="text-warning-amber" />
</div> </Box>
<div className="text-2xl font-bold text-white">{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</div> <Text size="2xl" weight="bold" color="text-white" block>{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</Text>
<div className="text-xs text-gray-500">min/weekend</div> <Text size="xs" color="text-gray-500" block>min/weekend</Text>
</div> </Box>
{/* Championships */} {/* Championships */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center"> <Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4} textAlign="center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-aqua/10 mx-auto mb-2"> <Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-neon-aqua/10" mx="auto" mb={2}>
<Award className="w-5 h-5 text-neon-aqua" /> <Icon icon={Award} size={5} color="text-neon-aqua" />
</div> </Box>
<div className="text-2xl font-bold text-white"> <Text size="2xl" weight="bold" color="text-white" block>
{[championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].filter(Boolean).length} {[championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].filter(Boolean).length}
</div> </Text>
<div className="text-xs text-gray-500">championships</div> <Text size="xs" color="text-gray-500" block>championships</Text>
</div> </Box>
</div> </Box>
</div> </Stack>
{/* Detail Cards Grid */} {/* Detail Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <Box display="grid" gridCols={{ base: 1, md: 2 }} gap={4}>
{/* Schedule Card */} {/* Schedule Card */}
<ReviewCard icon={Calendar} title="Race Weekend"> <ReviewCard icon={Calendar} title="Race Weekend">
<div className="space-y-1"> <Stack gap={1}>
{timings.practiceMinutes && timings.practiceMinutes > 0 && ( {timings.practiceMinutes && timings.practiceMinutes > 0 && (
<InfoRow icon={Clock} label="Practice" value={formatMinutes(timings.practiceMinutes)} /> <InfoRow icon={Clock} label="Practice" value={formatMinutes(timings.practiceMinutes)} />
)} )}
@@ -316,89 +322,98 @@ const stewardingLabel = (() => {
<InfoRow icon={Zap} label="Sprint Race" value={formatMinutes(timings.sprintRaceMinutes)} /> <InfoRow icon={Zap} label="Sprint Race" value={formatMinutes(timings.sprintRaceMinutes)} />
)} )}
<InfoRow icon={Flag} label="Main Race" value={formatMinutes(timings.mainRaceMinutes)} /> <InfoRow icon={Flag} label="Main Race" value={formatMinutes(timings.mainRaceMinutes)} />
</div> </Stack>
</ReviewCard> </ReviewCard>
{/* Scoring Card */} {/* Scoring Card */}
<ReviewCard icon={Trophy} iconColor="text-warning-amber" bgColor="bg-warning-amber/10" title="Scoring System"> <ReviewCard icon={Trophy} iconColor="text-warning-amber" bgColor="bg-warning-amber/10" title="Scoring System">
<div className="space-y-3"> <Stack gap={3}>
{/* Scoring Preset */} {/* Scoring Preset */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline/30"> <Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<span className="text-2xl">{getScoringEmoji()}</span> <Text size="2xl">{getScoringEmoji()}</Text>
<div className="flex-1 min-w-0"> <Box flexGrow={1} minWidth="0">
<div className="text-sm font-medium text-white">{preset?.name ?? 'Custom'}</div> <Text size="sm" weight="medium" color="text-white" block>{preset?.name ?? 'Custom'}</Text>
<div className="text-xs text-gray-500">{preset?.sessionSummary ?? 'Custom scoring enabled'}</div> <Text size="xs" color="text-gray-500" block>{preset?.sessionSummary ?? 'Custom scoring enabled'}</Text>
</div> </Box>
{scoring.customScoringEnabled && ( {scoring.customScoringEnabled && (
<span className="px-2 py-0.5 rounded bg-primary-blue/20 text-[10px] font-medium text-primary-blue">Custom</span> <Box as="span" px={2} py={0.5} rounded="sm" bg="bg-primary-blue/20">
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-primary-blue"
>
Custom
</Text>
</Box>
)} )}
</div> </Box>
{/* Drop Rule */} {/* Drop Rule */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline/30"> <Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-charcoal-outline/50"> <Box display="flex" h="8" w="8" alignItems="center" justifyContent="center" rounded="lg" bg="bg-charcoal-outline/50">
<span className="text-base">{dropRuleInfo.emoji}</span> <Text size="base">{dropRuleInfo.emoji}</Text>
</div> </Box>
<div className="flex-1 min-w-0"> <Box flexGrow={1} minWidth="0">
<div className="text-sm font-medium text-white">{dropRuleInfo.label}</div> <Text size="sm" weight="medium" color="text-white" block>{dropRuleInfo.label}</Text>
<div className="text-xs text-gray-500">{dropRuleInfo.description}</div> <Text size="xs" color="text-gray-500" block>{dropRuleInfo.description}</Text>
</div> </Box>
</div> </Box>
</div> </Stack>
</ReviewCard> </ReviewCard>
</div> </Box>
{/* Championships Section */} {/* Championships Section */}
<ReviewCard icon={Award} iconColor="text-neon-aqua" bgColor="bg-neon-aqua/10" title="Active Championships"> <ReviewCard icon={Award} iconColor="text-neon-aqua" bgColor="bg-neon-aqua/10" title="Active Championships">
<div className="flex flex-wrap gap-2"> <Box display="flex" flexWrap="wrap" gap={2}>
{championships.enableDriverChampionship && ( {championships.enableDriverChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue"> <Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={2}>
<Trophy className="w-3.5 h-3.5" /> <Icon icon={Trophy} size={3.5} color="text-primary-blue" />
Driver Championship <Text size="xs" weight="medium" color="text-primary-blue">Driver Championship</Text>
<Check className="w-3 h-3 text-performance-green" /> <Icon icon={Check} size={3} color="text-performance-green" />
</span> </Box>
)} )}
{championships.enableTeamChampionship && ( {championships.enableTeamChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue"> <Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={2}>
<Award className="w-3.5 h-3.5" /> <Icon icon={Award} size={3.5} color="text-primary-blue" />
Team Championship <Text size="xs" weight="medium" color="text-primary-blue">Team Championship</Text>
<Check className="w-3 h-3 text-performance-green" /> <Icon icon={Check} size={3} color="text-performance-green" />
</span> </Box>
)} )}
{championships.enableNationsChampionship && ( {championships.enableNationsChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue"> <Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={2}>
<Globe className="w-3.5 h-3.5" /> <Icon icon={Globe} size={3.5} color="text-primary-blue" />
Nations Cup <Text size="xs" weight="medium" color="text-primary-blue">Nations Cup</Text>
<Check className="w-3 h-3 text-performance-green" /> <Icon icon={Check} size={3} color="text-performance-green" />
</span> </Box>
)} )}
{championships.enableTrophyChampionship && ( {championships.enableTrophyChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue"> <Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={2}>
<Medal className="w-3.5 h-3.5" /> <Icon icon={Medal} size={3.5} color="text-primary-blue" />
Trophy Championship <Text size="xs" weight="medium" color="text-primary-blue">Trophy Championship</Text>
<Check className="w-3 h-3 text-performance-green" /> <Icon icon={Check} size={3} color="text-performance-green" />
</span> </Box>
)} )}
{![championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].some(Boolean) && ( {![championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].some(Boolean) && (
<span className="text-sm text-gray-500">No championships enabled</span> <Text size="sm" color="text-gray-500">No championships enabled</Text>
)} )}
</div> </Box>
</ReviewCard> </ReviewCard>
{/* Ready to launch message */} {/* Ready to launch message */}
<div className="rounded-xl bg-performance-green/5 border border-performance-green/20 p-4"> <Box rounded="xl" bg="bg-performance-green/5" border borderColor="border-performance-green/20" p={4}>
<div className="flex items-center gap-3"> <Box display="flex" alignItems="center" gap={3}>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/20"> <Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-performance-green/20">
<Check className="w-5 h-5 text-performance-green" /> <Icon icon={Check} size={5} color="text-performance-green" />
</div> </Box>
<div> <Box>
<p className="text-sm font-medium text-white">Ready to launch!</p> <Text size="sm" weight="medium" color="text-white" block>Ready to launch!</Text>
<p className="text-xs text-gray-400"> <Text size="xs" color="text-gray-400" block>
Click "Create League" to launch your racing series. You can modify all settings later. Click &quot;Create League&quot; to launch your racing series. You can modify all settings later.
</p> </Text>
</div> </Box>
</div> </Box>
</div> </Box>
</div> </Stack>
); );
} }

View File

@@ -1,79 +0,0 @@
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import LeagueSchedule from './LeagueSchedule';
import { LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
const mockPush = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}));
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-123',
}));
const mockUseLeagueSchedule = vi.fn();
vi.mock('@/hooks/useLeagueService', () => ({
useLeagueSchedule: (...args: unknown[]) => mockUseLeagueSchedule(...args),
}));
vi.mock('@/hooks/useRaceService', () => ({
useRegisterForRace: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
useWithdrawFromRace: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
describe('LeagueSchedule', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
mockPush.mockReset();
mockUseLeagueSchedule.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders a schedule race (no crash)', () => {
mockUseLeagueSchedule.mockReturnValue({
isLoading: false,
data: new LeagueScheduleViewModel([
{
id: 'race-1',
name: 'Round 1',
scheduledAt: new Date('2025-01-02T20:00:00Z'),
isPast: false,
isUpcoming: true,
status: 'scheduled',
},
]),
});
render(<LeagueSchedule leagueId="league-1" />);
expect(screen.getByText('Round 1')).toBeInTheDocument();
});
it('renders loading state while schedule is loading', () => {
mockUseLeagueSchedule.mockReturnValue({
isLoading: true,
data: undefined,
});
render(<LeagueSchedule leagueId="league-1" />);
expect(screen.getByText('Loading schedule...')).toBeInTheDocument();
});
});

View File

@@ -3,22 +3,25 @@
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId"; import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useRegisterForRace } from "@/hooks/race/useRegisterForRace"; import { useRegisterForRace } from "@/hooks/race/useRegisterForRace";
import { useWithdrawFromRace } from "@/hooks/race/useWithdrawFromRace"; import { useWithdrawFromRace } from "@/hooks/race/useWithdrawFromRace";
import { useRouter } from 'next/navigation'; import { useState } from 'react';
import { useMemo, useState } from 'react';
import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel'; import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
// Shared state components // Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer'; import { StateContainer } from '@/ui/StateContainer';
import { EmptyState } from '@/components/shared/state/EmptyState';
import { useLeagueSchedule } from "@/hooks/league/useLeagueSchedule"; import { useLeagueSchedule } from "@/hooks/league/useLeagueSchedule";
import { Calendar } from 'lucide-react'; import { Calendar } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
interface LeagueScheduleProps { interface LeagueScheduleProps {
leagueId: string; leagueId: string;
onRaceClick?: (raceId: string) => void;
} }
export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) { export function LeagueSchedule({ leagueId, onRaceClick }: LeagueScheduleProps) {
const router = useRouter();
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming'); const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
@@ -28,10 +31,6 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const registerMutation = useRegisterForRace(); const registerMutation = useRegisterForRace();
const withdrawMutation = useWithdrawFromRace(); const withdrawMutation = useWithdrawFromRace();
const races = useMemo(() => {
return schedule?.races ?? [];
}, [schedule]);
const handleRegister = async (race: LeagueScheduleRaceViewModel, e: React.MouseEvent) => { const handleRegister = async (race: LeagueScheduleRaceViewModel, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -62,24 +61,6 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
} }
}; };
const upcomingRaces = races.filter((race) => race.isUpcoming);
const pastRaces = races.filter((race) => race.isPast);
const getDisplayRaces = () => {
switch (filter) {
case 'upcoming':
return upcomingRaces;
case 'past':
return [...pastRaces].reverse();
case 'all':
return [...upcomingRaces, ...[...pastRaces].reverse()];
default:
return races;
}
};
const displayRaces = getDisplayRaces();
return ( return (
<StateContainer <StateContainer
data={schedule} data={schedule}
@@ -106,8 +87,10 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
case 'upcoming': case 'upcoming':
return upcomingRaces; return upcomingRaces;
case 'past': case 'past':
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
return [...pastRaces].reverse(); return [...pastRaces].reverse();
case 'all': case 'all':
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
return [...upcomingRaces, ...[...pastRaces].reverse()]; return [...upcomingRaces, ...[...pastRaces].reverse()];
default: default:
return races; return races;
@@ -117,56 +100,47 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const displayRaces = getDisplayRaces(); const displayRaces = getDisplayRaces();
return ( return (
<div> <Stack gap={4}>
{/* Filter Controls */} {/* Filter Controls */}
<div className="mb-4 flex items-center justify-between"> <Box display="flex" alignItems="center" justifyContent="between">
<p className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400">
{displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'} {displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'}
</p> </Text>
<div className="flex gap-2"> <Box display="flex" gap={2}>
<button <Button
variant={filter === 'upcoming' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setFilter('upcoming')} onClick={() => setFilter('upcoming')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
filter === 'upcoming'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
> >
Upcoming ({upcomingRaces.length}) Upcoming ({upcomingRaces.length})
</button> </Button>
<button <Button
variant={filter === 'past' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setFilter('past')} onClick={() => setFilter('past')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
filter === 'past'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
> >
Past ({pastRaces.length}) Past ({pastRaces.length})
</button> </Button>
<button <Button
variant={filter === 'all' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setFilter('all')} onClick={() => setFilter('all')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
filter === 'all'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
> >
All ({races.length}) All ({races.length})
</button> </Button>
</div> </Box>
</div> </Box>
{/* Race List */} {/* Race List */}
{displayRaces.length === 0 ? ( {displayRaces.length === 0 ? (
<div className="text-center py-8 text-gray-400"> <Box textAlign="center" py={8}>
<p className="mb-2">No {filter} races</p> <Text color="text-gray-400" block mb={2}>No {filter} races</Text>
{filter === 'upcoming' && ( {filter === 'upcoming' && (
<p className="text-sm text-gray-500">Schedule your first race to get started</p> <Text size="sm" color="text-gray-500" block>Schedule your first race to get started</Text>
)} )}
</div> </Box>
) : ( ) : (
<div className="space-y-3"> <Stack gap={3}>
{displayRaces.map((race) => { {displayRaces.map((race) => {
const isPast = race.isPast; const isPast = race.isPast;
const isUpcoming = race.isUpcoming; const isUpcoming = race.isUpcoming;
@@ -178,91 +152,103 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
registerMutation.isPending || withdrawMutation.isPending; registerMutation.isPending || withdrawMutation.isPending;
return ( return (
<div <Box
key={race.id} key={race.id}
className={`p-4 rounded-lg border transition-all duration-200 cursor-pointer hover:scale-[1.02] ${ p={4}
isPast rounded="lg"
? 'bg-iron-gray/50 border-charcoal-outline/50 opacity-75' border
: 'bg-deep-graphite border-charcoal-outline hover:border-primary-blue' transition
}`} cursor="pointer"
onClick={() => router.push(`/races/${race.id}`)} hoverScale
bg={isPast ? 'bg-iron-gray/50' : 'bg-deep-graphite'}
borderColor={isPast ? 'border-charcoal-outline/50' : 'border-charcoal-outline'}
hoverBorderColor={!isPast ? 'border-primary-blue' : undefined}
opacity={isPast ? 0.75 : 1}
onClick={() => onRaceClick?.(race.id)}
> >
<div className="flex items-center justify-between gap-4"> <Box display="flex" alignItems="center" justifyContent="between" gap={4}>
<div className="flex-1"> <Box flexGrow={1}>
<div className="flex items-center gap-2 mb-1 flex-wrap"> <Box display="flex" alignItems="center" gap={2} mb={1} flexWrap="wrap">
<h3 className="text-white font-medium">{trackLabel}</h3> <Heading level={3} fontSize="base" weight="medium" color="text-white">{trackLabel}</Heading>
{isUpcoming && !isRegistered && ( {isUpcoming && !isRegistered && (
<span className="px-2 py-0.5 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30"> <Box as="span" px={2} py={0.5} bg="bg-primary-blue/10" border borderColor="border-primary-blue/30" rounded="sm">
Upcoming <Text size="xs" weight="medium" color="text-primary-blue">Upcoming</Text>
</span> </Box>
)} )}
{isUpcoming && isRegistered && ( {isUpcoming && isRegistered && (
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-400 rounded border border-green-500/30"> <Box as="span" px={2} py={0.5} bg="bg-green-500/10" border borderColor="border-green-500/30" rounded="sm">
Registered <Text size="xs" weight="medium" color="text-green-400"> Registered</Text>
</span> </Box>
)} )}
{isPast && ( {isPast && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50"> <Box as="span" px={2} py={0.5} bg="bg-gray-700/50" border borderColor="border-gray-600/50" rounded="sm">
Completed <Text size="xs" weight="medium" color="text-gray-400">Completed</Text>
</span> </Box>
)} )}
</div> </Box>
<p className="text-sm text-gray-400">{carLabel}</p> <Text size="sm" color="text-gray-400" block>{carLabel}</Text>
<div className="flex items-center gap-3 mt-2"> <Box mt={2}>
<p className="text-xs text-gray-500 uppercase">{sessionTypeLabel}</p> <Text size="xs" color="text-gray-500" transform="uppercase">{sessionTypeLabel}</Text>
</div> </Box>
</div> </Box>
<div className="flex items-center gap-3"> <Box display="flex" alignItems="center" gap={3}>
<div className="text-right"> <Box textAlign="right">
<p className="text-white font-medium"> <Text color="text-white" weight="medium" block>
{race.scheduledAt.toLocaleDateString('en-US', { {race.scheduledAt.toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
})} })}
</p> </Text>
<p className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400" block>
{race.scheduledAt.toLocaleTimeString([], { {race.scheduledAt.toLocaleTimeString([], {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
})} })}
</p> </Text>
{isPast && race.status === 'completed' && ( {isPast && race.status === 'completed' && (
<p className="text-xs text-primary-blue mt-1">View Results </p> <Text size="xs" color="text-primary-blue" mt={1} block>View Results </Text>
)} )}
</div> </Box>
{/* Registration Actions */} {/* Registration Actions */}
{isUpcoming && ( {isUpcoming && (
<div onClick={(e) => e.stopPropagation()}> <Box onClick={(e: React.MouseEvent) => e.stopPropagation()}>
{!isRegistered ? ( {!isRegistered ? (
<button <Button
variant="primary"
size="sm"
onClick={(e) => handleRegister(race, e)} onClick={(e) => handleRegister(race, e)}
disabled={isProcessing} disabled={isProcessing}
className="px-3 py-1.5 text-sm font-medium bg-primary-blue hover:bg-primary-blue/80 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap" // eslint-disable-next-line gridpilot-rules/component-classification
className="whitespace-nowrap"
> >
{registerMutation.isPending ? 'Registering...' : 'Register'} {registerMutation.isPending ? 'Registering...' : 'Register'}
</button> </Button>
) : ( ) : (
<button <Button
variant="secondary"
size="sm"
onClick={(e) => handleWithdraw(race, e)} onClick={(e) => handleWithdraw(race, e)}
disabled={isProcessing} disabled={isProcessing}
className="px-3 py-1.5 text-sm font-medium bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap" color="text-gray-300"
// eslint-disable-next-line gridpilot-rules/component-classification
className="whitespace-nowrap"
> >
{withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'} {withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'}
</button> </Button>
)} )}
</div> </Box>
)} )}
</div> </Box>
</div> </Box>
</div> </Box>
); );
})} })}
</div> </Stack>
)} )}
</div> </Stack>
); );
}} }}
</StateContainer> </StateContainer>

View File

@@ -1,14 +1,14 @@
'use client'; 'use client';
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X, LucideIcon } from 'lucide-react'; import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X } from 'lucide-react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel'; import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel'; import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel';
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid'; import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface'; import { Surface } from '@/ui/Surface';
@@ -102,16 +102,18 @@ function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutP
ref={flyoutRef} ref={flyoutRef}
position="fixed" position="fixed"
zIndex={50} zIndex={50}
width="380px" w="380px"
backgroundColor="iron-gray" bg="bg-iron-gray"
border border
borderColor="charcoal-outline" borderColor="border-charcoal-outline"
rounded="xl" rounded="xl"
// eslint-disable-next-line gridpilot-rules/component-classification
className="max-h-[80vh] overflow-y-auto shadow-2xl animate-fade-in" className="max-h-[80vh] overflow-y-auto shadow-2xl animate-fade-in"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ top: position.top, left: position.left }} style={{ top: position.top, left: position.left }}
> >
{/* Header */} {/* Header */}
<Box display="flex" align="center" justify="between" padding={4} border borderBottom borderColor="charcoal-outline" position="sticky" top={0} backgroundColor="iron-gray" zIndex={10}> <Box display="flex" alignItems="center" justifyContent="between" p={4} borderBottom borderColor="border-charcoal-outline" position="sticky" top="0" bg="bg-iron-gray" zIndex={10}>
<Stack direction="row" align="center" gap={2}> <Stack direction="row" align="center" gap={2}>
<Icon icon={HelpCircle} size={4} color="text-primary-blue" /> <Icon icon={HelpCircle} size={4} color="text-primary-blue" />
<Text size="sm" weight="semibold" color="text-white">{title}</Text> <Text size="sm" weight="semibold" color="text-white">{title}</Text>
@@ -120,14 +122,14 @@ function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutP
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onClose} onClick={onClose}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-6 w-6 p-0" className="h-6 w-6 p-0"
icon={<Icon icon={X} size={4} color="text-gray-400" />}
> >
{null} <Icon icon={X} size={4} color="text-gray-400" />
</Button> </Button>
</Box> </Box>
{/* Content */} {/* Content */}
<Box padding={4}> <Box p={4}>
{children} {children}
</Box> </Box>
</Box>, </Box>,
@@ -142,10 +144,10 @@ function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: Re
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onClick} onClick={onClick}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-5 p-0 rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10" className="h-5 w-5 p-0 rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10"
icon={<Icon icon={HelpCircle} size={3.5} />}
> >
{null} <Icon icon={HelpCircle} size={3.5} />
</Button> </Button>
); );
} }
@@ -164,32 +166,64 @@ function PointsSystemMockup() {
]; ];
return ( return (
<Surface variant="dark" rounded="lg" padding={4}> <Surface variant="dark" rounded="lg" p={4}>
<Stack gap={3}> <Stack gap={3}>
<Box display="flex" align="center" justify="between" className="text-[10px] text-gray-500 uppercase tracking-wide px-1"> <Box display="flex" alignItems="center" justifyContent="between" px={1}>
<Text>Position</Text> <Text
<Text>Points</Text> // eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Position
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Points
</Text>
</Box> </Box>
{positions.map((p) => ( {positions.map((p) => (
<Stack key={p.pos} direction="row" align="center" gap={3}> <Stack key={p.pos} direction="row" align="center" gap={3}>
<Box width={8} height={8} rounded="lg" className={p.color} display="flex" center> <Box w="8" h="8" rounded="lg" bg={p.color} display="flex" alignItems="center" justifyContent="center">
<Text size="sm" weight="bold" className={p.pos <= 3 ? 'text-deep-graphite' : 'text-gray-400'}>P{p.pos}</Text> <Text size="sm" weight="bold" color={p.pos <= 3 ? 'text-deep-graphite' : 'text-gray-400'}>P{p.pos}</Text>
</Box> </Box>
<Box flex={1} height={2} backgroundColor="charcoal-outline" rounded="full" className="overflow-hidden opacity-50"> <Box flexGrow={1} h="2" bg="bg-charcoal-outline" rounded="full" overflow="hidden" opacity={0.5}>
<Box <Box
height="full" h="full"
className="bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full" bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
rounded="full"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ width: `${(p.pts / 25) * 100}%` }} style={{ width: `${(p.pts / 25) * 100}%` }}
/> />
</Box> </Box>
<Box width={8} textAlign="right"> <Box w="8" textAlign="right">
<Text size="sm" font="mono" weight="semibold" color="text-white">{p.pts}</Text> <Text size="sm" font="mono" weight="semibold" color="text-white">{p.pts}</Text>
</Box> </Box>
</Stack> </Stack>
))} ))}
<Box display="flex" align="center" justify="center" gap={1} pt={2} className="text-[10px] text-gray-500"> <Box display="flex" alignItems="center" justifyContent="center" gap={1} pt={2}>
<Text>...</Text> <Text
<Text>down to P10 = 1 point</Text> // eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
...
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
down to P10 = 1 point
</Text>
</Box> </Box>
</Stack> </Stack>
</Surface> </Surface>
@@ -204,15 +238,22 @@ function BonusPointsMockup() {
]; ];
return ( return (
<Surface variant="dark" rounded="lg" padding={4}> <Surface variant="dark" rounded="lg" p={4}>
<Stack gap={2}> <Stack gap={2}>
{bonuses.map((b, i) => ( {bonuses.map((b, i) => (
<Surface key={i} variant="muted" border rounded="lg" padding={2}> <Surface key={i} variant="muted" border rounded="lg" p={2}>
<Stack direction="row" align="center" gap={3}> <Stack direction="row" align="center" gap={3}>
<Text size="xl">{b.emoji}</Text> <Text size="xl">{b.emoji}</Text>
<Box flex={1}> <Box flexGrow={1}>
<Text size="xs" weight="medium" color="text-white" block>{b.label}</Text> <Text size="xs" weight="medium" color="text-white" block>{b.label}</Text>
<Text className="text-[10px] text-gray-500" block>{b.desc}</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
{b.desc}
</Text>
</Box> </Box>
<Text size="sm" font="mono" weight="semibold" color="text-performance-green">{b.pts}</Text> <Text size="sm" font="mono" weight="semibold" color="text-performance-green">{b.pts}</Text>
</Stack> </Stack>
@@ -231,80 +272,48 @@ function ChampionshipMockup() {
]; ];
return ( return (
<Surface variant="dark" rounded="lg" padding={4}> <Surface variant="dark" rounded="lg" p={4}>
<Box display="flex" align="center" gap={2} mb={3} pb={2} border borderBottom borderColor="charcoal-outline" className="opacity-50"> <Box display="flex" alignItems="center" gap={2} mb={3} pb={2} borderBottom borderColor="border-charcoal-outline" opacity={0.5}>
<Icon icon={Trophy} size={4} color="text-yellow-500" /> <Icon icon={Trophy} size={4} color="text-yellow-500" />
<Text size="xs" weight="semibold" color="text-white">Driver Championship</Text> <Text size="xs" weight="semibold" color="text-white">Driver Championship</Text>
</Box> </Box>
<Stack gap={2}> <Stack gap={2}>
{standings.map((s) => ( {standings.map((s) => (
<Stack key={s.pos} direction="row" align="center" gap={2}> <Stack key={s.pos} direction="row" align="center" gap={2}>
<Box width={6} height={6} rounded="full" display="flex" center className={s.pos === 1 ? 'bg-yellow-500 text-deep-graphite' : 'bg-charcoal-outline text-gray-400'}> <Box w="6" h="6" rounded="full" display="flex" alignItems="center" justifyContent="center" bg={s.pos === 1 ? 'bg-yellow-500' : 'bg-charcoal-outline'} color={s.pos === 1 ? 'text-deep-graphite' : 'text-gray-400'}>
<Text className="text-[10px]" weight="bold">{s.pos}</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="bold"
>
{s.pos}
</Text>
</Box> </Box>
<Box flex={1}> <Box flexGrow={1}>
<Text size="xs" color="text-white" className="truncate" block>{s.name}</Text> <Text size="xs" color="text-white" truncate block>{s.name}</Text>
</Box> </Box>
<Text size="xs" font="mono" weight="semibold" color="text-white">{s.pts}</Text> <Text size="xs" font="mono" weight="semibold" color="text-white">{s.pts}</Text>
{s.delta && ( {s.delta && (
<Text className="text-[10px] font-mono text-gray-500">{s.delta}</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
font="mono"
color="text-gray-500"
>
{s.delta}
</Text>
)} )}
</Stack> </Stack>
))} ))}
</Stack> </Stack>
<Box mt={3} pt={2} border borderTop borderColor="charcoal-outline" className="text-[10px] text-gray-500 opacity-50" textAlign="center"> <Box mt={3} pt={2} borderTop borderColor="border-charcoal-outline" opacity={0.5} textAlign="center">
<Text>Points accumulated across all races</Text> <Text
</Box> // eslint-disable-next-line gridpilot-rules/component-classification
</Surface> style={{ fontSize: '10px' }}
); color="text-gray-500"
} >
Points accumulated across all races
function DropRulesMockup() { </Text>
const results = [
{ round: 'R1', pts: 25, dropped: false },
{ round: 'R2', pts: 18, dropped: false },
{ round: 'R3', pts: 4, dropped: true },
{ round: 'R4', pts: 15, dropped: false },
{ round: 'R5', pts: 12, dropped: false },
{ round: 'R6', pts: 0, dropped: true },
];
const total = results.filter(r => !r.dropped).reduce((sum, r) => sum + r.pts, 0);
const wouldBe = results.reduce((sum, r) => sum + r.pts, 0);
return (
<Surface variant="dark" rounded="lg" padding={4}>
<Box mb={3} pb={2} border borderBottom borderColor="charcoal-outline" className="opacity-50">
<Text size="xs" weight="semibold" color="text-white">Best 4 of 6 Results</Text>
</Box>
<Stack direction="row" gap={1} mb={3}>
{results.map((r, i) => (
<Box
key={i}
flex={1}
padding={2}
rounded="lg"
textAlign="center"
border
borderColor={r.dropped ? 'charcoal-outline' : 'performance-green'}
backgroundColor={r.dropped ? 'transparent' : 'performance-green'}
opacity={r.dropped ? 0.5 : 0.1}
className="transition-all"
>
<Text className="text-[9px] text-gray-500" block>{r.round}</Text>
<Text size="xs" font="mono" weight="semibold" color={r.dropped ? 'text-gray-500' : 'text-white'} className={r.dropped ? 'line-through' : ''} block>
{r.pts}
</Text>
</Box>
))}
</Stack>
<Box display="flex" justify="between" align="center">
<Text size="xs" color="text-gray-500">Total counted:</Text>
<Text font="mono" weight="semibold" color="text-performance-green">{total} pts</Text>
</Box>
<Box display="flex" justify="between" align="center" mt={1} className="text-[10px] text-gray-500">
<Text>Without drops:</Text>
<Text font="mono">{wouldBe} pts</Text>
</Box> </Box>
</Surface> </Surface>
); );
@@ -418,7 +427,10 @@ export function LeagueScoringSection({
} }
return ( return (
<Grid cols={2} gap={6} className="lg:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)]"> <Grid cols={2} gap={6}
// eslint-disable-next-line gridpilot-rules/component-classification
className="lg:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)]"
>
{patternPanel} {patternPanel}
{championshipsPanel} {championshipsPanel}
</Grid> </Grid>
@@ -560,10 +572,10 @@ export function ScoringPatternSection({
<Stack gap={5}> <Stack gap={5}>
{/* Section header */} {/* Section header */}
<Stack direction="row" align="center" gap={3}> <Stack direction="row" align="center" gap={3}>
<Box width={10} height={10} display="flex" center rounded="xl" backgroundColor="primary-blue" opacity={0.1}> <Box w="10" h="10" display="flex" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue" opacity={0.1}>
<Icon icon={Trophy} size={5} color="text-primary-blue" /> <Icon icon={Trophy} size={5} color="text-primary-blue" />
</Box> </Box>
<Box flex={1}> <Box flexGrow={1}>
<Stack direction="row" align="center" gap={2}> <Stack direction="row" align="center" gap={2}>
<Heading level={3}>Points System</Heading> <Heading level={3}>Points System</Heading>
<InfoButton buttonRef={pointsInfoRef} onClick={() => setShowPointsFlyout(true)} /> <InfoButton buttonRef={pointsInfoRef} onClick={() => setShowPointsFlyout(true)} />
@@ -586,16 +598,32 @@ export function ScoringPatternSection({
</Text> </Text>
<Box> <Box>
<Box mb={2} className="text-[10px] text-gray-500 uppercase tracking-wide"> <Box mb={2}>
<Text>Example: F1-Style Points</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Example: F1-Style Points
</Text>
</Box> </Box>
<PointsSystemMockup /> <PointsSystemMockup />
</Box> </Box>
<Surface variant="muted" border rounded="lg" padding={3}> <Surface variant="muted" border rounded="lg" p={3}>
<Stack direction="row" align="start" gap={2}> <Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" className="mt-0.5" /> <Icon icon={Zap} size={3.5} color="text-primary-blue"
<Text className="text-[11px] text-gray-400"> // eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
>
<Text weight="medium" color="text-primary-blue">Pro tip:</Text> Sprint formats <Text weight="medium" color="text-primary-blue">Pro tip:</Text> Sprint formats
award points in both races, typically with reduced points for the sprint. award points in both races, typically with reduced points for the sprint.
</Text> </Text>
@@ -605,11 +633,14 @@ export function ScoringPatternSection({
</InfoFlyout> </InfoFlyout>
{/* Two-column layout: Presets | Custom */} {/* Two-column layout: Presets | Custom */}
<Grid cols={2} gap={4} className="lg:grid-cols-[1fr_auto]"> <Grid cols={2} gap={4}
// eslint-disable-next-line gridpilot-rules/component-classification
className="lg:grid-cols-[1fr_auto]"
>
{/* Preset options */} {/* Preset options */}
<Stack gap={2}> <Stack gap={2}>
{presets.length === 0 ? ( {presets.length === 0 ? (
<Box padding={4} border borderStyle="dashed" borderColor="charcoal-outline" rounded="lg"> <Box p={4} border borderStyle="dashed" borderColor="border-charcoal-outline" rounded="lg">
<Text size="sm" color="text-gray-400">Loading presets...</Text> <Text size="sm" color="text-gray-400">Loading presets...</Text>
</Box> </Box>
) : ( ) : (
@@ -622,6 +653,7 @@ export function ScoringPatternSection({
variant="ghost" variant="ghost"
onClick={() => onChangePatternId?.(preset.id)} onClick={() => onChangePatternId?.(preset.id)}
disabled={disabled} disabled={disabled}
// eslint-disable-next-line gridpilot-rules/component-classification
className={` className={`
w-full flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all duration-200 h-auto w-full flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all duration-200 h-auto
${isSelected ${isSelected
@@ -632,15 +664,17 @@ export function ScoringPatternSection({
> >
{/* Radio indicator */} {/* Radio indicator */}
<Box <Box
width={5} w="5"
height={5} h="5"
display="flex" display="flex"
center alignItems="center"
justifyContent="center"
rounded="full" rounded="full"
border border
borderColor={isSelected ? 'primary-blue' : 'gray-500'} borderColor={isSelected ? 'border-primary-blue' : 'border-gray-500'}
backgroundColor={isSelected ? 'primary-blue' : 'transparent'} bg={isSelected ? 'bg-primary-blue' : 'transparent'}
className="shrink-0 transition-colors" transition
flexShrink={0}
> >
{isSelected && <Icon icon={Check} size={3} color="text-white" />} {isSelected && <Icon icon={Check} size={3} color="text-white" />}
</Box> </Box>
@@ -649,14 +683,20 @@ export function ScoringPatternSection({
<Text size="xl">{getPresetEmoji(preset)}</Text> <Text size="xl">{getPresetEmoji(preset)}</Text>
{/* Text */} {/* Text */}
<Box flex={1} className="min-w-0"> <Box flexGrow={1}
// eslint-disable-next-line gridpilot-rules/component-classification
className="min-w-0"
>
<Text size="sm" weight="medium" color="text-white" block>{preset.name}</Text> <Text size="sm" weight="medium" color="text-white" block>{preset.name}</Text>
<Text size="xs" color="text-gray-500" block>{getPresetDescription(preset)}</Text> <Text size="xs" color="text-gray-500" block>{getPresetDescription(preset)}</Text>
</Box> </Box>
{/* Bonus badge */} {/* Bonus badge */}
{preset.bonusSummary && ( {preset.bonusSummary && (
<Box className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-full bg-charcoal-outline/30 text-[10px] text-gray-400"> <Box
// eslint-disable-next-line gridpilot-rules/component-classification
className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-full bg-charcoal-outline/30 text-[10px] text-gray-400"
>
<Icon icon={Zap} size={3} /> <Icon icon={Zap} size={3} />
<Text>{preset.bonusSummary}</Text> <Text>{preset.bonusSummary}</Text>
</Box> </Box>
@@ -664,7 +704,7 @@ export function ScoringPatternSection({
{/* Info button */} {/* Info button */}
<Box <Box
ref={(el: any) => { presetInfoRefs.current[preset.id] = el; }} ref={(el: HTMLElement | null) => { presetInfoRefs.current[preset.id] = el; }}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
@@ -678,7 +718,17 @@ export function ScoringPatternSection({
setActivePresetFlyout(activePresetFlyout === preset.id ? null : preset.id); setActivePresetFlyout(activePresetFlyout === preset.id ? null : preset.id);
} }
}} }}
className="flex h-6 w-6 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0" display="flex"
h="6"
w="6"
alignItems="center"
justifyContent="center"
rounded="full"
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
transition
flexShrink={0}
> >
<Icon icon={HelpCircle} size={3.5} /> <Icon icon={HelpCircle} size={3.5} />
</Box> </Box>
@@ -695,13 +745,28 @@ export function ScoringPatternSection({
<Text size="xs" color="text-gray-400">{presetInfo.description}</Text> <Text size="xs" color="text-gray-400">{presetInfo.description}</Text>
<Stack gap={2}> <Stack gap={2}>
<Box className="text-[10px] text-gray-500 uppercase tracking-wide"> <Box>
<Text>Key Features</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Key Features
</Text>
</Box> </Box>
<Box as="ul" className="space-y-1.5"> <Box as="ul"
// eslint-disable-next-line gridpilot-rules/component-classification
className="space-y-1.5"
>
{presetInfo.details.map((detail, idx) => ( {presetInfo.details.map((detail, idx) => (
<Box as="li" key={idx} display="flex" align="start" gap={2}> <Box as="li" key={idx} display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green" className="mt-0.5" /> <Icon icon={Check} size={3} color="text-performance-green"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text size="xs" color="text-gray-400">{detail}</Text> <Text size="xs" color="text-gray-400">{detail}</Text>
</Box> </Box>
))} ))}
@@ -709,10 +774,17 @@ export function ScoringPatternSection({
</Stack> </Stack>
{preset.bonusSummary && ( {preset.bonusSummary && (
<Surface variant="muted" border rounded="lg" padding={3}> <Surface variant="muted" border rounded="lg" p={3}>
<Stack direction="row" align="start" gap={2}> <Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" className="mt-0.5" /> <Icon icon={Zap} size={3.5} color="text-primary-blue"
<Text className="text-[11px] text-gray-400"> // eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
>
<Text weight="medium" color="text-primary-blue">Bonus points:</Text> {preset.bonusSummary} <Text weight="medium" color="text-primary-blue">Bonus points:</Text> {preset.bonusSummary}
</Text> </Text>
</Stack> </Stack>
@@ -727,11 +799,15 @@ export function ScoringPatternSection({
</Stack> </Stack>
{/* Custom scoring option */} {/* Custom scoring option */}
<Box width="full" className="lg:w-48"> <Box w="full"
// eslint-disable-next-line gridpilot-rules/component-classification
className="lg:w-48"
>
<Button <Button
variant="ghost" variant="ghost"
onClick={onToggleCustomScoring} onClick={onToggleCustomScoring}
disabled={!onToggleCustomScoring || readOnly} disabled={!onToggleCustomScoring || readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className={` className={`
w-full h-full min-h-[100px] flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 transition-all duration-200 w-full h-full min-h-[100px] flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 transition-all duration-200
${isCustom ${isCustom
@@ -741,25 +817,40 @@ export function ScoringPatternSection({
`} `}
> >
<Box <Box
width={10} w="10"
height={10} h="10"
display="flex" display="flex"
center alignItems="center"
justifyContent="center"
rounded="xl" rounded="xl"
backgroundColor={isCustom ? 'primary-blue' : 'charcoal-outline'} bg={isCustom ? 'bg-primary-blue' : 'bg-charcoal-outline'}
opacity={isCustom ? 0.2 : 0.3} opacity={isCustom ? 0.2 : 0.3}
className="transition-colors" transition
> >
<Icon icon={Settings} size={5} color={isCustom ? 'text-primary-blue' : 'text-gray-500'} /> <Icon icon={Settings} size={5} color={isCustom ? 'text-primary-blue' : 'text-gray-500'} />
</Box> </Box>
<Box textAlign="center"> <Box textAlign="center">
<Text size="sm" weight="medium" color={isCustom ? 'text-white' : 'text-gray-400'} block>Custom</Text> <Text size="sm" weight="medium" color={isCustom ? 'text-white' : 'text-gray-400'} block>Custom</Text>
<Text className="text-[10px] text-gray-500" block>Define your own</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Define your own
</Text>
</Box> </Box>
{isCustom && ( {isCustom && (
<Box display="flex" align="center" gap={1} px={2} py={0.5} rounded="full" backgroundColor="primary-blue" opacity={0.2}> <Box display="flex" alignItems="center" gap={1} px={2} py={0.5} rounded="full" bg="bg-primary-blue" opacity={0.2}>
<Icon icon={Check} size={2.5} color="text-primary-blue" /> <Icon icon={Check} size={2.5} color="text-primary-blue" />
<Text className="text-[10px]" weight="medium" color="text-primary-blue">Active</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-primary-blue"
>
Active
</Text>
</Box> </Box>
)} )}
</Button> </Button>
@@ -773,10 +864,10 @@ export function ScoringPatternSection({
{/* Custom scoring editor - inline, no placeholder */} {/* Custom scoring editor - inline, no placeholder */}
{isCustom && ( {isCustom && (
<Surface variant="muted" border rounded="xl" padding={4}> <Surface variant="muted" border rounded="xl" p={4}>
<Stack gap={4}> <Stack gap={4}>
{/* Header with reset button */} {/* Header with reset button */}
<Box display="flex" align="center" justify="between"> <Box display="flex" alignItems="center" justifyContent="between">
<Stack direction="row" align="center" gap={2}> <Stack direction="row" align="center" gap={2}>
<Icon icon={Settings} size={4} color="text-primary-blue" /> <Icon icon={Settings} size={4} color="text-primary-blue" />
<Text size="sm" weight="medium" color="text-white">Custom Points Table</Text> <Text size="sm" weight="medium" color="text-white">Custom Points Table</Text>
@@ -797,16 +888,32 @@ export function ScoringPatternSection({
</Text> </Text>
<Box> <Box>
<Box mb={2} className="text-[10px] text-gray-500 uppercase tracking-wide"> <Box mb={2}>
<Text>Available Bonuses</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Available Bonuses
</Text>
</Box> </Box>
<BonusPointsMockup /> <BonusPointsMockup />
</Box> </Box>
<Surface variant="muted" border rounded="lg" padding={3}> <Surface variant="muted" border rounded="lg" p={3}>
<Stack direction="row" align="start" gap={2}> <Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" className="mt-0.5" /> <Icon icon={Zap} size={3.5} color="text-primary-blue"
<Text className="text-[11px] text-gray-400"> // eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
>
<Text weight="medium" color="text-primary-blue">Example:</Text> A driver finishing <Text weight="medium" color="text-primary-blue">Example:</Text> A driver finishing
P1 with pole and fastest lap would earn 25 + 1 + 1 = 27 points. P1 with pole and fastest lap would earn 25 + 1 + 1 = 27 points.
</Text> </Text>
@@ -820,16 +927,19 @@ export function ScoringPatternSection({
size="sm" size="sm"
onClick={resetToDefaults} onClick={resetToDefaults}
disabled={readOnly} disabled={readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-auto py-1 px-2 text-[10px] text-gray-400 hover:text-primary-blue hover:bg-primary-blue/10" className="h-auto py-1 px-2 text-[10px] text-gray-400 hover:text-primary-blue hover:bg-primary-blue/10"
icon={<Icon icon={RotateCcw} size={3} />}
> >
Reset <Stack direction="row" align="center" gap={1}>
<Icon icon={RotateCcw} size={3} />
<Text>Reset</Text>
</Stack>
</Button> </Button>
</Box> </Box>
{/* Race position points */} {/* Race position points */}
<Stack gap={2}> <Stack gap={2}>
<Box display="flex" align="center" justify="between"> <Box display="flex" alignItems="center" justifyContent="between">
<Text size="xs" color="text-gray-400">Finish position points</Text> <Text size="xs" color="text-gray-400">Finish position points</Text>
<Stack direction="row" align="center" gap={1}> <Stack direction="row" align="center" gap={1}>
<Button <Button
@@ -837,54 +947,70 @@ export function ScoringPatternSection({
size="sm" size="sm"
onClick={removePosition} onClick={removePosition}
disabled={readOnly || customPoints.racePoints.length <= 3} disabled={readOnly || customPoints.racePoints.length <= 3}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-5 p-0" className="h-5 w-5 p-0"
icon={<Icon icon={Minus} size={3} />}
> >
{null} <Icon icon={Minus} size={3} />
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={addPosition} onClick={addPosition}
disabled={readOnly || customPoints.racePoints.length >= 20} disabled={readOnly || customPoints.racePoints.length >= 20}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-5 p-0" className="h-5 w-5 p-0"
icon={<Icon icon={Plus} size={3} />}
> >
{null} <Icon icon={Plus} size={3} />
</Button> </Button>
</Stack> </Stack>
</Box> </Box>
<Stack direction="row" wrap gap={1}> <Box display="flex" flexWrap="wrap" gap={1}>
{customPoints.racePoints.map((pts, idx) => ( {customPoints.racePoints.map((pts, idx) => (
<Stack key={idx} align="center"> <Stack key={idx} align="center">
<Text className="text-[9px] text-gray-500" mb={0.5}>P{idx + 1}</Text> <Text
<Stack direction="row" align="center" gap={0.5}> // eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
mb={0.5}
>
P{idx + 1}
</Text>
<Box display="flex" alignItems="center">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => updateRacePoints(idx, -1)} onClick={() => updateRacePoints(idx, -1)}
disabled={readOnly || pts <= 0} disabled={readOnly || pts <= 0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-4 p-0 rounded-r-none text-[10px]" className="h-5 w-4 p-0 rounded-r-none text-[10px]"
> >
</Button> </Button>
<Box width={6} height={5} display="flex" center backgroundColor="deep-graphite" border borderTop borderBottom borderColor="charcoal-outline"> <Box w="6" h="5" display="flex" alignItems="center" justifyContent="center" bg="bg-deep-graphite" border borderTop borderBottom borderColor="border-charcoal-outline">
<Text className="text-[10px]" weight="medium" color="text-white">{pts}</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-white"
>
{pts}
</Text>
</Box> </Box>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => updateRacePoints(idx, 1)} onClick={() => updateRacePoints(idx, 1)}
disabled={readOnly} disabled={readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-4 p-0 rounded-l-none text-[10px]" className="h-5 w-4 p-0 rounded-l-none text-[10px]"
> >
+ +
</Button> </Button>
</Stack> </Box>
</Stack> </Stack>
))} ))}
</Stack> </Box>
</Stack> </Stack>
{/* Bonus points */} {/* Bonus points */}
@@ -895,18 +1021,25 @@ export function ScoringPatternSection({
{ key: 'leaderLapPoints' as const, label: 'Led lap', emoji: '🥇' }, { key: 'leaderLapPoints' as const, label: 'Led lap', emoji: '🥇' },
].map((bonus) => ( ].map((bonus) => (
<Stack key={bonus.key} align="center" gap={1}> <Stack key={bonus.key} align="center" gap={1}>
<Text className="text-[10px] text-gray-500">{bonus.emoji} {bonus.label}</Text> <Text
<Stack direction="row" align="center" gap={0.5}> // eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
{bonus.emoji} {bonus.label}
</Text>
<Box display="flex" alignItems="center">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => updateBonus(bonus.key, -1)} onClick={() => updateBonus(bonus.key, -1)}
disabled={readOnly || customPoints[bonus.key] <= 0} disabled={readOnly || customPoints[bonus.key] <= 0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-6 w-5 p-0 rounded-r-none" className="h-6 w-5 p-0 rounded-r-none"
> >
</Button> </Button>
<Box width={7} height={6} display="flex" center backgroundColor="deep-graphite" border borderTop borderBottom borderColor="charcoal-outline"> <Box w="7" h="6" display="flex" alignItems="center" justifyContent="center" bg="bg-deep-graphite" border borderTop borderBottom borderColor="border-charcoal-outline">
<Text size="xs" weight="medium" color="text-white">{customPoints[bonus.key]}</Text> <Text size="xs" weight="medium" color="text-white">{customPoints[bonus.key]}</Text>
</Box> </Box>
<Button <Button
@@ -914,11 +1047,12 @@ export function ScoringPatternSection({
size="sm" size="sm"
onClick={() => updateBonus(bonus.key, 1)} onClick={() => updateBonus(bonus.key, 1)}
disabled={readOnly} disabled={readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-6 w-5 p-0 rounded-l-none" className="h-6 w-5 p-0 rounded-l-none"
> >
+ +
</Button> </Button>
</Stack> </Box>
</Stack> </Stack>
))} ))}
</Grid> </Grid>
@@ -1040,10 +1174,10 @@ export function ChampionshipsSection({
<Stack gap={4}> <Stack gap={4}>
{/* Section header */} {/* Section header */}
<Stack direction="row" align="center" gap={3}> <Stack direction="row" align="center" gap={3}>
<Box width={10} height={10} display="flex" center rounded="xl" backgroundColor="primary-blue" opacity={0.1}> <Box w="10" h="10" display="flex" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue" opacity={0.1}>
<Icon icon={Award} size={5} color="text-primary-blue" /> <Icon icon={Award} size={5} color="text-primary-blue" />
</Box> </Box>
<Box flex={1}> <Box flexGrow={1}>
<Stack direction="row" align="center" gap={2}> <Stack direction="row" align="center" gap={2}>
<Heading level={3}>Championships</Heading> <Heading level={3}>Championships</Heading>
<InfoButton buttonRef={champInfoRef} onClick={() => setShowChampFlyout(true)} /> <InfoButton buttonRef={champInfoRef} onClick={() => setShowChampFlyout(true)} />
@@ -1066,15 +1200,33 @@ export function ChampionshipsSection({
</Text> </Text>
<Box> <Box>
<Box mb={2} className="text-[10px] text-gray-500 uppercase tracking-wide"> <Box mb={2}>
<Text>Live Standings Example</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Live Standings Example
</Text>
</Box> </Box>
<ChampionshipMockup /> <ChampionshipMockup />
</Box> </Box>
<Stack gap={2}> <Stack gap={2}>
<Box className="text-[10px] text-gray-500 uppercase tracking-wide"> <Box>
<Text>Championship Types</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Championship Types
</Text>
</Box> </Box>
<Grid cols={2} gap={2}> <Grid cols={2} gap={2}>
{[ {[
@@ -1083,12 +1235,27 @@ export function ChampionshipsSection({
{ icon: Globe, label: 'Nations', desc: 'By country' }, { icon: Globe, label: 'Nations', desc: 'By country' },
{ icon: Medal, label: 'Trophy', desc: 'Special class' }, { icon: Medal, label: 'Trophy', desc: 'Special class' },
].map((t, i) => ( ].map((t, i) => (
<Surface key={i} variant="dark" border rounded="lg" padding={2}> <Surface key={i} variant="dark" border rounded="lg" p={2}>
<Stack direction="row" align="center" gap={2}> <Stack direction="row" align="center" gap={2}>
<Icon icon={t.icon} size={3.5} color="text-primary-blue" /> <Icon icon={t.icon} size={3.5} color="text-primary-blue" />
<Box> <Box>
<Text className="text-[10px]" weight="medium" color="text-white" block>{t.label}</Text> <Text
<Text className="text-[9px] text-gray-500" block>{t.desc}</Text> // eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-white"
block
>
{t.label}
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
block
>
{t.desc}
</Text>
</Box> </Box>
</Stack> </Stack>
</Surface> </Surface>
@@ -1110,6 +1277,7 @@ export function ChampionshipsSection({
variant="ghost" variant="ghost"
disabled={disabled || !champ.available} disabled={disabled || !champ.available}
onClick={() => champ.available && updateChampionship(champ.key, !champ.enabled)} onClick={() => champ.available && updateChampionship(champ.key, !champ.enabled)}
// eslint-disable-next-line gridpilot-rules/component-classification
className={` className={`
w-full flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all duration-200 h-auto w-full flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all duration-200 h-auto
${isEnabled ${isEnabled
@@ -1122,34 +1290,54 @@ export function ChampionshipsSection({
> >
{/* Toggle indicator */} {/* Toggle indicator */}
<Box <Box
width={5} w="5"
height={5} h="5"
display="flex" display="flex"
center alignItems="center"
justifyContent="center"
rounded="md" rounded="md"
backgroundColor={isEnabled ? 'primary-blue' : 'charcoal-outline'} bg={isEnabled ? 'bg-primary-blue' : 'bg-charcoal-outline'}
opacity={isEnabled ? 1 : 0.5} opacity={isEnabled ? 1 : 0.5}
className="shrink-0 transition-colors" transition
flexShrink={0}
> >
{isEnabled && <Icon icon={Check} size={3} color="text-white" />} {isEnabled && <Icon icon={Check} size={3} color="text-white" />}
</Box> </Box>
{/* Icon */} {/* Icon */}
<Icon icon={champ.icon} size={4} color={isEnabled ? 'text-primary-blue' : 'text-gray-500'} className="shrink-0" /> <Icon icon={champ.icon} size={4} color={isEnabled ? 'text-primary-blue' : 'text-gray-500'}
// eslint-disable-next-line gridpilot-rules/component-classification
className="shrink-0"
/>
{/* Text */} {/* Text */}
<Box flex={1} className="min-w-0"> <Box flexGrow={1}
<Text className={`text-xs font-medium truncate ${isEnabled ? 'text-white' : 'text-gray-400'}`} block> // eslint-disable-next-line gridpilot-rules/component-classification
className="min-w-0"
>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-medium truncate ${isEnabled ? 'text-white' : 'text-gray-400'}`}
block
>
{champ.label} {champ.label}
</Text> </Text>
{!champ.available && champ.unavailableHint && ( {!champ.available && champ.unavailableHint && (
<Text className="text-[10px] text-warning-amber/70" block>{champ.unavailableHint}</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-warning-amber"
opacity={0.7}
block
>
{champ.unavailableHint}
</Text>
)} )}
</Box> </Box>
{/* Info button */} {/* Info button */}
<Box <Box
ref={(el: any) => { champItemRefs.current[champ.key] = el; }} ref={(el: HTMLElement | null) => { champItemRefs.current[champ.key] = el; }}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
@@ -1163,7 +1351,17 @@ export function ChampionshipsSection({
setActiveChampFlyout(activeChampFlyout === champ.key ? null : champ.key); setActiveChampFlyout(activeChampFlyout === champ.key ? null : champ.key);
} }
}} }}
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0" display="flex"
h="5"
w="5"
alignItems="center"
justifyContent="center"
rounded="full"
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
transition
flexShrink={0}
> >
<Icon icon={HelpCircle} size={3} /> <Icon icon={HelpCircle} size={3} />
</Box> </Box>
@@ -1175,19 +1373,34 @@ export function ChampionshipsSection({
isOpen={activeChampFlyout === champ.key} isOpen={activeChampFlyout === champ.key}
onClose={() => setActiveChampFlyout(null)} onClose={() => setActiveChampFlyout(null)}
title={champInfo.title} title={champInfo.title}
anchorRef={{ current: champItemRefs.current[champ.key] ?? champInfoRef.current }} anchorRef={{ current: (champItemRefs.current[champ.key] as HTMLElement | null) ?? champInfoRef.current }}
> >
<Stack gap={4}> <Stack gap={4}>
<Text size="xs" color="text-gray-400">{champInfo.description}</Text> <Text size="xs" color="text-gray-400">{champInfo.description}</Text>
<Stack gap={2}> <Stack gap={2}>
<Box className="text-[10px] text-gray-500 uppercase tracking-wide"> <Box>
<Text>How It Works</Text> <Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
How It Works
</Text>
</Box> </Box>
<Box as="ul" className="space-y-1.5"> <Box as="ul"
// eslint-disable-next-line gridpilot-rules/component-classification
className="space-y-1.5"
>
{champInfo.details.map((detail, idx) => ( {champInfo.details.map((detail, idx) => (
<Box as="li" key={idx} display="flex" align="start" gap={2}> <Box as="li" key={idx} display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green" className="mt-0.5" /> <Icon icon={Check} size={3} color="text-performance-green"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text size="xs" color="text-gray-400">{detail}</Text> <Text size="xs" color="text-gray-400">{detail}</Text>
</Box> </Box>
))} ))}
@@ -1195,10 +1408,20 @@ export function ChampionshipsSection({
</Stack> </Stack>
{!champ.available && ( {!champ.available && (
<Surface variant="muted" border rounded="lg" padding={3} className="bg-warning-amber/5 border-warning-amber/20"> <Surface variant="muted" border rounded="lg" p={3}
// eslint-disable-next-line gridpilot-rules/component-classification
className="bg-warning-amber/5 border-warning-amber/20"
>
<Stack direction="row" align="start" gap={2}> <Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-warning-amber" className="mt-0.5" /> <Icon icon={Zap} size={3.5} color="text-warning-amber"
<Text className="text-[11px] text-gray-400"> // eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
>
<Text weight="medium" color="text-warning-amber">Note:</Text> {champ.unavailableHint}. Switch to Teams mode to enable this championship. <Text weight="medium" color="text-warning-amber">Note:</Text> {champ.unavailableHint}. Switch to Teams mode to enable this championship.
</Text> </Text>
</Stack> </Stack>

View File

@@ -2,13 +2,20 @@
import { Award, DollarSign, Star, X } from 'lucide-react'; import { Award, DollarSign, Star, X } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import PendingSponsorshipRequests, { type PendingRequestDTO } from '../sponsors/PendingSponsorshipRequests'; import { PendingSponsorshipRequests } from '../sponsors/PendingSponsorshipRequests';
import Button from '../ui/Button'; import { Button } from '@/ui/Button';
import Input from '../ui/Input'; import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { Badge } from '@/ui/Badge';
import { StatBox } from '@/ui/StatBox';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useLeagueSeasons } from "@/lib/hooks/league/useLeagueSeasons"; import { useLeagueSeasons } from "@/hooks/league/useLeagueSeasons";
import { useSponsorshipRequests } from "@/lib/hooks/league/useSponsorshipRequests"; import { useSponsorshipRequests } from "@/hooks/league/useSponsorshipRequests";
import { useInject } from '@/lib/di/hooks/useInject'; import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens'; import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
@@ -43,12 +50,13 @@ export function LeagueSponsorshipsSection({
const [tempPrice, setTempPrice] = useState<string>(''); const [tempPrice, setTempPrice] = useState<string>('');
// Load season ID if not provided // Load season ID if not provided
const { data: seasons = [], isLoading: seasonsLoading } = useLeagueSeasons(leagueId); const { data: seasons = [] } = useLeagueSeasons(leagueId);
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0]; const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
const seasonId = propSeasonId || activeSeason?.seasonId; const seasonId = propSeasonId || activeSeason?.seasonId;
// Load pending sponsorship requests // Load pending sponsorship requests
const { data: pendingRequests = [], isLoading: requestsLoading, refetch: refetchRequests } = useSponsorshipRequests('season', seasonId || ''); const { data: pendingRequestsData, isLoading: requestsLoading, refetch: refetchRequests } = useSponsorshipRequests('season', seasonId || '');
const pendingRequests = pendingRequestsData?.requests || [];
const handleAcceptRequest = async (requestId: string) => { const handleAcceptRequest = async (requestId: string) => {
if (!currentDriverId) return; if (!currentDriverId) return;
@@ -107,107 +115,111 @@ export function LeagueSponsorshipsSection({
const netRevenue = totalRevenue - platformFee; const netRevenue = totalRevenue - platformFee;
const availableSlots = slots.filter(s => !s.isOccupied).length; const availableSlots = slots.filter(s => !s.isOccupied).length;
const occupiedSlots = slots.filter(s => s.isOccupied).length;
return ( return (
<div className="space-y-6"> <Stack gap={6}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <Box display="flex" alignItems="center" justifyContent="between">
<div> <Box>
<h3 className="text-lg font-semibold text-white">Sponsorships</h3> <Heading level={3}>Sponsorships</Heading>
<p className="text-sm text-gray-400 mt-1"> <Text size="sm" color="text-gray-400" mt={1} block>
Define pricing for sponsor slots in this league. Sponsors pay per season. Define pricing for sponsor slots in this league. Sponsors pay per season.
</p> </Text>
<p className="text-xs text-gray-500 mt-1"> <Text size="xs" color="text-gray-500" mt={1} block>
These sponsors are attached to seasons in this league, so you can change partners from season to season. These sponsors are attached to seasons in this league, so you can change partners from season to season.
</p> </Text>
</div> </Box>
{!readOnly && ( {!readOnly && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/30"> <Box display="flex" alignItems="center" gap={2} px={3} py={1.5} rounded="full" bg="bg-primary-blue/10" border borderColor="border-primary-blue/30">
<DollarSign className="w-4 h-4 text-primary-blue" /> <Icon icon={DollarSign} size={4} color="var(--primary-blue)" />
<span className="text-xs font-medium text-primary-blue"> <Text size="xs" weight="medium" color="text-primary-blue">
{availableSlots} slot{availableSlots !== 1 ? 's' : ''} available {availableSlots} slot{availableSlots !== 1 ? 's' : ''} available
</span> </Text>
</div> </Box>
)} )}
</div> </Box>
{/* Revenue Summary */} {/* Revenue Summary */}
{totalRevenue > 0 && ( {totalRevenue > 0 && (
<div className="grid grid-cols-3 gap-4"> <Box display="grid" gridCols={3} gap={4}>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4"> <StatBox
<div className="text-xs text-gray-400 mb-1">Total Revenue</div> icon={DollarSign}
<div className="text-xl font-bold text-white"> label="Total Revenue"
${totalRevenue.toFixed(2)} value={`$${totalRevenue.toFixed(2)}`}
</div> color="var(--primary-blue)"
</div> />
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4"> <StatBox
<div className="text-xs text-gray-400 mb-1">Platform Fee (10%)</div> icon={DollarSign}
<div className="text-xl font-bold text-warning-amber"> label="Platform Fee (10%)"
-${platformFee.toFixed(2)} value={`-$${platformFee.toFixed(2)}`}
</div> color="var(--warning-amber)"
</div> />
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4"> <StatBox
<div className="text-xs text-gray-400 mb-1">Net Revenue</div> icon={DollarSign}
<div className="text-xl font-bold text-performance-green"> label="Net Revenue"
${netRevenue.toFixed(2)} value={`$${netRevenue.toFixed(2)}`}
</div> color="var(--performance-green)"
</div> />
</div> </Box>
)} )}
{/* Sponsorship Slots */} {/* Sponsorship Slots */}
<div className="space-y-3"> <Stack gap={3}>
{slots.map((slot, index) => { {slots.map((slot, index) => {
const isEditing = editingIndex === index; const isEditing = editingIndex === index;
const Icon = slot.tier === 'main' ? Star : Award; const IconComp = slot.tier === 'main' ? Star : Award;
return ( return (
<div <Box
key={index} key={index}
className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4" rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-deep-graphite/70"
p={4}
> >
<div className="flex items-center justify-between gap-4"> <Box display="flex" alignItems="center" justifyContent="between" gap={4}>
<div className="flex items-center gap-3 flex-1"> <Box display="flex" alignItems="center" gap={3} flexGrow={1}>
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${ <Box
slot.tier === 'main' display="flex"
? 'bg-primary-blue/10' h="10"
: 'bg-gray-500/10' w="10"
}`}> alignItems="center"
<Icon className={`w-5 h-5 ${ justifyContent="center"
slot.tier === 'main' rounded="lg"
? 'text-primary-blue' bg={slot.tier === 'main' ? 'bg-primary-blue/10' : 'bg-gray-500/10'}
: 'text-gray-400' >
}`} /> <Icon icon={IconComp} size={5} color={slot.tier === 'main' ? 'var(--primary-blue)' : 'var(--gray-400)'} />
</div> </Box>
<div className="flex-1"> <Box flexGrow={1}>
<div className="flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2}>
<h4 className="text-sm font-semibold text-white"> <Heading level={4}>
{slot.tier === 'main' ? 'Main Sponsor' : 'Secondary Sponsor'} {slot.tier === 'main' ? 'Main Sponsor' : 'Secondary Sponsor'}
</h4> </Heading>
{slot.isOccupied && ( {slot.isOccupied && (
<span className="px-2 py-0.5 text-xs bg-performance-green/10 text-performance-green border border-performance-green/30 rounded-full"> <Badge variant="success">
Occupied Occupied
</span> </Badge>
)} )}
</div> </Box>
<p className="text-xs text-gray-500 mt-0.5"> <Text size="xs" color="text-gray-500" mt={0.5} block>
{slot.tier === 'main' {slot.tier === 'main'
? 'Big livery slot • League page logo • Name in league title' ? 'Big livery slot • League page logo • Name in league title'
: 'Small livery slot • League page logo'} : 'Small livery slot • League page logo'}
</p> </Text>
</div> </Box>
</div> </Box>
<div className="flex items-center gap-3"> <Box display="flex" alignItems="center" gap={3}>
{isEditing ? ( {isEditing ? (
<div className="flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2}>
<Input <Input
type="number" type="number"
value={tempPrice} value={tempPrice}
onChange={(e) => setTempPrice(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTempPrice(e.target.value)}
placeholder="Price" placeholder="Price"
// eslint-disable-next-line gridpilot-rules/component-classification
className="w-32" className="w-32"
min="0" min="0"
step="0.01" step="0.01"
@@ -215,47 +227,47 @@ export function LeagueSponsorshipsSection({
<Button <Button
variant="primary" variant="primary"
onClick={() => handleSavePrice(index)} onClick={() => handleSavePrice(index)}
className="px-3 py-1" size="sm"
> >
Save Save
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={handleCancelEdit} onClick={handleCancelEdit}
className="px-3 py-1" size="sm"
> >
<X className="w-4 h-4" /> <Icon icon={X} size={4} />
</Button> </Button>
</div> </Box>
) : ( ) : (
<> <>
<div className="text-right"> <Box textAlign="right">
<div className="text-lg font-bold text-white"> <Text size="lg" weight="bold" color="text-white" block>
${slot.price.toFixed(2)} ${slot.price.toFixed(2)}
</div> </Text>
<div className="text-xs text-gray-500">per season</div> <Text size="xs" color="text-gray-500" block>per season</Text>
</div> </Box>
{!readOnly && !slot.isOccupied && ( {!readOnly && !slot.isOccupied && (
<Button <Button
variant="secondary" variant="secondary"
onClick={() => handleEditPrice(index)} onClick={() => handleEditPrice(index)}
className="px-3 py-1" size="sm"
> >
Edit Price Edit Price
</Button> </Button>
)} )}
</> </>
)} )}
</div> </Box>
</div> </Box>
</div> </Box>
); );
})} })}
</div> </Stack>
{/* Pending Sponsorship Requests */} {/* Pending Sponsorship Requests */}
{!readOnly && (pendingRequests.length > 0 || requestsLoading) && ( {!readOnly && (pendingRequests.length > 0 || requestsLoading) && (
<div className="mt-8 pt-6 border-t border-charcoal-outline"> <Box mt={8} pt={6} borderTop borderColor="border-charcoal-outline">
<PendingSponsorshipRequests <PendingSponsorshipRequests
entityType="season" entityType="season"
entityId={seasonId || ''} entityId={seasonId || ''}
@@ -265,16 +277,16 @@ export function LeagueSponsorshipsSection({
onReject={handleRejectRequest} onReject={handleRejectRequest}
isLoading={requestsLoading} isLoading={requestsLoading}
/> />
</div> </Box>
)} )}
{/* Alpha Notice */} {/* Alpha Notice */}
<div className="rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4"> <Box rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
<p className="text-xs text-gray-400"> <Text size="xs" color="text-gray-400" block>
<strong className="text-warning-amber">Alpha Note:</strong> Sponsorship management is demonstration-only. <Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Sponsorship management is demonstration-only.
In production, sponsors can browse leagues, select slots, and complete payment integration. In production, sponsors can browse leagues, select slots, and complete payment integration.
</p> </Text>
</div> </Box>
</div> </Stack>
); );
} }

View File

@@ -1,8 +1,15 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { Scale, Users, Clock, Bell, Shield, Vote, UserCheck, AlertTriangle } from 'lucide-react'; import { Scale, Clock, Bell, Shield, Vote, AlertTriangle } from 'lucide-react';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Select } from '@/ui/Select';
import { Checkbox } from '@/ui/Checkbox';
interface LeagueStewardingSectionProps { interface LeagueStewardingSectionProps {
form: LeagueConfigFormModel; form: LeagueConfigFormModel;
@@ -23,14 +30,14 @@ const decisionModeOptions: DecisionModeOption[] = [
value: 'single_steward', value: 'single_steward',
label: 'Single Steward', label: 'Single Steward',
description: 'A single steward/admin makes all penalty decisions', description: 'A single steward/admin makes all penalty decisions',
icon: <Shield className="w-5 h-5" />, icon: <Icon icon={Shield} size={5} />,
requiresVotes: false, requiresVotes: false,
}, },
{ {
value: 'committee_vote', value: 'committee_vote',
label: 'Committee Vote', label: 'Committee Vote',
description: 'A group votes to uphold/dismiss protests', description: 'A group votes to uphold/dismiss protests',
icon: <Scale className="w-5 h-5" />, icon: <Icon icon={Scale} size={5} />,
requiresVotes: true, requiresVotes: true,
}, },
]; ];
@@ -66,305 +73,342 @@ export function LeagueStewardingSection({
const selectedMode = decisionModeOptions.find((m) => m.value === stewarding.decisionMode); const selectedMode = decisionModeOptions.find((m) => m.value === stewarding.decisionMode);
return ( return (
<div className="space-y-8"> <Stack gap={8}>
{/* Decision Mode Selection */} {/* Decision Mode Selection */}
<div> <Box>
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2"> <Heading level={3} fontSize="sm" weight="semibold" color="text-white" mb={1}>
<Scale className="w-4 h-4 text-primary-blue" /> <Stack direction="row" align="center" gap={2}>
How are protest decisions made? <Icon icon={Scale} size={4} color="text-primary-blue" />
</h3> How are protest decisions made?
<p className="text-xs text-gray-400 mb-4"> </Stack>
</Heading>
<Text size="xs" color="text-gray-400" mb={4} block>
Choose who has the authority to issue penalties Choose who has the authority to issue penalties
</p> </Text>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> <Box display="grid" gridCols={{ base: 1, sm: 2, lg: 3 }} gap={3}>
{decisionModeOptions.map((option) => ( {decisionModeOptions.map((option) => (
<button <Box
key={option.value} key={option.value}
as="button"
type="button" type="button"
disabled={readOnly} disabled={readOnly}
onClick={() => updateStewarding({ decisionMode: option.value })} onClick={() => updateStewarding({ decisionMode: option.value })}
className={` position="relative"
relative flex flex-col items-start gap-2 p-4 rounded-xl border-2 transition-all text-left display="flex"
${stewarding.decisionMode === option.value flexDirection="col"
? 'border-primary-blue bg-primary-blue/5 shadow-[0_0_16px_rgba(25,140,255,0.15)]' alignItems="start"
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500' gap={2}
} p={4}
${readOnly ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'} rounded="xl"
`} border
borderWidth="2px"
transition
textAlign="left"
borderColor={stewarding.decisionMode === option.value ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={stewarding.decisionMode === option.value ? 'bg-primary-blue/5' : 'bg-iron-gray/30'}
shadow={stewarding.decisionMode === option.value ? '0_0_16px_rgba(25,140,255,0.15)' : undefined}
hoverBorderColor={!readOnly && stewarding.decisionMode !== option.value ? 'border-gray-500' : undefined}
opacity={readOnly ? 0.6 : 1}
cursor={readOnly ? 'not-allowed' : 'pointer'}
> >
<div <Box
className={`p-2 rounded-lg ${ p={2}
stewarding.decisionMode === option.value rounded="lg"
? 'bg-primary-blue/20 text-primary-blue' bg={stewarding.decisionMode === option.value ? 'bg-primary-blue/20' : 'bg-charcoal-outline/50'}
: 'bg-charcoal-outline/50 text-gray-400' color={stewarding.decisionMode === option.value ? 'text-primary-blue' : 'text-gray-400'}
}`}
> >
{option.icon} {option.icon}
</div> </Box>
<div> <Box>
<p className="text-sm font-medium text-white">{option.label}</p> <Text size="sm" weight="medium" color="text-white" block>{option.label}</Text>
<p className="text-xs text-gray-400 mt-0.5">{option.description}</p> <Text size="xs" color="text-gray-400" mt={0.5} block>{option.description}</Text>
</div> </Box>
{stewarding.decisionMode === option.value && ( {stewarding.decisionMode === option.value && (
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-primary-blue" /> <Box position="absolute" top="2" right="2" w="2" h="2" rounded="full" bg="bg-primary-blue" />
)} )}
</button> </Box>
))} ))}
</div> </Box>
</div> </Box>
{/* Vote Requirements (conditional) */} {/* Vote Requirements (conditional) */}
{selectedMode?.requiresVotes && ( {selectedMode?.requiresVotes && (
<div className="p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline space-y-4"> <Box p={4} rounded="xl" bg="bg-iron-gray/40" border borderColor="border-charcoal-outline">
<h4 className="text-sm font-medium text-white flex items-center gap-2"> <Stack gap={4}>
<Vote className="w-4 h-4 text-primary-blue" /> <Heading level={4} fontSize="sm" weight="medium" color="text-white">
Voting Configuration <Stack direction="row" align="center" gap={2}>
</h4> <Icon icon={Vote} size={4} color="text-primary-blue" />
Voting Configuration
</Stack>
</Heading>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={4}>
<div> <Box>
<label className="block text-xs font-medium text-gray-400 mb-1.5"> <Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Required votes to uphold Required votes to uphold
</label> </Text>
<select <Select
value={stewarding.requiredVotes ?? 2} value={stewarding.requiredVotes?.toString() ?? '2'}
onChange={(e) => updateStewarding({ requiredVotes: parseInt(e.target.value, 10) })} onChange={(e) => updateStewarding({ requiredVotes: parseInt(e.target.value, 10) })}
disabled={readOnly} disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue" options={[
> { value: '1', label: '1 vote' },
<option value={1}>1 vote</option> { value: '2', label: '2 votes' },
<option value={2}>2 votes</option> { value: '3', label: '3 votes (majority of 5)' },
<option value={3}>3 votes (majority of 5)</option> { value: '4', label: '4 votes' },
<option value={4}>4 votes</option> { value: '5', label: '5 votes' },
<option value={5}>5 votes</option> ]}
</select> />
</div> </Box>
<div> <Box>
<label className="block text-xs font-medium text-gray-400 mb-1.5"> <Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Voting time limit Voting time limit
</label> </Text>
<select <Select
value={stewarding.voteTimeLimit} value={stewarding.voteTimeLimit?.toString()}
onChange={(e) => updateStewarding({ voteTimeLimit: parseInt(e.target.value, 10) })} onChange={(e) => updateStewarding({ voteTimeLimit: parseInt(e.target.value, 10) })}
disabled={readOnly} disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue" options={[
> { value: '24', label: '24 hours' },
<option value={24}>24 hours</option> { value: '48', label: '48 hours' },
<option value={48}>48 hours</option> { value: '72', label: '72 hours (3 days)' },
<option value={72}>72 hours (3 days)</option> { value: '96', label: '96 hours (4 days)' },
<option value={96}>96 hours (4 days)</option> { value: '168', label: '168 hours (7 days)' },
<option value={168}>168 hours (7 days)</option> ]}
</select> />
</div> </Box>
</div> </Box>
</div> </Stack>
</Box>
)} )}
{/* Defense Settings */} {/* Defense Settings */}
<div> <Box>
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2"> <Heading level={3} fontSize="sm" weight="semibold" color="text-white" mb={1}>
<Shield className="w-4 h-4 text-primary-blue" /> <Stack direction="row" align="center" gap={2}>
Defense Requirements <Icon icon={Shield} size={4} color="text-primary-blue" />
</h3> Defense Requirements
<p className="text-xs text-gray-400 mb-4"> </Stack>
</Heading>
<Text size="xs" color="text-gray-400" mb={4} block>
Should accused drivers be required to submit a defense? Should accused drivers be required to submit a defense?
</p> </Text>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={3}>
<button <Box
as="button"
type="button" type="button"
disabled={readOnly} disabled={readOnly}
onClick={() => updateStewarding({ requireDefense: false })} onClick={() => updateStewarding({ requireDefense: false })}
className={` display="flex"
flex items-center gap-3 p-4 rounded-xl border-2 transition-all text-left alignItems="center"
${!stewarding.requireDefense gap={3}
? 'border-primary-blue bg-primary-blue/5' p={4}
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500' rounded="xl"
} border
${readOnly ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'} borderWidth="2px"
`} transition
textAlign="left"
borderColor={!stewarding.requireDefense ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={!stewarding.requireDefense ? 'bg-primary-blue/5' : 'bg-iron-gray/30'}
hoverBorderColor={!readOnly && stewarding.requireDefense ? 'border-gray-500' : undefined}
opacity={readOnly ? 0.6 : 1}
cursor={readOnly ? 'not-allowed' : 'pointer'}
> >
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${ <Box w="4" h="4" rounded="full" border borderWidth="2px" display="flex" alignItems="center" justifyContent="center" borderColor={!stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500'}>
!stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500' {!stewarding.requireDefense && <Box w="2" h="2" rounded="full" bg="bg-primary-blue" />}
}`}> </Box>
{!stewarding.requireDefense && <div className="w-2 h-2 rounded-full bg-primary-blue" />} <Box>
</div> <Text size="sm" weight="medium" color="text-white" block>Defense optional</Text>
<div> <Text size="xs" color="text-gray-400" block>Proceed without waiting for defense</Text>
<p className="text-sm font-medium text-white">Defense optional</p> </Box>
<p className="text-xs text-gray-400">Proceed without waiting for defense</p> </Box>
</div>
</button>
<button <Box
as="button"
type="button" type="button"
disabled={readOnly} disabled={readOnly}
onClick={() => updateStewarding({ requireDefense: true })} onClick={() => updateStewarding({ requireDefense: true })}
className={` display="flex"
flex items-center gap-3 p-4 rounded-xl border-2 transition-all text-left alignItems="center"
${stewarding.requireDefense gap={3}
? 'border-primary-blue bg-primary-blue/5' p={4}
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500' rounded="xl"
} border
${readOnly ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'} borderWidth="2px"
`} transition
textAlign="left"
borderColor={stewarding.requireDefense ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={stewarding.requireDefense ? 'bg-primary-blue/5' : 'bg-iron-gray/30'}
hoverBorderColor={!readOnly && !stewarding.requireDefense ? 'border-gray-500' : undefined}
opacity={readOnly ? 0.6 : 1}
cursor={readOnly ? 'not-allowed' : 'pointer'}
> >
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${ <Box w="4" h="4" rounded="full" border borderWidth="2px" display="flex" alignItems="center" justifyContent="center" borderColor={stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500'}>
stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500' {stewarding.requireDefense && <Box w="2" h="2" rounded="full" bg="bg-primary-blue" />}
}`}> </Box>
{stewarding.requireDefense && <div className="w-2 h-2 rounded-full bg-primary-blue" />} <Box>
</div> <Text size="sm" weight="medium" color="text-white" block>Defense required</Text>
<div> <Text size="xs" color="text-gray-400" block>Wait for defense before deciding</Text>
<p className="text-sm font-medium text-white">Defense required</p> </Box>
<p className="text-xs text-gray-400">Wait for defense before deciding</p> </Box>
</div> </Box>
</button>
</div>
{stewarding.requireDefense && ( {stewarding.requireDefense && (
<div className="mt-4 p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline"> <Box mt={4} p={4} rounded="xl" bg="bg-iron-gray/40" border borderColor="border-charcoal-outline">
<label className="block text-xs font-medium text-gray-400 mb-1.5"> <Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Defense time limit Defense time limit
</label> </Text>
<select <Select
value={stewarding.defenseTimeLimit} value={stewarding.defenseTimeLimit?.toString()}
onChange={(e) => updateStewarding({ defenseTimeLimit: parseInt(e.target.value, 10) })} onChange={(e) => updateStewarding({ defenseTimeLimit: parseInt(e.target.value, 10) })}
disabled={readOnly} disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue" options={[
> { value: '24', label: '24 hours' },
<option value={24}>24 hours</option> { value: '48', label: '48 hours (2 days)' },
<option value={48}>48 hours (2 days)</option> { value: '72', label: '72 hours (3 days)' },
<option value={72}>72 hours (3 days)</option> { value: '96', label: '96 hours (4 days)' },
<option value={96}>96 hours (4 days)</option> ]}
</select> />
<p className="text-xs text-gray-500 mt-2"> <Text size="xs" color="text-gray-500" mt={2} block>
After this time, the decision can proceed without a defense After this time, the decision can proceed without a defense
</p> </Text>
</div> </Box>
)} )}
</div> </Box>
{/* Deadlines */} {/* Deadlines */}
<div> <Box>
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2"> <Heading level={3} fontSize="sm" weight="semibold" color="text-white" mb={1}>
<Clock className="w-4 h-4 text-primary-blue" /> <Stack direction="row" align="center" gap={2}>
Deadlines <Icon icon={Clock} size={4} color="text-primary-blue" />
</h3> Deadlines
<p className="text-xs text-gray-400 mb-4"> </Stack>
</Heading>
<Text size="xs" color="text-gray-400" mb={4} block>
Set time limits for filing protests and closing stewarding Set time limits for filing protests and closing stewarding
</p> </Text>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={4}>
<div className="p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline"> <Box p={4} rounded="xl" bg="bg-iron-gray/40" border borderColor="border-charcoal-outline">
<label className="block text-xs font-medium text-gray-400 mb-1.5"> <Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Protest filing deadline (after race) Protest filing deadline (after race)
</label> </Text>
<select <Select
value={stewarding.protestDeadlineHours} value={stewarding.protestDeadlineHours?.toString()}
onChange={(e) => updateStewarding({ protestDeadlineHours: parseInt(e.target.value, 10) })} onChange={(e) => updateStewarding({ protestDeadlineHours: parseInt(e.target.value, 10) })}
disabled={readOnly} disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue" options={[
> { value: '12', label: '12 hours' },
<option value={12}>12 hours</option> { value: '24', label: '24 hours (1 day)' },
<option value={24}>24 hours (1 day)</option> { value: '48', label: '48 hours (2 days)' },
<option value={48}>48 hours (2 days)</option> { value: '72', label: '72 hours (3 days)' },
<option value={72}>72 hours (3 days)</option> { value: '168', label: '168 hours (7 days)' },
<option value={168}>168 hours (7 days)</option> ]}
</select> />
<p className="text-xs text-gray-500 mt-2"> <Text size="xs" color="text-gray-500" mt={2} block>
Drivers cannot file protests after this time Drivers cannot file protests after this time
</p> </Text>
</div> </Box>
<div className="p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline"> <Box p={4} rounded="xl" bg="bg-iron-gray/40" border borderColor="border-charcoal-outline">
<label className="block text-xs font-medium text-gray-400 mb-1.5"> <Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Stewarding closes (after race) Stewarding closes (after race)
</label> </Text>
<select <Select
value={stewarding.stewardingClosesHours} value={stewarding.stewardingClosesHours?.toString()}
onChange={(e) => updateStewarding({ stewardingClosesHours: parseInt(e.target.value, 10) })} onChange={(e) => updateStewarding({ stewardingClosesHours: parseInt(e.target.value, 10) })}
disabled={readOnly} disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue" options={[
> { value: '72', label: '72 hours (3 days)' },
<option value={72}>72 hours (3 days)</option> { value: '96', label: '96 hours (4 days)' },
<option value={96}>96 hours (4 days)</option> { value: '168', label: '168 hours (7 days)' },
<option value={168}>168 hours (7 days)</option> { value: '336', label: '336 hours (14 days)' },
<option value={336}>336 hours (14 days)</option> ]}
</select> />
<p className="text-xs text-gray-500 mt-2"> <Text size="xs" color="text-gray-500" mt={2} block>
All stewarding must be concluded by this time All stewarding must be concluded by this time
</p> </Text>
</div> </Box>
</div> </Box>
</div> </Box>
{/* Notifications */} {/* Notifications */}
<div> <Box>
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2"> <Heading level={3} fontSize="sm" weight="semibold" color="text-white" mb={1}>
<Bell className="w-4 h-4 text-primary-blue" /> <Stack direction="row" align="center" gap={2}>
Notifications <Icon icon={Bell} size={4} color="text-primary-blue" />
</h3> Notifications
<p className="text-xs text-gray-400 mb-4"> </Stack>
</Heading>
<Text size="xs" color="text-gray-400" mb={4} block>
Configure automatic notifications for involved parties Configure automatic notifications for involved parties
</p> </Text>
<div className="space-y-3"> <Stack gap={3}>
<label <Box
className={`flex items-center gap-3 p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline cursor-pointer hover:bg-iron-gray/60 transition-colors ${ p={4}
readOnly ? 'opacity-60 cursor-not-allowed' : '' rounded="xl"
}`} bg="bg-iron-gray/40"
border
borderColor="border-charcoal-outline"
transition
hoverBg={!readOnly ? 'bg-iron-gray/60' : undefined}
opacity={readOnly ? 0.6 : 1}
> >
<input <Checkbox
type="checkbox" label="Notify accused driver"
checked={stewarding.notifyAccusedOnProtest} checked={stewarding.notifyAccusedOnProtest}
onChange={(e) => updateStewarding({ notifyAccusedOnProtest: e.target.checked })} onChange={(checked) => updateStewarding({ notifyAccusedOnProtest: checked })}
disabled={readOnly} disabled={readOnly}
className="w-4 h-4 rounded border-charcoal-outline bg-deep-graphite text-primary-blue focus:ring-primary-blue focus:ring-offset-0"
/> />
<div> <Box ml={7} mt={1}>
<p className="text-sm font-medium text-white">Notify accused driver</p> <Text size="xs" color="text-gray-400" block>
<p className="text-xs text-gray-400">
Send notification when a protest is filed against them Send notification when a protest is filed against them
</p> </Text>
</div> </Box>
</label> </Box>
<label <Box
className={`flex items-center gap-3 p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline cursor-pointer hover:bg-iron-gray/60 transition-colors ${ p={4}
readOnly ? 'opacity-60 cursor-not-allowed' : '' rounded="xl"
}`} bg="bg-iron-gray/40"
border
borderColor="border-charcoal-outline"
transition
hoverBg={!readOnly ? 'bg-iron-gray/60' : undefined}
opacity={readOnly ? 0.6 : 1}
> >
<input <Checkbox
type="checkbox" label="Notify voters"
checked={stewarding.notifyOnVoteRequired} checked={stewarding.notifyOnVoteRequired}
onChange={(e) => updateStewarding({ notifyOnVoteRequired: e.target.checked })} onChange={(checked) => updateStewarding({ notifyOnVoteRequired: checked })}
disabled={readOnly} disabled={readOnly}
className="w-4 h-4 rounded border-charcoal-outline bg-deep-graphite text-primary-blue focus:ring-primary-blue focus:ring-offset-0"
/> />
<div> <Box ml={7} mt={1}>
<p className="text-sm font-medium text-white">Notify voters</p> <Text size="xs" color="text-gray-400" block>
<p className="text-xs text-gray-400">
Send notification to stewards/members when their vote is needed Send notification to stewards/members when their vote is needed
</p> </Text>
</div> </Box>
</label> </Box>
</div> </Stack>
</div> </Box>
{/* Warning about strict settings */} {/* Warning about strict settings */}
{stewarding.requireDefense && stewarding.decisionMode !== 'single_steward' && ( {stewarding.requireDefense && stewarding.decisionMode !== 'single_steward' && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-warning-amber/10 border border-warning-amber/20"> <Box display="flex" alignItems="start" gap={3} p={4} rounded="xl" bg="bg-warning-amber/10" border borderColor="border-warning-amber/20">
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" /> <Icon icon={AlertTriangle} size={5} color="text-warning-amber" mt={0.5} />
<div> <Box>
<p className="text-sm font-medium text-warning-amber">Strict settings enabled</p> <Text size="sm" weight="medium" color="text-warning-amber" block>Strict settings enabled</Text>
<p className="text-xs text-warning-amber/80 mt-1"> <Text size="xs" color="text-warning-amber" opacity={0.8} mt={1} block>
Requiring defense and voting may delay penalty decisions. Make sure your stewards/members Requiring defense and voting may delay penalty decisions. Make sure your stewards/members
are active enough to meet the deadlines. are active enough to meet the deadlines.
</p> </Text>
</div> </Box>
</div> </Box>
)} )}
</div> </Stack>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +0,0 @@
'use client';
import React from 'react';
import { ArrowRight } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
import { Link } from '@/ui/Link';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
interface LeagueSummaryCardProps {
league: {
id: string;
name: string;
description?: string;
settings: {
maxDrivers: number;
qualifyingFormat: string;
};
};
}
export function LeagueSummaryCard({ league }: LeagueSummaryCardProps) {
return (
<Card p={0} style={{ overflow: 'hidden' }}>
<Box p={4}>
<Stack direction="row" align="center" gap={4} mb={4}>
<Box style={{ width: '3.5rem', height: '3.5rem', borderRadius: '0.75rem', overflow: 'hidden', backgroundColor: '#262626', flexShrink: 0 }}>
<Image src={`/media/league-logo/${league.id}`} alt={league.name} width={56} height={56} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={0.5}>League</Text>
<Heading level={3} style={{ fontSize: '1rem' }}>{league.name}</Heading>
</Box>
</Stack>
{league.description && (
<Text size="sm" color="text-gray-400" block mb={4} style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{league.description}</Text>
)}
<Box mb={4}>
<Grid cols={2} gap={3}>
<Surface variant="dark" rounded="lg" padding={3}>
<Text size="xs" color="text-gray-500" block mb={1}>Max Drivers</Text>
<Text weight="medium" color="text-white">{league.settings.maxDrivers}</Text>
</Surface>
<Surface variant="dark" rounded="lg" padding={3}>
<Text size="xs" color="text-gray-500" block mb={1}>Format</Text>
<Text weight="medium" color="text-white" style={{ textTransform: 'capitalize' }}>{league.settings.qualifyingFormat}</Text>
</Surface>
</Grid>
</Box>
<Box>
<Link href={`/leagues/${league.id}`} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={ArrowRight} size={4} />}>
View League
</Button>
</Link>
</Box>
</Box>
</Card>
);
}

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack'; import { Stack } from '@/ui/Stack';
import { Link } from '@/ui/Link'; import { Link } from '@/ui/Link';
import { Text } from '@/ui/Text';
interface Tab { interface Tab {
label: string; label: string;
@@ -17,8 +18,8 @@ interface LeagueTabsProps {
export function LeagueTabs({ tabs }: LeagueTabsProps) { export function LeagueTabs({ tabs }: LeagueTabsProps) {
return ( return (
<Box style={{ borderBottom: '1px solid #262626' }}> <Box borderBottom borderColor="border-charcoal-outline">
<Stack direction="row" gap={6} style={{ overflowX: 'auto' }}> <Stack direction="row" gap={6} overflow="auto">
{tabs.map((tab) => ( {tabs.map((tab) => (
<Link <Link
key={tab.href} key={tab.href}
@@ -26,7 +27,12 @@ export function LeagueTabs({ tabs }: LeagueTabsProps) {
variant="ghost" variant="ghost"
> >
<Box pb={3} px={1}> <Box pb={3} px={1}>
<span style={{ fontWeight: 500, whiteSpace: 'nowrap' }}>{tab.label}</span> <Text weight="medium"
// eslint-disable-next-line gridpilot-rules/component-classification
className="whitespace-nowrap"
>
{tab.label}
</Text>
</Box> </Box>
</Link> </Link>
))} ))}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,11 @@ import { useState, useRef, useEffect } from 'react';
import type * as React from 'react'; import type * as React from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
// Minimum drivers for ranked leagues // Minimum drivers for ranked leagues
const MIN_RANKED_DRIVERS = 10; const MIN_RANKED_DRIVERS = 10;
@@ -82,28 +87,55 @@ function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutP
if (!isOpen || !mounted) return null; if (!isOpen || !mounted) return null;
return createPortal( return createPortal(
<div <Box
ref={flyoutRef} ref={flyoutRef}
className="fixed z-50 w-[340px] max-h-[80vh] overflow-y-auto bg-iron-gray border border-charcoal-outline rounded-xl shadow-2xl animate-fade-in" position="fixed"
style={{ top: position.top, left: position.left }} zIndex={50}
w="340px"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
rounded="xl"
shadow="2xl"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ top: position.top, left: position.left, maxHeight: '80vh', overflowY: 'auto' }}
> >
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 sticky top-0 bg-iron-gray z-10"> <Box
<div className="flex items-center gap-2"> display="flex"
<HelpCircle className="w-4 h-4 text-primary-blue" /> alignItems="center"
<span className="text-sm font-semibold text-white">{title}</span> justifyContent="between"
</div> p={4}
<button borderBottom
borderColor="border-charcoal-outline/50"
position="sticky"
top="0"
bg="bg-iron-gray"
zIndex={10}
>
<Stack direction="row" align="center" gap={2}>
<Icon icon={HelpCircle} size={4} color="text-primary-blue" />
<Text size="sm" weight="semibold" color="text-white">{title}</Text>
</Stack>
<Box
as="button"
type="button" type="button"
onClick={onClose} onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-charcoal-outline transition-colors" display="flex"
h="6"
w="6"
alignItems="center"
justifyContent="center"
rounded="md"
transition
hoverBg="bg-charcoal-outline"
> >
<X className="w-4 h-4 text-gray-400" /> <Icon icon={X} size={4} color="text-gray-400" />
</button> </Box>
</div> </Box>
<div className="p-4"> <Box p={4}>
{children} {children}
</div> </Box>
</div>, </Box>,
document.body document.body
); );
} }
@@ -155,96 +187,139 @@ export function LeagueVisibilitySection({
}; };
return ( return (
<div className="space-y-8"> <Stack gap={8}>
{/* Emotional header for the step */} {/* Emotional header for the step */}
<div className="text-center pb-2"> <Box textAlign="center" pb={2}>
<h3 className="text-lg font-semibold text-white mb-2"> <Heading level={3} mb={2}>
Choose your league's destiny Choose your league&apos;s destiny
</h3> </Heading>
<p className="text-sm text-gray-400 max-w-lg mx-auto"> <Text size="sm" color="text-gray-400" maxWidth="lg" mx="auto" block>
Will you compete for glory on the global leaderboards, or race with friends in a private series? Will you compete for glory on the global leaderboards, or race with friends in a private series?
</p> </Text>
</div> </Box>
{/* League Type Selection */} {/* League Type Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <Box display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
{/* Ranked (Public) Option */} {/* Ranked (Public) Option */}
<div className="relative"> <Box position="relative">
<button <Box
as="button"
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => handleVisibilityChange('public')} onClick={() => handleVisibilityChange('public')}
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${ display="flex"
isRanked flexDirection="col"
? 'border-primary-blue bg-gradient-to-br from-primary-blue/15 to-primary-blue/5 shadow-[0_0_30px_rgba(25,140,255,0.25)]' gap={4}
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500 hover:bg-iron-gray/50' p={6}
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`} textAlign="left"
rounded="xl"
border
borderColor={isRanked ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={isRanked ? 'bg-primary-blue/15' : 'bg-iron-gray/30'}
w="full"
position="relative"
transition
shadow={isRanked ? '0_0_30px_rgba(25,140,255,0.25)' : undefined}
hoverBorderColor={!isRanked && !disabled ? 'border-gray-500' : undefined}
hoverBg={!isRanked && !disabled ? 'bg-iron-gray/50' : undefined}
opacity={disabled ? 0.6 : 1}
cursor={disabled ? 'not-allowed' : 'pointer'}
group
> >
{/* Header */} {/* Header */}
<div className="flex items-start justify-between"> <Box display="flex" alignItems="start" justifyContent="between">
<div className="flex items-center gap-3"> <Stack direction="row" align="center" gap={3}>
<div className={`flex h-14 w-14 items-center justify-center rounded-xl ${ <Box
isRanked ? 'bg-primary-blue/30' : 'bg-charcoal-outline/50' display="flex"
}`}> h="14"
<Trophy className={`w-7 h-7 ${isRanked ? 'text-primary-blue' : 'text-gray-400'}`} /> w="14"
</div> alignItems="center"
<div> justifyContent="center"
<div className={`text-xl font-bold ${isRanked ? 'text-white' : 'text-gray-300'}`}> rounded="xl"
bg={isRanked ? 'bg-primary-blue/30' : 'bg-charcoal-outline/50'}
>
<Icon icon={Trophy} size={7} color={isRanked ? 'text-primary-blue' : 'text-gray-400'} />
</Box>
<Box>
<Text weight="bold" size="xl" color={isRanked ? 'text-white' : 'text-gray-300'} block>
Ranked Ranked
</div> </Text>
<div className={`text-sm ${isRanked ? 'text-primary-blue' : 'text-gray-500'}`}> <Text size="sm" color={isRanked ? 'text-primary-blue' : 'text-gray-500'} block>
Compete for glory Compete for glory
</div> </Text>
</div> </Box>
</div> </Stack>
{/* Radio indicator */} {/* Radio indicator */}
<div className={`flex h-7 w-7 items-center justify-center rounded-full border-2 shrink-0 transition-colors ${ <Box
isRanked ? 'border-primary-blue bg-primary-blue' : 'border-gray-500' display="flex"
}`}> h="7"
{isRanked && <Check className="w-4 h-4 text-white" />} w="7"
</div> alignItems="center"
</div> justifyContent="center"
rounded="full"
border
borderColor={isRanked ? 'border-primary-blue' : 'border-gray-500'}
bg={isRanked ? 'bg-primary-blue' : ''}
flexShrink={0}
transition
>
{isRanked && <Icon icon={Check} size={4} color="text-white" />}
</Box>
</Box>
{/* Emotional tagline */} {/* Emotional tagline */}
<p className={`text-sm ${isRanked ? 'text-gray-300' : 'text-gray-500'}`}> <Text size="sm" color={isRanked ? 'text-gray-300' : 'text-gray-500'} block>
Your results matter. Build your reputation in the global standings and climb the ranks. Your results matter. Build your reputation in the global standings and climb the ranks.
</p> </Text>
{/* Features */} {/* Features */}
<div className="space-y-2.5 py-2"> <Stack gap={2.5} py={2}>
<div className="flex items-center gap-2 text-sm text-gray-400"> <Stack direction="row" align="center" gap={2}>
<Check className="w-4 h-4 text-performance-green" /> <Icon icon={Check} size={4} color="text-performance-green" />
<span>Discoverable by all drivers</span> <Text size="sm" color="text-gray-400">Discoverable by all drivers</Text>
</div> </Stack>
<div className="flex items-center gap-2 text-sm text-gray-400"> <Stack direction="row" align="center" gap={2}>
<Check className="w-4 h-4 text-performance-green" /> <Icon icon={Check} size={4} color="text-performance-green" />
<span>Affects driver ratings & rankings</span> <Text size="sm" color="text-gray-400">Affects driver ratings & rankings</Text>
</div> </Stack>
<div className="flex items-center gap-2 text-sm text-gray-400"> <Stack direction="row" align="center" gap={2}>
<Check className="w-4 h-4 text-performance-green" /> <Icon icon={Check} size={4} color="text-performance-green" />
<span>Featured on leaderboards</span> <Text size="sm" color="text-gray-400">Featured on leaderboards</Text>
</div> </Stack>
</div> </Stack>
{/* Requirement badge */} {/* Requirement badge */}
<div className="flex items-center gap-2 mt-auto px-3 py-2 rounded-lg bg-warning-amber/10 border border-warning-amber/20 w-fit"> <Box display="flex" alignItems="center" gap={2} mt="auto" px={3} py={2} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/20" w="fit">
<Users className="w-4 h-4 text-warning-amber" /> <Icon icon={Users} size={4} color="text-warning-amber" />
<span className="text-xs text-warning-amber font-medium"> <Text size="xs" color="text-warning-amber" weight="medium">
Requires {MIN_RANKED_DRIVERS}+ drivers for competitive integrity Requires {MIN_RANKED_DRIVERS}+ drivers for competitive integrity
</span> </Text>
</div> </Box>
</button> </Box>
{/* Info button */} {/* Info button */}
<button <Box
as="button"
ref={rankedInfoRef} ref={rankedInfoRef}
type="button" type="button"
onClick={() => setShowRankedFlyout(true)} onClick={() => setShowRankedFlyout(true)}
className="absolute top-3 right-3 flex h-7 w-7 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors" position="absolute"
top="3"
right="3"
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="full"
transition
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
> >
<HelpCircle className="w-4 h-4" /> <Icon icon={HelpCircle} size={4} />
</button> </Box>
</div> </Box>
{/* Ranked Info Flyout */} {/* Ranked Info Flyout */}
<InfoFlyout <InfoFlyout
@@ -253,119 +328,176 @@ export function LeagueVisibilitySection({
title="Ranked Leagues" title="Ranked Leagues"
anchorRef={rankedInfoRef} anchorRef={rankedInfoRef}
> >
<div className="space-y-4"> <Stack gap={4}>
<p className="text-xs text-gray-400"> <Text size="xs" color="text-gray-400" block>
Ranked leagues are competitive series where results matter. Your performance Ranked leagues are competitive series where results matter. Your performance
affects your driver rating and contributes to global leaderboards. affects your driver rating and contributes to global leaderboards.
</p> </Text>
<div className="space-y-2"> <Stack gap={2}>
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Requirements</div> <Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
<ul className="space-y-1.5"> // eslint-disable-next-line gridpilot-rules/component-classification
<li className="flex items-start gap-2 text-xs text-gray-400"> className="tracking-wide"
<Users className="w-3.5 h-3.5 text-warning-amber shrink-0 mt-0.5" /> block
<span><strong className="text-white">Minimum {MIN_RANKED_DRIVERS} drivers</strong> for competitive integrity</span> >
</li> Requirements
<li className="flex items-start gap-2 text-xs text-gray-400"> </Text>
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" /> <Stack gap={1.5}>
<span>Anyone can discover and join your league</span> <Box display="flex" alignItems="start" gap={2}>
</li> <Icon icon={Users} size={3.5} color="text-warning-amber" flexShrink={0} mt={0.5} />
</ul> <Text size="xs" color="text-gray-400">
</div> <Text weight="bold" color="text-white">Minimum {MIN_RANKED_DRIVERS} drivers</Text> for competitive integrity
</Text>
</Box>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-performance-green" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Anyone can discover and join your league</Text>
</Box>
</Stack>
</Stack>
<div className="space-y-2"> <Stack gap={2}>
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Benefits</div> <Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
<ul className="space-y-1.5"> // eslint-disable-next-line gridpilot-rules/component-classification
<li className="flex items-start gap-2 text-xs text-gray-400"> className="tracking-wide"
<Trophy className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" /> block
<span>Results affect driver ratings and rankings</span> >
</li> Benefits
<li className="flex items-start gap-2 text-xs text-gray-400"> </Text>
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" /> <Stack gap={1.5}>
<span>Featured in league discovery</span> <Box display="flex" alignItems="start" gap={2}>
</li> <Icon icon={Trophy} size={3.5} color="text-primary-blue" flexShrink={0} mt={0.5} />
</ul> <Text size="xs" color="text-gray-400">Results affect driver ratings and rankings</Text>
</div> </Box>
</div> <Box display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-performance-green" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Featured in league discovery</Text>
</Box>
</Stack>
</Stack>
</Stack>
</InfoFlyout> </InfoFlyout>
{/* Unranked (Private) Option */} {/* Unranked (Private) Option */}
<div className="relative"> <Box position="relative">
<button <Box
as="button"
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => handleVisibilityChange('private')} onClick={() => handleVisibilityChange('private')}
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${ display="flex"
!isRanked flexDirection="col"
? 'border-neon-aqua bg-gradient-to-br from-neon-aqua/15 to-neon-aqua/5 shadow-[0_0_30px_rgba(67,201,230,0.2)]' gap={4}
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500 hover:bg-iron-gray/50' p={6}
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`} textAlign="left"
rounded="xl"
border
borderColor={!isRanked ? 'border-neon-aqua' : 'border-charcoal-outline'}
bg={!isRanked ? 'bg-neon-aqua/15' : 'bg-iron-gray/30'}
w="full"
position="relative"
transition
shadow={!isRanked ? '0_0_30px_rgba(67,201,230,0.2)' : undefined}
hoverBorderColor={isRanked && !disabled ? 'border-gray-500' : undefined}
hoverBg={isRanked && !disabled ? 'bg-iron-gray/50' : undefined}
opacity={disabled ? 0.6 : 1}
cursor={disabled ? 'not-allowed' : 'pointer'}
group
> >
{/* Header */} {/* Header */}
<div className="flex items-start justify-between"> <Box display="flex" alignItems="start" justifyContent="between">
<div className="flex items-center gap-3"> <Stack direction="row" align="center" gap={3}>
<div className={`flex h-14 w-14 items-center justify-center rounded-xl ${ <Box
!isRanked ? 'bg-neon-aqua/30' : 'bg-charcoal-outline/50' display="flex"
}`}> h="14"
<Users className={`w-7 h-7 ${!isRanked ? 'text-neon-aqua' : 'text-gray-400'}`} /> w="14"
</div> alignItems="center"
<div> justifyContent="center"
<div className={`text-xl font-bold ${!isRanked ? 'text-white' : 'text-gray-300'}`}> rounded="xl"
bg={!isRanked ? 'bg-neon-aqua/30' : 'bg-charcoal-outline/50'}
>
<Icon icon={Users} size={7} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
</Box>
<Box>
<Text weight="bold" size="xl" color={!isRanked ? 'text-white' : 'text-gray-300'} block>
Unranked Unranked
</div> </Text>
<div className={`text-sm ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`}> <Text size="sm" color={!isRanked ? 'text-neon-aqua' : 'text-gray-500'} block>
Race with friends Race with friends
</div> </Text>
</div> </Box>
</div> </Stack>
{/* Radio indicator */} {/* Radio indicator */}
<div className={`flex h-7 w-7 items-center justify-center rounded-full border-2 shrink-0 transition-colors ${ <Box
!isRanked ? 'border-neon-aqua bg-neon-aqua' : 'border-gray-500' display="flex"
}`}> h="7"
{!isRanked && <Check className="w-4 h-4 text-deep-graphite" />} w="7"
</div> alignItems="center"
</div> justifyContent="center"
rounded="full"
border
borderColor={!isRanked ? 'border-neon-aqua' : 'border-gray-500'}
bg={!isRanked ? 'bg-neon-aqua' : ''}
flexShrink={0}
transition
>
{!isRanked && <Icon icon={Check} size={4} color="text-deep-graphite" />}
</Box>
</Box>
{/* Emotional tagline */} {/* Emotional tagline */}
<p className={`text-sm ${!isRanked ? 'text-gray-300' : 'text-gray-500'}`}> <Text size="sm" color={!isRanked ? 'text-gray-300' : 'text-gray-500'} block>
Pure racing fun. No pressure, no rankings just you and your crew hitting the track. Pure racing fun. No pressure, no rankings just you and your crew hitting the track.
</p> </Text>
{/* Features */} {/* Features */}
<div className="space-y-2.5 py-2"> <Stack gap={2.5} py={2}>
<div className="flex items-center gap-2 text-sm text-gray-400"> <Stack direction="row" align="center" gap={2}>
<Check className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`} /> <Icon icon={Check} size={4} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
<span>Private, invite-only access</span> <Text size="sm" color="text-gray-400">Private, invite-only access</Text>
</div> </Stack>
<div className="flex items-center gap-2 text-sm text-gray-400"> <Stack direction="row" align="center" gap={2}>
<Check className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`} /> <Icon icon={Check} size={4} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
<span>Zero impact on your rating</span> <Text size="sm" color="text-gray-400">Zero impact on your rating</Text>
</div> </Stack>
<div className="flex items-center gap-2 text-sm text-gray-400"> <Stack direction="row" align="center" gap={2}>
<Check className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`} /> <Icon icon={Check} size={4} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
<span>Perfect for practice & fun</span> <Text size="sm" color="text-gray-400">Perfect for practice & fun</Text>
</div> </Stack>
</div> </Stack>
{/* Flexibility badge */} {/* Flexibility badge */}
<div className="flex items-center gap-2 mt-auto px-3 py-2 rounded-lg bg-neon-aqua/10 border border-neon-aqua/20 w-fit"> <Box display="flex" alignItems="center" gap={2} mt="auto" px={3} py={2} rounded="lg" bg="bg-neon-aqua/10" border borderColor="border-neon-aqua/20" w="fit">
<Users className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-400'}`} /> <Icon icon={Users} size={4} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
<span className={`text-xs font-medium ${!isRanked ? 'text-neon-aqua' : 'text-gray-400'}`}> <Text size="xs" color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} weight="medium">
Any size even 2 friends Any size even 2 friends
</span> </Text>
</div> </Box>
</button> </Box>
{/* Info button */} {/* Info button */}
<button <Box
as="button"
ref={unrankedInfoRef} ref={unrankedInfoRef}
type="button" type="button"
onClick={() => setShowUnrankedFlyout(true)} onClick={() => setShowUnrankedFlyout(true)}
className="absolute top-3 right-3 flex h-7 w-7 items-center justify-center rounded-full text-gray-500 hover:text-neon-aqua hover:bg-neon-aqua/10 transition-colors" position="absolute"
top="3"
right="3"
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="full"
transition
color="text-gray-500"
hoverTextColor="text-neon-aqua"
hoverBg="bg-neon-aqua/10"
> >
<HelpCircle className="w-4 h-4" /> <Icon icon={HelpCircle} size={4} />
</button> </Box>
</div> </Box>
{/* Unranked Info Flyout */} {/* Unranked Info Flyout */}
<InfoFlyout <InfoFlyout
@@ -374,88 +506,103 @@ export function LeagueVisibilitySection({
title="Unranked Leagues" title="Unranked Leagues"
anchorRef={unrankedInfoRef} anchorRef={unrankedInfoRef}
> >
<div className="space-y-4"> <Stack gap={4}>
<p className="text-xs text-gray-400"> <Text size="xs" color="text-gray-400" block>
Unranked leagues are casual, private series for racing with friends. Unranked leagues are casual, private series for racing with friends.
Results don't affect driver ratings, so you can practice and have fun Results don&apos;t affect driver ratings, so you can practice and have fun
without pressure. without pressure.
</p> </Text>
<div className="space-y-2"> <Stack gap={2}>
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Perfect For</div> <Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
<ul className="space-y-1.5"> // eslint-disable-next-line gridpilot-rules/component-classification
<li className="flex items-start gap-2 text-xs text-gray-400"> className="tracking-wide"
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" /> block
<span>Private racing with friends</span> >
</li> Perfect For
<li className="flex items-start gap-2 text-xs text-gray-400"> </Text>
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" /> <Stack gap={1.5}>
<span>Practice and training sessions</span> <Box display="flex" alignItems="start" gap={2}>
</li> <Icon icon={Check} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<li className="flex items-start gap-2 text-xs text-gray-400"> <Text size="xs" color="text-gray-400">Private racing with friends</Text>
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" /> </Box>
<span>Small groups (2+ drivers)</span> <Box display="flex" alignItems="start" gap={2}>
</li> <Icon icon={Check} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
</ul> <Text size="xs" color="text-gray-400">Practice and training sessions</Text>
</div> </Box>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Small groups (2+ drivers)</Text>
</Box>
</Stack>
</Stack>
<div className="space-y-2"> <Stack gap={2}>
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Features</div> <Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
<ul className="space-y-1.5"> // eslint-disable-next-line gridpilot-rules/component-classification
<li className="flex items-start gap-2 text-xs text-gray-400"> className="tracking-wide"
<Users className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" /> block
<span>Invite-only membership</span> >
</li> Features
<li className="flex items-start gap-2 text-xs text-gray-400"> </Text>
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" /> <Stack gap={1.5}>
<span>Full stats and standings (internal only)</span> <Box display="flex" alignItems="start" gap={2}>
</li> <Icon icon={Users} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
</ul> <Text size="xs" color="text-gray-400">Invite-only membership</Text>
</div> </Box>
</div> <Box display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Full stats and standings (internal only)</Text>
</Box>
</Stack>
</Stack>
</Stack>
</InfoFlyout> </InfoFlyout>
</div> </Box>
{errors?.visibility && ( {errors?.visibility && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/20"> <Box display="flex" alignItems="center" gap={2} p={3} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/20">
<HelpCircle className="w-4 h-4 text-warning-amber shrink-0" /> <Icon icon={HelpCircle} size={4} color="text-warning-amber" flexShrink={0} />
<p className="text-xs text-warning-amber">{errors.visibility}</p> <Text size="xs" color="text-warning-amber">{errors.visibility}</Text>
</div> </Box>
)} )}
{/* Contextual info based on selection */} {/* Contextual info based on selection */}
<div className={`rounded-xl p-5 border transition-all duration-300 ${ <Box
isRanked rounded="xl"
? 'bg-primary-blue/5 border-primary-blue/20' p={5}
: 'bg-neon-aqua/5 border-neon-aqua/20' border
}`}> transition
<div className="flex items-start gap-3"> bg={isRanked ? 'bg-primary-blue/5' : 'bg-neon-aqua/5'}
borderColor={isRanked ? 'border-primary-blue/20' : 'border-neon-aqua/20'}
>
<Box display="flex" alignItems="start" gap={3}>
{isRanked ? ( {isRanked ? (
<> <>
<Trophy className="w-5 h-5 text-primary-blue shrink-0 mt-0.5" /> <Icon icon={Trophy} size={5} color="text-primary-blue" flexShrink={0} mt={0.5} />
<div> <Box>
<p className="text-sm font-medium text-white mb-1">Ready to compete</p> <Text size="sm" weight="medium" color="text-white" block mb={1}>Ready to compete</Text>
<p className="text-xs text-gray-400"> <Text size="xs" color="text-gray-400" block>
Your league will be visible to all GridPilot drivers. Results will affect driver ratings Your league will be visible to all GridPilot drivers. Results will affect driver ratings
and contribute to the global leaderboards. Make sure you have at least {MIN_RANKED_DRIVERS} drivers and contribute to the global leaderboards. Make sure you have at least {MIN_RANKED_DRIVERS} drivers
to ensure competitive integrity. to ensure competitive integrity.
</p> </Text>
</div> </Box>
</> </>
) : ( ) : (
<> <>
<Users className="w-5 h-5 text-neon-aqua shrink-0 mt-0.5" /> <Icon icon={Users} size={5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<div> <Box>
<p className="text-sm font-medium text-white mb-1">Private racing awaits</p> <Text size="sm" weight="medium" color="text-white" block mb={1}>Private racing awaits</Text>
<p className="text-xs text-gray-400"> <Text size="xs" color="text-gray-400" block>
Your league will be invite-only. Perfect for racing with friends, practice sessions, Your league will be invite-only. Perfect for racing with friends, practice sessions,
or any time you want to have fun without affecting your official ratings. or any time you want to have fun without affecting your official ratings.
</p> </Text>
</div> </Box>
</> </>
)} )}
</div> </Box>
</div> </Box>
</div> </Stack>
); );
} }

View File

@@ -1,80 +1,63 @@
'use client'; 'use client';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { getMembership } from '@/lib/leagueMembership'; import { getMembership } from '@/lib/leagueMembership';
import type { MembershipRole } from '@/lib/types/MembershipRole'; import type { MembershipRole } from '@/lib/types/MembershipRole';
import { Badge } from '@/ui/Badge';
interface MembershipStatusProps { interface MembershipStatusProps {
leagueId: string; leagueId: string;
className?: string;
} }
export default function MembershipStatus({ leagueId, className = '' }: MembershipStatusProps) { export function MembershipStatus({ leagueId }: MembershipStatusProps) {
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
if (!currentDriverId) { if (!currentDriverId) {
return ( return (
<span className={`px-3 py-1 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50 ${className}`}> <Badge variant="default">
Not a Member Not a Member
</span> </Badge>
); );
} }
const membership = getMembership(leagueId, currentDriverId); const membership = currentDriverId ? getMembership(leagueId, currentDriverId) : null;
if (!membership) { if (!membership) {
return ( return (
<span className={`px-3 py-1 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50 ${className}`}> <Badge variant="default">
Not a Member Not a Member
</span> </Badge>
); );
} }
const getRoleDisplay = (role: MembershipRole): { text: string; bgColor: string; textColor: string; borderColor: string } => { const getRoleVariant = (role: MembershipRole): 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' => {
switch (role) { switch (role) {
case 'owner': case 'owner':
return { return 'warning';
text: 'Owner',
bgColor: 'bg-yellow-500/10',
textColor: 'text-yellow-500',
borderColor: 'border-yellow-500/30',
};
case 'admin': case 'admin':
return { return 'primary';
text: 'Admin',
bgColor: 'bg-purple-500/10',
textColor: 'text-purple-400',
borderColor: 'border-purple-500/30',
};
case 'steward': case 'steward':
return { return 'info';
text: 'Steward',
bgColor: 'bg-blue-500/10',
textColor: 'text-blue-400',
borderColor: 'border-blue-500/30',
};
case 'member': case 'member':
return { return 'primary';
text: 'Member',
bgColor: 'bg-primary-blue/10',
textColor: 'text-primary-blue',
borderColor: 'border-primary-blue/30',
};
default: default:
return { return 'default';
text: 'Member',
bgColor: 'bg-primary-blue/10',
textColor: 'text-primary-blue',
borderColor: 'border-primary-blue/30',
};
} }
}; };
const { text, bgColor, textColor, borderColor } = getRoleDisplay(membership.role); const getRoleText = (role: MembershipRole): string => {
switch (role) {
case 'owner': return 'Owner';
case 'admin': return 'Admin';
case 'steward': return 'Steward';
case 'member': return 'Member';
default: return 'Member';
}
};
return ( return (
<span className={`px-3 py-1 text-xs font-medium ${bgColor} ${textColor} rounded border ${borderColor} ${className}`}> <Badge variant={getRoleVariant(membership.role)}>
{text} {getRoleText(membership.role)}
</span> </Badge>
); );
} }

View File

@@ -1,23 +0,0 @@
'use client';
import { Plus } from 'lucide-react';
import Button from '../ui/Button';
interface PenaltyFABProps {
onClick: () => void;
}
export default function PenaltyFAB({ onClick }: PenaltyFABProps) {
return (
<div className="fixed bottom-6 right-6 z-50">
<Button
variant="primary"
className="w-14 h-14 rounded-full shadow-lg"
onClick={onClick}
title="Add Penalty"
>
<Plus className="w-6 h-6" />
</Button>
</div>
);
}

View File

@@ -5,8 +5,12 @@ import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel"; import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel"; import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { Card } from "@/ui/Card"; import { Card } from "@/ui/Card";
import { Button } from "@/ui/Button"; import { Box } from "@/ui/Box";
import { Clock, Grid3x3, TrendingDown, AlertCircle, Filter, Flag } from "lucide-react"; import { Stack } from "@/ui/Stack";
import { Text } from "@/ui/Text";
import { Heading } from "@/ui/Heading";
import { Icon } from "@/ui/Icon";
import { AlertCircle, Flag } from "lucide-react";
interface PenaltyHistoryListProps { interface PenaltyHistoryListProps {
protests: ProtestViewModel[]; protests: ProtestViewModel[];
@@ -20,7 +24,6 @@ export function PenaltyHistoryList({
drivers, drivers,
}: PenaltyHistoryListProps) { }: PenaltyHistoryListProps) {
const [filteredProtests, setFilteredProtests] = useState<ProtestViewModel[]>([]); const [filteredProtests, setFilteredProtests] = useState<ProtestViewModel[]>([]);
const [filterType, setFilterType] = useState<"all">("all");
useEffect(() => { useEffect(() => {
setFilteredProtests(protests); setFilteredProtests(protests);
@@ -29,86 +32,108 @@ export function PenaltyHistoryList({
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case "upheld": case "upheld":
return "text-red-400 bg-red-500/20"; return { text: "text-red-400", bg: "bg-red-500/20" };
case "dismissed": case "dismissed":
return "text-gray-400 bg-gray-500/20"; return { text: "text-gray-400", bg: "bg-gray-500/20" };
case "withdrawn": case "withdrawn":
return "text-blue-400 bg-blue-500/20"; return { text: "text-blue-400", bg: "bg-blue-500/20" };
default: default:
return "text-orange-400 bg-orange-500/20"; return { text: "text-orange-400", bg: "bg-orange-500/20" };
} }
}; };
return ( return (
<div className="space-y-4"> <Stack gap={4}>
{filteredProtests.length === 0 ? ( {filteredProtests.length === 0 ? (
<Card className="p-12 text-center"> <Card py={12} textAlign="center">
<div className="flex flex-col items-center gap-4 text-gray-400"> <Stack alignItems="center" gap={4}>
<AlertCircle className="h-12 w-12 opacity-50" /> <Icon icon={AlertCircle} size={12} color="text-gray-400" opacity={0.5} />
<div> <Box>
<p className="font-medium text-lg">No Resolved Protests</p> <Text weight="medium" size="lg" color="text-gray-400" block>No Resolved Protests</Text>
<p className="text-sm mt-1"> <Text size="sm" color="text-gray-500" mt={1} block>
No protests have been resolved in this league No protests have been resolved in this league
</p> </Text>
</div> </Box>
</div> </Stack>
</Card> </Card>
) : ( ) : (
<div className="space-y-3"> <Stack gap={3}>
{filteredProtests.map((protest) => { {filteredProtests.map((protest) => {
const race = races[protest.raceId]; const race = races[protest.raceId];
const protester = drivers[protest.protestingDriverId]; const protester = drivers[protest.protestingDriverId];
const accused = drivers[protest.accusedDriverId]; const accused = drivers[protest.accusedDriverId];
const incident = protest.incident; const incident = protest.incident;
const resolvedDate = protest.reviewedAt || protest.filedAt; const resolvedDate = protest.reviewedAt || protest.filedAt;
const statusColors = getStatusColor(protest.status);
return ( return (
<Card key={protest.id} className="p-4"> <Card key={protest.id} p={4}>
<div className="flex items-start gap-4"> <Box display="flex" alignItems="start" gap={4}>
<div className={`h-10 w-10 rounded-full flex items-center justify-center flex-shrink-0 ${getStatusColor(protest.status)}`}> <Box
<Flag className="h-5 w-5" /> w="10"
</div> h="10"
<div className="flex-1 space-y-2"> rounded="full"
<div className="flex items-start justify-between gap-4"> display="flex"
<div> alignItems="center"
<h3 className="font-semibold text-white"> justifyContent="center"
Protest #{protest.id.substring(0, 8)} flexShrink={0}
</h3> bg={statusColors.bg}
<p className="text-sm text-gray-400"> color={statusColors.text}
{resolvedDate ? `Resolved ${new Date(resolvedDate).toLocaleDateString()}` : 'Resolved'} >
</p> <Icon icon={Flag} size={5} />
</div> </Box>
<span className={`px-3 py-1 rounded-full text-xs font-medium flex-shrink-0 ${getStatusColor(protest.status)}`}> <Box flex={1}>
{protest.status.toUpperCase()} <Stack gap={2}>
</span> <Box display="flex" alignItems="start" justifyContent="between" gap={4}>
</div> <Box>
<div className="space-y-1 text-sm"> <Heading level={3}>
<p className="text-gray-400"> Protest #{protest.id.substring(0, 8)}
<span className="font-medium">{protester?.name || 'Unknown'}</span> vs <span className="font-medium">{accused?.name || 'Unknown'}</span> </Heading>
</p> <Text size="sm" color="text-gray-400" block>
{race && incident && ( {resolvedDate ? `Resolved ${new Date(resolvedDate).toLocaleDateString()}` : 'Resolved'}
<p className="text-gray-500"> </Text>
{race.track} ({race.car}) - Lap {incident.lap} </Box>
</p> <Box
px={3}
py={1}
rounded="full"
bg={statusColors.bg}
color={statusColors.text}
fontSize="12px"
weight="medium"
flexShrink={0}
>
{protest.status.toUpperCase()}
</Box>
</Box>
<Box>
<Text size="sm" color="text-gray-400" block>
<Text weight="medium" color="text-white">{protester?.name || 'Unknown'}</Text> vs <Text weight="medium" color="text-white">{accused?.name || 'Unknown'}</Text>
</Text>
{race && incident && (
<Text size="sm" color="text-gray-500" block>
{race.track} ({race.car}) - Lap {incident.lap}
</Text>
)}
</Box>
{incident && (
<Text size="sm" color="text-gray-300" block>{incident.description}</Text>
)} )}
</div> {protest.decisionNotes && (
{incident && ( <Box mt={2} p={2} rounded="md" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline/50">
<p className="text-gray-300 text-sm">{incident.description}</p> <Text size="xs" color="text-gray-400" block>
)} <Text weight="medium">Steward Notes:</Text> {protest.decisionNotes}
{protest.decisionNotes && ( </Text>
<div className="mt-2 p-2 rounded bg-iron-gray/30 border border-charcoal-outline/50"> </Box>
<p className="text-xs text-gray-400"> )}
<span className="font-medium">Steward Notes:</span> {protest.decisionNotes} </Stack>
</p> </Box>
</div> </Box>
)}
</div>
</div>
</Card> </Card>
); );
})} })}
</div> </Stack>
)} )}
</div> </Stack>
); );
} }

View File

@@ -1,115 +0,0 @@
"use client";
import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { Card } from "@/ui/Card";
import { Button } from "@/ui/Button";
import Link from "next/link";
import { AlertCircle, Video, ChevronRight, Flag, Clock, AlertTriangle } from "lucide-react";
interface PendingProtestsListProps {
protests: ProtestViewModel[];
races: Record<string, RaceViewModel>;
drivers: Record<string, DriverViewModel>;
leagueId: string;
onReviewProtest: (protest: ProtestViewModel) => void;
onProtestReviewed: () => void;
}
export function PendingProtestsList({
protests,
races,
drivers,
leagueId,
onReviewProtest,
onProtestReviewed,
}: PendingProtestsListProps) {
if (protests.length === 0) {
return (
<Card className="p-12 text-center">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="h-8 w-8 text-performance-green" />
</div>
<div>
<p className="font-semibold text-lg text-white mb-2">All Clear! 🏁</p>
<p className="text-sm text-gray-400">No pending protests to review</p>
</div>
</div>
</Card>
);
}
return (
<div className="space-y-4">
{protests.map((protest) => {
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt || protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2;
return (
<Card
key={protest.id}
className={`p-6 hover:border-warning-amber/40 transition-all ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="h-10 w-10 rounded-full bg-warning-amber/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="h-5 w-5 text-warning-amber" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white">
Protest #{protest.id.substring(0, 8)}
</h3>
<p className="text-sm text-gray-400">
Filed {new Date(protest.filedAt || protest.submittedAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full flex items-center gap-1">
<Clock className="h-3 w-3" />
Pending
</span>
{isUrgent && (
<span className="px-2 py-1 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{daysSinceFiled}d old
</span>
)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Flag className="h-4 w-4 text-gray-400" />
<span className="text-gray-400">Lap {protest.incident?.lap || 'N/A'}</span>
</div>
<p className="text-gray-300 line-clamp-2 leading-relaxed">
{protest.incident?.description || protest.description}
</p>
{protest.proofVideoUrl && (
<div className="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-primary-blue/10 text-primary-blue rounded-lg border border-primary-blue/20">
<Video className="h-4 w-4" />
<span>Video evidence attached</span>
</div>
)}
</div>
</div>
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button
variant="secondary"
className="flex-shrink-0"
>
<ChevronRight className="h-5 w-5" />
</Button>
</Link>
</div>
</Card>
);
})}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More