website refactor
This commit is contained in:
@@ -249,7 +249,8 @@
|
||||
"rules": {
|
||||
"gridpilot-rules/no-raw-html-in-app": "error",
|
||||
"gridpilot-rules/no-nextjs-imports-in-ui": "error",
|
||||
"gridpilot-rules/no-hardcoded-routes": "error"
|
||||
"gridpilot-rules/no-hardcoded-routes": "error",
|
||||
"gridpilot-rules/component-classification": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -271,7 +272,7 @@
|
||||
"rules": {
|
||||
"gridpilot-rules/ui-element-purity": "error",
|
||||
"gridpilot-rules/no-nextjs-imports-in-ui": "error",
|
||||
"gridpilot-rules/component-classification": "warn"
|
||||
"gridpilot-rules/component-classification": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -281,7 +282,7 @@
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/no-nextjs-imports-in-ui": "error",
|
||||
"gridpilot-rules/component-classification": "warn",
|
||||
"gridpilot-rules/component-classification": "error",
|
||||
"gridpilot-rules/no-hardcoded-routes": "error",
|
||||
"gridpilot-rules/no-raw-html-in-app": "error"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
||||
import Section from '@/components/ui/Section';
|
||||
import Section from '@/ui/Section';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AdminDashboardPageQuery } from '@/lib/page-queries/AdminDashboardPageQuery';
|
||||
import { AdminDashboardWrapper } from '@/components/admin/AdminDashboardWrapper';
|
||||
import { ErrorBanner } from '@/components/ui/ErrorBanner';
|
||||
import { ErrorBanner } from '@/ui/ErrorBanner';
|
||||
|
||||
export default async function AdminPage() {
|
||||
const result = await AdminDashboardPageQuery.execute();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AdminUsersPageQuery } from '@/lib/page-queries/AdminUsersPageQuery';
|
||||
import { AdminUsersWrapper } from '@/components/admin/AdminUsersWrapper';
|
||||
import { ErrorBanner } from '@/components/ui/ErrorBanner';
|
||||
import { ErrorBanner } from '@/ui/ErrorBanner';
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
// Execute PageQuery using static method
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { ForgotPasswordPageQuery } from '@/lib/page-queries/auth/ForgotPasswordPageQuery';
|
||||
import { ForgotPasswordClient } from './ForgotPasswordClient';
|
||||
import { AuthError } from '@/components/ui/AuthError';
|
||||
import { AuthError } from '@/ui/AuthError';
|
||||
|
||||
export default async function ForgotPasswordPage({
|
||||
searchParams,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
|
||||
import { AuthContainer } from '@/components/ui/AuthContainer';
|
||||
import { AuthContainer } from '@/ui/AuthContainer';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -17,7 +17,7 @@ import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
|
||||
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
|
||||
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
|
||||
import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
|
||||
import { AuthLoading } from '@/components/ui/AuthLoading';
|
||||
import { AuthLoading } from '@/ui/AuthLoading';
|
||||
|
||||
interface LoginClientProps {
|
||||
viewData: LoginViewData;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { LoginPageQuery } from '@/lib/page-queries/auth/LoginPageQuery';
|
||||
import { LoginClient } from './LoginClient';
|
||||
import { AuthError } from '@/components/ui/AuthError';
|
||||
import { AuthError } from '@/ui/AuthError';
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { ResetPasswordPageQuery } from '@/lib/page-queries/auth/ResetPasswordPageQuery';
|
||||
import { ResetPasswordClient } from './ResetPasswordClient';
|
||||
import { AuthError } from '@/components/ui/AuthError';
|
||||
import { AuthError } from '@/ui/AuthError';
|
||||
|
||||
export default async function ResetPasswordPage({
|
||||
searchParams,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { SignupPageQuery } from '@/lib/page-queries/auth/SignupPageQuery';
|
||||
import { SignupClient } from './SignupClient';
|
||||
import { AuthError } from '@/components/ui/AuthError';
|
||||
import { AuthError } from '@/ui/AuthError';
|
||||
|
||||
export default async function SignupPage({
|
||||
searchParams,
|
||||
|
||||
36
apps/website/app/drivers/DriversPageClient.tsx
Normal file
36
apps/website/app/drivers/DriversPageClient.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DriversTemplate } from '@/templates/DriversTemplate';
|
||||
import { useDriverSearch } from '@/lib/hooks/useDriverSearch';
|
||||
import type { DriverLeaderboardViewModel } from '@/lib/view-data/DriverLeaderboardViewModel';
|
||||
|
||||
interface DriversPageClientProps {
|
||||
data: DriverLeaderboardViewModel | null;
|
||||
}
|
||||
|
||||
export function DriversPageClient({ data }: DriversPageClientProps) {
|
||||
const router = useRouter();
|
||||
const drivers = data?.drivers || [];
|
||||
const { searchQuery, setSearchQuery, filteredDrivers } = useDriverSearch(drivers);
|
||||
|
||||
const handleDriverClick = (driverId: string) => {
|
||||
router.push(`/drivers/${driverId}`);
|
||||
};
|
||||
|
||||
const handleViewLeaderboard = () => {
|
||||
router.push('/leaderboards/drivers');
|
||||
};
|
||||
|
||||
return (
|
||||
<DriversTemplate
|
||||
data={data}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filteredDrivers={filteredDrivers}
|
||||
onDriverClick={handleDriverClick}
|
||||
onViewLeaderboard={handleViewLeaderboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { initializeApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import React from 'react';
|
||||
import './globals.css';
|
||||
import { AppWrapper } from '@/ui/AppWrapper';
|
||||
import { AppWrapper } from '@/components/AppWrapper';
|
||||
import { Header } from '@/ui/Header';
|
||||
import { HeaderContent } from '@/ui/HeaderContent';
|
||||
import { MainContent } from '@/ui/MainContent';
|
||||
|
||||
41
apps/website/app/leaderboards/LeaderboardsPageClient.tsx
Normal file
41
apps/website/app/leaderboards/LeaderboardsPageClient.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LeaderboardsTemplate } from '@/templates/LeaderboardsTemplate';
|
||||
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface LeaderboardsPageClientProps {
|
||||
viewData: LeaderboardsViewData;
|
||||
}
|
||||
|
||||
export function LeaderboardsPageClient({ viewData }: LeaderboardsPageClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleDriverClick = (driverId: string) => {
|
||||
router.push(routes.driver.detail(driverId));
|
||||
};
|
||||
|
||||
const handleTeamClick = (teamId: string) => {
|
||||
router.push(routes.team.detail(teamId));
|
||||
};
|
||||
|
||||
const handleNavigateToDrivers = () => {
|
||||
router.push(routes.leaderboards.drivers);
|
||||
};
|
||||
|
||||
const handleNavigateToTeams = () => {
|
||||
router.push(routes.team.leaderboard);
|
||||
};
|
||||
|
||||
return (
|
||||
<LeaderboardsTemplate
|
||||
viewData={viewData}
|
||||
onDriverClick={handleDriverClick}
|
||||
onTeamClick={handleTeamClick}
|
||||
onNavigateToDrivers={handleNavigateToDrivers}
|
||||
onNavigateToTeams={handleNavigateToTeams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -23,18 +23,26 @@ export default async function LeagueLayout({
|
||||
// Return error state
|
||||
return (
|
||||
<LeagueDetailTemplate
|
||||
leagueId={leagueId}
|
||||
leagueName="Error"
|
||||
leagueDescription="Failed to load league"
|
||||
viewData={{
|
||||
leagueId,
|
||||
name: 'Error',
|
||||
description: 'Failed to load league',
|
||||
info: { name: 'Error', membersCount: 0, racesCount: 0, avgSOF: 0, structure: '', scoring: '', createdAt: '' },
|
||||
runningRaces: [],
|
||||
sponsors: [],
|
||||
ownerSummary: null,
|
||||
adminSummaries: [],
|
||||
stewardSummaries: [],
|
||||
sponsorInsights: null
|
||||
}}
|
||||
tabs={[]}
|
||||
>
|
||||
<Text align="center" className="text-gray-400">Failed to load league</Text>
|
||||
<Text align="center">Failed to load league</Text>
|
||||
</LeagueDetailTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
const data = result.unwrap();
|
||||
const league = data.league;
|
||||
const viewData = result.unwrap();
|
||||
|
||||
// Define tab configuration
|
||||
const baseTabs = [
|
||||
@@ -58,9 +66,7 @@ export default async function LeagueLayout({
|
||||
|
||||
return (
|
||||
<LeagueDetailTemplate
|
||||
leagueId={leagueId}
|
||||
leagueName={league.name}
|
||||
leagueDescription={league.description}
|
||||
viewData={viewData}
|
||||
tabs={tabs}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { notFound } from 'next/navigation';
|
||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery';
|
||||
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
|
||||
import { ErrorBanner } from '@/components/ui/ErrorBanner';
|
||||
import { ErrorBanner } from '@/ui/ErrorBanner';
|
||||
|
||||
interface Props {
|
||||
params: { id: string };
|
||||
@@ -49,5 +49,5 @@ export default async function Page({ params }: Props) {
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
return <LeagueDetailTemplate viewData={viewData} />;
|
||||
return <LeagueDetailTemplate viewData={viewData} tabs={[]} children={null} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
|
||||
import { type RulebookSection } from '@/components/leagues/RulebookTabs';
|
||||
import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData';
|
||||
|
||||
interface LeagueRulebookPageClientProps {
|
||||
viewData: LeagueRulebookViewData;
|
||||
}
|
||||
|
||||
export function LeagueRulebookPageClient({ viewData }: LeagueRulebookPageClientProps) {
|
||||
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
|
||||
|
||||
return (
|
||||
<LeagueRulebookTemplate
|
||||
viewData={viewData}
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import PenaltyFAB from '@/components/leagues/PenaltyFAB';
|
||||
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
|
||||
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
||||
import StewardingStats from '@/components/leagues/StewardingStats';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
import { useLeagueStewardingMutations } from "@/lib/hooks/league/useLeagueStewardingMutations";
|
||||
import {
|
||||
AlertCircle,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
import Button from '@/ui/Button';
|
||||
import TransactionRow from '@/components/leagues/TransactionRow';
|
||||
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
|
||||
import {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import React from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard';
|
||||
import Section from '@/components/ui/Section';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Section from '@/ui/Section';
|
||||
import Container from '@/ui/Container';
|
||||
|
||||
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';
|
||||
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Input from '@/components/ui/Input';
|
||||
import TabNavigation from '@/components/ui/TabNavigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { Result } from '@/lib/contracts/Result';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ProfileTemplate, type ProfileTab } from '@/templates/ProfileTemplate';
|
||||
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
|
||||
|
||||
type ProfileTab = 'overview' | 'history' | 'stats';
|
||||
|
||||
type SaveError = string | null;
|
||||
import type { Result } from '@/lib/contracts/Result';
|
||||
|
||||
interface ProfilePageClientProps {
|
||||
viewData: ProfileViewData;
|
||||
@@ -23,112 +13,55 @@ interface ProfilePageClientProps {
|
||||
}
|
||||
|
||||
export function ProfilePageClient({ viewData, mode, onSaveSettings }: ProfilePageClientProps) {
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview');
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const tabParam = searchParams.get('tab') as ProfileTab | null;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [bio, setBio] = useState(viewData.driver.bio ?? '');
|
||||
const [countryCode, setCountryCode] = useState(viewData.driver.countryCode ?? '');
|
||||
const [saveError, setSaveError] = useState<SaveError>(null);
|
||||
const [friendRequestSent, setFriendRequestSent] = useState(false);
|
||||
|
||||
if (mode === 'needs-profile') {
|
||||
return (
|
||||
<Container size="md">
|
||||
<Heading level={1}>Create your driver profile</Heading>
|
||||
<Card>
|
||||
<p>Driver profile not found for this account.</p>
|
||||
<Link href={routes.protected.onboarding}>
|
||||
<Button variant="primary">Start onboarding</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (activeTab === 'overview') {
|
||||
params.delete('tab');
|
||||
} else {
|
||||
params.set('tab', activeTab);
|
||||
}
|
||||
const query = params.toString();
|
||||
const currentQuery = searchParams.toString();
|
||||
|
||||
if (query !== currentQuery) {
|
||||
router.replace(`/profile${query ? `?${query}` : ''}`, { scroll: false });
|
||||
}
|
||||
}, [activeTab, searchParams, router]);
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
<Container size="md">
|
||||
<Heading level={1}>Edit profile</Heading>
|
||||
|
||||
<Card>
|
||||
<Heading level={3}>Profile</Heading>
|
||||
|
||||
<Input
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
placeholder="Bio"
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={countryCode}
|
||||
onChange={(e) => setCountryCode(e.target.value)}
|
||||
placeholder="Country code (e.g. DE)"
|
||||
/>
|
||||
|
||||
{saveError ? <p>{saveError}</p> : null}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={async () => {
|
||||
setSaveError(null);
|
||||
const result = await onSaveSettings({ bio, country: countryCode });
|
||||
if (result.isErr()) {
|
||||
setSaveError(result.getError());
|
||||
return;
|
||||
}
|
||||
setEditMode(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" onClick={() => setEditMode(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get('tab') as ProfileTab | null;
|
||||
if (tab && tab !== activeTab) {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}, [searchParams, activeTab]);
|
||||
|
||||
return (
|
||||
<Container size="lg">
|
||||
<Heading level={1}>{viewData.driver.name || 'Profile'}</Heading>
|
||||
|
||||
<Button variant="primary" onClick={() => setEditMode(true)}>
|
||||
Edit profile
|
||||
</Button>
|
||||
|
||||
<TabNavigation
|
||||
tabs={[
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'history', label: 'Race History' },
|
||||
{ id: 'stats', label: 'Detailed Stats' },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tabId) => setActiveTab(tabId as ProfileTab)}
|
||||
/>
|
||||
|
||||
{activeTab === 'overview' ? (
|
||||
<Card>
|
||||
<Heading level={3}>Driver</Heading>
|
||||
<p>{viewData.driver.countryCode}</p>
|
||||
<p>{viewData.driver.joinedAtLabel}</p>
|
||||
<p>{viewData.driver.bio ?? ''}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'history' ? (
|
||||
<Card>
|
||||
<Heading level={3}>Race history</Heading>
|
||||
<p>Race history is currently unavailable in this view.</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'stats' ? (
|
||||
<Card>
|
||||
<Heading level={3}>Stats</Heading>
|
||||
<p>{viewData.stats?.ratingLabel ?? ''}</p>
|
||||
<p>{viewData.stats?.globalRankLabel ?? ''}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
</Container>
|
||||
<ProfileTemplate
|
||||
viewData={viewData}
|
||||
mode={mode}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
editMode={editMode}
|
||||
onEditModeChange={setEditMode}
|
||||
friendRequestSent={friendRequestSent}
|
||||
onFriendRequestSend={() => setFriendRequestSent(true)}
|
||||
onSaveSettings={async (updates) => {
|
||||
const result = await onSaveSettings(updates);
|
||||
if (result.isErr()) {
|
||||
// In a real app, we'd show a toast or error message.
|
||||
// For now, we just throw to let the UI handle it if needed,
|
||||
// or we could add an error state to this client component.
|
||||
throw new Error(result.getError());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Button from '@/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
import Container from '@/ui/Container';
|
||||
import Heading from '@/ui/Heading';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export default async function ProfileLiveriesPage() {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Button from '@/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
import Container from '@/ui/Container';
|
||||
import Heading from '@/ui/Heading';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export default async function ProfileLiveryUploadPage() {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { updateProfileAction } from './actions';
|
||||
import { ProfilePageClient } from './ProfilePageClient';
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const result = await ProfilePageQuery.execute();
|
||||
const query = new ProfilePageQuery();
|
||||
const result = await query.execute();
|
||||
|
||||
if (result.isErr()) {
|
||||
notFound();
|
||||
@@ -13,5 +14,11 @@ export default async function ProfilePage() {
|
||||
const viewData = result.unwrap();
|
||||
const mode = viewData.driver.id ? 'profile-exists' : 'needs-profile';
|
||||
|
||||
return <ProfilePageClient viewData={viewData} mode={mode} onSaveSettings={updateProfileAction} />;
|
||||
return (
|
||||
<ProfilePageClient
|
||||
viewData={viewData}
|
||||
mode={mode}
|
||||
onSaveSettings={updateProfileAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Button from '@/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
import Container from '@/ui/Container';
|
||||
import Heading from '@/ui/Heading';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export default async function ProfileSettingsPage() {
|
||||
|
||||
65
apps/website/app/races/RacesPageClient.tsx
Normal file
65
apps/website/app/races/RacesPageClient.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { RacesTemplate, type TimeFilter, type RaceStatusFilter } from '@/templates/RacesTemplate';
|
||||
import type { RacesViewData } from '@/lib/view-data/RacesViewData';
|
||||
|
||||
interface RacesPageClientProps {
|
||||
viewData: RacesViewData;
|
||||
}
|
||||
|
||||
export function RacesPageClient({ viewData }: RacesPageClientProps) {
|
||||
const [statusFilter, setStatusFilter] = useState<RaceStatusFilter>('all');
|
||||
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||
const [timeFilter, setTimeFilter] = useState<TimeFilter>('upcoming');
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
|
||||
const filteredRaces = useMemo(() => {
|
||||
return viewData.races.filter((race) => {
|
||||
if (statusFilter !== 'all' && race.status !== statusFilter) return false;
|
||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) return false;
|
||||
if (timeFilter === 'upcoming' && !race.isUpcoming) return false;
|
||||
if (timeFilter === 'live' && !race.isLive) return false;
|
||||
if (timeFilter === 'past' && !race.isPast) return false;
|
||||
return true;
|
||||
});
|
||||
}, [viewData.races, statusFilter, leagueFilter, timeFilter]);
|
||||
|
||||
const racesByDate = useMemo(() => {
|
||||
const grouped = new Map<string, typeof filteredRaces[0][]>();
|
||||
filteredRaces.forEach((race) => {
|
||||
const dateKey = race.scheduledAt.split('T')[0]!;
|
||||
if (!grouped.has(dateKey)) {
|
||||
grouped.set(dateKey, []);
|
||||
}
|
||||
grouped.get(dateKey)!.push(race);
|
||||
});
|
||||
return Array.from(grouped.entries()).map(([dateKey, dayRaces]) => ({
|
||||
dateKey,
|
||||
dateLabel: dayRaces[0]?.scheduledAtLabel || '',
|
||||
races: dayRaces,
|
||||
}));
|
||||
}, [filteredRaces]);
|
||||
|
||||
return (
|
||||
<RacesTemplate
|
||||
viewData={{
|
||||
...viewData,
|
||||
races: filteredRaces,
|
||||
racesByDate,
|
||||
}}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
timeFilter={timeFilter}
|
||||
setTimeFilter={setTimeFilter}
|
||||
showFilterModal={showFilterModal}
|
||||
setShowFilterModal={setShowFilterModal}
|
||||
onRaceClick={(id) => console.log('Race click', id)}
|
||||
onLeagueClick={(id) => console.log('League click', id)}
|
||||
onWithdraw={(id) => console.log('Withdraw', id)}
|
||||
onCancel={(id) => console.log('Cancel', id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
85
apps/website/app/races/[id]/RaceDetailPageClient.tsx
Normal file
85
apps/website/app/races/[id]/RaceDetailPageClient.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { RaceDetailTemplate, type RaceDetailViewData } from '@/templates/RaceDetailTemplate';
|
||||
|
||||
interface RaceDetailPageClientProps {
|
||||
viewData: RaceDetailViewData;
|
||||
onBack: () => void;
|
||||
onRegister: () => void;
|
||||
onWithdraw: () => void;
|
||||
onCancel: () => void;
|
||||
onReopen: () => void;
|
||||
onEndRace: () => void;
|
||||
onFileProtest: () => void;
|
||||
onResultsClick: () => void;
|
||||
onStewardingClick: () => void;
|
||||
onLeagueClick: (id: string) => void;
|
||||
onDriverClick: (id: string) => void;
|
||||
isOwnerOrAdmin: boolean;
|
||||
}
|
||||
|
||||
export function RaceDetailPageClient({
|
||||
viewData,
|
||||
onBack,
|
||||
onRegister,
|
||||
onWithdraw,
|
||||
onCancel,
|
||||
onReopen,
|
||||
onEndRace,
|
||||
onFileProtest,
|
||||
onResultsClick,
|
||||
onStewardingClick,
|
||||
onLeagueClick,
|
||||
onDriverClick,
|
||||
isOwnerOrAdmin
|
||||
}: RaceDetailPageClientProps) {
|
||||
const [showProtestModal, setShowProtestModal] = useState(false);
|
||||
const [showEndRaceModal, setShowEndRaceModal] = useState(false);
|
||||
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
|
||||
|
||||
const ratingChange = viewData.userResult?.ratingChange ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (ratingChange !== null) {
|
||||
let start = 0;
|
||||
const end = ratingChange;
|
||||
const duration = 1000;
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const current = Math.round(start + (end - start) * eased);
|
||||
setAnimatedRatingChange(current);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
}, [ratingChange]);
|
||||
|
||||
return (
|
||||
<RaceDetailTemplate
|
||||
viewData={viewData}
|
||||
isLoading={false}
|
||||
onBack={onBack}
|
||||
onRegister={onRegister}
|
||||
onWithdraw={onWithdraw}
|
||||
onCancel={onCancel}
|
||||
onReopen={onReopen}
|
||||
onEndRace={onEndRace}
|
||||
onFileProtest={onFileProtest}
|
||||
onResultsClick={onResultsClick}
|
||||
onStewardingClick={onStewardingClick}
|
||||
onLeagueClick={onLeagueClick}
|
||||
onDriverClick={onDriverClick}
|
||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||
animatedRatingChange={animatedRatingChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
|
||||
data={null}
|
||||
Template={({ data: _data }) => (
|
||||
<RaceDetailTemplate
|
||||
viewModel={undefined}
|
||||
viewData={undefined}
|
||||
isLoading={false}
|
||||
error={new Error('Failed to load race details')}
|
||||
onBack={() => {}}
|
||||
@@ -48,12 +48,8 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
|
||||
onStewardingClick={() => {}}
|
||||
onLeagueClick={() => {}}
|
||||
onDriverClick={() => {}}
|
||||
currentDriverId={''}
|
||||
isOwnerOrAdmin={false}
|
||||
showProtestModal={false}
|
||||
setShowProtestModal={() => {}}
|
||||
showEndRaceModal={false}
|
||||
setShowEndRaceModal={() => {}}
|
||||
animatedRatingChange={0}
|
||||
mutationLoading={{
|
||||
register: false,
|
||||
withdraw: false,
|
||||
@@ -78,23 +74,12 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
|
||||
|
||||
const viewData = result.unwrap();
|
||||
|
||||
// Convert ViewData to ViewModel for the template
|
||||
// The template expects a ViewModel, so we need to adapt
|
||||
const viewModel = {
|
||||
race: viewData.race,
|
||||
league: viewData.league,
|
||||
entryList: viewData.entryList,
|
||||
registration: viewData.registration,
|
||||
userResult: viewData.userResult,
|
||||
canReopenRace: viewData.canReopenRace,
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={viewData}
|
||||
Template={({ data: _data }) => (
|
||||
<RaceDetailTemplate
|
||||
viewModel={viewModel}
|
||||
viewData={viewData}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
@@ -108,12 +93,8 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
|
||||
onStewardingClick={() => {}}
|
||||
onLeagueClick={() => {}}
|
||||
onDriverClick={() => {}}
|
||||
currentDriverId={''}
|
||||
isOwnerOrAdmin={false}
|
||||
showProtestModal={false}
|
||||
setShowProtestModal={() => {}}
|
||||
showEndRaceModal={false}
|
||||
setShowEndRaceModal={() => {}}
|
||||
animatedRatingChange={0}
|
||||
mutationLoading={{
|
||||
register: false,
|
||||
withdraw: false,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { RacesTemplate } from '@/templates/RacesTemplate';
|
||||
import { RacesPageQuery } from '@/lib/page-queries/races/RacesPageQuery';
|
||||
import { RacesPageClient } from './RacesPageClient';
|
||||
|
||||
export default async function Page() {
|
||||
const result = await RacesPageQuery.execute();
|
||||
const query = new RacesPageQuery();
|
||||
const result = await query.execute();
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
@@ -12,59 +13,13 @@ export default async function Page() {
|
||||
case 'notFound':
|
||||
notFound();
|
||||
case 'redirect':
|
||||
// Would redirect to login or other page
|
||||
notFound();
|
||||
default:
|
||||
// For other errors, show error state in template
|
||||
return <RacesTemplate
|
||||
races={[]}
|
||||
totalCount={0}
|
||||
scheduledRaces={[]}
|
||||
runningRaces={[]}
|
||||
completedRaces={[]}
|
||||
isLoading={false}
|
||||
statusFilter="all"
|
||||
setStatusFilter={() => {}}
|
||||
leagueFilter="all"
|
||||
setLeagueFilter={() => {}}
|
||||
timeFilter="upcoming"
|
||||
setTimeFilter={() => {}}
|
||||
onRaceClick={() => {}}
|
||||
onLeagueClick={() => {}}
|
||||
onRegister={() => {}}
|
||||
onWithdraw={() => {}}
|
||||
onCancel={() => {}}
|
||||
showFilterModal={false}
|
||||
setShowFilterModal={() => {}}
|
||||
currentDriverId={undefined}
|
||||
userMemberships={[]}
|
||||
/>;
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
const viewData = result.unwrap();
|
||||
|
||||
return <RacesTemplate
|
||||
races={viewData.races}
|
||||
totalCount={viewData.totalCount}
|
||||
scheduledRaces={viewData.scheduledRaces}
|
||||
runningRaces={viewData.runningRaces}
|
||||
completedRaces={viewData.completedRaces}
|
||||
isLoading={false}
|
||||
statusFilter="all"
|
||||
setStatusFilter={() => {}}
|
||||
leagueFilter="all"
|
||||
setLeagueFilter={() => {}}
|
||||
timeFilter="upcoming"
|
||||
setTimeFilter={() => {}}
|
||||
onRaceClick={() => {}}
|
||||
onLeagueClick={() => {}}
|
||||
onRegister={() => {}}
|
||||
onWithdraw={() => {}}
|
||||
onCancel={() => {}}
|
||||
showFilterModal={false}
|
||||
setShowFilterModal={() => {}}
|
||||
currentDriverId={undefined}
|
||||
userMemberships={[]}
|
||||
/>;
|
||||
}
|
||||
return <RacesPageClient viewData={viewData} />;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import StatCard from '@/components/ui/StatCard';
|
||||
import SectionHeader from '@/components/ui/SectionHeader';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import InfoBanner from '@/components/ui/InfoBanner';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import Card from '@/ui/Card';
|
||||
import Button from '@/ui/Button';
|
||||
import StatCard from '@/ui/StatCard';
|
||||
import SectionHeader from '@/ui/SectionHeader';
|
||||
import StatusBadge from '@/ui/StatusBadge';
|
||||
import InfoBanner from '@/ui/InfoBanner';
|
||||
import PageHeader from '@/ui/PageHeader';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import { useSponsorBilling } from "@/lib/hooks/sponsor/useSponsorBilling";
|
||||
import {
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import InfoBanner from '@/components/ui/InfoBanner';
|
||||
import Card from '@/ui/Card';
|
||||
import Button from '@/ui/Button';
|
||||
import InfoBanner from '@/ui/InfoBanner';
|
||||
import { useSponsorSponsorships } from "@/lib/hooks/sponsor/useSponsorSponsorships";
|
||||
import {
|
||||
Megaphone,
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Toggle from '@/components/ui/Toggle';
|
||||
import SectionHeader from '@/components/ui/SectionHeader';
|
||||
import FormField from '@/components/ui/FormField';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import Card from '@/ui/Card';
|
||||
import Button from '@/ui/Button';
|
||||
import Input from '@/ui/Input';
|
||||
import Toggle from '@/ui/Toggle';
|
||||
import SectionHeader from '@/ui/SectionHeader';
|
||||
import FormField from '@/ui/FormField';
|
||||
import PageHeader from '@/ui/PageHeader';
|
||||
import {
|
||||
Settings,
|
||||
Building2,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Card from '@/ui/Card';
|
||||
import Button from '@/ui/Button';
|
||||
import Input from '@/ui/Input';
|
||||
import SponsorHero from '@/components/sponsors/SponsorHero';
|
||||
import SponsorWorkflowMockup from '@/components/sponsors/SponsorWorkflowMockup';
|
||||
import SponsorBenefitCard from '@/components/sponsors/SponsorBenefitCard';
|
||||
|
||||
@@ -1,77 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import type { TeamsViewData } from '@/lib/view-data/TeamsViewData';
|
||||
import { TeamsTemplate } from '@/templates/TeamsTemplate';
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { TeamsTemplate } from '@/templates/TeamsTemplate';
|
||||
import type { TeamsViewData } from '@/lib/view-data/TeamsViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface TeamsPageClientProps extends TeamsViewData {
|
||||
searchQuery?: string;
|
||||
showCreateForm?: boolean;
|
||||
onSearchChange?: (query: string) => void;
|
||||
onShowCreateForm?: () => void;
|
||||
onHideCreateForm?: () => void;
|
||||
onTeamClick?: (teamId: string) => void;
|
||||
onCreateSuccess?: (teamId: string) => void;
|
||||
onBrowseTeams?: () => void;
|
||||
onSkillLevelClick?: (level: string) => void;
|
||||
interface TeamsPageClientProps {
|
||||
viewData: TeamsViewData;
|
||||
}
|
||||
|
||||
export function TeamsPageClient({ teams }: TeamsPageClientProps) {
|
||||
export function TeamsPageClient({ viewData }: TeamsPageClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// UI state only (no business logic)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
// Event handlers
|
||||
const handleSearchChange = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
};
|
||||
|
||||
const handleShowCreateForm = () => {
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
const handleHideCreateForm = () => {
|
||||
setShowCreateForm(false);
|
||||
};
|
||||
|
||||
const handleTeamClick = (teamId: string) => {
|
||||
router.push(`/teams/${teamId}`);
|
||||
};
|
||||
|
||||
const handleCreateSuccess = (teamId: string) => {
|
||||
setShowCreateForm(false);
|
||||
router.push(`/teams/${teamId}`);
|
||||
const handleViewFullLeaderboard = () => {
|
||||
router.push(routes.team.leaderboard);
|
||||
};
|
||||
|
||||
const handleBrowseTeams = () => {
|
||||
const element = document.getElementById('teams-list');
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkillLevelClick = (level: string) => {
|
||||
const element = document.getElementById(`level-${level}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
const handleCreateTeam = () => {
|
||||
router.push(routes.team.detail('create'));
|
||||
};
|
||||
|
||||
return (
|
||||
<TeamsTemplate
|
||||
teams={teams}
|
||||
searchQuery={searchQuery}
|
||||
showCreateForm={showCreateForm}
|
||||
onSearchChange={handleSearchChange}
|
||||
onShowCreateForm={handleShowCreateForm}
|
||||
onHideCreateForm={handleHideCreateForm}
|
||||
viewData={viewData}
|
||||
onTeamClick={handleTeamClick}
|
||||
onCreateSuccess={handleCreateSuccess}
|
||||
onBrowseTeams={handleBrowseTeams}
|
||||
onSkillLevelClick={handleSkillLevelClick}
|
||||
onViewFullLeaderboard={handleViewFullLeaderboard}
|
||||
onCreateTeam={handleCreateTeam}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { TeamsPageQuery } from '@/lib/page-queries/page-queries/TeamsPageQuery';
|
||||
import { TeamsViewDataBuilder } from '@/lib/builders/view-data/TeamsViewDataBuilder';
|
||||
import { TeamsPageClient } from './TeamsPageClient';
|
||||
|
||||
export default async function Page() {
|
||||
const result = await TeamsPageQuery.execute();
|
||||
const query = new TeamsPageQuery();
|
||||
const result = await query.execute();
|
||||
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
const viewData = TeamsViewDataBuilder.build(result.dto);
|
||||
return <TeamsPageClient teams={viewData.teams} />;
|
||||
case 'notFound':
|
||||
notFound();
|
||||
case 'redirect':
|
||||
// This would typically use redirect() from next/navigation
|
||||
// but we need to handle it at the page level
|
||||
return null;
|
||||
case 'error':
|
||||
// For now, treat errors as not found
|
||||
// In production, you might want a proper error page
|
||||
notFound();
|
||||
if (result.isErr()) {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
const viewData = result.unwrap();
|
||||
return <TeamsPageClient viewData={viewData} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Search, Star, Trophy, Percent, Hash } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Button from '@/ui/Button';
|
||||
import Input from '@/ui/Input';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
||||
|
||||
@@ -24,7 +24,7 @@ export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapper
|
||||
|
||||
return (
|
||||
<AdminDashboardTemplate
|
||||
adminDashboardViewData={initialViewData}
|
||||
viewData={initialViewData}
|
||||
onRefresh={handleRefresh}
|
||||
isLoading={loading}
|
||||
/>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
||||
|
||||
return (
|
||||
<AdminUsersTemplate
|
||||
adminUsersViewData={initialViewData}
|
||||
viewData={initialViewData}
|
||||
onRefresh={handleRefresh}
|
||||
onSearch={handleSearch}
|
||||
onFilterRole={handleFilterRole}
|
||||
|
||||
92
apps/website/components/admin/UserFilters.tsx
Normal file
92
apps/website/components/admin/UserFilters.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Filter, Search } from 'lucide-react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Select } from '@/ui/Select';
|
||||
|
||||
interface UserFiltersProps {
|
||||
search: string;
|
||||
roleFilter: string;
|
||||
statusFilter: string;
|
||||
onSearch: (search: string) => void;
|
||||
onFilterRole: (role: string) => void;
|
||||
onFilterStatus: (status: string) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
export function UserFilters({
|
||||
search,
|
||||
roleFilter,
|
||||
statusFilter,
|
||||
onSearch,
|
||||
onFilterRole,
|
||||
onFilterStatus,
|
||||
onClearFilters,
|
||||
}: UserFiltersProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Filter} size={4} color="#9ca3af" />
|
||||
<Text weight="medium" color="text-white">Filters</Text>
|
||||
</Stack>
|
||||
{(search || roleFilter || statusFilter) && (
|
||||
<Button
|
||||
onClick={onClearFilters}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Grid cols={3} gap={4}>
|
||||
<Box position="relative">
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
|
||||
<Icon icon={Search} size={4} color="#9ca3af" />
|
||||
</Box>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by email or name..."
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
style={{ paddingLeft: '2.25rem' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Select
|
||||
value={roleFilter}
|
||||
onChange={(e) => onFilterRole(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'All Roles' },
|
||||
{ value: 'owner', label: 'Owner' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'user', label: 'User' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => onFilterStatus(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'All Status' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'deleted', label: 'Deleted' },
|
||||
]}
|
||||
/>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
51
apps/website/components/admin/UserStatsSummary.tsx
Normal file
51
apps/website/components/admin/UserStatsSummary.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Users, Shield } from 'lucide-react';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface UserStatsSummaryProps {
|
||||
total: number;
|
||||
activeCount: number;
|
||||
adminCount: number;
|
||||
}
|
||||
|
||||
export function UserStatsSummary({ total, activeCount, adminCount }: UserStatsSummaryProps) {
|
||||
return (
|
||||
<Grid cols={3} gap={4}>
|
||||
<Surface variant="muted" rounded="xl" border padding={4} style={{ background: 'linear-gradient(to bottom right, rgba(30, 58, 138, 0.2), rgba(29, 78, 216, 0.1))', borderColor: 'rgba(59, 130, 246, 0.2)' }}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Box>
|
||||
<Text size="sm" color="text-gray-400" block mb={1}>Total Users</Text>
|
||||
<Text size="2xl" weight="bold" color="text-white">{total}</Text>
|
||||
</Box>
|
||||
<Icon icon={Users} size={6} color="#60a5fa" />
|
||||
</Stack>
|
||||
</Surface>
|
||||
<Surface variant="muted" rounded="xl" border padding={4} style={{ background: 'linear-gradient(to bottom right, rgba(20, 83, 45, 0.2), rgba(21, 128, 61, 0.1))', borderColor: 'rgba(16, 185, 129, 0.2)' }}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Box>
|
||||
<Text size="sm" color="text-gray-400" block mb={1}>Active</Text>
|
||||
<Text size="2xl" weight="bold" color="text-white">{activeCount}</Text>
|
||||
</Box>
|
||||
<Text color="text-performance-green" weight="bold">✓</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
<Surface variant="muted" rounded="xl" border padding={4} style={{ background: 'linear-gradient(to bottom right, rgba(88, 28, 135, 0.2), rgba(126, 34, 206, 0.1))', borderColor: 'rgba(168, 85, 247, 0.2)' }}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Box>
|
||||
<Text size="sm" color="text-gray-400" block mb={1}>Admins</Text>
|
||||
<Text size="2xl" weight="bold" color="text-white">{adminCount}</Text>
|
||||
</Box>
|
||||
<Icon icon={Shield} size={6} color="#a855f7" />
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
62
apps/website/components/dashboard/ActivityFeed.tsx
Normal file
62
apps/website/components/dashboard/ActivityFeed.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
56
apps/website/components/dashboard/ChampionshipStandings.tsx
Normal file
56
apps/website/components/dashboard/ChampionshipStandings.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
101
apps/website/components/dashboard/DashboardHero.tsx
Normal file
101
apps/website/components/dashboard/DashboardHero.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
83
apps/website/components/dashboard/FriendsSidebar.tsx
Normal file
83
apps/website/components/dashboard/FriendsSidebar.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
74
apps/website/components/dashboard/NextRaceCard.tsx
Normal file
74
apps/website/components/dashboard/NextRaceCard.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
33
apps/website/components/dashboard/StatBox.tsx
Normal file
33
apps/website/components/dashboard/StatBox.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Trophy, Medal, Target, Users, LucideIcon } from 'lucide-react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface StatBoxProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string | number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function StatBox({ icon, label, value, color }: StatBoxProps) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="xl" border padding={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', borderColor: '#262626', backdropFilter: 'blur(4px)' }}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${color}20`, color }}>
|
||||
<Icon icon={icon} size={5} />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Text size="2xl" weight="bold" color="text-white" block>{value}</Text>
|
||||
<Text size="xs" color="text-gray-500" block>{label}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
68
apps/website/components/dashboard/UpcomingRaces.tsx
Normal file
68
apps/website/components/dashboard/UpcomingRaces.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'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,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
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' },
|
||||
@@ -13,7 +11,9 @@ const CATEGORIES = [
|
||||
];
|
||||
|
||||
interface CategoryDistributionProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
drivers: {
|
||||
category?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function CategoryDistribution({ drivers }: CategoryDistributionProps) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
import RankBadge from '@/components/drivers/RankBadge';
|
||||
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import PlaceholderImage from '@/components/ui/PlaceholderImage';
|
||||
import PlaceholderImage from '@/ui/PlaceholderImage';
|
||||
|
||||
export interface DriverIdentityProps {
|
||||
driver: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
export interface DriverRanking {
|
||||
type: 'overall' | 'league';
|
||||
|
||||
87
apps/website/components/drivers/DriversHero.tsx
Normal file
87
apps/website/components/drivers/DriversHero.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Users, Trophy, LucideIcon } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { DecorativeBlur } from '@/ui/DecorativeBlur';
|
||||
|
||||
interface StatItemProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color: string;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
function StatItem({ label, value, color, animate }: StatItemProps) {
|
||||
return (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Box style={{ width: '0.5rem', height: '0.5rem', borderRadius: '9999px', backgroundColor: color }} className={animate ? 'animate-pulse' : ''} />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
<Text weight="semibold" color="text-white">{value}</Text> {label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface DriversHeroProps {
|
||||
driverCount: number;
|
||||
activeCount: number;
|
||||
totalWins: number;
|
||||
totalRaces: number;
|
||||
onViewLeaderboard: () => void;
|
||||
}
|
||||
|
||||
export function DriversHero({
|
||||
driverCount,
|
||||
activeCount,
|
||||
totalWins,
|
||||
totalRaces,
|
||||
onViewLeaderboard,
|
||||
}: DriversHeroProps) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="2xl" border padding={8} style={{ position: 'relative', overflow: 'hidden', background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.2), rgba(38, 38, 38, 0.8), #0f1115)', borderColor: 'rgba(59, 130, 246, 0.3)' }}>
|
||||
<DecorativeBlur color="blue" size="lg" position="top-right" opacity={10} />
|
||||
<DecorativeBlur color="yellow" size="md" position="bottom-left" opacity={5} />
|
||||
|
||||
<Stack direction="row" align="center" justify="between" wrap gap={8} style={{ position: 'relative', zIndex: 10 }}>
|
||||
<Box style={{ maxWidth: '42rem' }}>
|
||||
<Stack direction="row" align="center" gap={3} mb={4}>
|
||||
<Surface variant="muted" rounded="xl" padding={3} style={{ background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.05))', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
||||
<Icon icon={Users} size={6} color="#3b82f6" />
|
||||
</Surface>
|
||||
<Heading level={1}>Drivers</Heading>
|
||||
</Stack>
|
||||
<Text size="lg" color="text-gray-400" block mb={6} style={{ lineHeight: 1.625 }}>
|
||||
Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid.
|
||||
</Text>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Stack direction="row" gap={6} wrap>
|
||||
<StatItem label="drivers" value={driverCount} color="#3b82f6" />
|
||||
<StatItem label="active" value={activeCount} color="#10b981" animate />
|
||||
<StatItem label="total wins" value={totalWins.toLocaleString()} color="#f59e0b" />
|
||||
<StatItem label="races" value={totalRaces.toLocaleString()} color="#00f2ff" />
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* CTA */}
|
||||
<Stack align="center" gap={4}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onViewLeaderboard}
|
||||
icon={<Icon icon={Trophy} size={5} />}
|
||||
>
|
||||
View Leaderboard
|
||||
</Button>
|
||||
<Text size="xs" color="text-gray-500">See full driver rankings</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
30
apps/website/components/drivers/DriversSearch.tsx
Normal file
30
apps/website/components/drivers/DriversSearch.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Input } from '@/ui/Input';
|
||||
|
||||
interface DriversSearchProps {
|
||||
query: string;
|
||||
onChange: (query: string) => void;
|
||||
}
|
||||
|
||||
export function DriversSearch({ query, onChange }: DriversSearchProps) {
|
||||
return (
|
||||
<Box mb={8}>
|
||||
<Box style={{ position: 'relative', maxWidth: '28rem' }}>
|
||||
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
|
||||
<Search style={{ width: '1.25rem', height: '1.25rem', color: '#6b7280' }} />
|
||||
</Box>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search drivers by name or nationality..."
|
||||
value={query}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ paddingLeft: '2.75rem' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
import { Trophy, Crown, Star, TrendingUp, Shield, Flag } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
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' },
|
||||
@@ -22,7 +20,17 @@ const CATEGORIES = [
|
||||
];
|
||||
|
||||
interface FeaturedDriverCardProps {
|
||||
driver: DriverLeaderboardItemViewModel;
|
||||
driver: {
|
||||
id: string;
|
||||
name: string;
|
||||
nationality: string;
|
||||
avatarUrl?: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
skillLevel?: string;
|
||||
category?: string;
|
||||
};
|
||||
position: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Heading from '@/ui/Heading';
|
||||
import { Trophy, Users } from 'lucide-react';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Award, Crown, Flag, ChevronRight } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Button from '@/ui/Button';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
const SKILL_LEVELS = [
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
|
||||
@@ -24,7 +22,16 @@ const CATEGORIES = [
|
||||
];
|
||||
|
||||
interface LeaderboardPreviewProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
drivers: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
nationality: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
skillLevel?: string;
|
||||
category?: string;
|
||||
}[];
|
||||
onDriverClick: (id: string) => void;
|
||||
}
|
||||
|
||||
|
||||
94
apps/website/components/drivers/RankingsPodium.tsx
Normal file
94
apps/website/components/drivers/RankingsPodium.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
122
apps/website/components/drivers/RankingsTable.tsx
Normal file
122
apps/website/components/drivers/RankingsTable.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
import { Activity } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
const SKILL_LEVELS = [
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
|
||||
@@ -22,7 +20,14 @@ const CATEGORIES = [
|
||||
];
|
||||
|
||||
interface RecentActivityProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
drivers: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
isActive: boolean;
|
||||
skillLevel?: string;
|
||||
category?: string;
|
||||
}[];
|
||||
onDriverClick: (id: string) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||
|
||||
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' },
|
||||
@@ -11,7 +9,9 @@ const SKILL_LEVELS = [
|
||||
];
|
||||
|
||||
interface SkillDistributionProps {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
drivers: {
|
||||
skillLevel?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function SkillDistribution({ drivers }: SkillDistributionProps) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
import Button from '@/ui/Button';
|
||||
|
||||
export default function FeedEmptyState() {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
import Button from '@/ui/Button';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface FeedItemData {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
import FeedList from '@/components/feed/FeedList';
|
||||
import UpcomingRacesSidebar from '@/components/races/UpcomingRacesSidebar';
|
||||
import LatestResultsSidebar from '@/components/races/LatestResultsSidebar';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Container from '@/ui/Container';
|
||||
import Heading from '@/ui/Heading';
|
||||
import { useParallax } from "@/lib/hooks/useScrollProgress";
|
||||
import { useRef } from 'react';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Button from '@/ui/Button';
|
||||
|
||||
export default function DiscordCTA() {
|
||||
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import Section from '@/components/ui/Section';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import MockupStack from '@/components/ui/MockupStack';
|
||||
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';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
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 || '#';
|
||||
|
||||
54
apps/website/components/landing/LandingItems.tsx
Normal file
54
apps/website/components/landing/LandingItems.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'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,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Button from '@/ui/Button';
|
||||
import Image from 'next/image';
|
||||
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
|
||||
59
apps/website/components/leaderboards/LeaderboardsHero.tsx
Normal file
59
apps/website/components/leaderboards/LeaderboardsHero.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Award, Trophy, Users } 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 { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { DecorativeBlur } from '@/ui/DecorativeBlur';
|
||||
|
||||
interface LeaderboardsHeroProps {
|
||||
onNavigateToDrivers: () => void;
|
||||
onNavigateToTeams: () => void;
|
||||
}
|
||||
|
||||
export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: LeaderboardsHeroProps) {
|
||||
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)' }}>
|
||||
<DecorativeBlur color="yellow" size="lg" position="top-right" opacity={10} />
|
||||
<DecorativeBlur color="blue" size="md" position="bottom-left" opacity={5} />
|
||||
|
||||
<Box style={{ position: 'relative', zIndex: 10 }}>
|
||||
<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)' }}>
|
||||
<Icon icon={Award} size={7} color="#facc15" />
|
||||
</Surface>
|
||||
<Box>
|
||||
<Heading level={1}>Leaderboards</Heading>
|
||||
<Text color="text-gray-400" block mt={1}>Where champions rise and legends are made</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Text size="lg" color="text-gray-400" block mb={6} style={{ lineHeight: 1.625, maxWidth: '42rem' }}>
|
||||
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
|
||||
</Text>
|
||||
|
||||
<Stack direction="row" gap={3} wrap>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onNavigateToDrivers}
|
||||
icon={<Icon icon={Trophy} size={4} color="#3b82f6" />}
|
||||
>
|
||||
Driver Rankings
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onNavigateToTeams}
|
||||
icon={<Icon icon={Users} size={4} color="#a855f7" />}
|
||||
>
|
||||
Team Rankings
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Users, Crown, Shield, ChevronRight } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Button from '@/ui/Button';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
interface BonusPointsCardProps {
|
||||
bonusSummary: string[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
import type { LeagueScoringChampionshipViewModel } from '@/lib/view-models/LeagueScoringChampionshipViewModel';
|
||||
|
||||
type PointsPreviewRow = {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Button from '@/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
import Heading from '@/ui/Heading';
|
||||
import Input from '@/ui/Input';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import {
|
||||
AlertCircle,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
interface DropRulesExplanationProps {
|
||||
dropPolicyDescription: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Trophy, Sparkles, Search } from 'lucide-react';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Heading from '@/ui/Heading';
|
||||
import Button from '@/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { AlertTriangle, TestTube, CheckCircle2 } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Button from '@/ui/Button';
|
||||
|
||||
interface EndRaceModalProps {
|
||||
raceId: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Trophy, Plus } from 'lucide-react';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/ui/Heading';
|
||||
import Button from '@/ui/Button';
|
||||
|
||||
interface StatItem {
|
||||
value: number;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Input from '@/ui/Input';
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
|
||||
interface LeagueBasicsSectionProps {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
|
||||
import PlaceholderImage from '@/components/ui/PlaceholderImage';
|
||||
import PlaceholderImage from '@/ui/PlaceholderImage';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
|
||||
interface LeagueCardProps {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface LeagueChampionshipStatsProps {
|
||||
standings: Array<{
|
||||
@@ -21,45 +28,45 @@ export function LeagueChampionshipStats({ standings, drivers }: LeagueChampionsh
|
||||
const totalRaces = Math.max(...standings.map(s => s.racesFinished), 0);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Grid cols={3} gap={4}>
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-yellow-500/10 flex items-center justify-center">
|
||||
<span className="text-2xl">🏆</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Championship Leader</p>
|
||||
<p className="font-bold text-white">{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}</p>
|
||||
<p className="text-sm text-yellow-400 font-medium">{leader?.totalPoints || 0} points</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="full" padding={3} style={{ backgroundColor: 'rgba(250, 204, 21, 0.1)' }}>
|
||||
<Text size="2xl">🏆</Text>
|
||||
</Surface>
|
||||
<Box>
|
||||
<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>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-blue/10 flex items-center justify-center">
|
||||
<span className="text-2xl">🏁</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Races Completed</p>
|
||||
<p className="text-2xl font-bold text-white">{totalRaces}</p>
|
||||
<p className="text-sm text-gray-400">Season in progress</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="full" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
|
||||
<Text size="2xl">🏁</Text>
|
||||
</Surface>
|
||||
<Box>
|
||||
<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>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<span className="text-2xl">👥</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Active Drivers</p>
|
||||
<p className="text-2xl font-bold text-white">{standings.length}</p>
|
||||
<p className="text-sm text-gray-400">Competing for points</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Surface variant="muted" rounded="full" padding={3} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
|
||||
<Text size="2xl">👥</Text>
|
||||
</Surface>
|
||||
<Box>
|
||||
<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>
|
||||
</div>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
import Button from '@/ui/Button';
|
||||
import {
|
||||
Move,
|
||||
RotateCw,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Button from '@/ui/Button';
|
||||
import { UserCog } from 'lucide-react';
|
||||
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { User, Users2, Info, Check, HelpCircle, X } from 'lucide-react';
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import type * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Input from '@/ui/Input';
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
|
||||
// ============================================================================
|
||||
|
||||
70
apps/website/components/leagues/LeagueSummaryCard.tsx
Normal file
70
apps/website/components/leagues/LeagueSummaryCard.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
36
apps/website/components/leagues/LeagueTabs.tsx
Normal file
36
apps/website/components/leagues/LeagueTabs.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Link } from '@/ui/Link';
|
||||
|
||||
interface Tab {
|
||||
label: string;
|
||||
href: string;
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
interface LeagueTabsProps {
|
||||
tabs: Tab[];
|
||||
}
|
||||
|
||||
export function LeagueTabs({ tabs }: LeagueTabsProps) {
|
||||
return (
|
||||
<Box style={{ borderBottom: '1px solid #262626' }}>
|
||||
<Stack direction="row" gap={6} style={{ overflowX: 'auto' }}>
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
variant="ghost"
|
||||
>
|
||||
<Box pb={3} px={1}>
|
||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap' }}>{tab.label}</span>
|
||||
</Box>
|
||||
</Link>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
import type { Weekday } from '@/lib/types/Weekday';
|
||||
import Input from '@/components/ui/Input';
|
||||
import RangeField from '@/components/ui/RangeField';
|
||||
import Input from '@/ui/Input';
|
||||
import RangeField from '@/ui/RangeField';
|
||||
|
||||
// Common time zones for racing leagues
|
||||
const TIME_ZONES = [
|
||||
|
||||
@@ -19,10 +19,10 @@ import {
|
||||
Timer,
|
||||
} from 'lucide-react';
|
||||
import LeagueCard from '@/components/leagues/LeagueCard';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import Button from '@/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
import Input from '@/ui/Input';
|
||||
import Heading from '@/ui/Heading';
|
||||
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/ui/Button';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
interface NoResultsStateProps {
|
||||
icon?: React.ElementType;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
interface PointsBreakdownTableProps {
|
||||
positionPoints: Array<{ position: number; points: number }>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
interface PointsTableProps {
|
||||
title?: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Button from '@/ui/Button';
|
||||
import { usePenaltyMutation } from "@/lib/hooks/league/usePenaltyMutation";
|
||||
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
|
||||
|
||||
|
||||
40
apps/website/components/leagues/RulebookTabs.tsx
Normal file
40
apps/website/components/leagues/RulebookTabs.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
export type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties';
|
||||
|
||||
interface RulebookTabsProps {
|
||||
activeSection: RulebookSection;
|
||||
onSectionChange: (section: RulebookSection) => void;
|
||||
}
|
||||
|
||||
export function RulebookTabs({ activeSection, onSectionChange }: RulebookTabsProps) {
|
||||
const sections: { id: RulebookSection; label: string }[] = [
|
||||
{ id: 'scoring', label: 'Scoring' },
|
||||
{ id: 'conduct', label: 'Conduct' },
|
||||
{ id: 'protests', label: 'Protests' },
|
||||
{ id: 'penalties', label: 'Penalties' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: '#0f1115', border: '1px solid #262626' }}>
|
||||
<Box style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
{sections.map((section) => (
|
||||
<Button
|
||||
key={section.id}
|
||||
variant={activeSection === section.id ? 'secondary' : 'ghost'}
|
||||
onClick={() => onSectionChange(section.id)}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
{section.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
77
apps/website/components/leagues/ScheduleRaceCard.tsx
Normal file
77
apps/website/components/leagues/ScheduleRaceCard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Calendar, Clock, MapPin, Car, Trophy } from 'lucide-react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface Race {
|
||||
id: string;
|
||||
name: string;
|
||||
track?: string;
|
||||
car?: string;
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
sessionType?: string;
|
||||
isPast?: boolean;
|
||||
}
|
||||
|
||||
interface ScheduleRaceCardProps {
|
||||
race: Race;
|
||||
}
|
||||
|
||||
export function ScheduleRaceCard({ race }: ScheduleRaceCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box style={{ width: '0.75rem', height: '0.75rem', borderRadius: '9999px', backgroundColor: race.isPast ? '#10b981' : '#3b82f6' }} />
|
||||
<Heading level={3} style={{ fontSize: '1.125rem' }}>{race.name}</Heading>
|
||||
<Badge variant={race.status === 'completed' ? 'success' : 'primary'}>
|
||||
{race.status === 'completed' ? 'Completed' : 'Scheduled'}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Grid cols={4} gap={4}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Calendar} size={4} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-300">{new Date(race.scheduledAt).toLocaleDateString()}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Clock} size={4} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-300">{new Date(race.scheduledAt).toLocaleTimeString()}</Text>
|
||||
</Stack>
|
||||
|
||||
{race.track && (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={MapPin} size={4} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-300" truncate>{race.track}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{race.car && (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Car} size={4} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-300" truncate>{race.car}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{race.sessionType && (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Trophy} size={4} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-400">{race.sessionType} Session</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
interface ScoringOverviewCardProps {
|
||||
gameName: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Search, Filter } from 'lucide-react';
|
||||
import Input from '@/components/ui/Input';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/ui/Input';
|
||||
import Button from '@/ui/Button';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
|
||||
75
apps/website/components/leagues/SponsorshipRequestCard.tsx
Normal file
75
apps/website/components/leagues/SponsorshipRequestCard.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface SponsorshipRequest {
|
||||
id: string;
|
||||
sponsorName: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
requestedAt: string;
|
||||
slotName: string;
|
||||
}
|
||||
|
||||
interface SponsorshipRequestCardProps {
|
||||
request: SponsorshipRequest;
|
||||
}
|
||||
|
||||
export function SponsorshipRequestCard({ request }: SponsorshipRequestCardProps) {
|
||||
const statusVariant = {
|
||||
pending: 'warning' as const,
|
||||
approved: 'success' as const,
|
||||
rejected: 'danger' as const,
|
||||
}[request.status];
|
||||
|
||||
const statusIcon = {
|
||||
pending: AlertCircle,
|
||||
approved: CheckCircle,
|
||||
rejected: XCircle,
|
||||
}[request.status];
|
||||
|
||||
const statusColor = {
|
||||
pending: '#f59e0b',
|
||||
approved: '#10b981',
|
||||
rejected: '#ef4444',
|
||||
}[request.status];
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={4}
|
||||
style={{
|
||||
backgroundColor: `${statusColor}0D`,
|
||||
borderColor: statusColor
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" align="start" justify="between">
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" align="center" gap={3} mb={2}>
|
||||
<Icon icon={statusIcon} size={5} color={statusColor} />
|
||||
<Text weight="semibold" color="text-white">{request.sponsorName}</Text>
|
||||
<Badge variant={statusVariant}>
|
||||
{request.status}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Text size="sm" color="text-gray-300" block mb={2}>
|
||||
Requested: {request.slotName}
|
||||
</Text>
|
||||
|
||||
<Text size="xs" color="text-gray-400" block>
|
||||
{new Date(request.requestedAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
67
apps/website/components/leagues/SponsorshipSlotCard.tsx
Normal file
67
apps/website/components/leagues/SponsorshipSlotCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { DollarSign } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface SponsorshipSlot {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
isAvailable: boolean;
|
||||
sponsoredBy?: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SponsorshipSlotCardProps {
|
||||
slot: SponsorshipSlot;
|
||||
}
|
||||
|
||||
export function SponsorshipSlotCard({ slot }: SponsorshipSlotCardProps) {
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={4}
|
||||
style={{
|
||||
backgroundColor: slot.isAvailable ? 'rgba(16, 185, 129, 0.05)' : 'rgba(38, 38, 38, 0.3)',
|
||||
borderColor: slot.isAvailable ? '#10b981' : '#262626'
|
||||
}}
|
||||
>
|
||||
<Stack gap={3}>
|
||||
<Stack direction="row" align="start" justify="between">
|
||||
<Heading level={4}>{slot.name}</Heading>
|
||||
<Badge variant={slot.isAvailable ? 'success' : 'default'}>
|
||||
{slot.isAvailable ? 'Available' : 'Taken'}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Text size="sm" color="text-gray-300" block>{slot.description}</Text>
|
||||
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={DollarSign} size={4} color="#9ca3af" />
|
||||
<Text weight="semibold" color="text-white">
|
||||
{slot.price} {slot.currency}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{!slot.isAvailable && slot.sponsoredBy && (
|
||||
<Box pt={3} style={{ borderTop: '1px solid #262626' }}>
|
||||
<Text size="xs" color="text-gray-400" block mb={1}>Sponsored by</Text>
|
||||
<Text size="sm" weight="medium" color="text-white">{slot.sponsoredBy.name}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import CountryFlag from '@/components/ui/CountryFlag';
|
||||
import PlaceholderImage from '@/components/ui/PlaceholderImage';
|
||||
import CountryFlag from '@/ui/CountryFlag';
|
||||
import PlaceholderImage from '@/ui/PlaceholderImage';
|
||||
|
||||
// League role display data
|
||||
const leagueRoleDisplay = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user