website refactor
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
"Best 6" or "Drop 2" 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 "Create League" to launch your racing series. You can modify all settings later.
|
||||||
</p>
|
</Text>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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'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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user