website refactor

This commit is contained in:
2026-01-14 10:51:05 +01:00
parent 4522d41aef
commit 0d89ad027e
291 changed files with 6887 additions and 3685 deletions

View File

@@ -1,4 +1,12 @@
import Card from '@/components/ui/Card';
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
import { routes } from '@/lib/routing/RouteConfig';
import { Layout } from '@/ui/Layout';
import { Card } from '@/ui/Card';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { StatCard } from '@/ui/StatCard';
import { QuickActionLink } from '@/ui/QuickActionLink';
import { StatusBadge } from '@/ui/StatusBadge';
import {
Users,
Shield,
@@ -6,7 +14,6 @@ import {
Clock,
RefreshCw
} from 'lucide-react';
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
/**
* AdminDashboardTemplate
@@ -22,106 +29,114 @@ export function AdminDashboardTemplate(props: {
const { adminDashboardViewData: viewData, onRefresh, isLoading } = props;
return (
<div className="container mx-auto p-6 space-y-6">
<Layout padding="p-6" gap="gap-6" className="container mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<Layout flex flexCol={false} items="center" justify="between">
<div>
<h1 className="text-2xl font-bold text-white">Admin Dashboard</h1>
<p className="text-gray-400 mt-1">System overview and statistics</p>
<Text size="2xl" weight="bold" color="text-white">
Admin Dashboard
</Text>
<Text size="sm" color="text-gray-400" className="mt-1">
System overview and statistics
</Text>
</div>
<button
<Button
onClick={onRefresh}
disabled={isLoading}
className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:bg-iron-gray/80 transition-colors flex items-center gap-2 disabled:opacity-50"
variant="secondary"
className="flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</Button>
</Layout>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Total Users</div>
<div className="text-3xl font-bold text-white">{viewData.stats.totalUsers}</div>
</div>
<Users className="w-8 h-8 text-blue-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Admins</div>
<div className="text-3xl font-bold text-white">{viewData.stats.systemAdmins}</div>
</div>
<Shield className="w-8 h-8 text-purple-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Active Users</div>
<div className="text-3xl font-bold text-white">{viewData.stats.activeUsers}</div>
</div>
<Activity className="w-8 h-8 text-green-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-orange-900/20 to-orange-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Recent Logins</div>
<div className="text-3xl font-bold text-white">{viewData.stats.recentLogins}</div>
</div>
<Clock className="w-8 h-8 text-orange-400" />
</div>
</Card>
</div>
<Layout grid gridCols={4} gap="gap-4">
<StatCard
label="Total Users"
value={viewData.stats.totalUsers}
icon={<Users className="w-8 h-8" />}
variant="blue"
/>
<StatCard
label="Admins"
value={viewData.stats.systemAdmins}
icon={<Shield className="w-8 h-8" />}
variant="purple"
/>
<StatCard
label="Active Users"
value={viewData.stats.activeUsers}
icon={<Activity className="w-8 h-8" />}
variant="green"
/>
<StatCard
label="Recent Logins"
value={viewData.stats.recentLogins}
icon={<Clock className="w-8 h-8" />}
variant="orange"
/>
</Layout>
{/* System Status */}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">System Status</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">System Health</span>
<span className="px-2 py-1 text-xs rounded-full bg-performance-green/20 text-performance-green">
<Text size="lg" weight="semibold" color="text-white" className="mb-4">
System Status
</Text>
<Layout flex flexCol gap="gap-4">
<Layout flex flexCol={false} items="center" justify="between">
<Text size="sm" color="text-gray-400">
System Health
</Text>
<StatusBadge variant="success">
Healthy
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Suspended Users</span>
<span className="text-white font-medium">{viewData.stats.suspendedUsers}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Deleted Users</span>
<span className="text-white font-medium">{viewData.stats.deletedUsers}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">New Users Today</span>
<span className="text-white font-medium">{viewData.stats.newUsersToday}</span>
</div>
</div>
</StatusBadge>
</Layout>
<Layout flex flexCol={false} items="center" justify="between">
<Text size="sm" color="text-gray-400">
Suspended Users
</Text>
<Text size="base" weight="medium" color="text-white">
{viewData.stats.suspendedUsers}
</Text>
</Layout>
<Layout flex flexCol={false} items="center" justify="between">
<Text size="sm" color="text-gray-400">
Deleted Users
</Text>
<Text size="base" weight="medium" color="text-white">
{viewData.stats.deletedUsers}
</Text>
</Layout>
<Layout flex flexCol={false} items="center" justify="between">
<Text size="sm" color="text-gray-400">
New Users Today
</Text>
<Text size="base" weight="medium" color="text-white">
{viewData.stats.newUsersToday}
</Text>
</Layout>
</Layout>
</Card>
{/* Quick Actions */}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Quick Actions</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<a href="/admin/users" className="px-4 py-3 bg-primary-blue/20 border border-primary-blue/30 text-primary-blue rounded-lg hover:bg-primary-blue/30 transition-colors text-sm font-medium text-center">
<Text size="lg" weight="semibold" color="text-white" className="mb-4">
Quick Actions
</Text>
<Layout grid gridCols={3} gap="gap-3">
<QuickActionLink href={routes.admin.users} variant="blue">
View All Users
</a>
<a href="/admin" className="px-4 py-3 bg-purple-500/20 border border-purple-500/30 text-purple-300 rounded-lg hover:bg-purple-500/30 transition-colors text-sm font-medium text-center">
</QuickActionLink>
<QuickActionLink href="/admin" variant="purple">
Manage Admins
</a>
<a href="/admin" className="px-4 py-3 bg-orange-500/20 border border-orange-500/30 text-orange-300 rounded-lg hover:bg-orange-500/30 transition-colors text-sm font-medium text-center">
</QuickActionLink>
<QuickActionLink href="/admin" variant="orange">
View Audit Log
</a>
</div>
</QuickActionLink>
</Layout>
</Card>
</div>
</Layout>
);
}

