-
-
{race.track}
- {/* Status badge */}
-
- {race.status === 'running' && (
-
- )}
-
+
+
+
+ {race.track}
+
+
+
+
+ {race.car}
+
+ {race.strengthOfField && (
+
+
+ SOF {race.strengthOfField}
+
+ )}
+
+
+
+ {/* Status Badge */}
+
+
{config.label}
-
- {/* Meta info */}
-
-
-
- {race.car}
-
- {race.strengthOfField && (
-
-
- SOF {race.strengthOfField}
-
- )}
- {leagueName && (
-
-
- {leagueName}
-
- )}
+
+ {/* League Link */}
+
+
e.stopPropagation()}
+ className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
+ >
+
+ {race.leagueName}
+
+
- {/* Right side - Date/Time */}
-
-
{formatDate(race.scheduledAt)}
-
{formatTime(race.scheduledAt)}
- {relativeTime && (
-
{relativeTime}
- )}
-
-
-
- {/* Bottom row */}
-
-
- {race.sessionType}
-
- {race.registeredCount !== undefined && (
-
- {race.registeredCount} registered
- {race.maxParticipants && ` / ${race.maxParticipants}`}
-
- )}
+ {/* Arrow */}
+
);
diff --git a/apps/website/components/races/RaceResultsHeader.tsx b/apps/website/components/races/RaceResultsHeader.tsx
new file mode 100644
index 000000000..4492a79d2
--- /dev/null
+++ b/apps/website/components/races/RaceResultsHeader.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { Calendar, Trophy, Users, Zap } from 'lucide-react';
+
+interface RaceResultsHeaderProps {
+ raceTrack?: string;
+ raceScheduledAt?: string;
+ totalDrivers?: number;
+ leagueName?: string;
+ raceSOF?: number | null;
+}
+
+const DEFAULT_RACE_TRACK = 'Race';
+
+export default function RaceResultsHeader({
+ raceTrack = 'Race',
+ raceScheduledAt,
+ totalDrivers,
+ leagueName,
+ raceSOF
+}: RaceResultsHeaderProps) {
+ return (
+
+
+
+
+
+
+
+
+ Final Results
+
+
+ {raceSOF && (
+
+
+ SOF {raceSOF}
+
+ )}
+
+
+
+ {raceTrack || DEFAULT_RACE_TRACK} Results
+
+
+
+ {raceScheduledAt && (
+
+
+ {new Date(raceScheduledAt).toLocaleDateString('en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ })}
+
+ )}
+ {totalDrivers !== undefined && totalDrivers !== null && (
+
+
+ {totalDrivers} drivers classified
+
+ )}
+ {leagueName && {leagueName}}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/races/RaceStats.tsx b/apps/website/components/races/RaceStats.tsx
new file mode 100644
index 000000000..b2ab4eb8d
--- /dev/null
+++ b/apps/website/components/races/RaceStats.tsx
@@ -0,0 +1,46 @@
+import { CalendarDays, Clock, Zap, Trophy } from 'lucide-react';
+
+interface RaceStatsProps {
+ stats: {
+ total: number;
+ scheduled: number;
+ running: number;
+ completed: number;
+ };
+ className?: string;
+}
+
+export function RaceStats({ stats, className }: RaceStatsProps) {
+ return (
+
+
+
+
+ Total
+
+
{stats.total}
+
+
+
+
+ Scheduled
+
+
{stats.scheduled}
+
+
+
+
+ Live Now
+
+
{stats.running}
+
+
+
+
+ Completed
+
+
{stats.completed}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/races/RaceStewardingStats.tsx b/apps/website/components/races/RaceStewardingStats.tsx
new file mode 100644
index 000000000..e8b917df4
--- /dev/null
+++ b/apps/website/components/races/RaceStewardingStats.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { CheckCircle, Clock, Gavel } from 'lucide-react';
+
+interface RaceStewardingStatsProps {
+ pendingCount: number;
+ resolvedCount: number;
+ penaltiesCount: number;
+}
+
+export default function RaceStewardingStats({ pendingCount, resolvedCount, penaltiesCount }: RaceStewardingStatsProps) {
+ return (
+
+
+
+
+ Pending
+
+
{pendingCount}
+
+
+
+
+ Resolved
+
+
{resolvedCount}
+
+
+
+
+ Penalties
+
+
{penaltiesCount}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/races/SidebarRaceItem.tsx b/apps/website/components/races/SidebarRaceItem.tsx
new file mode 100644
index 000000000..9b1a60889
--- /dev/null
+++ b/apps/website/components/races/SidebarRaceItem.tsx
@@ -0,0 +1,34 @@
+import { ChevronRight } from 'lucide-react';
+import { formatTime, formatDate } from '@/lib/utilities/time';
+
+interface SidebarRaceItemProps {
+ race: {
+ id: string;
+ track: string;
+ scheduledAt: string;
+ };
+ onClick?: () => void;
+ className?: string;
+}
+
+export function SidebarRaceItem({ race, onClick, className }: SidebarRaceItemProps) {
+ const scheduledAtDate = new Date(race.scheduledAt);
+
+ return (
+
+
+
+ {scheduledAtDate.getDate()}
+
+
+
+
{race.track}
+
{formatTime(scheduledAtDate)}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/shared/EmptyState.tsx b/apps/website/components/shared/EmptyState.tsx
new file mode 100644
index 000000000..2bb3a64bd
--- /dev/null
+++ b/apps/website/components/shared/EmptyState.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { LucideIcon } from 'lucide-react';
+import Button from '@/components/ui/Button';
+
+interface EmptyStateProps {
+ icon: LucideIcon;
+ title: string;
+ description?: string;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
+ className?: string;
+}
+
+export const EmptyState = ({
+ icon: Icon,
+ title,
+ description,
+ action,
+ className = ''
+}: EmptyStateProps) => (
+
+
+
+
+
+
{title}
+ {description && (
+
{description}
+ )}
+ {action && (
+
+ )}
+
+
+);
\ No newline at end of file
diff --git a/apps/website/components/shared/HeroSection.tsx b/apps/website/components/shared/HeroSection.tsx
new file mode 100644
index 000000000..ec28a7519
--- /dev/null
+++ b/apps/website/components/shared/HeroSection.tsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import { LucideIcon } from 'lucide-react';
+import Heading from '@/components/ui/Heading';
+import Button from '@/components/ui/Button';
+
+interface HeroSectionProps {
+ title: string;
+ description?: string;
+ icon?: LucideIcon;
+ backgroundPattern?: React.ReactNode;
+ stats?: Array<{
+ icon: LucideIcon;
+ value: string | number;
+ label: string;
+ }>;
+ actions?: Array<{
+ label: string;
+ onClick: () => void;
+ variant?: 'primary' | 'secondary';
+ }>;
+ children?: React.ReactNode;
+ className?: string;
+}
+
+export const HeroSection = ({
+ title,
+ description,
+ icon: Icon,
+ backgroundPattern,
+ stats,
+ actions,
+ children,
+ className = ''
+}: HeroSectionProps) => (
+
+ {/* Background Pattern */}
+ {backgroundPattern && (
+
+ {backgroundPattern}
+
+ )}
+
+
+
+ {/* Main Content */}
+
+ {Icon && (
+
+ )}
+ {!Icon && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Stats */}
+ {stats && stats.length > 0 && (
+
+ {stats.map((stat, index) => (
+
+
+
+ {stat.value} {stat.label}
+
+
+ ))}
+
+ )}
+
+
+ {/* Actions or Custom Content */}
+ {actions && actions.length > 0 && (
+
+ {actions.map((action, index) => (
+
+ ))}
+
+ )}
+
+ {children}
+
+
+
+);
\ No newline at end of file
diff --git a/apps/website/components/shared/LoadingState.tsx b/apps/website/components/shared/LoadingState.tsx
new file mode 100644
index 000000000..c28cad85b
--- /dev/null
+++ b/apps/website/components/shared/LoadingState.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+
+interface LoadingStateProps {
+ message?: string;
+ className?: string;
+}
+
+export const LoadingState = ({ message = 'Loading...', className = '' }: LoadingStateProps) => (
+
+);
\ No newline at end of file
diff --git a/apps/website/components/shared/StatusBadge.tsx b/apps/website/components/shared/StatusBadge.tsx
new file mode 100644
index 000000000..a8f6e2546
--- /dev/null
+++ b/apps/website/components/shared/StatusBadge.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { LucideIcon } from 'lucide-react';
+
+interface StatusBadgeProps {
+ status: string;
+ config?: {
+ icon: LucideIcon;
+ color: string;
+ bg: string;
+ border: string;
+ label: string;
+ };
+ className?: string;
+}
+
+export const StatusBadge = ({ status, config, className = '' }: StatusBadgeProps) => {
+ const defaultConfig = {
+ scheduled: {
+ icon: () => null,
+ color: 'text-primary-blue',
+ bg: 'bg-primary-blue/10',
+ border: 'border-primary-blue/30',
+ label: 'Scheduled',
+ },
+ running: {
+ icon: () => null,
+ color: 'text-performance-green',
+ bg: 'bg-performance-green/10',
+ border: 'border-performance-green/30',
+ label: 'LIVE',
+ },
+ completed: {
+ icon: () => null,
+ color: 'text-gray-400',
+ bg: 'bg-gray-500/10',
+ border: 'border-gray-500/30',
+ label: 'Completed',
+ },
+ cancelled: {
+ icon: () => null,
+ color: 'text-warning-amber',
+ bg: 'bg-warning-amber/10',
+ border: 'border-warning-amber/30',
+ label: 'Cancelled',
+ },
+ };
+
+ const badgeConfig = config || defaultConfig[status as keyof typeof defaultConfig] || {
+ icon: () => null,
+ color: 'text-gray-400',
+ bg: 'bg-gray-500/10',
+ border: 'border-gray-500/30',
+ label: status,
+ };
+
+ const Icon = badgeConfig.icon;
+
+ return (
+
+ {Icon && }
+
+ {badgeConfig.label}
+
+
+ );
+};
\ No newline at end of file
diff --git a/apps/website/components/social/FriendPill.tsx b/apps/website/components/social/FriendPill.tsx
new file mode 100644
index 000000000..5320bb7ee
--- /dev/null
+++ b/apps/website/components/social/FriendPill.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { useServices } from '@/lib/services/ServiceProvider';
+
+interface Friend {
+ id: string;
+ name: string;
+ country: string;
+}
+
+interface FriendPillProps {
+ friend: Friend;
+}
+
+function getCountryFlag(countryCode: string): string {
+ const code = countryCode.toUpperCase();
+ if (code.length === 2) {
+ const codePoints = [...code].map(char => 127397 + char.charCodeAt(0));
+ return String.fromCodePoint(...codePoints);
+ }
+ return '🏁';
+}
+
+export default function FriendPill({ friend }: FriendPillProps) {
+ const { mediaService } = useServices();
+
+ return (
+
+
+
+
+
{friend.name}
+
{getCountryFlag(friend.country)}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/social/SocialHandles.tsx b/apps/website/components/social/SocialHandles.tsx
new file mode 100644
index 000000000..0dd10bfb8
--- /dev/null
+++ b/apps/website/components/social/SocialHandles.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { MessageCircle, Twitter, Youtube, Twitch } from 'lucide-react';
+import type { DriverProfileSocialHandleViewModel } from '@/lib/view-models/DriverProfileViewModel';
+
+interface SocialHandlesProps {
+ socialHandles: DriverProfileSocialHandleViewModel[];
+}
+
+function getSocialIcon(platform: DriverProfileSocialHandleViewModel['platform']) {
+ switch (platform) {
+ case 'twitter':
+ return Twitter;
+ case 'youtube':
+ return Youtube;
+ case 'twitch':
+ return Twitch;
+ case 'discord':
+ return MessageCircle;
+ }
+}
+
+function getSocialColor(platform: DriverProfileSocialHandleViewModel['platform']) {
+ switch (platform) {
+ case 'twitter':
+ return 'hover:text-sky-400 hover:bg-sky-400/10';
+ case 'youtube':
+ return 'hover:text-red-500 hover:bg-red-500/10';
+ case 'twitch':
+ return 'hover:text-purple-400 hover:bg-purple-400/10';
+ case 'discord':
+ return 'hover:text-indigo-400 hover:bg-indigo-400/10';
+ }
+}
+
+export default function SocialHandles({ socialHandles }: SocialHandlesProps) {
+ if (socialHandles.length === 0) return null;
+
+ return (
+
+
+
Connect:
+ {socialHandles.map((social) => {
+ const Icon = getSocialIcon(social.platform);
+ return (
+
+
+ {social.handle}
+
+
+ );
+ })}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/sponsors/ActivityItem.tsx b/apps/website/components/sponsors/ActivityItem.tsx
new file mode 100644
index 000000000..744148bab
--- /dev/null
+++ b/apps/website/components/sponsors/ActivityItem.tsx
@@ -0,0 +1,29 @@
+interface ActivityItemProps {
+ activity: {
+ id: string;
+ message: string;
+ time: string;
+ typeColor: string;
+ formattedImpressions?: string | null;
+ };
+}
+
+export default function ActivityItem({ activity }: ActivityItemProps) {
+ return (
+
+
+
+
{activity.message}
+
+ {activity.time}
+ {activity.formattedImpressions && (
+ <>
+ •
+ {activity.formattedImpressions} views
+ >
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/sponsors/MetricCard.tsx b/apps/website/components/sponsors/MetricCard.tsx
new file mode 100644
index 000000000..7c4c21775
--- /dev/null
+++ b/apps/website/components/sponsors/MetricCard.tsx
@@ -0,0 +1,55 @@
+import { motion, useReducedMotion } from 'framer-motion';
+import { ArrowUpRight, ArrowDownRight } from 'lucide-react';
+import Card from '@/components/ui/Card';
+
+interface MetricCardProps {
+ title: string;
+ value: number | string;
+ change?: number;
+ icon: React.ElementType;
+ suffix?: string;
+ prefix?: string;
+ delay?: number;
+}
+
+export default function MetricCard({
+ title,
+ value,
+ change,
+ icon: Icon,
+ suffix = '',
+ prefix = '',
+ delay = 0,
+}: MetricCardProps) {
+ const shouldReduceMotion = useReducedMotion();
+ const isPositive = change && change > 0;
+ const isNegative = change && change < 0;
+
+ return (
+
+
+
+
+
+
+ {change !== undefined && (
+
+ {isPositive ?
: isNegative ?
: null}
+ {Math.abs(change)}%
+
+ )}
+
+
+ {prefix}{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
+
+ {title}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/sponsors/RenewalAlert.tsx b/apps/website/components/sponsors/RenewalAlert.tsx
new file mode 100644
index 000000000..6c71054c9
--- /dev/null
+++ b/apps/website/components/sponsors/RenewalAlert.tsx
@@ -0,0 +1,41 @@
+import { Trophy, Users, Car, Flag, Megaphone } from 'lucide-react';
+import Button from '@/components/ui/Button';
+
+interface RenewalAlertProps {
+ renewal: {
+ id: string;
+ type: 'league' | 'team' | 'driver' | 'race' | 'platform';
+ name: string;
+ formattedRenewDate: string;
+ formattedPrice: string;
+ };
+}
+
+export default function RenewalAlert({ renewal }: RenewalAlertProps) {
+ const typeIcons = {
+ league: Trophy,
+ team: Users,
+ driver: Car,
+ race: Flag,
+ platform: Megaphone,
+ };
+ const Icon = typeIcons[renewal.type] || Trophy;
+
+ return (
+
+
+
+
+
{renewal.name}
+
Renews {renewal.formattedRenewDate}
+
+
+
+
{renewal.formattedPrice}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/sponsors/SponsorshipCategoryCard.tsx b/apps/website/components/sponsors/SponsorshipCategoryCard.tsx
new file mode 100644
index 000000000..14a1c9426
--- /dev/null
+++ b/apps/website/components/sponsors/SponsorshipCategoryCard.tsx
@@ -0,0 +1,42 @@
+import Link from 'next/link';
+import Card from '@/components/ui/Card';
+
+interface SponsorshipCategoryCardProps {
+ icon: React.ElementType;
+ title: string;
+ count: number;
+ impressions: number;
+ color: string;
+ href: string;
+}
+
+export default function SponsorshipCategoryCard({
+ icon: Icon,
+ title,
+ count,
+ impressions,
+ color,
+ href
+}: SponsorshipCategoryCardProps) {
+ return (
+
+
+
+
+
+
+
+
+
{title}
+
{count} active
+
+
+
+
{impressions.toLocaleString()}
+
impressions
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/teams/FeaturedRecruiting.tsx b/apps/website/components/teams/FeaturedRecruiting.tsx
new file mode 100644
index 000000000..f88050a15
--- /dev/null
+++ b/apps/website/components/teams/FeaturedRecruiting.tsx
@@ -0,0 +1,110 @@
+import { UserPlus, Users, Trophy } from 'lucide-react';
+import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
+
+const SKILL_LEVELS: {
+ id: string;
+ label: string;
+ icon: React.ElementType;
+ color: string;
+ bgColor: string;
+ borderColor: string;
+}[] = [
+ {
+ id: 'pro',
+ label: 'Pro',
+ icon: () => null, // We'll import Crown if needed
+ color: 'text-yellow-400',
+ bgColor: 'bg-yellow-400/10',
+ borderColor: 'border-yellow-400/30',
+ },
+ {
+ id: 'advanced',
+ label: 'Advanced',
+ icon: () => null,
+ color: 'text-purple-400',
+ bgColor: 'bg-purple-400/10',
+ borderColor: 'border-purple-400/30',
+ },
+ {
+ id: 'intermediate',
+ label: 'Intermediate',
+ icon: () => null,
+ color: 'text-primary-blue',
+ bgColor: 'bg-primary-blue/10',
+ borderColor: 'border-primary-blue/30',
+ },
+ {
+ id: 'beginner',
+ label: 'Beginner',
+ icon: () => null,
+ color: 'text-green-400',
+ bgColor: 'bg-green-400/10',
+ borderColor: 'border-green-400/30',
+ },
+];
+
+interface FeaturedRecruitingProps {
+ teams: TeamSummaryViewModel[];
+ onTeamClick: (id: string) => void;
+}
+
+export default function FeaturedRecruiting({ teams, onTeamClick }: FeaturedRecruitingProps) {
+ const recruitingTeams = teams.filter((t) => t.isRecruiting).slice(0, 4);
+
+ if (recruitingTeams.length === 0) return null;
+
+ return (
+
+
+
+
+
+
+
Looking for Drivers
+
Teams actively recruiting new members
+
+
+
+
+ {recruitingTeams.map((team) => {
+ const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/teams/SkillLevelSection.tsx b/apps/website/components/teams/SkillLevelSection.tsx
new file mode 100644
index 000000000..6329b1fc4
--- /dev/null
+++ b/apps/website/components/teams/SkillLevelSection.tsx
@@ -0,0 +1,98 @@
+import { useState } from 'react';
+import { ChevronRight, Users, Trophy, UserPlus } from 'lucide-react';
+import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
+import TeamCard from './TeamCard';
+
+type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
+
+interface SkillLevelConfig {
+ id: SkillLevel;
+ label: string;
+ icon: React.ElementType;
+ color: string;
+ bgColor: string;
+ borderColor: string;
+ description: string;
+}
+
+interface SkillLevelSectionProps {
+ level: SkillLevelConfig;
+ teams: TeamSummaryViewModel[];
+ onTeamClick: (id: string) => void;
+ defaultExpanded?: boolean;
+}
+
+export default function SkillLevelSection({
+ level,
+ teams,
+ onTeamClick,
+ defaultExpanded = false
+}: SkillLevelSectionProps) {
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+ const recruitingTeams = teams.filter((t) => t.isRecruiting);
+ const displayedTeams = isExpanded ? teams : teams.slice(0, 3);
+ const Icon = level.icon;
+
+ if (teams.length === 0) return null;
+
+ return (
+
+ {/* Section Header */}
+
+
+
+
+
+
+
+
{level.label}
+
+ {teams.length} {teams.length === 1 ? 'team' : 'teams'}
+
+ {recruitingTeams.length > 0 && (
+
+
+ {recruitingTeams.length} recruiting
+
+ )}
+
+
{level.description}
+
+
+
+ {teams.length > 3 && (
+
+ )}
+
+
+ {/* Teams Grid */}
+
+ {displayedTeams.map((team) => (
+ onTeamClick(team.id)}
+ />
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/teams/StatItem.tsx b/apps/website/components/teams/StatItem.tsx
new file mode 100644
index 000000000..1e23e2f9f
--- /dev/null
+++ b/apps/website/components/teams/StatItem.tsx
@@ -0,0 +1,14 @@
+interface StatItemProps {
+ label: string;
+ value: string;
+ color: string;
+}
+
+export default function StatItem({ label, value, color }: StatItemProps) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/teams/TeamLeaderboardPreview.tsx b/apps/website/components/teams/TeamLeaderboardPreview.tsx
new file mode 100644
index 000000000..2dc1948f7
--- /dev/null
+++ b/apps/website/components/teams/TeamLeaderboardPreview.tsx
@@ -0,0 +1,175 @@
+import { useRouter } from 'next/navigation';
+import { Award, ChevronRight, Crown, Trophy, Users } from 'lucide-react';
+import Button from '@/components/ui/Button';
+import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
+
+const SKILL_LEVELS: {
+ id: string;
+ label: string;
+ icon: React.ElementType;
+ color: string;
+ bgColor: string;
+ borderColor: string;
+}[] = [
+ {
+ id: 'pro',
+ label: 'Pro',
+ icon: () => null,
+ color: 'text-yellow-400',
+ bgColor: 'bg-yellow-400/10',
+ borderColor: 'border-yellow-400/30',
+ },
+ {
+ id: 'advanced',
+ label: 'Advanced',
+ icon: () => null,
+ color: 'text-purple-400',
+ bgColor: 'bg-purple-400/10',
+ borderColor: 'border-purple-400/30',
+ },
+ {
+ id: 'intermediate',
+ label: 'Intermediate',
+ icon: () => null,
+ color: 'text-primary-blue',
+ bgColor: 'bg-primary-blue/10',
+ borderColor: 'border-primary-blue/30',
+ },
+ {
+ id: 'beginner',
+ label: 'Beginner',
+ icon: () => null,
+ color: 'text-green-400',
+ bgColor: 'bg-green-400/10',
+ borderColor: 'border-green-400/30',
+ },
+];
+
+interface TeamLeaderboardPreviewProps {
+ topTeams: TeamSummaryViewModel[];
+ onTeamClick: (id: string) => void;
+}
+
+export default function TeamLeaderboardPreview({
+ topTeams,
+ onTeamClick
+}: TeamLeaderboardPreviewProps) {
+ const router = useRouter();
+
+ const getMedalColor = (position: number) => {
+ switch (position) {
+ case 0:
+ return 'text-yellow-400';
+ case 1:
+ return 'text-gray-300';
+ case 2:
+ return 'text-amber-600';
+ default:
+ return 'text-gray-500';
+ }
+ };
+
+ const getMedalBg = (position: number) => {
+ switch (position) {
+ case 0:
+ return 'bg-yellow-400/10 border-yellow-400/30';
+ case 1:
+ return 'bg-gray-300/10 border-gray-300/30';
+ case 2:
+ return 'bg-amber-600/10 border-amber-600/30';
+ default:
+ return 'bg-iron-gray/50 border-charcoal-outline';
+ }
+ };
+
+ if (topTeams.length === 0) return null;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Top Teams
+
Highest rated racing teams
+
+
+
+
+
+
+ {/* Compact Leaderboard */}
+
+
+ {topTeams.map((team, index) => {
+ const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
+
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/teams/TeamMembershipCard.tsx b/apps/website/components/teams/TeamMembershipCard.tsx
new file mode 100644
index 000000000..335a4a8c6
--- /dev/null
+++ b/apps/website/components/teams/TeamMembershipCard.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import Link from 'next/link';
+import { Users, ChevronRight } from 'lucide-react';
+
+interface TeamMembership {
+ teamId: string;
+ teamName: string;
+ teamTag?: string;
+ role: string;
+ joinedAt: string;
+}
+
+interface TeamMembershipCardProps {
+ membership: TeamMembership;
+}
+
+export default function TeamMembershipCard({ membership }: TeamMembershipCardProps) {
+ return (
+
+
+
+
+
+
+ {membership.teamName}
+
+
+
+ {membership.role}
+
+
+ Since {new Date(membership.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/teams/TopThreePodium.tsx b/apps/website/components/teams/TopThreePodium.tsx
new file mode 100644
index 000000000..7520f6f92
--- /dev/null
+++ b/apps/website/components/teams/TopThreePodium.tsx
@@ -0,0 +1,175 @@
+import { Trophy, Crown, Users } from 'lucide-react';
+import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
+
+const SKILL_LEVELS: {
+ id: string;
+ icon: React.ElementType;
+ color: string;
+ bgColor: string;
+ borderColor: string;
+}[] = [
+ {
+ id: 'pro',
+ icon: () => null,
+ color: 'text-yellow-400',
+ bgColor: 'bg-yellow-400/10',
+ borderColor: 'border-yellow-400/30',
+ },
+ {
+ id: 'advanced',
+ icon: () => null,
+ color: 'text-purple-400',
+ bgColor: 'bg-purple-400/10',
+ borderColor: 'border-purple-400/30',
+ },
+ {
+ id: 'intermediate',
+ icon: () => null,
+ color: 'text-primary-blue',
+ bgColor: 'bg-primary-blue/10',
+ borderColor: 'border-primary-blue/30',
+ },
+ {
+ id: 'beginner',
+ icon: () => null,
+ color: 'text-green-400',
+ bgColor: 'bg-green-400/10',
+ borderColor: 'border-green-400/30',
+ },
+];
+
+interface TopThreePodiumProps {
+ teams: TeamSummaryViewModel[];
+ onClick: (id: string) => void;
+}
+
+export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps) {
+ const top3 = teams.slice(0, 3) as [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel];
+ if (teams.length < 3) return null;
+
+ // Display order: 2nd, 1st, 3rd
+ const podiumOrder: [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel] = [
+ top3[1],
+ top3[0],
+ top3[2],
+ ];
+ const podiumHeights = ['h-28', 'h-36', 'h-20'];
+ const podiumPositions = [2, 1, 3];
+
+ const getPositionColor = (position: number) => {
+ switch (position) {
+ case 1:
+ return 'text-yellow-400';
+ case 2:
+ return 'text-gray-300';
+ case 3:
+ return 'text-amber-600';
+ default:
+ return 'text-gray-500';
+ }
+ };
+
+ const getGradient = (position: number) => {
+ switch (position) {
+ case 1:
+ return 'from-yellow-400/30 via-yellow-500/20 to-yellow-600/10';
+ case 2:
+ return 'from-gray-300/30 via-gray-400/20 to-gray-500/10';
+ case 3:
+ return 'from-amber-500/30 via-amber-600/20 to-amber-700/10';
+ default:
+ return 'from-gray-600/30 to-gray-700/10';
+ }
+ };
+
+ const getBorderColor = (position: number) => {
+ switch (position) {
+ case 1:
+ return 'border-yellow-400/50';
+ case 2:
+ return 'border-gray-300/50';
+ case 3:
+ return 'border-amber-600/50';
+ default:
+ return 'border-charcoal-outline';
+ }
+ };
+
+ return (
+
+
+
+
Top 3 Teams
+
+
+
+ {podiumOrder.map((team, index) => {
+ const position = podiumPositions[index] ?? 0;
+ const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/teams/WhyJoinTeamSection.tsx b/apps/website/components/teams/WhyJoinTeamSection.tsx
new file mode 100644
index 000000000..c7bc049fd
--- /dev/null
+++ b/apps/website/components/teams/WhyJoinTeamSection.tsx
@@ -0,0 +1,55 @@
+import {
+ Handshake,
+ MessageCircle,
+ Calendar,
+ Trophy,
+} from 'lucide-react';
+
+export default function WhyJoinTeamSection() {
+ const benefits = [
+ {
+ icon: Handshake,
+ title: 'Shared Strategy',
+ description: 'Develop setups together, share telemetry, and coordinate pit strategies for endurance races.',
+ },
+ {
+ icon: MessageCircle,
+ title: 'Team Communication',
+ description: 'Discord integration, voice chat during races, and dedicated team channels.',
+ },
+ {
+ icon: Calendar,
+ title: 'Coordinated Schedule',
+ description: 'Team calendars, practice sessions, and organized race attendance.',
+ },
+ {
+ icon: Trophy,
+ title: 'Team Championships',
+ description: 'Compete in team-based leagues and build your collective reputation.',
+ },
+ ];
+
+ return (
+
+
+
Why Join a Team?
+
Racing is better when you have teammates to share the journey
+
+
+
+ {benefits.map((benefit) => (
+
+
+
+
+
{benefit.title}
+
{benefit.description}
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/ui/TabContent.tsx b/apps/website/components/ui/TabContent.tsx
new file mode 100644
index 000000000..8dd6d29c5
--- /dev/null
+++ b/apps/website/components/ui/TabContent.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+
+interface TabContentProps {
+ activeTab: string;
+ children: React.ReactNode;
+ className?: string;
+}
+
+export default function TabContent({ activeTab, children, className = '' }: TabContentProps) {
+ return (
+
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/ui/TabNavigation.tsx b/apps/website/components/ui/TabNavigation.tsx
new file mode 100644
index 000000000..9945e6437
--- /dev/null
+++ b/apps/website/components/ui/TabNavigation.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+
+interface Tab {
+ id: string;
+ label: string;
+ icon?: React.ComponentType<{ className?: string }>;
+}
+
+interface TabNavigationProps {
+ tabs: Tab[];
+ activeTab: string;
+ onTabChange: (tabId: string) => void;
+ className?: string;
+}
+
+export default function TabNavigation({ tabs, activeTab, onTabChange, className = '' }: TabNavigationProps) {
+ return (
+
+ {tabs.map((tab) => {
+ const Icon = tab.icon;
+ return (
+
+ );
+ })}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/lib/utilities/country.ts b/apps/website/lib/utilities/country.ts
new file mode 100644
index 000000000..55e43e074
--- /dev/null
+++ b/apps/website/lib/utilities/country.ts
@@ -0,0 +1,8 @@
+export function getCountryFlag(countryCode: string): string {
+ const code = countryCode.toUpperCase();
+ if (code.length === 2) {
+ const codePoints = [...code].map(char => 127397 + char.charCodeAt(0));
+ return String.fromCodePoint(...codePoints);
+ }
+ return '🏁';
+}
\ No newline at end of file
diff --git a/apps/website/lib/utilities/raceStatus.ts b/apps/website/lib/utilities/raceStatus.ts
new file mode 100644
index 000000000..cd1c3ec60
--- /dev/null
+++ b/apps/website/lib/utilities/raceStatus.ts
@@ -0,0 +1,32 @@
+import { Clock, PlayCircle, CheckCircle2, XCircle } from 'lucide-react';
+
+export const raceStatusConfig = {
+ scheduled: {
+ icon: Clock,
+ color: 'text-primary-blue',
+ bg: 'bg-primary-blue/10',
+ border: 'border-primary-blue/30',
+ label: 'Scheduled',
+ },
+ running: {
+ icon: PlayCircle,
+ color: 'text-performance-green',
+ bg: 'bg-performance-green/10',
+ border: 'border-performance-green/30',
+ label: 'LIVE',
+ },
+ completed: {
+ icon: CheckCircle2,
+ color: 'text-gray-400',
+ bg: 'bg-gray-500/10',
+ border: 'border-gray-500/30',
+ label: 'Completed',
+ },
+ cancelled: {
+ icon: XCircle,
+ color: 'text-warning-amber',
+ bg: 'bg-warning-amber/10',
+ border: 'border-warning-amber/30',
+ label: 'Cancelled',
+ },
+};
\ No newline at end of file
diff --git a/apps/website/lib/utilities/time.ts b/apps/website/lib/utilities/time.ts
new file mode 100644
index 000000000..25d0c4b73
--- /dev/null
+++ b/apps/website/lib/utilities/time.ts
@@ -0,0 +1,81 @@
+export function timeUntil(date: Date): string {
+ const now = new Date();
+ const diffMs = date.getTime() - now.getTime();
+
+ if (diffMs < 0) return 'Started';
+
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+ const diffDays = Math.floor(diffHours / 24);
+
+ if (diffDays > 0) {
+ return `${diffDays}d ${diffHours % 24}h`;
+ }
+ if (diffHours > 0) {
+ const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
+ return `${diffHours}h ${diffMinutes}m`;
+ }
+ const diffMinutes = Math.floor(diffMs / (1000 * 60));
+ return `${diffMinutes}m`;
+}
+
+export function timeAgo(timestamp: Date | string): string {
+ const time = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
+ const diffMs = Date.now() - time.getTime();
+ const diffMinutes = Math.floor(diffMs / 60000);
+ if (diffMinutes < 1) return 'Just now';
+ if (diffMinutes < 60) return `${diffMinutes}m ago`;
+ const diffHours = Math.floor(diffMinutes / 60);
+ if (diffHours < 24) return `${diffHours}h ago`;
+ const diffDays = Math.floor(diffHours / 24);
+ return `${diffDays}d ago`;
+}
+
+export function getGreeting(): string {
+ const hour = new Date().getHours();
+ if (hour < 12) return 'Good morning';
+ if (hour < 18) return 'Good afternoon';
+ return 'Good evening';
+}
+
+export function formatTime(date: Date | string): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
+ return d.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+export function formatDate(date: Date | string): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
+ return d.toLocaleDateString('en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ });
+}
+
+export function formatFullDate(date: Date | string): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
+ return d.toLocaleDateString('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+export function getRelativeTime(date?: Date | string): string {
+ if (!date) return '';
+ const now = new Date();
+ const targetDate = typeof date === 'string' ? new Date(date) : date;
+ const diffMs = targetDate.getTime() - now.getTime();
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffMs < 0) return 'Past';
+ if (diffHours < 1) return 'Starting soon';
+ if (diffHours < 24) return `In ${diffHours}h`;
+ if (diffDays === 1) return 'Tomorrow';
+ if (diffDays < 7) return `In ${diffDays} days`;
+ return formatDate(targetDate);
+}
\ No newline at end of file