diff --git a/apps/website/app/auth/login/page.tsx b/apps/website/app/auth/login/page.tsx index 7570f78b0..3718b5c97 100644 --- a/apps/website/app/auth/login/page.tsx +++ b/apps/website/app/auth/login/page.tsx @@ -27,6 +27,7 @@ import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import { useAuth } from '@/lib/auth/AuthContext'; import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup'; +import UserRolesPreview from '@/components/auth/UserRolesPreview'; interface FormErrors { email?: string; @@ -34,26 +35,6 @@ interface FormErrors { submit?: string; } -const USER_ROLES = [ - { - icon: Car, - title: 'Driver', - description: 'Race, track stats, join teams', - color: 'primary-blue', - }, - { - icon: Trophy, - title: 'League Admin', - description: 'Organize leagues and events', - color: 'performance-green', - }, - { - icon: Users, - title: 'Team Manager', - description: 'Manage team and drivers', - color: 'purple-400', - }, -]; export default function LoginPage() { const router = useRouter(); @@ -167,25 +148,7 @@ export default function LoginPage() {

{/* Role Cards */} -
- {USER_ROLES.map((role, index) => ( - -
- -
-
-

{role.title}

-

{role.description}

-
-
- ))} -
+ {/* Workflow Mockup */} @@ -365,19 +328,7 @@ export default function LoginPage() {

{/* Mobile Role Info */} -
-

One account for all roles

-
- {USER_ROLES.map((role) => ( -
-
- -
- {role.title} -
- ))} -
-
+ diff --git a/apps/website/app/dashboard/page.tsx b/apps/website/app/dashboard/page.tsx index 27833d310..104e2f3a7 100644 --- a/apps/website/app/dashboard/page.tsx +++ b/apps/website/app/dashboard/page.tsx @@ -23,58 +23,15 @@ import { import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; +import { StatCard } from '@/components/dashboard/StatCard'; +import { LeagueStandingItem } from '@/components/dashboard/LeagueStandingItem'; +import { UpcomingRaceItem } from '@/components/dashboard/UpcomingRaceItem'; +import { FriendItem } from '@/components/dashboard/FriendItem'; +import { FeedItemRow } from '@/components/dashboard/FeedItemRow'; import { useDashboardOverview } from '@/hooks/useDashboardService'; - - -// Helper functions -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 '🏁'; -} - -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`; -} - -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`; -} - -function getGreeting(): string { - const hour = new Date().getHours(); - if (hour < 12) return 'Good morning'; - if (hour < 18) return 'Good afternoon'; - return 'Good evening'; -} +import { getCountryFlag } from '@/lib/utilities/country'; +import { getGreeting, timeUntil, timeAgo } from '@/lib/utilities/time'; import { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; @@ -176,50 +133,10 @@ export default function DashboardPage() { {/* Quick Stats Row */}
-
-
-
- -
-
-

{wins}

-

Wins

-
-
-
-
-
-
- -
-
-

{podiums}

-

Podiums

-
-
-
-
-
-
- -
-
-

{consistency}%

-

Consistency

-
-
-
-
-
-
- -
-
-

{activeLeaguesCount}

-

Active Leagues

-
-
-
+ + + +
@@ -300,38 +217,14 @@ export default function DashboardPage() {
{leagueStandingsSummaries.map(({ leagueId, leagueName, position, points, totalDrivers }) => ( - -
- {position > 0 ? `P${position}` : '-'} -
-
-

- {leagueName} -

-

- {points} points • {totalDrivers} drivers -

-
-
- {position <= 3 && position > 0 && ( - - )} - -
- + leagueId={leagueId} + leagueName={leagueName} + position={position} + points={points} + totalDrivers={totalDrivers} + /> ))}
@@ -376,30 +269,16 @@ export default function DashboardPage() { {upcomingRaces.length > 0 ? (
- {upcomingRaces.slice(0, 5).map((race) => { - const isMyRace = race.isMyLeague; - return ( - -
-

{race.track}

- {isMyRace && ( - - )} -
-

{race.car}

-
- - {race.scheduledAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - - {timeUntil(race.scheduledAt)} -
- - ); - })} + {upcomingRaces.slice(0, 5).map((race) => ( + + ))}
) : (

No upcoming races

@@ -418,25 +297,13 @@ export default function DashboardPage() { {friends.length > 0 ? (
{friends.slice(0, 6).map((friend) => ( - -
- {friend.name} -
-
-

{friend.name}

-

{getCountryFlag(friend.country)}

-
- + id={friend.id} + name={friend.name} + avatarUrl={friend.avatarUrl} + country={friend.country} + /> ))} {friends.length > 6 && ( ); } - -// Feed Item Row Component -function FeedItemRow({ item }: { item: DashboardFeedItemSummaryViewModel }) { - const getActivityIcon = (type: string) => { - if (type.includes('win')) return { icon: Trophy, color: 'text-yellow-400 bg-yellow-400/10' }; - if (type.includes('podium')) return { icon: Medal, color: 'text-warning-amber bg-warning-amber/10' }; - if (type.includes('join')) return { icon: UserPlus, color: 'text-performance-green bg-performance-green/10' }; - if (type.includes('friend')) return { icon: Heart, color: 'text-pink-400 bg-pink-400/10' }; - if (type.includes('league')) return { icon: Flag, color: 'text-primary-blue bg-primary-blue/10' }; - if (type.includes('race')) return { icon: Play, color: 'text-red-400 bg-red-400/10' }; - return { icon: Activity, color: 'text-gray-400 bg-gray-400/10' }; - }; - - const { icon: Icon, color } = getActivityIcon(item.type); - - return ( -
-
- -
-
-

{item.headline}

- {item.body && ( -

{item.body}

- )} -

{timeAgo(item.timestamp)}

-
- {item.ctaHref && ( - - - - )} -
- ); -} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/rulebook/page.tsx b/apps/website/app/leagues/[id]/rulebook/page.tsx index 72295a49a..aedf7a25c 100644 --- a/apps/website/app/leagues/[id]/rulebook/page.tsx +++ b/apps/website/app/leagues/[id]/rulebook/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; import Card from '@/components/ui/Card'; +import PointsTable from '@/components/leagues/PointsTable'; import { useServices } from '@/lib/services/ServiceProvider'; import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel'; @@ -124,49 +125,7 @@ export default function LeagueRulebookPage() {
{/* Points Table */} - -

Points Distribution

-
- - - - - - - - - {positionPoints.map(({ position, points }) => ( - - - - - ))} - -
PositionPoints
-
-
- {position} -
- - {position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`} - -
-
- {points} - pts -
-
-
+ {/* Bonus Points */} {primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && ( diff --git a/apps/website/app/leagues/[id]/settings/page.tsx b/apps/website/app/leagues/[id]/settings/page.tsx index b377cea34..ebfff0c11 100644 --- a/apps/website/app/leagues/[id]/settings/page.tsx +++ b/apps/website/app/leagues/[id]/settings/page.tsx @@ -1,8 +1,7 @@ 'use client'; import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo'; -import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; -import Button from '@/components/ui/Button'; +import LeagueOwnershipTransfer from '@/components/leagues/LeagueOwnershipTransfer'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; @@ -22,9 +21,6 @@ export default function LeagueSettingsPage() { const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [isAdmin, setIsAdmin] = useState(false); - const [showTransferDialog, setShowTransferDialog] = useState(false); - const [selectedNewOwner, setSelectedNewOwner] = useState(''); - const [transferring, setTransferring] = useState(false); const router = useRouter(); useEffect(() => { @@ -58,20 +54,12 @@ export default function LeagueSettingsPage() { const ownerSummary = settings?.owner || null; - const handleTransferOwnership = async () => { - if (!selectedNewOwner || !settings) return; - - setTransferring(true); + const handleTransferOwnership = async (newOwnerId: string) => { try { - await leagueSettingsService.transferOwnership(leagueId, currentDriverId, selectedNewOwner); - - setShowTransferDialog(false); + await leagueSettingsService.transferOwnership(leagueId, currentDriverId, newOwnerId); router.refresh(); } catch (err) { - console.error('Failed to transfer ownership:', err); - alert(err instanceof Error ? err.message : 'Failed to transfer ownership'); - } finally { - setTransferring(false); + throw err; // Let the component handle the error } }; @@ -128,76 +116,11 @@ export default function LeagueSettingsPage() {
- {/* League Owner - Compact */} -
-

League Owner

- {ownerSummary ? ( - - ) : ( -

Loading owner details...

- )} -
- - {/* Transfer Ownership - Owner Only */} - {settings.league.ownerId === currentDriverId && settings.members.length > 0 && ( -
-
- -

Transfer Ownership

-
-

- Transfer league ownership to another active member. You will become an admin. -

- - {!showTransferDialog ? ( - - ) : ( -
- - -
- - -
-
- )} -
- )} +
); diff --git a/apps/website/app/leagues/[id]/standings/page.tsx b/apps/website/app/leagues/[id]/standings/page.tsx index 771f52301..4724b83c3 100644 --- a/apps/website/app/leagues/[id]/standings/page.tsx +++ b/apps/website/app/leagues/[id]/standings/page.tsx @@ -1,6 +1,7 @@ 'use client'; import StandingsTable from '@/components/leagues/StandingsTable'; +import LeagueChampionshipStats from '@/components/leagues/LeagueChampionshipStats'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import type { LeagueMembership, MembershipRole } from '@/lib/types'; @@ -86,54 +87,10 @@ export default function LeagueStandingsPage() { ); } - const leader = standings[0]; - const totalRaces = Math.max(...standings.map(s => s.races), 0); - return (
{/* Championship Stats */} - {standings.length > 0 && ( -
- -
-
- 🏆 -
-
-

Championship Leader

-

{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}

-

{leader?.points || 0} points

-
-
-
- - -
-
- 🏁 -
-
-

Races Completed

-

{totalRaces}

-

Season in progress

-
-
-
- - -
-
- 👥 -
-
-

Active Drivers

-

{standings.length}

-

Competing for points

-
-
-
-
- )} +

Championship Standings

diff --git a/apps/website/app/leagues/[id]/stewarding/page.tsx b/apps/website/app/leagues/[id]/stewarding/page.tsx index cbfec099d..065bca39f 100644 --- a/apps/website/app/leagues/[id]/stewarding/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/page.tsx @@ -3,6 +3,7 @@ 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 { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; @@ -183,29 +184,11 @@ export default function LeagueStewardingPage() { {/* Stats summary */} {!loading && stewardingData && ( -
-
-
- - Pending Review -
-
{stewardingData.totalPending}
-
-
-
- - Resolved -
-
{stewardingData.totalResolved}
-
-
-
- - Penalties -
-
{stewardingData.totalPenalties}
-
-
+ )} {/* Tab navigation */} diff --git a/apps/website/app/leagues/[id]/wallet/page.tsx b/apps/website/app/leagues/[id]/wallet/page.tsx index ca39f0797..8ef881c61 100644 --- a/apps/website/app/leagues/[id]/wallet/page.tsx +++ b/apps/website/app/leagues/[id]/wallet/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; +import TransactionRow from '@/components/leagues/TransactionRow'; import { useServices } from '@/lib/services/ServiceProvider'; import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel'; import { @@ -21,69 +22,6 @@ import { Calendar } from 'lucide-react'; -function TransactionRow({ transaction }: { transaction: any }) { - const isIncoming = transaction.amount > 0; - - const typeIcons = { - sponsorship: DollarSign, - membership: CreditCard, - withdrawal: ArrowUpRight, - prize: TrendingUp, - }; - const TypeIcon = typeIcons[transaction.type]; - - const statusConfig = { - completed: { color: 'text-performance-green', bg: 'bg-performance-green/10', icon: CheckCircle }, - pending: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', icon: Clock }, - failed: { color: 'text-racing-red', bg: 'bg-racing-red/10', icon: XCircle }, - }; - const status = statusConfig[transaction.status]; - const StatusIcon = status.icon; - - return ( -
-
-
- {isIncoming ? ( - - ) : ( - - )} -
-
-
- {transaction.description} - - {transaction.status} - -
-
- - {transaction.type} - {transaction.reference && ( - <> - - {transaction.reference} - - )} - - {transaction.formattedDate} -
-
-
-
-
- {transaction.formattedAmount} -
- {transaction.fee > 0 && ( -
- Fee: ${transaction.fee.toFixed(2)} -
- )} -
-
- ); -} export default function LeagueWalletPage() { const params = useParams(); diff --git a/apps/website/app/races/[id]/results/page.tsx b/apps/website/app/races/[id]/results/page.tsx index a0d2c382b..16c2987a8 100644 --- a/apps/website/app/races/[id]/results/page.tsx +++ b/apps/website/app/races/[id]/results/page.tsx @@ -3,6 +3,7 @@ import Breadcrumbs from '@/components/layout/Breadcrumbs'; import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal'; import ImportResultsForm from '@/components/races/ImportResultsForm'; +import RaceResultsHeader from '@/components/races/RaceResultsHeader'; import ResultsTable from '@/components/races/ResultsTable'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; @@ -132,50 +133,13 @@ export default function RaceResultsPage() {
-
-
- -
-
-
- - - Final Results - -
- {raceSOF && ( - - - SOF {raceSOF} - - )} -
- -

- {raceData?.race?.track ?? 'Race'} Results -

- -
- {raceData?.race && ( - <> - - - {new Date(raceData.race.scheduledAt).toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - })} - - - - {raceData.stats.totalDrivers} drivers classified - - - )} - {raceData?.league && {raceData.league.name}} -
-
-
+ {importSuccess && (
diff --git a/apps/website/app/races/[id]/stewarding/page.tsx b/apps/website/app/races/[id]/stewarding/page.tsx index 6c2218328..57bd05a26 100644 --- a/apps/website/app/races/[id]/stewarding/page.tsx +++ b/apps/website/app/races/[id]/stewarding/page.tsx @@ -1,6 +1,7 @@ 'use client'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; +import RaceStewardingStats from '@/components/races/RaceStewardingStats'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; @@ -154,29 +155,11 @@ export default function RaceStewardingPage() {
{/* Stats */} -
-
-
- - Pending -
-
{stewardingData?.pendingCount ?? 0}
-
-
-
- - Resolved -
-
{stewardingData?.resolvedCount ?? 0}
-
-
-
- - Penalties -
-
{stewardingData?.penaltiesCount ?? 0}
-
-
+ {/* Tab Navigation */} diff --git a/apps/website/app/sponsor/dashboard/page.tsx b/apps/website/app/sponsor/dashboard/page.tsx index 6a3b88829..d8ce2b322 100644 --- a/apps/website/app/sponsor/dashboard/page.tsx +++ b/apps/website/app/sponsor/dashboard/page.tsx @@ -6,6 +6,10 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import StatusBadge from '@/components/ui/StatusBadge'; import InfoBanner from '@/components/ui/InfoBanner'; +import MetricCard from '@/components/sponsors/MetricCard'; +import SponsorshipCategoryCard from '@/components/sponsors/SponsorshipCategoryCard'; +import ActivityItem from '@/components/sponsors/ActivityItem'; +import RenewalAlert from '@/components/sponsors/RenewalAlert'; import { BarChart3, Eye, @@ -35,146 +39,9 @@ import { SponsorService } from '@/lib/services/sponsors/SponsorService'; import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardViewModel'; import { ServiceFactory } from '@/lib/services/ServiceFactory'; -// Metric Card Component -function MetricCard({ - title, - value, - change, - icon: Icon, - suffix = '', - prefix = '', - delay = 0, -}: { - title: string; - value: number | string; - change?: number; - icon: typeof Eye; - suffix?: string; - prefix?: string; - delay?: number; -}) { - 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}
-
-
- ); -} -// Sponsorship Category Card -function SponsorshipCategoryCard({ - icon: Icon, - title, - count, - impressions, - color, - href -}: { - icon: typeof Trophy; - title: string; - count: number; - impressions: number; - color: string; - href: string; -}) { - return ( - - -
-
-
- -
-
-

{title}

-

{count} active

-
-
-
-

{impressions.toLocaleString()}

-

impressions

-
-
-
- - ); -} -// Activity Item -function ActivityItem({ activity }: { activity: any }) { - return ( -
-
-
-

{activity.message}

-
- {activity.time} - {activity.formattedImpressions && ( - <> - - {activity.formattedImpressions} views - - )} -
-
-
- ); -} - -// Renewal Alert -function RenewalAlert({ renewal }: { renewal: any }) { - 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}

- -
-
- ); -} export default function SponsorDashboardPage() { const shouldReduceMotion = useReducedMotion(); diff --git a/apps/website/app/teams/[id]/page.tsx b/apps/website/app/teams/[id]/page.tsx index 7292a5328..9eeef900e 100644 --- a/apps/website/app/teams/[id]/page.tsx +++ b/apps/website/app/teams/[id]/page.tsx @@ -12,6 +12,7 @@ import JoinTeamButton from '@/components/teams/JoinTeamButton'; import TeamAdmin from '@/components/teams/TeamAdmin'; import TeamRoster from '@/components/teams/TeamRoster'; import TeamStandings from '@/components/teams/TeamStandings'; +import StatItem from '@/components/teams/StatItem'; import { useServices } from '@/lib/services/ServiceProvider'; import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel'; import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel'; @@ -319,12 +320,3 @@ export default function TeamDetailPage() {
); } - -function StatItem({ label, value, color }: { label: string; value: string; color: string }) { - return ( -
- {label} - {value} -
- ); -} \ No newline at end of file diff --git a/apps/website/app/teams/leaderboard/page.tsx b/apps/website/app/teams/leaderboard/page.tsx index 70c9d1d44..fa414e473 100644 --- a/apps/website/app/teams/leaderboard/page.tsx +++ b/apps/website/app/teams/leaderboard/page.tsx @@ -22,6 +22,7 @@ import { import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; +import TopThreePodium from '@/components/teams/TopThreePodium'; import { useAllTeams } from '@/hooks/useTeamService'; import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; @@ -109,146 +110,6 @@ const SORT_OPTIONS: { id: SortBy; label: string; icon: React.ElementType }[] = [ { id: 'races', label: 'Races', icon: Hash }, ]; -// ============================================================================ -// TOP THREE PODIUM COMPONENT -// ============================================================================ - -interface TopThreePodiumProps { - teams: TeamDisplayData[]; - onTeamClick: (teamId: string) => void; -} - -function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) { - const top3 = teams.slice(0, 3) as [TeamDisplayData, TeamDisplayData, TeamDisplayData]; - if (teams.length < 3) return null; - - // Display order: 2nd, 1st, 3rd - const podiumOrder: [TeamDisplayData, TeamDisplayData, TeamDisplayData] = [ - 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); - const LevelIcon = levelConfig?.icon || Shield; - - return ( - - ); - })} -
-
- ); -} // ============================================================================ // MAIN PAGE COMPONENT @@ -455,7 +316,7 @@ export default function TeamLeaderboardPage() { {/* Podium for Top 3 - only show when viewing by rating without filters */} {sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && ( - + )} {/* Stats Summary */} diff --git a/apps/website/app/teams/page.tsx b/apps/website/app/teams/page.tsx index 450356e93..92982dcc3 100644 --- a/apps/website/app/teams/page.tsx +++ b/apps/website/app/teams/page.tsx @@ -28,6 +28,10 @@ import Card from '@/components/ui/Card'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import CreateTeamForm from '@/components/teams/CreateTeamForm'; +import WhyJoinTeamSection from '@/components/teams/WhyJoinTeamSection'; +import SkillLevelSection from '@/components/teams/SkillLevelSection'; +import FeaturedRecruiting from '@/components/teams/FeaturedRecruiting'; +import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview'; import { useAllTeams } from '@/hooks/useTeamService'; import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; @@ -88,341 +92,9 @@ const SKILL_LEVELS: { }, ]; -// ============================================================================ -// WHY JOIN A TEAM SECTION -// ============================================================================ -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}

-
- ))} -
-
- ); -} - -// ============================================================================ -// SKILL LEVEL SECTION COMPONENT -// ============================================================================ - -interface SkillLevelSectionProps { - level: typeof SKILL_LEVELS[0]; - teams: TeamDisplayData[]; - onTeamClick: (id: string) => void; - defaultExpanded?: boolean; -} - -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)} - /> - ))} -
-
- ); -} - -// ============================================================================ -// FEATURED RECRUITING TEAMS -// ============================================================================ - -interface FeaturedRecruitingProps { - teams: TeamDisplayData[]; - onTeamClick: (id: string) => void; -} - -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); - const LevelIcon = levelConfig?.icon || Shield; - - return ( - - ); - })} -
-
- ); -} - -// ============================================================================ -// TEAM LEADERBOARD PREVIEW COMPONENT (Top 5 + Link) -// ============================================================================ - -interface TeamLeaderboardPreviewProps { - topTeams: TeamDisplayData[]; - onTeamClick: (id: string) => void; -} - -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); - const LevelIcon = levelConfig?.icon || Shield; - - return ( - - ); - })} -
-
-
- ); -} // ============================================================================ // MAIN PAGE COMPONENT diff --git a/apps/website/components/achievements/AchievementCard.tsx b/apps/website/components/achievements/AchievementCard.tsx new file mode 100644 index 000000000..9da166650 --- /dev/null +++ b/apps/website/components/achievements/AchievementCard.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Trophy, Medal, Star, Crown, Target, Zap } from 'lucide-react'; +import type { DriverProfileAchievementViewModel } from '@/lib/view-models/DriverProfileViewModel'; + +interface AchievementCardProps { + achievement: DriverProfileAchievementViewModel; +} + +function getRarityColor(rarity: DriverProfileAchievementViewModel['rarity']) { + switch (rarity) { + case 'common': + return 'text-gray-400 bg-gray-400/10 border-gray-400/30'; + case 'rare': + return 'text-primary-blue bg-primary-blue/10 border-primary-blue/30'; + case 'epic': + return 'text-purple-400 bg-purple-400/10 border-purple-400/30'; + case 'legendary': + return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30'; + } +} + +function getAchievementIcon(icon: DriverProfileAchievementViewModel['icon']) { + switch (icon) { + case 'trophy': + return Trophy; + case 'medal': + return Medal; + case 'star': + return Star; + case 'crown': + return Crown; + case 'target': + return Target; + case 'zap': + return Zap; + } +} + +export default function AchievementCard({ achievement }: AchievementCardProps) { + const Icon = getAchievementIcon(achievement.icon); + const rarityClasses = getRarityColor(achievement.rarity); + + return ( +
+
+
+ +
+
+

{achievement.title}

+

{achievement.description}

+

+ {new Date(achievement.earnedAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} +

+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/auth/UserRolesPreview.tsx b/apps/website/components/auth/UserRolesPreview.tsx new file mode 100644 index 000000000..77b854639 --- /dev/null +++ b/apps/website/components/auth/UserRolesPreview.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Car, Trophy, Users } from 'lucide-react'; + +const USER_ROLES = [ + { + icon: Car, + title: 'Driver', + description: 'Race, track stats, join teams', + color: 'primary-blue', + }, + { + icon: Trophy, + title: 'League Admin', + description: 'Organize leagues and events', + color: 'performance-green', + }, + { + icon: Users, + title: 'Team Manager', + description: 'Manage team and drivers', + color: 'purple-400', + }, +]; + +interface UserRolesPreviewProps { + variant?: 'full' | 'compact'; +} + +export default function UserRolesPreview({ variant = 'full' }: UserRolesPreviewProps) { + if (variant === 'compact') { + return ( +
+

One account for all roles

+
+ {USER_ROLES.map((role) => ( +
+
+ +
+ {role.title} +
+ ))} +
+
+ ); + } + + return ( +
+ {USER_ROLES.map((role, index) => ( + +
+ +
+
+

{role.title}

+

{role.description}

+
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/charts/CircularProgress.tsx b/apps/website/components/charts/CircularProgress.tsx new file mode 100644 index 000000000..5e6f5001e --- /dev/null +++ b/apps/website/components/charts/CircularProgress.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +interface CircularProgressProps { + value: number; + max: number; + label: string; + color: string; + size?: number; +} + +export default function CircularProgress({ value, max, label, color, size = 80 }: CircularProgressProps) { + const percentage = Math.min((value / max) * 100, 100); + const strokeWidth = 6; + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const strokeDashoffset = circumference - (percentage / 100) * circumference; + + return ( +
+
+ + + + +
+ {percentage.toFixed(0)}% +
+
+ {label} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/charts/FinishDistributionChart.tsx b/apps/website/components/charts/FinishDistributionChart.tsx new file mode 100644 index 000000000..6cedaa96c --- /dev/null +++ b/apps/website/components/charts/FinishDistributionChart.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +interface FinishDistributionProps { + wins: number; + podiums: number; + topTen: number; + total: number; +} + +export default function FinishDistributionChart({ wins, podiums, topTen, total }: FinishDistributionProps) { + const outsideTopTen = total - topTen; + const podiumsNotWins = podiums - wins; + const topTenNotPodium = topTen - podiums; + + const segments = [ + { label: 'Wins', value: wins, color: 'bg-performance-green', textColor: 'text-performance-green' }, + { label: 'Podiums', value: podiumsNotWins, color: 'bg-warning-amber', textColor: 'text-warning-amber' }, + { label: 'Top 10', value: topTenNotPodium, color: 'bg-primary-blue', textColor: 'text-primary-blue' }, + { label: 'Other', value: outsideTopTen, color: 'bg-gray-600', textColor: 'text-gray-400' }, + ].filter(s => s.value > 0); + + return ( +
+
+ {segments.map((segment, index) => ( +
+ ))} +
+
+ {segments.map((segment) => ( +
+
+ + {segment.label}: {segment.value} ({((segment.value / total) * 100).toFixed(0)}%) + +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/charts/HorizontalBarChart.tsx b/apps/website/components/charts/HorizontalBarChart.tsx new file mode 100644 index 000000000..97d7652a3 --- /dev/null +++ b/apps/website/components/charts/HorizontalBarChart.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +interface BarChartProps { + data: { label: string; value: number; color: string }[]; + maxValue: number; +} + +export default function HorizontalBarChart({ data, maxValue }: BarChartProps) { + return ( +
+ {data.map((item) => ( +
+
+ {item.label} + {item.value} +
+
+
+
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/dashboard/FeedItemRow.tsx b/apps/website/components/dashboard/FeedItemRow.tsx new file mode 100644 index 000000000..76f43910b --- /dev/null +++ b/apps/website/components/dashboard/FeedItemRow.tsx @@ -0,0 +1,43 @@ +import { Activity, Trophy, Medal, UserPlus, Heart, Flag, Play } from 'lucide-react'; +import Button from '@/components/ui/Button'; +import Link from 'next/link'; +import { timeAgo } from '@/lib/utilities/time'; +import { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; + +function FeedItemRow({ item }: { item: DashboardFeedItemSummaryViewModel }) { + const getActivityIcon = (type: string) => { + if (type.includes('win')) return { icon: Trophy, color: 'text-yellow-400 bg-yellow-400/10' }; + if (type.includes('podium')) return { icon: Medal, color: 'text-warning-amber bg-warning-amber/10' }; + if (type.includes('join')) return { icon: UserPlus, color: 'text-performance-green bg-performance-green/10' }; + if (type.includes('friend')) return { icon: Heart, color: 'text-pink-400 bg-pink-400/10' }; + if (type.includes('league')) return { icon: Flag, color: 'text-primary-blue bg-primary-blue/10' }; + if (type.includes('race')) return { icon: Play, color: 'text-red-400 bg-red-400/10' }; + return { icon: Activity, color: 'text-gray-400 bg-gray-400/10' }; + }; + + const { icon: Icon, color } = getActivityIcon(item.type); + + return ( +
+
+ +
+
+

{item.headline}

+ {item.body && ( +

{item.body}

+ )} +

{timeAgo(item.timestamp)}

+
+ {item.ctaHref && ( + + + + )} +
+ ); +} + +export { FeedItemRow }; \ No newline at end of file diff --git a/apps/website/components/dashboard/FriendItem.tsx b/apps/website/components/dashboard/FriendItem.tsx new file mode 100644 index 000000000..3b25667d6 --- /dev/null +++ b/apps/website/components/dashboard/FriendItem.tsx @@ -0,0 +1,33 @@ +import Link from 'next/link'; +import Image from 'next/image'; +import { getCountryFlag } from '@/lib/utilities/country'; + +interface FriendItemProps { + id: string; + name: string; + avatarUrl: string; + country: string; +} + +export function FriendItem({ id, name, avatarUrl, country }: FriendItemProps) { + return ( + +
+ {name} +
+
+

{name}

+

{getCountryFlag(country)}

+
+ + ); +} \ No newline at end of file diff --git a/apps/website/components/dashboard/LeagueStandingItem.tsx b/apps/website/components/dashboard/LeagueStandingItem.tsx new file mode 100644 index 000000000..87e9615db --- /dev/null +++ b/apps/website/components/dashboard/LeagueStandingItem.tsx @@ -0,0 +1,54 @@ +import Link from 'next/link'; +import { Crown, ChevronRight } from 'lucide-react'; + +interface LeagueStandingItemProps { + leagueId: string; + leagueName: string; + position: number; + points: number; + totalDrivers: number; + className?: string; +} + +export function LeagueStandingItem({ + leagueId, + leagueName, + position, + points, + totalDrivers, + className, +}: LeagueStandingItemProps) { + return ( + +
+ {position > 0 ? `P${position}` : '-'} +
+
+

+ {leagueName} +

+

+ {points} points • {totalDrivers} drivers +

+
+
+ {position <= 3 && position > 0 && ( + + )} + +
+ + ); +} \ No newline at end of file diff --git a/apps/website/components/dashboard/StatCard.tsx b/apps/website/components/dashboard/StatCard.tsx new file mode 100644 index 000000000..7972e3f10 --- /dev/null +++ b/apps/website/components/dashboard/StatCard.tsx @@ -0,0 +1,25 @@ +import { LucideIcon } from 'lucide-react'; + +interface StatCardProps { + icon: LucideIcon; + value: string | number; + label: string; + color: string; + className?: string; +} + +export function StatCard({ icon: Icon, value, label, color, className }: StatCardProps) { + return ( +
+
+
+ +
+
+

{value}

+

{label}

+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/dashboard/UpcomingRaceItem.tsx b/apps/website/components/dashboard/UpcomingRaceItem.tsx new file mode 100644 index 000000000..ea6772452 --- /dev/null +++ b/apps/website/components/dashboard/UpcomingRaceItem.tsx @@ -0,0 +1,39 @@ +import Link from 'next/link'; +import { timeUntil } from '@/lib/utilities/time'; + +interface UpcomingRaceItemProps { + id: string; + track: string; + car: string; + scheduledAt: Date; + isMyLeague: boolean; +} + +export function UpcomingRaceItem({ + id, + track, + car, + scheduledAt, + isMyLeague, +}: UpcomingRaceItemProps) { + return ( + +
+

{track}

+ {isMyLeague && ( + + )} +
+

{car}

+
+ + {scheduledAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + + {timeUntil(scheduledAt)} +
+ + ); +} \ No newline at end of file diff --git a/apps/website/components/drivers/HeroSection.tsx b/apps/website/components/drivers/HeroSection.tsx new file mode 100644 index 000000000..30e27d5cc --- /dev/null +++ b/apps/website/components/drivers/HeroSection.tsx @@ -0,0 +1,84 @@ +import { Users, Trophy, ChevronRight } from 'lucide-react'; +import Heading from '@/components/ui/Heading'; +import Button from '@/components/ui/Button'; + +interface HeroSectionProps { + icon?: React.ElementType; + title: string; + description: string; + stats: Array<{ + value: number | string; + label: string; + color: string; + animate?: boolean; + }>; + ctaLabel?: string; + ctaDescription?: string; + onCtaClick?: () => void; + className?: string; +} + +export function HeroSection({ + icon: Icon = Users, + title, + description, + stats, + ctaLabel = "View Leaderboard", + ctaDescription = "See full driver rankings", + onCtaClick, + className, +}: HeroSectionProps) { + return ( +
+ {/* Background decoration */} +
+
+
+ +
+
+
+
+ +
+ + {title} + +
+

+ {description} +

+ + {/* Quick Stats */} +
+ {stats.map((stat, index) => ( +
+
+ + + {typeof stat.value === 'number' ? stat.value.toLocaleString() : stat.value} + {stat.label} + +
+ ))} +
+
+ + {/* CTA */} + {onCtaClick && ( +
+ +

{ctaDescription}

+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/landing/FeatureItem.tsx b/apps/website/components/landing/FeatureItem.tsx new file mode 100644 index 000000000..4ccdf8d27 --- /dev/null +++ b/apps/website/components/landing/FeatureItem.tsx @@ -0,0 +1,23 @@ +import { LucideIcon } from 'lucide-react'; + +interface FeatureItemProps { + icon: LucideIcon; + text: string; + className?: string; +} + +export function FeatureItem({ icon: Icon, text, className }: FeatureItemProps) { + return ( +
+
+
+
+ +
+ + {text} + +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/EmptyState.tsx b/apps/website/components/leagues/EmptyState.tsx new file mode 100644 index 000000000..aade98e00 --- /dev/null +++ b/apps/website/components/leagues/EmptyState.tsx @@ -0,0 +1,53 @@ +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'; + +interface EmptyStateProps { + title: string; + description: string; + icon?: React.ElementType; + actionIcon?: React.ElementType; + actionLabel?: string; + onAction?: () => void; + children?: React.ReactNode; + className?: string; +} + +export function EmptyState({ + title, + description, + icon: Icon = Trophy, + actionIcon: ActionIcon = Sparkles, + actionLabel, + onAction, + children, + className, +}: EmptyStateProps) { + return ( + +
+
+ +
+ + {title} + +

+ {description} +

+ {children} + {actionLabel && onAction && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/HeroSection.tsx b/apps/website/components/leagues/HeroSection.tsx new file mode 100644 index 000000000..fdae70ff8 --- /dev/null +++ b/apps/website/components/leagues/HeroSection.tsx @@ -0,0 +1,85 @@ +import { Trophy, Plus } from 'lucide-react'; +import Heading from '@/components/ui/Heading'; +import Button from '@/components/ui/Button'; + +interface StatItem { + value: number; + label: string; + color: string; + animate?: boolean; +} + +interface HeroSectionProps { + icon?: React.ElementType; + title: string; + description: string; + stats?: StatItem[]; + ctaLabel?: string; + ctaDescription?: string; + onCtaClick?: () => void; + className?: string; +} + +export function HeroSection({ + icon: Icon = Trophy, + title, + description, + stats = [], + ctaLabel = "Create League", + ctaDescription = "Set up your own racing series", + onCtaClick, + className, +}: HeroSectionProps) { + return ( +
+ {/* Background decoration */} +
+
+ +
+
+
+
+ +
+ + {title} + +
+

+ {description} +

+ + {/* Stats */} + {stats.length > 0 && ( +
+ {stats.map((stat, index) => ( +
+
+ + {stat.value} {stat.label} + +
+ ))} +
+ )} +
+ + {/* CTA */} + {onCtaClick && ( +
+ +

{ctaDescription}

+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/LeagueChampionshipStats.tsx b/apps/website/components/leagues/LeagueChampionshipStats.tsx new file mode 100644 index 000000000..9a4f88204 --- /dev/null +++ b/apps/website/components/leagues/LeagueChampionshipStats.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import Card from '@/components/ui/Card'; +import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel'; +import { DriverViewModel } from '@/lib/view-models'; + +interface LeagueChampionshipStatsProps { + standings: StandingEntryViewModel[]; + drivers: DriverViewModel[]; +} + +export default function LeagueChampionshipStats({ standings, drivers }: LeagueChampionshipStatsProps) { + if (standings.length === 0) return null; + + const leader = standings[0]; + const totalRaces = Math.max(...standings.map(s => s.races), 0); + + return ( +
+ +
+
+ 🏆 +
+
+

Championship Leader

+

{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}

+

{leader?.points || 0} points

+
+
+
+ + +
+
+ 🏁 +
+
+

Races Completed

+

{totalRaces}

+

Season in progress

+
+
+
+ + +
+
+ 👥 +
+
+

Active Drivers

+

{standings.length}

+

Competing for points

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/LeagueOwnershipTransfer.tsx b/apps/website/components/leagues/LeagueOwnershipTransfer.tsx new file mode 100644 index 000000000..1ece03f04 --- /dev/null +++ b/apps/website/components/leagues/LeagueOwnershipTransfer.tsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react'; +import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; +import Button from '@/components/ui/Button'; +import { UserCog } from 'lucide-react'; +import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel'; + +interface LeagueOwnershipTransferProps { + settings: LeagueSettingsViewModel; + currentDriverId: string; + onTransferOwnership: (newOwnerId: string) => Promise; +} + +export default function LeagueOwnershipTransfer({ + settings, + currentDriverId, + onTransferOwnership +}: LeagueOwnershipTransferProps) { + const [showTransferDialog, setShowTransferDialog] = useState(false); + const [selectedNewOwner, setSelectedNewOwner] = useState(''); + const [transferring, setTransferring] = useState(false); + + const handleTransferOwnership = async () => { + if (!selectedNewOwner) return; + + setTransferring(true); + try { + await onTransferOwnership(selectedNewOwner); + setShowTransferDialog(false); + setSelectedNewOwner(''); + } catch (err) { + console.error('Failed to transfer ownership:', err); + alert(err instanceof Error ? err.message : 'Failed to transfer ownership'); + } finally { + setTransferring(false); + } + }; + + const ownerSummary = settings.owner; + + return ( +
+ {/* League Owner */} +
+

League Owner

+ {ownerSummary ? ( + + ) : ( +

Loading owner details...

+ )} +
+ + {/* Transfer Ownership - Owner Only */} + {settings.league.ownerId === currentDriverId && settings.members.length > 0 && ( +
+
+ +

Transfer Ownership

+
+

+ Transfer league ownership to another active member. You will become an admin. +

+ + {!showTransferDialog ? ( + + ) : ( +
+ + +
+ + +
+
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/LeagueSlider.tsx b/apps/website/components/leagues/LeagueSlider.tsx new file mode 100644 index 000000000..28c52498f --- /dev/null +++ b/apps/website/components/leagues/LeagueSlider.tsx @@ -0,0 +1,204 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; +import LeagueCard from '@/components/leagues/LeagueCard'; + +interface LeagueSliderProps { + title: string; + icon: React.ElementType; + description: string; + leagues: LeagueSummaryViewModel[]; + onLeagueClick: (id: string) => void; + autoScroll?: boolean; + iconColor?: string; + scrollSpeedMultiplier?: number; + scrollDirection?: 'left' | 'right'; +} + +export const LeagueSlider = ({ + title, + icon: Icon, + description, + leagues, + onLeagueClick, + autoScroll = true, + iconColor = 'text-primary-blue', + scrollSpeedMultiplier = 1, + scrollDirection = 'right', +}: LeagueSliderProps) => { + const scrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + const [isHovering, setIsHovering] = useState(false); + const animationRef = useRef(null); + const scrollPositionRef = useRef(0); + + const checkScrollButtons = useCallback(() => { + if (scrollRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10); + } + }, []); + + const scroll = useCallback((direction: 'left' | 'right') => { + if (scrollRef.current) { + const cardWidth = 340; + const scrollAmount = direction === 'left' ? -cardWidth : cardWidth; + // Update the ref so auto-scroll continues from new position + scrollPositionRef.current = scrollRef.current.scrollLeft + scrollAmount; + scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' }); + } + }, []); + + // Initialize scroll position for left-scrolling sliders + useEffect(() => { + if (scrollDirection === 'left' && scrollRef.current) { + const { scrollWidth, clientWidth } = scrollRef.current; + scrollPositionRef.current = scrollWidth - clientWidth; + scrollRef.current.scrollLeft = scrollPositionRef.current; + } + }, [scrollDirection, leagues.length]); + + // Smooth continuous auto-scroll using requestAnimationFrame with variable speed and direction + useEffect(() => { + // Allow scroll even with just 2 leagues (minimum threshold = 1) + if (!autoScroll || leagues.length <= 1) return; + + const scrollContainer = scrollRef.current; + if (!scrollContainer) return; + + let lastTimestamp = 0; + // Base speed with multiplier for variation between sliders + const baseSpeed = 0.025; + const scrollSpeed = baseSpeed * scrollSpeedMultiplier; + const directionMultiplier = scrollDirection === 'left' ? -1 : 1; + + const animate = (timestamp: number) => { + if (!isHovering && scrollContainer) { + const delta = lastTimestamp ? timestamp - lastTimestamp : 0; + lastTimestamp = timestamp; + + scrollPositionRef.current += scrollSpeed * delta * directionMultiplier; + + const { scrollWidth, clientWidth } = scrollContainer; + const maxScroll = scrollWidth - clientWidth; + + // Handle wrap-around for both directions + if (scrollDirection === 'right' && scrollPositionRef.current >= maxScroll) { + scrollPositionRef.current = 0; + } else if (scrollDirection === 'left' && scrollPositionRef.current <= 0) { + scrollPositionRef.current = maxScroll; + } + + scrollContainer.scrollLeft = scrollPositionRef.current; + } else { + lastTimestamp = timestamp; + } + + animationRef.current = requestAnimationFrame(animate); + }; + + animationRef.current = requestAnimationFrame(animate); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [autoScroll, leagues.length, isHovering, scrollSpeedMultiplier, scrollDirection]); + + // Sync scroll position when user manually scrolls + useEffect(() => { + const scrollContainer = scrollRef.current; + if (!scrollContainer) return; + + const handleScroll = () => { + scrollPositionRef.current = scrollContainer.scrollLeft; + checkScrollButtons(); + }; + + scrollContainer.addEventListener('scroll', handleScroll); + return () => scrollContainer.removeEventListener('scroll', handleScroll); + }, [checkScrollButtons]); + + if (leagues.length === 0) return null; + + return ( +
+ {/* Section header */} +
+
+
+ +
+
+

{title}

+

{description}

+
+ + {leagues.length} + +
+ + {/* Navigation arrows */} +
+ + +
+
+ + {/* Scrollable container with fade edges */} +
+ {/* Left fade gradient */} +
+ {/* Right fade gradient */} +
+ +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + className="flex gap-4 overflow-x-auto pb-4 px-4" + style={{ + scrollbarWidth: 'none', + msOverflowStyle: 'none', + }} + > + + {leagues.map((league) => ( +
+ onLeagueClick(league.id)} /> +
+ ))} +
+
+
+ ); +}; \ No newline at end of file diff --git a/apps/website/components/leagues/NoResultsState.tsx b/apps/website/components/leagues/NoResultsState.tsx new file mode 100644 index 000000000..0d9cdc561 --- /dev/null +++ b/apps/website/components/leagues/NoResultsState.tsx @@ -0,0 +1,45 @@ +import { Search } from 'lucide-react'; +import Button from '@/components/ui/Button'; +import Card from '@/components/ui/Card'; + +interface NoResultsStateProps { + icon?: React.ElementType; + message?: string; + searchQuery?: string; + actionLabel?: string; + onAction?: () => void; + children?: React.ReactNode; + className?: string; +} + +export function NoResultsState({ + icon: Icon = Search, + message, + searchQuery, + actionLabel = "Clear filters", + onAction, + children, + className +}: NoResultsStateProps) { + const defaultMessage = message || `No leagues found${searchQuery ? ` matching "${searchQuery}"` : ' in this category'}`; + + return ( + +
+ +

+ {defaultMessage} +

+ {children} + {actionLabel && onAction && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/PointsTable.tsx b/apps/website/components/leagues/PointsTable.tsx new file mode 100644 index 000000000..7fc619ad0 --- /dev/null +++ b/apps/website/components/leagues/PointsTable.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import Card from '@/components/ui/Card'; + +interface PointsTableProps { + title?: string; + points: { position: number; points: number }[]; +} + +export default function PointsTable({ title = 'Points Distribution', points }: PointsTableProps) { + return ( + +

{title}

+
+ + + + + + + + + {points.map(({ position, points: pts }) => ( + + + + + ))} + +
PositionPoints
+
+
+ {position} +
+ + {position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`} + +
+
+ {pts} + pts +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/SearchAndFilterBar.tsx b/apps/website/components/leagues/SearchAndFilterBar.tsx new file mode 100644 index 000000000..099d6f877 --- /dev/null +++ b/apps/website/components/leagues/SearchAndFilterBar.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { Search, Filter } from 'lucide-react'; +import Input from '@/components/ui/Input'; +import Button from '@/components/ui/Button'; + +interface Category { + id: string; + label: string; + icon: React.ElementType; + description: string; + color?: string; +} + +interface SearchAndFilterBarProps { + searchQuery: string; + onSearchChange: (query: string) => void; + activeCategory: string; + onCategoryChange: (category: string) => void; + categories: Category[]; + leaguesByCategory: Record; + className?: string; +} + +export function SearchAndFilterBar({ + searchQuery, + onSearchChange, + activeCategory, + onCategoryChange, + categories, + leaguesByCategory, + className, +}: SearchAndFilterBarProps) { + const [showFilters, setShowFilters] = useState(false); + + return ( +
+
+ {/* Search */} +
+ + onSearchChange(e.target.value)} + className="pl-11" + /> +
+ + {/* Filter toggle (mobile) */} + +
+ + {/* Category Tabs */} +
+
+ {categories.map((category) => { + const Icon = category.icon; + const count = leaguesByCategory[category.id]?.length || 0; + const isActive = activeCategory === category.id; + + return ( + + ); + })} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/StewardingStats.tsx b/apps/website/components/leagues/StewardingStats.tsx new file mode 100644 index 000000000..88af6ea45 --- /dev/null +++ b/apps/website/components/leagues/StewardingStats.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { CheckCircle, Clock, Gavel } from 'lucide-react'; + +interface StewardingStatsProps { + totalPending: number; + totalResolved: number; + totalPenalties: number; +} + +export default function StewardingStats({ totalPending, totalResolved, totalPenalties }: StewardingStatsProps) { + return ( +
+
+
+ + Pending Review +
+
{totalPending}
+
+
+
+ + Resolved +
+
{totalResolved}
+
+
+
+ + Penalties +
+
{totalPenalties}
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/TransactionRow.tsx b/apps/website/components/leagues/TransactionRow.tsx new file mode 100644 index 000000000..0dbe15eed --- /dev/null +++ b/apps/website/components/leagues/TransactionRow.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { + ArrowDownLeft, + ArrowUpRight, + CheckCircle, + Clock, + CreditCard, + DollarSign, + TrendingUp, + XCircle +} from 'lucide-react'; + +interface Transaction { + id: string; + amount: number; + type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize'; + status: 'completed' | 'pending' | 'failed'; + description: string; + reference?: string; + formattedDate: string; + formattedAmount: string; + fee: number; +} + +interface TransactionRowProps { + transaction: Transaction; +} + +export default function TransactionRow({ transaction }: TransactionRowProps) { + const isIncoming = transaction.amount > 0; + + const typeIcons = { + sponsorship: DollarSign, + membership: CreditCard, + withdrawal: ArrowUpRight, + prize: TrendingUp, + }; + const TypeIcon = typeIcons[transaction.type]; + + const statusConfig = { + completed: { color: 'text-performance-green', bg: 'bg-performance-green/10', icon: CheckCircle }, + pending: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', icon: Clock }, + failed: { color: 'text-racing-red', bg: 'bg-racing-red/10', icon: XCircle }, + }; + const status = statusConfig[transaction.status]; + const StatusIcon = status.icon; + + return ( +
+
+
+ {isIncoming ? ( + + ) : ( + + )} +
+
+
+ {transaction.description} + + {transaction.status} + +
+
+ + {transaction.type} + {transaction.reference && ( + <> + + {transaction.reference} + + )} + + {transaction.formattedDate} +
+
+
+
+
+ {transaction.formattedAmount} +
+ {transaction.fee > 0 && ( +
+ Fee: ${transaction.fee.toFixed(2)} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/profile/LiveryCard.tsx b/apps/website/components/profile/LiveryCard.tsx new file mode 100644 index 000000000..10c76a600 --- /dev/null +++ b/apps/website/components/profile/LiveryCard.tsx @@ -0,0 +1,76 @@ +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { Car, Download, Trash2, Edit } from 'lucide-react'; + +interface DriverLiveryItem { + id: string; + carId: string; + carName: string; + thumbnailUrl: string; + uploadedAt: Date; + isValidated: boolean; +} + +interface LiveryCardProps { + livery: DriverLiveryItem; + onEdit?: (id: string) => void; + onDownload?: (id: string) => void; + onDelete?: (id: string) => void; +} + +export default function LiveryCard({ livery, onEdit, onDownload, onDelete }: LiveryCardProps) { + return ( + + {/* Livery Preview */} +
+ +
+ + {/* Livery Info */} +
+
+

{livery.carName}

+ {livery.isValidated ? ( + + Validated + + ) : ( + + Pending + + )} +
+ +

+ Uploaded {new Date(livery.uploadedAt).toLocaleDateString()} +

+ + {/* Actions */} +
+ + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/races/LiveRaceBanner.tsx b/apps/website/components/races/LiveRaceBanner.tsx new file mode 100644 index 000000000..9726276a7 --- /dev/null +++ b/apps/website/components/races/LiveRaceBanner.tsx @@ -0,0 +1,51 @@ +import { ChevronRight, PlayCircle } from 'lucide-react'; + +interface LiveRaceBannerProps { + liveRaces: Array<{ + id: string; + track: string; + leagueName: string; + }>; + onRaceClick?: (raceId: string) => void; + className?: string; +} + +export function LiveRaceBanner({ liveRaces, onRaceClick, className }: LiveRaceBannerProps) { + if (liveRaces.length === 0) return null; + + return ( +
+
+ +
+
+
+ + LIVE NOW +
+
+ +
+ {liveRaces.map((race) => ( +
onRaceClick?.(race.id)} + className="flex items-center justify-between p-4 bg-deep-graphite/80 rounded-lg border border-performance-green/20 cursor-pointer hover:border-performance-green/40 transition-all" + > +
+
+ +
+
+

{race.track}

+

{race.leagueName}

+
+
+ +
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/races/QuickActions.tsx b/apps/website/components/races/QuickActions.tsx new file mode 100644 index 000000000..93edc3728 --- /dev/null +++ b/apps/website/components/races/QuickActions.tsx @@ -0,0 +1,32 @@ +import Link from 'next/link'; +import { Users, Trophy, ChevronRight } from 'lucide-react'; + +export function QuickActions({ className }: { className?: string }) { + return ( +
+

Quick Actions

+
+ +
+ +
+ Browse Leagues + + + +
+ +
+ View Leaderboards + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/races/RaceCard.tsx b/apps/website/components/races/RaceCard.tsx index f99d0d30e..d3d728214 100644 --- a/apps/website/components/races/RaceCard.tsx +++ b/apps/website/components/races/RaceCard.tsx @@ -1,185 +1,98 @@ -'use client'; - -import { Race } from '@core/racing/domain/entities/Race'; -import { Clock, PlayCircle, CheckCircle2, XCircle, Zap, Car, Trophy } from 'lucide-react'; +import Link from 'next/link'; +import { ChevronRight, Car, Zap, Trophy, ArrowRight } from 'lucide-react'; +import { formatTime, getRelativeTime } from '@/lib/utilities/time'; +import { raceStatusConfig } from '@/lib/utilities/raceStatus'; interface RaceCardProps { - race: Race; - leagueName?: string; + race: { + id: string; + track: string; + car: string; + scheduledAt: string; + status: string; + leagueId?: string; + leagueName: string; + strengthOfField?: number | null; + }; onClick?: () => void; - compact?: boolean; + className?: string; } -export default function RaceCard({ race, leagueName, onClick, compact = false }: RaceCardProps) { - const statusConfig = { - 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', - }, - }; - - const config = statusConfig[race.status]; - const StatusIcon = config.icon; - - const formatDate = (date: Date) => { - return new Date(date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); - }; - - const formatTime = (date: Date) => { - return new Date(date).toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - timeZoneName: 'short', - }); - }; - - const getRelativeTime = (date: Date) => { - const now = new Date(); - const targetDate = new 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 null; - 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 null; - }; - - const relativeTime = race.status === 'scheduled' ? getRelativeTime(race.scheduledAt) : null; - - if (compact) { - return ( -
- {race.status === 'running' && ( -
- )} -
-
- -
-
-

{race.track}

-

{formatTime(race.scheduledAt)}

-
- {relativeTime && ( - {relativeTime} - )} -
-
- ); - } +export function RaceCard({ race, onClick, className }: RaceCardProps) { + const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig]; return (
- {/* Live indicator bar */} + {/* Live indicator */} {race.status === 'running' && (
)} -
- {/* Left side - Race info */} +
+ {/* Time Column */} +
+

+ {formatTime(race.scheduledAt)} +

+

+ {race.status === 'running' + ? 'LIVE' + : getRelativeTime(race.scheduledAt)} +

+
+ + {/* Divider */} +
+ + {/* Main Content */}
-
-

{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 && ( +
+
+ +
+ + {title} + +
+ )} + {!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) => ( +
+
+
+

{message}

+
+
+); \ 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} +
+ {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