View File

@@ -1,6 +1,5 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Trophy,
@@ -17,7 +16,7 @@ import { SkillDistribution } from '@/components/drivers/SkillDistribution';
import { CategoryDistribution } from '@/components/drivers/CategoryDistribution';
import { LeaderboardPreview } from '@/components/drivers/LeaderboardPreview';
import { RecentActivity } from '@/components/drivers/RecentActivity';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import { useDriverSearch } from '@/lib/hooks/useDriverSearch';
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
interface DriversTemplateProps {
@@ -32,21 +31,12 @@ export function DriversTemplate({ data }: DriversTemplateProps) {
const isLoading = false;
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const { searchQuery, setSearchQuery, filteredDrivers } = useDriverSearch(drivers);
const handleDriverClick = (driverId: string) => {
router.push(`/drivers/${driverId}`);
};
// Filter by search
const filteredDrivers = drivers.filter((driver) => {
if (!searchQuery) return true;
return (
driver.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
driver.nationality.toLowerCase().includes(searchQuery.toLowerCase())
);
});
// Featured drivers (top 4)
const featuredDrivers = filteredDrivers.slice(0, 4);

View File

@@ -4,8 +4,8 @@ import React from 'react';
import { Trophy, Users, Award } from 'lucide-react';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import DriverLeaderboardPreview from '@/components/leaderboards/DriverLeaderboardPreview';
import TeamLeaderboardPreview from '@/components/leaderboards/TeamLeaderboardPreview';
import { DriverLeaderboardPreview } from '@/components/leaderboards/DriverLeaderboardPreview';
import { TeamLeaderboardPreview } from '@/components/leaderboards/TeamLeaderboardPreview';
// ============================================================================
// TYPES
@@ -43,7 +43,7 @@ interface LeaderboardsTemplateProps {
// MAIN TEMPLATE COMPONENT
// ============================================================================
export default function LeaderboardsTemplate({
export function LeaderboardsTemplate({
drivers,
teams,
onDriverClick,
@@ -53,9 +53,7 @@ export default function LeaderboardsTemplate({
}: LeaderboardsTemplateProps) {
return (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Hero Section */}
<div className="relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-yellow-600/20 via-iron-gray/80 to-deep-graphite border border-yellow-500/20 overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 w-96 h-96 bg-yellow-400/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-amber-600/5 rounded-full blur-3xl" />
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-purple-500/5 rounded-full blur-2xl" />
@@ -77,7 +75,6 @@ export default function LeaderboardsTemplate({
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
</p>
{/* Quick Nav */}
<div className="flex flex-wrap gap-3">
<Button
variant="secondary"
@@ -99,10 +96,9 @@ export default function LeaderboardsTemplate({
</div>
</div>
{/* Leaderboard Grids */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<DriverLeaderboardPreview drivers={drivers} onDriverClick={onDriverClick} />
<TeamLeaderboardPreview teams={teams} onTeamClick={onTeamClick} />
<DriverLeaderboardPreview drivers={drivers} onDriverClick={onDriverClick} onNavigateToDrivers={onNavigateToDrivers} />
<TeamLeaderboardPreview teams={teams} onTeamClick={onTeamClick} onNavigateToTeams={onNavigateToTeams} />
</div>
</div>
);

View File

@@ -1,498 +1,68 @@
'use client';
import { Section } from '@/ui/Section';
import { Layout } from '@/ui/Layout';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed';
import SponsorInsightsCard from '@/components/sponsors/SponsorInsightsCard';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import type { DriverSummaryData, LeagueDetailViewData, LeagueInfoData, LiveRaceData, SponsorInfo } from '@/lib/view-data/LeagueDetailViewData';
import { Calendar, ExternalLink, Star, Trophy, Users } from 'lucide-react';
import Image from 'next/image';
import { ReactNode } from 'react';
// ============================================================================
// TYPES
// ============================================================================
interface Tab {
label: string;
href: string;
exact?: boolean;
}
interface LeagueDetailTemplateProps {
viewData: LeagueDetailViewData;
leagueId: string;
isSponsor: boolean;
membership: { role: string } | null;
onMembershipChange: () => void;
onEndRaceModalOpen: (raceId: string) => void;
onLiveRaceClick: (raceId: string) => void;
children?: ReactNode;
leagueName: string;
leagueDescription: string;
tabs: Tab[];
children: React.ReactNode;
}
interface LiveRaceCardProps {
races: LiveRaceData[];
membership: { role: string } | null;
onLiveRaceClick: (raceId: string) => void;
onEndRaceModalOpen: (raceId: string) => void;
}
interface LeagueInfoCardProps {
info: LeagueInfoData;
}
interface SponsorsSectionProps {
sponsors: SponsorInfo[];
}
interface ManagementSectionProps {
ownerSummary: DriverSummaryData | null;
adminSummaries: DriverSummaryData[];
stewardSummaries: DriverSummaryData[];
}
// ============================================================================
// LIVE RACE CARD COMPONENT
// ============================================================================
function LiveRaceCard({ races, membership, onLiveRaceClick, onEndRaceModalOpen }: LiveRaceCardProps) {
if (races.length === 0) return null;
return (
<Card className="border-2 border-performance-green/50 bg-gradient-to-r from-performance-green/10 to-performance-green/5 mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-3 h-3 bg-performance-green rounded-full animate-pulse"></div>
<h2 className="text-xl font-bold text-white">🏁 Live Race in Progress</h2>
</div>
<div className="space-y-3">
{races.map((race) => (
<div
key={race.id}
className="p-4 rounded-lg bg-deep-graphite border border-performance-green/30"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
<div className="flex items-center gap-3">
<div className="px-3 py-1 bg-performance-green/20 border border-performance-green/40 rounded-full">
<span className="text-sm font-semibold text-performance-green">LIVE</span>
</div>
<h3 className="text-lg font-semibold text-white">
{race.name}
</h3>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="primary"
onClick={() => onLiveRaceClick(race.id)}
className="bg-performance-green hover:bg-performance-green/80 text-white"
>
View Live Race
</Button>
{membership?.role === 'admin' && (
<Button
variant="secondary"
onClick={() => onEndRaceModalOpen(race.id)}
className="border-performance-green/50 text-performance-green hover:bg-performance-green/10"
>
End Race & Process Results
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-400">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>Started {new Date(race.date).toLocaleDateString()}</span>
</div>
{race.registeredCount && (
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>{race.registeredCount} drivers registered</span>
</div>
)}
{race.strengthOfField && (
<div className="flex items-center gap-2">
<Trophy className="w-4 h-4" />
<span>SOF: {race.strengthOfField}</span>
</div>
)}
</div>
</div>
))}
</div>
</Card>
);
}
// ============================================================================
// LEAGUE INFO CARD COMPONENT
// ============================================================================
function LeagueInfoCard({ info }: LeagueInfoCardProps) {
return (
<Card>
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-white">{info.membersCount}</div>
<div className="text-xs text-gray-500">Members</div>
</div>
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-white">{info.racesCount}</div>
<div className="text-xs text-gray-500">Races</div>
</div>
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-warning-amber">{info.avgSOF ?? '—'}</div>
<div className="text-xs text-gray-500">Avg SOF</div>
</div>
</div>
{/* Details */}
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
<span className="text-gray-500">Structure</span>
<span className="text-white">{info.structure}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
<span className="text-gray-500">Scoring</span>
<span className="text-white">{info.scoring}</span>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-gray-500">Created</span>
<span className="text-white">
{new Date(info.createdAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric'
})}
</span>
</div>
</div>
{(info.discordUrl || info.youtubeUrl || info.websiteUrl) && (
<div className="mt-4 pt-4 border-t border-charcoal-outline">
<div className="flex flex-wrap gap-2">
{info.discordUrl && (
<a
href={info.discordUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-2 py-1 text-xs text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
Discord
</a>
)}
{info.youtubeUrl && (
<a
href={info.youtubeUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-2 py-1 text-xs text-red-400 hover:bg-red-500/20 transition-colors"
>
YouTube
</a>
)}
{info.websiteUrl && (
<a
href={info.websiteUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-2 py-1 text-xs text-gray-100 hover:bg-iron-gray transition-colors"
>
Website
</a>
)}
</div>
</div>
)}
</Card>
);
}
// ============================================================================
// SPONSORS SECTION COMPONENT
// ============================================================================
function SponsorsSection({ sponsors }: SponsorsSectionProps) {
if (sponsors.length === 0) return null;
return (
<Card>
<h3 className="text-lg font-semibold text-white mb-4">
{sponsors.find(s => s.tier === 'main') ? 'Presented by' : 'Sponsors'}
</h3>
<div className="space-y-3">
{/* Main Sponsor - Featured prominently */}
{sponsors.filter(s => s.tier === 'main').map(sponsor => (
<div
key={sponsor.id}
className="p-3 rounded-lg bg-gradient-to-r from-yellow-500/10 to-transparent border border-yellow-500/30"
>
<div className="flex items-center gap-3">
{sponsor.logoUrl ? (
<div className="w-12 h-12 rounded-lg bg-white flex items-center justify-center overflow-hidden">
<Image
src={sponsor.logoUrl}
alt={sponsor.name}
width={40}
height={40}
className="w-10 h-10 object-contain"
/>
</div>
) : (
<div className="w-12 h-12 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<Trophy className="w-6 h-6 text-yellow-400" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-white truncate">{sponsor.name}</span>
<span className="px-1.5 py-0.5 rounded text-[10px] bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
Main
</span>
</div>
{sponsor.tagline && (
<p className="text-xs text-gray-400 truncate mt-0.5">{sponsor.tagline}</p>
)}
</div>
{sponsor.websiteUrl && (
<a
href={sponsor.websiteUrl}
target="_blank"
rel="noreferrer"
className="p-1.5 rounded-lg bg-iron-gray hover:bg-charcoal-outline transition-colors"
>
<ExternalLink className="w-4 h-4 text-gray-400" />
</a>
)}
</div>
</div>
))}
{/* Secondary Sponsors - Smaller display */}
{sponsors.filter(s => s.tier === 'secondary').length > 0 && (
<div className="grid grid-cols-2 gap-2">
{sponsors.filter(s => s.tier === 'secondary').map(sponsor => (
<div
key={sponsor.id}
className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline"
>
<div className="flex items-center gap-2">
{sponsor.logoUrl ? (
<div className="w-8 h-8 rounded bg-white flex items-center justify-center overflow-hidden flex-shrink-0">
<Image
src={sponsor.logoUrl}
alt={sponsor.name}
width={24}
height={24}
className="w-6 h-6 object-contain"
/>
</div>
) : (
<div className="w-8 h-8 rounded bg-purple-500/20 flex items-center justify-center flex-shrink-0">
<Star className="w-4 h-4 text-purple-400" />
</div>
)}
<div className="flex-1 min-w-0">
<span className="text-sm text-white truncate block">{sponsor.name}</span>
</div>
{sponsor.websiteUrl && (
<a
href={sponsor.websiteUrl}
target="_blank"
rel="noreferrer"
className="p-1 rounded hover:bg-charcoal-outline transition-colors"
>
<ExternalLink className="w-3 h-3 text-gray-500" />
</a>
)}
</div>
</div>
))}
</div>
)}
</div>
</Card>
);
}
// ============================================================================
// MANAGEMENT SECTION COMPONENT
// ============================================================================
function ManagementSection({ ownerSummary, adminSummaries, stewardSummaries }: ManagementSectionProps) {
if (!ownerSummary && adminSummaries.length === 0 && stewardSummaries.length === 0) return null;
return (
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Management</h3>
<div className="space-y-2">
{ownerSummary && (
<div className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={{
id: ownerSummary.driverId,
name: ownerSummary.driverName,
avatarUrl: ownerSummary.avatarUrl,
}}
href={ownerSummary.profileUrl}
meta={ownerSummary.rating !== null
? `Rating ${ownerSummary.rating}${ownerSummary.rank ? ` • Rank ${ownerSummary.rank}` : ''}`
: undefined}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${ownerSummary.roleBadgeClasses}`}>
{ownerSummary.roleBadgeText}
</span>
</div>
)}
{adminSummaries.map((summary) => (
<div key={summary.driverId} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={{
id: summary.driverId,
name: summary.driverName,
avatarUrl: summary.avatarUrl,
}}
href={summary.profileUrl}
meta={summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: undefined}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${summary.roleBadgeClasses}`}>
{summary.roleBadgeText}
</span>
</div>
))}
{stewardSummaries.map((summary) => (
<div key={summary.driverId} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={{
id: summary.driverId,
name: summary.driverName,
avatarUrl: summary.avatarUrl,
}}
href={summary.profileUrl}
meta={summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: undefined}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${summary.roleBadgeClasses}`}>
{summary.roleBadgeText}
</span>
</div>
))}
</div>
</Card>
);
}
// ============================================================================
// MAIN TEMPLATE COMPONENT
// ============================================================================
export function LeagueDetailTemplate({
viewData,
leagueId,
isSponsor,
membership,
onMembershipChange,
onEndRaceModalOpen,
onLiveRaceClick,
leagueName,
leagueDescription,
tabs,
children,
}: LeagueDetailTemplateProps) {
return (
<>
{/* Sponsor Insights Card - Only shown to sponsors, at top of page */}
{isSponsor && viewData.sponsorInsights && (
<SponsorInsightsCard
entityType="league"
entityId={leagueId}
entityName={viewData.name}
tier={viewData.sponsorInsights.tier}
metrics={viewData.sponsorInsights.metrics}
slots={viewData.sponsorInsights.slots}
trustScore={viewData.sponsorInsights.trustScore}
discordMembers={viewData.sponsorInsights.discordMembers}
monthlyActivity={viewData.sponsorInsights.monthlyActivity}
additionalStats={{
label: 'League Stats',
items: [
{ label: 'Total Races', value: viewData.info.racesCount },
{ label: 'Active Members', value: viewData.info.membersCount },
{ label: 'Total Impressions', value: viewData.sponsorInsights.totalImpressions },
],
}}
<Layout>
<Section>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Leagues', href: '/leagues' },
{ label: leagueName },
]}
/>
)}
{/* Live Race Card - Prominently show running races */}
{viewData.runningRaces.length > 0 && (
<LiveRaceCard
races={viewData.runningRaces}
membership={membership}
onLiveRaceClick={onLiveRaceClick}
onEndRaceModalOpen={onEndRaceModalOpen}
/>
)}
<Section>
<Text size="3xl" weight="bold" className="text-white">
{leagueName}
</Text>
<Text size="base" className="text-gray-400 mt-2">
{leagueDescription}
</Text>
</Section>
{/* Action Card */}
{!membership && !isSponsor && (
<Card className="mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white mb-2">Join This League</h3>
<p className="text-gray-400 text-sm">Become a member to participate in races and track your progress</p>
</div>
<div className="w-48">
<JoinLeagueButton
leagueId={leagueId}
onMembershipChange={onMembershipChange}
/>
</div>
<Section>
<div className="flex gap-6 overflow-x-auto">
{tabs.map((tab) => (
<Link
key={tab.href}
href={tab.href}
className="pb-3 px-1 font-medium whitespace-nowrap transition-colors text-gray-400 hover:text-white"
>
{tab.label}
</Link>
))}
</div>
</Card>
)}
</Section>
{/* League Overview - Activity Center with Info Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Center - Activity Feed */}
<div className="lg:col-span-2 space-y-6">
<Card>
<h2 className="text-xl font-semibold text-white mb-6">Recent Activity</h2>
<LeagueActivityFeed leagueId={leagueId} limit={20} />
</Card>
</div>
{/* Right Sidebar - League Info */}
<div className="space-y-6">
{/* League Info - Combined */}
<LeagueInfoCard info={viewData.info} />
{/* Sponsors Section - Show sponsor logos */}
{viewData.sponsors.length > 0 && (
<SponsorsSection sponsors={viewData.sponsors} />
)}
{/* Management */}
<ManagementSection
ownerSummary={viewData.ownerSummary}
adminSummaries={viewData.adminSummaries}
stewardSummaries={viewData.stewardSummaries}
/>
</div>
</div>
{/* Children (for modals, etc.) */}
{children}
</>
<Section>
{children}
</Section>
</Section>
</Layout>
);
}

View File

@@ -259,7 +259,7 @@ export function ProfileTemplate({ viewData, mode, onSaveSettings }: ProfileTempl
<UserPlus className="w-4 h-4" />
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
</Button>
<Link href="/profile/leagues">
<Link href=routes.protected.profileLeagues>
<Button variant="secondary" className="w-full flex items-center gap-2">
<Flag className="w-4 h-4" />
My Leagues

View File

@@ -0,0 +1,165 @@
import { Card } from '@/ui/Card';
import { Section } from '@/ui/Section';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Select } from '@/ui/Select';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
interface RosterAdminTemplateProps {
joinRequests: LeagueRosterJoinRequestDTO[];
members: LeagueRosterMemberDTO[];
loading: boolean;
pendingCountLabel: string;
onApprove: (requestId: string) => Promise<void>;
onReject: (requestId: string) => Promise<void>;
onRoleChange: (driverId: string, newRole: MembershipRole) => Promise<void>;
onRemove: (driverId: string) => Promise<void>;
roleOptions: MembershipRole[];
}
export function RosterAdminTemplate({
joinRequests,
members,
loading,
pendingCountLabel,
onApprove,
onReject,
onRoleChange,
onRemove,
roleOptions,
}: RosterAdminTemplateProps) {
return (
<Section>
<Card>
<Section>
<Section>
<Text size="2xl" weight="bold" className="text-white">
Roster Admin
</Text>
<Text size="sm" className="text-gray-400">
Manage join requests and member roles.
</Text>
</Section>
<Section>
<div className="flex items-center justify-between gap-3">
<Text size="lg" weight="semibold" className="text-white">
Pending join requests
</Text>
<Text size="xs" className="text-gray-500">
{pendingCountLabel}
</Text>
</div>
{loading ? (
<Text size="sm" className="text-gray-400">
Loading
</Text>
) : joinRequests.length ? (
<div className="space-y-2">
{joinRequests.map((req) => (
<div
key={req.id}
className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
>
<div className="min-w-0">
<Text weight="medium" className="text-white truncate">
{(req.driver as any)?.name || 'Unknown'}
</Text>
<Text size="xs" className="text-gray-400 truncate">
{req.requestedAt}
</Text>
{req.message && (
<Text size="xs" className="text-gray-500 truncate">
{req.message}
</Text>
)}
</div>
<div className="flex items-center gap-2">
<Button
data-testid={`join-request-${req.id}-approve`}
onClick={() => onApprove(req.id)}
className="bg-primary-blue text-white"
>
Approve
</Button>
<Button
data-testid={`join-request-${req.id}-reject`}
onClick={() => onReject(req.id)}
className="bg-iron-gray text-gray-200"
>
Reject
</Button>
</div>
</div>
))}
</div>
) : (
<Text size="sm" className="text-gray-500">
No pending join requests.
</Text>
)}
</Section>
<Section>
<Text size="lg" weight="semibold" className="text-white">
Members
</Text>
{loading ? (
<Text size="sm" className="text-gray-400">
Loading
</Text>
) : members.length ? (
<div className="space-y-2">
{members.map((member) => (
<div
key={member.driverId}
className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
>
<div className="min-w-0">
<Text weight="medium" className="text-white truncate">
{(member.driver as any)?.name || 'Unknown'}
</Text>
<Text size="xs" className="text-gray-400 truncate">
{member.joinedAt}
</Text>
</div>
<div className="flex flex-col md:flex-row md:items-center gap-2">
<label className="text-xs text-gray-400" htmlFor={`role-${member.driverId}`}>
Role for {(member.driver as any)?.name || 'Unknown'}
</label>
<Select
id={`role-${member.driverId}`}
aria-label={`Role for ${(member.driver as any)?.name || 'Unknown'}`}
value={member.role}
onChange={(e) => onRoleChange(member.driverId, e.target.value as MembershipRole)}
options={roleOptions.map((role) => ({ value: role, label: role }))}
className="bg-iron-gray text-white px-3 py-2 rounded"
/>
<Button
data-testid={`member-${member.driverId}-remove`}
onClick={() => onRemove(member.driverId)}
className="bg-iron-gray text-gray-200"
>
Remove
</Button>
</div>
</div>
))}
</div>
) : (
<Text size="sm" className="text-gray-500">
No members found.
</Text>
)}
</Section>
</Section>
</Card>
</Section>
);
}

View File

@@ -1,35 +1,80 @@
import { SponsorshipRequestsPageViewDataBuilder } from '@/lib/builders/view-data/SponsorshipRequestsPageViewDataBuilder';
import { AcceptSponsorshipRequestMutation } from '@/lib/mutations/sponsors/AcceptSponsorshipRequestMutation';
import { RejectSponsorshipRequestMutation } from '@/lib/mutations/sponsors/RejectSponsorshipRequestMutation';
import { SponsorshipRequestsPageQuery } from '@/lib/page-queries/page-queries/SponsorshipRequestsPageQuery';
import { SponsorshipRequestsClient } from '@/app/profile/sponsorship-requests/SponsorshipRequestsClient';
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Container from '@/components/ui/Container';
import Heading from '@/components/ui/Heading';
export interface SponsorshipRequestsTemplateProps {
searchParams: Record<string, string>;
viewData: SponsorshipRequestsViewData;
onAccept: (requestId: string) => Promise<void>;
onReject: (requestId: string, reason?: string) => Promise<void>;
}
export async function SponsorshipRequestsTemplate({
searchParams,
export function SponsorshipRequestsTemplate({
viewData,
onAccept,
onReject,
}: SponsorshipRequestsTemplateProps) {
const pageQuery = new SponsorshipRequestsPageQuery();
const viewDataBuilder = new SponsorshipRequestsPageViewDataBuilder();
const acceptMutation = new AcceptSponsorshipRequestMutation();
const rejectMutation = new RejectSponsorshipRequestMutation();
const queryResult = await pageQuery.execute(searchParams);
if (queryResult.isErr()) {
// Handle error - redirect or show error page
throw new Error('Failed to load sponsorship requests');
}
const viewData = viewDataBuilder.build(queryResult.unwrap());
return (
<SponsorshipRequestsClient
viewData={viewData}
acceptMutation={acceptMutation}
rejectMutation={rejectMutation}
/>
<Container size="md" className="space-y-8">
<div>
<Heading level={1} className="text-white mb-2">
Sponsorship Requests
</Heading>
<p className="text-gray-400 text-sm">
Manage pending sponsorship requests for your profile.
</p>
</div>
{viewData.sections.map((section) => (
<Card key={`${section.entityType}-${section.entityId}`}>
<div className="flex items-center justify-between mb-4">
<Heading level={2} className="text-white">
{section.entityName}
</Heading>
<span className="text-xs text-gray-400">
{section.requests.length} {section.requests.length === 1 ? 'request' : 'requests'}
</span>
</div>
{section.requests.length === 0 ? (
<p className="text-sm text-gray-400">No pending requests.</p>
) : (
<div className="space-y-3">
{section.requests.map((request) => (
<div
key={request.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div className="flex-1">
<p className="text-white font-medium">{request.sponsorName}</p>
{request.message && (
<p className="text-xs text-gray-400 mt-1">{request.message}</p>
)}
<p className="text-xs text-gray-500 mt-1">
{new Date(request.createdAtIso).toLocaleDateString()}
</p>
</div>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => onAccept(request.id)}
>
Accept
</Button>
<Button
variant="secondary"
onClick={() => onReject(request.id)}
>
Reject
</Button>
</div>
</div>
))}
</div>
)}
</Card>
))}
</Container>
);
}

View File

@@ -1,7 +1,9 @@
'use client';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import SponsorInsightsCard, { SlotTemplates, useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
import SponsorInsightsCard from '@/components/sponsors/SponsorInsightsCard';
import { SlotTemplates } from '@/components/sponsors/SlotTemplates';
import { useSponsorMode } from '@/components/sponsors/useSponsorMode';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Image from 'next/image';

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { Users, Trophy, Crown, Award, ArrowLeft, Medal, Percent, Hash, Globe, Languages, Target } from 'lucide-react';
import { Users, Trophy, Crown, Award, ArrowLeft, Medal, Target, Globe, Languages } from 'lucide-react';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';

View File

@@ -99,4 +99,4 @@ export function TeamsTemplate({ teams }: TeamsTemplateProps) {
</div>
</main>
);
}
}

View File

@@ -22,7 +22,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import { ForgotPasswordViewData } from '@/lib/builders/view-data/ForgotPasswordViewDataBuilder';
import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
interface ForgotPasswordTemplateProps {
viewData: ForgotPasswordViewData;
@@ -191,4 +191,4 @@ export function ForgotPasswordTemplate({ viewData, formActions, mutationState }:
</div>
</main>
);
}
}

View File

@@ -27,7 +27,8 @@ import Heading from '@/components/ui/Heading';
import { EnhancedFormError } from '@/components/errors/EnhancedFormError';
import UserRolesPreview from '@/components/auth/UserRolesPreview';
import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup';
import { LoginViewData, FormState } from '@/lib/builders/view-data/LoginViewDataBuilder';
import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
import { FormState } from '@/lib/builders/view-data/types/FormState';
interface LoginTemplateProps {
viewData: LoginViewData;
@@ -208,7 +209,7 @@ export function LoginTemplate({ viewData, formActions, mutationState }: LoginTem
<div className="text-sm text-gray-300">
<strong className="text-warning-amber">Insufficient Permissions</strong>
<p className="mt-1">
You don&apos;t have permission to access that page. Please log in with an account that has the required role.
You don't have permission to access that page. Please log in with an account that has the required role.
</p>
</div>
</div>
@@ -262,9 +263,9 @@ export function LoginTemplate({ viewData, formActions, mutationState }: LoginTem
{/* Sign Up Link */}
<p className="mt-6 text-center text-sm text-gray-400">
Don&apos;t have an account?{''}
Don't have an account?{''}
<Link
href={`/auth/signup${viewData.returnTo !== '/dashboard' ? `?returnTo=${encodeURIComponent(viewData.returnTo)}` : ''}`}
href={viewData.returnTo && viewData.returnTo !== '/dashboard' ? `/auth/signup?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/signup'}
className="text-primary-blue hover:underline font-medium"
>
Create one
@@ -277,7 +278,7 @@ export function LoginTemplate({ viewData, formActions, mutationState }: LoginTem
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-gray-400 flex-shrink-0 mt-0.5" />
<div className="text-xs text-gray-400">
<strong>Note:</strong> Your display name cannot be changed after signup. Please ensure it&apos;s correct when creating your account.
<strong>Note:</strong> Your display name cannot be changed after signup. Please ensure it's correct when creating your account.
</div>
</div>
</div>
@@ -296,4 +297,4 @@ export function LoginTemplate({ viewData, formActions, mutationState }: LoginTem
</div>
</main>
);
}
}

View File

@@ -24,7 +24,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import { ResetPasswordViewData } from '@/lib/builders/view-data/ResetPasswordViewDataBuilder';
import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
interface ResetPasswordTemplateProps extends ResetPasswordViewData {
formActions: {
@@ -228,4 +228,4 @@ export function ResetPasswordTemplate(props: ResetPasswordTemplateProps) {
</div>
</main>
);
}
}

View File

@@ -31,7 +31,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import { SignupViewData } from '@/lib/builders/view-data/SignupViewDataBuilder';
import { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
import { checkPasswordStrength } from '@/lib/utils/validation';
interface SignupTemplateProps {
@@ -417,7 +417,7 @@ export function SignupTemplate({ viewData, formActions, uiState, mutationState }
<p className="mt-6 text-center text-sm text-gray-400">
Already have an account?{' '}
<Link
href={`/auth/login${viewData.returnTo !== '/onboarding' ? `?returnTo=${encodeURIComponent(viewData.returnTo)}` : ''}`}
href={viewData.returnTo && viewData.returnTo !== '/onboarding' ? `/auth/login?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/login'}
className="text-primary-blue hover:underline font-medium"
>
Sign in