diff --git a/apps/website/app/dashboard/page.tsx b/apps/website/app/dashboard/page.tsx index 0adaad7a6..777efdc15 100644 --- a/apps/website/app/dashboard/page.tsx +++ b/apps/website/app/dashboard/page.tsx @@ -6,6 +6,7 @@ import { getFeedRepository, getRaceRepository, getResultRepository, + getDriverRepository, } from '@/lib/di-container'; export const dynamic = 'force-dynamic'; @@ -21,7 +22,8 @@ export default async function DashboardPage() { const feedRepository = getFeedRepository(); const raceRepository = getRaceRepository(); const resultRepository = getResultRepository(); - + const driverRepository = getDriverRepository(); + const [feedItems, upcomingRaces, allResults] = await Promise.all([ feedRepository.getFeedForDriver(session.user.primaryDriverId ?? ''), raceRepository.findAll(), @@ -35,21 +37,27 @@ export default async function DashboardPage() { const completedRaces = upcomingRaces.filter((race) => race.status === 'completed'); - const latestResults = completedRaces.slice(0, 4).map((race) => { - const raceResults = allResults.filter((result) => result.raceId === race.id); - const winner = raceResults.sort((a, b) => a.position - b.position)[0]; - - return { - raceId: race.id, - leagueId: race.leagueId, - track: race.track, - car: race.car, - scheduledAt: race.scheduledAt, - winnerDriverId: winner?.driverId ?? '', - winnerName: 'Race Winner', - positionChange: winner ? winner.getPositionChange() : 0, - }; - }); + const latestResults = await Promise.all( + completedRaces.slice(0, 4).map(async (race) => { + const raceResults = allResults.filter((result) => result.raceId === race.id); + const winner = raceResults.slice().sort((a, b) => a.position - b.position)[0]; + const winnerDriverId = winner?.driverId ?? ''; + const winnerDriver = winnerDriverId + ? await driverRepository.findById(winnerDriverId) + : null; + + return { + raceId: race.id, + leagueId: race.leagueId, + track: race.track, + car: race.car, + scheduledAt: race.scheduledAt, + winnerDriverId, + winnerName: winnerDriver?.name ?? 'Race Winner', + positionChange: winner ? winner.getPositionChange() : 0, + }; + }), + ); return (
diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx index eef3a19f3..4dc85d13b 100644 --- a/apps/website/app/drivers/[id]/page.tsx +++ b/apps/website/app/drivers/[id]/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; +import Link from 'next/link'; import { useRouter, useParams } from 'next/navigation'; import { getDriverRepository } from '@/lib/di-container'; import DriverProfile from '@/components/drivers/DriverProfile'; @@ -10,7 +11,11 @@ import Breadcrumbs from '@/components/layout/Breadcrumbs'; import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; -export default function DriverDetailPage() { +export default function DriverDetailPage({ + searchParams, +}: { + searchParams?: { [key: string]: string | string[] | undefined }; +}) { const router = useRouter(); const params = useParams(); const driverId = params.id as string; @@ -19,6 +24,15 @@ export default function DriverDetailPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const from = + typeof searchParams?.from === 'string' ? searchParams.from : undefined; + const leagueId = + typeof searchParams?.leagueId === 'string' + ? searchParams.leagueId + : undefined; + const backLink = + from === 'league' && leagueId ? `/leagues/${leagueId}` : null; + useEffect(() => { loadDriver(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -84,6 +98,17 @@ export default function DriverDetailPage() { return (
+ {backLink && ( +
+ + ← Back to league + +
+ )} + {/* Breadcrumb */}
- -
- {driver.name.charAt(0)} + +
+ {driver.name}
diff --git a/apps/website/app/leagues/[id]/layout.tsx b/apps/website/app/leagues/[id]/layout.tsx new file mode 100644 index 000000000..93aa36293 --- /dev/null +++ b/apps/website/app/leagues/[id]/layout.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import Breadcrumbs from '@/components/layout/Breadcrumbs'; +import LeagueHeader from '@/components/leagues/LeagueHeader'; +import { getLeagueRepository, getDriverRepository } from '@/lib/di-container'; + +export default async function LeagueLayout(props: { + children: React.ReactNode; + params: Promise<{ id: string }>; +}) { + const { children, params } = props; + const resolvedParams = await params; + const leagueRepo = getLeagueRepository(); + const driverRepo = getDriverRepository(); + const league = await leagueRepo.findById(resolvedParams.id); + + if (!league) { + return ( +
+
+
League not found
+
+
+ ); + } + + const owner = await driverRepo.findById(league.ownerId); + const ownerName = owner ? owner.name : `${league.ownerId.slice(0, 8)}...`; + + return ( +
+
+ + + + +
{children}
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 6f728e591..f91f1a1aa 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -4,17 +4,13 @@ import { useState, useEffect } from 'react'; import { useRouter, useParams } from 'next/navigation'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; -import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip'; import JoinLeagueButton from '@/components/leagues/JoinLeagueButton'; -import MembershipStatus from '@/components/leagues/MembershipStatus'; import LeagueMembers from '@/components/leagues/LeagueMembers'; import LeagueSchedule from '@/components/leagues/LeagueSchedule'; import LeagueAdmin from '@/components/leagues/LeagueAdmin'; import StandingsTable from '@/components/leagues/StandingsTable'; -import Breadcrumbs from '@/components/layout/Breadcrumbs'; import { League } from '@gridpilot/racing/domain/entities/League'; import { Standing } from '@gridpilot/racing/domain/entities/Standing'; -import { Race } from '@gridpilot/racing/domain/entities/Race'; import { Driver } from '@gridpilot/racing/domain/entities/Driver'; import { getLeagueRepository, getRaceRepository, getDriverRepository, getStandingRepository } from '@/lib/di-container'; import { getMembership, isOwnerOrAdmin, getCurrentDriverId } from '@/lib/racingLegacyFacade'; @@ -80,31 +76,23 @@ export default function LeagueDetailPage() { if (loading) { return ( -
-
-
Loading league...
-
-
+
Loading league...
); } if (error || !league) { return ( -
-
- -
- {error || 'League not found'} -
- -
+ +
+ {error || 'League not found'}
-
+ + ); } @@ -114,221 +102,194 @@ export default function LeagueDetailPage() { }; return ( -
-
- {/* Breadcrumb */} - - - {/* League Header */} -
-
-
-

{league.name}

- + <> + {/* Action Card */} + {!membership && ( + +
+
+

Join This League

+

Become a member to participate in races and track your progress

+
+
+
- - - Alpha: Single League - -
-

{league.description}

-
+ + )} - {/* Action Card */} - {!membership && ( - -
+ {/* Overview section switcher (in-page, not primary tabs) */} +
+
+ + + + + {isAdmin && ( + + )} +
+
+ + {/* Tab Content */} + {activeTab === 'overview' && ( +
+ {/* League Info */} + +

League Information

+ +
-

Join This League

-

Become a member to participate in races and track your progress

+ +

{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}

-
- + +
+ +

+ {new Date(league.createdAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + })} +

-
- - )} - {/* Tabs Navigation */} -
-
- - - - - {isAdmin && ( - - )} -
-
+
+

League Settings

+ +
+
+ +

{league.settings.pointsSystem.toUpperCase()}

+
- {/* Tab Content */} - {activeTab === 'overview' && ( -
- {/* League Info */} - -

League Information

- -
-
- -

{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}

-
+
+ +

{league.settings.sessionDuration} minutes

+
-
- -

- {new Date(league.createdAt).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - })} -

-
- -
-

League Settings

- -
-
- -

{league.settings.pointsSystem.toUpperCase()}

-
- -
- -

{league.settings.sessionDuration} minutes

-
- -
- -

{league.settings.qualifyingFormat}

-
+
+ +

{league.settings.qualifyingFormat}

- +
+
- {/* Quick Actions */} - -

Quick Actions

- -
- {membership ? ( - <> - - - - - ) : ( + {/* Quick Actions */} + +

Quick Actions

+ +
+ {membership ? ( + <> + + - )} -
-
-
- )} - - {activeTab === 'schedule' && ( - - + + ) : ( + + )} +
- )} +
+ )} - {activeTab === 'standings' && ( - -

Standings

- -
- )} + {activeTab === 'schedule' && ( + + + + )} - {activeTab === 'members' && ( - -

League Members

- -
- )} + {activeTab === 'standings' && ( + +

Standings

+ +
+ )} - {activeTab === 'admin' && isAdmin && ( - - )} -
-
+ {activeTab === 'members' && ( + +

League Members

+ +
+ )} + + {activeTab === 'admin' && isAdmin && ( + + )} + ); } \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/races/[raceId]/page.tsx b/apps/website/app/leagues/[id]/races/[raceId]/page.tsx new file mode 100644 index 000000000..e96fda710 --- /dev/null +++ b/apps/website/app/leagues/[id]/races/[raceId]/page.tsx @@ -0,0 +1,402 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import Button from '@/components/ui/Button'; +import Card from '@/components/ui/Card'; +import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip'; +import type { Race } from '@gridpilot/racing/domain/entities/Race'; +import type { League } from '@gridpilot/racing/domain/entities/League'; +import type { Driver } from '@gridpilot/racing/domain/entities/Driver'; +import { getRaceRepository, getLeagueRepository, getDriverRepository } from '@/lib/di-container'; +import { + getMembership, + getCurrentDriverId, + isRegistered, + registerForRace, + withdrawFromRace, + getRegisteredDrivers, +} from '@/lib/racingLegacyFacade'; +import CompanionStatus from '@/components/alpha/CompanionStatus'; +import CompanionInstructions from '@/components/alpha/CompanionInstructions'; + +export default function LeagueRaceDetailPage() { + const router = useRouter(); + const params = useParams(); + const leagueId = params.id as string; + const raceId = params.raceId as string; + + const [race, setRace] = useState(null); + const [league, setLeague] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [cancelling, setCancelling] = useState(false); + const [registering, setRegistering] = useState(false); + const [entryList, setEntryList] = useState([]); + const [isUserRegistered, setIsUserRegistered] = useState(false); + const [canRegister, setCanRegister] = useState(false); + + const currentDriverId = getCurrentDriverId(); + + const loadRaceData = async () => { + try { + const raceRepo = getRaceRepository(); + const leagueRepo = getLeagueRepository(); + + const raceData = await raceRepo.findById(raceId); + + if (!raceData) { + setError('Race not found'); + setLoading(false); + return; + } + + setRace(raceData); + + const leagueData = await leagueRepo.findById(raceData.leagueId); + setLeague(leagueData); + + await loadEntryList(raceData.id, raceData.leagueId); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load race'); + } finally { + setLoading(false); + } + }; + + const loadEntryList = async (raceIdValue: string, leagueIdValue: string) => { + try { + const driverRepo = getDriverRepository(); + const registeredDriverIds = getRegisteredDrivers(raceIdValue); + const drivers = await Promise.all( + registeredDriverIds.map((id: string) => driverRepo.findById(id)) + ); + setEntryList( + drivers.filter((d: Driver | null): d is Driver => d !== null) + ); + + const userIsRegistered = isRegistered(raceIdValue, currentDriverId); + setIsUserRegistered(userIsRegistered); + + const membership = getMembership(leagueIdValue, currentDriverId); + const isUpcoming = race?.status === 'scheduled'; + setCanRegister(!!membership && membership.status === 'active' && !!isUpcoming); + } catch (err) { + console.error('Failed to load entry list:', err); + } + }; + + useEffect(() => { + loadRaceData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [raceId]); + + const handleCancelRace = async () => { + if (!race || race.status !== 'scheduled') return; + + const confirmed = window.confirm( + 'Are you sure you want to cancel this race? This action cannot be undone.' + ); + + if (!confirmed) return; + + setCancelling(true); + try { + const raceRepo = getRaceRepository(); + const cancelledRace = race.cancel(); + await raceRepo.update(cancelledRace); + setRace(cancelledRace); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to cancel race'); + } finally { + setCancelling(false); + } + }; + + const handleRegister = async () => { + if (!race || !league) return; + + const confirmed = window.confirm( + `Register for ${race.track}?\n\nYou'll be added to the entry list for this race.` + ); + + if (!confirmed) return; + + setRegistering(true); + try { + registerForRace(race.id, currentDriverId, league.id); + await loadEntryList(race.id, league.id); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to register for race'); + } finally { + setRegistering(false); + } + }; + + const handleWithdraw = async () => { + if (!race || !league) return; + + const confirmed = window.confirm( + 'Withdraw from this race?\n\nYou can register again later if you change your mind.' + ); + + if (!confirmed) return; + + setRegistering(true); + try { + withdrawFromRace(race.id, currentDriverId); + await loadEntryList(race.id, league.id); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to withdraw from race'); + } finally { + setRegistering(false); + } + }; + + 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 formatDateTime = (date: Date) => { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }); + }; + + const statusColors = { + scheduled: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30', + completed: 'bg-green-500/20 text-green-400 border-green-500/30', + cancelled: 'bg-gray-500/20 text-gray-400 border-gray-500/30', + } as const; + + if (loading) { + return ( +
Loading race details...
+ ); + } + + if (error || !race) { + return ( + +
+ {error || 'Race not found'} +
+ +
+ ); + } + + return ( +
+
+ +
+ + +
+ +
+
+ +
+
+
+

{race.track}

+ {league && ( +

{league.name}

+ )} +
+ + {race.status.charAt(0).toUpperCase() + race.status.slice(1)} + +
+
+ + {race.status === 'scheduled' && ( +
+ +
+ )} + +
+ +

Race Details

+ +
+
+ +

+ {formatDateTime(race.scheduledAt)} +

+
+ {formatDate(race.scheduledAt)} + {formatTime(race.scheduledAt)} +
+
+ +
+ +

{race.track}

+
+ +
+ +

{race.car}

+
+ +
+ +

{race.sessionType}

+
+
+
+ + +

Actions

+ +
+ {race.status === 'scheduled' && canRegister && !isUserRegistered && ( + + )} + + {race.status === 'scheduled' && isUserRegistered && ( +
+
+ ✓ Registered +
+ +
+ )} + + {race.status === 'completed' && ( + + )} + + {race.status === 'scheduled' && ( + + )} + + +
+
+
+ + {race.status === 'scheduled' && ( + +
+

Entry List

+ + {entryList.length} {entryList.length === 1 ? 'driver' : 'drivers'} registered + +
+ + {entryList.length === 0 ? ( +
+

No drivers registered yet

+

Be the first to register!

+
+ ) : ( +
+ {entryList.map((driver, index) => ( +
+ router.push( + `/drivers/${driver.id}?from=league&leagueId=${leagueId}`, + ) + } + > +
+ #{index + 1} +
+
+ + {driver.name.charAt(0)} + +
+
+

{driver.name}

+

{driver.country}

+
+ {driver.id === currentDriverId && ( + + You + + )} +
+ ))} +
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/standings/page.tsx b/apps/website/app/leagues/[id]/standings/page.tsx index 0c64029a0..9b621075e 100644 --- a/apps/website/app/leagues/[id]/standings/page.tsx +++ b/apps/website/app/leagues/[id]/standings/page.tsx @@ -1,25 +1,15 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useRouter, useParams } from 'next/navigation'; -import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import StandingsTable from '@/components/leagues/StandingsTable'; -import { League } from '@gridpilot/racing/domain/entities/League'; -import { Standing } from '@gridpilot/racing/domain/entities/Standing'; -import { Driver } from '@gridpilot/racing/domain/entities/Driver'; -import { - getLeagueRepository, - getStandingRepository, - getDriverRepository -} from '@/lib/di-container'; +import type { Standing } from '@gridpilot/racing/domain/entities/Standing'; +import type { Driver } from '@gridpilot/racing/domain/entities/Driver'; +import { getStandingRepository, getDriverRepository } from '@/lib/di-container'; -export default function LeagueStandingsPage() { - const router = useRouter(); - const params = useParams(); - const leagueId = params.id as string; +export default function LeagueStandingsPage({ params }: { params: { id: string } }) { + const leagueId = params.id; - const [league, setLeague] = useState(null); const [standings, setStandings] = useState([]); const [drivers, setDrivers] = useState([]); const [loading, setLoading] = useState(true); @@ -27,27 +17,15 @@ export default function LeagueStandingsPage() { const loadData = async () => { try { - const leagueRepo = getLeagueRepository(); const standingRepo = getStandingRepository(); const driverRepo = getDriverRepository(); - const leagueData = await leagueRepo.findById(leagueId); - - if (!leagueData) { - setError('League not found'); - setLoading(false); - return; - } + const allStandings = await standingRepo.findAll(); + const leagueStandings = allStandings.filter((s) => s.leagueId === leagueId); + setStandings(leagueStandings); - setLeague(leagueData); - - // Load standings - const standingsData = await standingRepo.findByLeagueId(leagueId); - setStandings(standingsData); - - // Load drivers - const driversData = await driverRepo.findAll(); - setDrivers(driversData); + const allDrivers = await driverRepo.findAll(); + setDrivers(allDrivers); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load standings'); } finally { @@ -62,73 +40,24 @@ export default function LeagueStandingsPage() { if (loading) { return ( -
-
-
Loading standings...
-
+
+ Loading standings...
); } - if (error || !league) { + if (error) { return ( -
-
- -
- {error || 'League not found'} -
- -
-
+
+ {error}
); } return ( -
-
- {/* Breadcrumb */} -
- -
- - {/* Page Header */} -
-

Championship Standings

-

{league.name}

-
- - {/* Standings Content */} - - {standings.length > 0 ? ( - <> -

Current Standings

- - - ) : ( -
-
No standings available yet
-

- Standings will appear after race results are imported -

-
- )} -
-
-
+ +

Standings

+ +
); } \ No newline at end of file diff --git a/apps/website/components/drivers/DriverCard.tsx b/apps/website/components/drivers/DriverCard.tsx index a76633aed..76e9e1ba2 100644 --- a/apps/website/components/drivers/DriverCard.tsx +++ b/apps/website/components/drivers/DriverCard.tsx @@ -1,5 +1,7 @@ +import Image from 'next/image'; import Card from '@/components/ui/Card'; import RankBadge from '@/components/drivers/RankBadge'; +import { getDriverAvatarUrl } from '@/lib/racingLegacyFacade'; export interface DriverCardProps { id: string; @@ -16,6 +18,7 @@ export interface DriverCardProps { export default function DriverCard(props: DriverCardProps) { const { + id, name, rating, nationality, @@ -35,8 +38,14 @@ export default function DriverCard(props: DriverCardProps) {
-
- {name.charAt(0)} +
+ {name}
diff --git a/apps/website/components/leagues/LeagueCard.tsx b/apps/website/components/leagues/LeagueCard.tsx index c02d3f06c..f2d94e977 100644 --- a/apps/website/components/leagues/LeagueCard.tsx +++ b/apps/website/components/leagues/LeagueCard.tsx @@ -1,7 +1,9 @@ 'use client'; +import Link from 'next/link'; import { League } from '@gridpilot/racing/domain/entities/League'; import Card from '../ui/Card'; +import { getLeagueCoverClasses } from '@/lib/leagueCovers'; interface LeagueCardProps { league: League; @@ -15,27 +17,35 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) { onClick={onClick} > -
-
-

{league.name}

- - {new Date(league.createdAt).toLocaleDateString()} - -
- -

- {league.description} -

- -
-
- Owner ID: {league.ownerId.slice(0, 8)}... +
+
); diff --git a/apps/website/components/leagues/LeagueHeader.tsx b/apps/website/components/leagues/LeagueHeader.tsx new file mode 100644 index 000000000..e5904e5a0 --- /dev/null +++ b/apps/website/components/leagues/LeagueHeader.tsx @@ -0,0 +1,70 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import MembershipStatus from '@/components/leagues/MembershipStatus'; +import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip'; +import { getLeagueCoverClasses } from '@/lib/leagueCovers'; + +interface LeagueHeaderProps { + leagueId: string; + leagueName: string; + description?: string | null; + ownerId: string; + ownerName: string; +} + +export default function LeagueHeader({ + leagueId, + leagueName, + description, + ownerId, + ownerName, +}: LeagueHeaderProps) { + const coverUrl = `https://picsum.photos/seed/${leagueId}/1200/280?blur=2`; + + return ( +
+
+ +
+ +
+
+

{leagueName}

+ +
+ + + Alpha: Single League + + +
+ + {description && ( +

{description}

+ )} + +
+ Owner: + + {ownerName} + +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/LeagueMembers.tsx b/apps/website/components/leagues/LeagueMembers.tsx index 82027b828..290f0cd10 100644 --- a/apps/website/components/leagues/LeagueMembers.tsx +++ b/apps/website/components/leagues/LeagueMembers.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; +import Link from 'next/link'; import { Driver } from '@gridpilot/racing/domain/entities/Driver'; import { getDriverRepository, getDriverStats } from '@/lib/di-container'; import { @@ -167,9 +168,12 @@ export default function LeagueMembers({ >
- + {getDriverName(member.driverId)} - + {isCurrentUser && ( (You) )} diff --git a/apps/website/components/leagues/LeagueSchedule.tsx b/apps/website/components/leagues/LeagueSchedule.tsx index 5fc0516c4..75ede81cf 100644 --- a/apps/website/components/leagues/LeagueSchedule.tsx +++ b/apps/website/components/leagues/LeagueSchedule.tsx @@ -182,7 +182,7 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) { ? 'bg-iron-gray/50 border-charcoal-outline/50 opacity-75' : 'bg-deep-graphite border-charcoal-outline hover:border-primary-blue' }`} - onClick={() => router.push(`/races/${race.id}`)} + onClick={() => router.push(`/leagues/${leagueId}/races/${race.id}`)} >
diff --git a/apps/website/components/leagues/StandingsTable.tsx b/apps/website/components/leagues/StandingsTable.tsx index c817ae838..faa5a708a 100644 --- a/apps/website/components/leagues/StandingsTable.tsx +++ b/apps/website/components/leagues/StandingsTable.tsx @@ -1,16 +1,18 @@ 'use client'; +import Link from 'next/link'; import { Standing } from '@gridpilot/racing/domain/entities/Standing'; import { Driver } from '@gridpilot/racing/domain/entities/Driver'; interface StandingsTableProps { standings: Standing[]; drivers: Driver[]; + leagueId: string; } -export default function StandingsTable({ standings, drivers }: StandingsTableProps) { +export default function StandingsTable({ standings, drivers, leagueId }: StandingsTableProps) { const getDriverName = (driverId: string): string => { - const driver = drivers.find(d => d.id === driverId); + const driver = drivers.find((d) => d.id === driverId); return driver?.name || 'Unknown Driver'; }; @@ -37,9 +39,9 @@ export default function StandingsTable({ standings, drivers }: StandingsTablePro {standings.map((standing) => { const isLeader = standing.position === 1; - + return ( - @@ -49,9 +51,16 @@ export default function StandingsTable({ standings, drivers }: StandingsTablePro - + {getDriverName(standing.driverId)} - + {standing.points} diff --git a/apps/website/components/profile/ProfileHeader.tsx b/apps/website/components/profile/ProfileHeader.tsx index c733e19c6..5b033dcba 100644 --- a/apps/website/components/profile/ProfileHeader.tsx +++ b/apps/website/components/profile/ProfileHeader.tsx @@ -1,8 +1,9 @@ 'use client'; +import Image from 'next/image'; import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; import Button from '../ui/Button'; -import { getDriverTeam } from '@/lib/racingLegacyFacade'; +import { getDriverTeam, getDriverAvatarUrl } from '@/lib/racingLegacyFacade'; interface ProfileHeaderProps { driver: DriverDTO; @@ -14,8 +15,14 @@ export default function ProfileHeader({ driver, isOwnProfile = false, onEditClic return (
-
- {driver.name.charAt(0).toUpperCase()} +
+ {driver.name}
diff --git a/apps/website/components/teams/TeamCard.tsx b/apps/website/components/teams/TeamCard.tsx index 0a40a375e..02013d081 100644 --- a/apps/website/components/teams/TeamCard.tsx +++ b/apps/website/components/teams/TeamCard.tsx @@ -1,6 +1,8 @@ 'use client'; +import Image from 'next/image'; import Card from '../ui/Card'; +import { getTeamLogoUrl } from '@/lib/racingLegacyFacade'; interface TeamCardProps { id: string; @@ -36,14 +38,14 @@ export default function TeamCard({
-
- {logo ? ( - {name} - ) : ( - - {name.charAt(0)} - - )} +
+ {name}

diff --git a/apps/website/lib/leagueCovers.ts b/apps/website/lib/leagueCovers.ts new file mode 100644 index 000000000..2264c55d2 --- /dev/null +++ b/apps/website/lib/leagueCovers.ts @@ -0,0 +1,23 @@ +function hashString(input: string): number { + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + hash = (hash * 31 + input.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +const GRADIENTS: string[] = [ + 'bg-gradient-to-r from-blue-500/80 via-indigo-500/80 to-purple-500/80', + 'bg-gradient-to-r from-emerald-500/80 via-teal-500/80 to-cyan-500/80', + 'bg-gradient-to-r from-amber-500/80 via-orange-500/80 to-rose-500/80', + 'bg-gradient-to-r from-fuchsia-500/80 via-purple-500/80 to-sky-500/80', + 'bg-gradient-to-r from-lime-500/80 via-emerald-500/80 to-green-500/80', + 'bg-gradient-to-r from-slate-500/80 via-slate-600/80 to-slate-700/80', +]; + +export function getLeagueCoverClasses(leagueId: string): string { + const index = hashString(leagueId) % GRADIENTS.length; + const baseLayout = + 'w-full h-32 rounded-lg overflow-hidden border border-charcoal-outline/60'; + return baseLayout + ' ' + GRADIENTS[index]; +} \ No newline at end of file diff --git a/apps/website/lib/leagueRoles.ts b/apps/website/lib/leagueRoles.ts new file mode 100644 index 000000000..6e5587b6f --- /dev/null +++ b/apps/website/lib/leagueRoles.ts @@ -0,0 +1,71 @@ +import type { MembershipRole } from '@/lib/racingLegacyFacade'; + +export type LeagueRole = MembershipRole; + +export function isLeagueOwnerRole(role: LeagueRole): boolean { + return role === 'owner'; +} + +export function isLeagueAdminRole(role: LeagueRole): boolean { + return role === 'admin'; +} + +export function isLeagueStewardRole(role: LeagueRole): boolean { + return role === 'steward'; +} + +export function isLeagueMemberRole(role: LeagueRole): boolean { + return role === 'member'; +} + +/** + * Returns true for roles that should be treated as having elevated permissions. + * This keeps UI logic open for future roles like steward, streamer, sponsor. + */ +export function isLeagueAdminOrHigherRole(role: LeagueRole): boolean { + return role === 'owner' || role === 'admin' || role === 'steward'; +} + +/** + * Ordering helper for sorting memberships in tables. + */ +export function getLeagueRoleOrder(role: LeagueRole): number { + const order: Record = { + owner: 0, + admin: 1, + steward: 2, + member: 3, + }; + return order[role] ?? 99; +} + +/** + * Centralized display configuration for league membership roles. + */ +export function getLeagueRoleDisplay( + role: LeagueRole, +): { text: string; badgeClasses: string } { + switch (role) { + case 'owner': + return { + text: 'Owner', + badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30', + }; + case 'admin': + return { + text: 'Admin', + badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30', + }; + case 'steward': + return { + text: 'Steward', + badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30', + }; + case 'member': + default: + return { + text: 'Member', + badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30', + }; + } +} \ No newline at end of file diff --git a/apps/website/lib/racingLegacyFacade.ts b/apps/website/lib/racingLegacyFacade.ts index f7aafc4f1..2609fbc77 100644 --- a/apps/website/lib/racingLegacyFacade.ts +++ b/apps/website/lib/racingLegacyFacade.ts @@ -11,6 +11,7 @@ import type { MembershipRole, MembershipStatus, } from '@gridpilot/racing/domain/entities/LeagueMembership'; +import { getDriverAvatar, getTeamLogo, getLeagueBanner, memberships as seedMemberships, leagues as seedLeagues } from '@gridpilot/testing-support'; export type { MembershipRole, MembershipStatus }; @@ -97,6 +98,69 @@ function generateId(prefix: string): string { return `${prefix}-${idCounter++}`; } +// Initialize league memberships from static seed data +(function initializeLeagueMembershipsFromSeed() { + if (leagueMemberships.size > 0) { + return; + } + + const membershipsByLeague = new Map(); + + // Create base active memberships from seed + for (const membership of seedMemberships) { + const list = membershipsByLeague.get(membership.leagueId) ?? []; + const joinedAt = new Date(2024, 0, 1 + (idCounter % 28)).toISOString(); + + list.push({ + leagueId: membership.leagueId, + driverId: membership.driverId, + role: 'member', + status: 'active', + joinedAt, + }); + + membershipsByLeague.set(membership.leagueId, list); + } + + // Ensure league owners are represented as owners in memberships + for (const league of seedLeagues) { + const list = membershipsByLeague.get(league.id) ?? []; + const existingOwnerMembership = list.find((m) => m.driverId === league.ownerId); + + if (existingOwnerMembership) { + existingOwnerMembership.role = 'owner'; + } else { + const joinedAt = new Date(2024, 0, 1 + (idCounter % 28)).toISOString(); + list.unshift({ + leagueId: league.id, + driverId: league.ownerId, + role: 'owner', + status: 'active', + joinedAt, + }); + } + + membershipsByLeague.set(league.id, list); + } + + // Store into facade-local maps + for (const [leagueId, list] of membershipsByLeague.entries()) { + leagueMemberships.set(leagueId, list); + } +})(); + +export function getDriverAvatarUrl(driverId: string): string { + return getDriverAvatar(driverId); +} + +export function getTeamLogoUrl(teamId: string): string { + return getTeamLogo(teamId); +} + +export function getLeagueBannerUrl(leagueId: string): string { + return getLeagueBanner(leagueId); +} + /** * League membership API */ diff --git a/apps/website/next.config.mjs b/apps/website/next.config.mjs index f41715e59..3229ee48c 100644 --- a/apps/website/next.config.mjs +++ b/apps/website/next.config.mjs @@ -1,6 +1,18 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'placehold.co', + }, + { + protocol: 'https', + hostname: 'picsum.photos', + }, + ], + }, typescript: { ignoreBuildErrors: false, }, diff --git a/apps/website/public/images/leagues/placeholder-cover.svg b/apps/website/public/images/leagues/placeholder-cover.svg new file mode 100644 index 000000000..dabfd8239 --- /dev/null +++ b/apps/website/public/images/leagues/placeholder-cover.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/testing-support/src/racing/StaticRacingSeed.ts b/packages/testing-support/src/racing/StaticRacingSeed.ts index 2a33f3cc5..33d7aa517 100644 --- a/packages/testing-support/src/racing/StaticRacingSeed.ts +++ b/packages/testing-support/src/racing/StaticRacingSeed.ts @@ -6,8 +6,8 @@ import { Standing } from '@gridpilot/racing/domain/entities/Standing'; import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem'; import type { FriendDTO } from '@gridpilot/social/application/dto/FriendDTO'; -import { faker } from '@gridpilot/testing-support'; -import { getTeamLogo, getLeagueBanner, getDriverAvatar } from '@gridpilot/testing-support'; +import { faker } from '../faker/faker'; +import { getTeamLogo, getLeagueBanner, getDriverAvatar } from '../images/images'; export type RacingMembership = { driverId: string; @@ -346,20 +346,25 @@ function createFeedEvents( const events: FeedItem[] = []; const now = new Date(); const completedRaces = races.filter((race) => race.status === 'completed'); - const globalDrivers = faker.helpers.shuffle(drivers).slice(0, 10); - globalDrivers.forEach((driver, index) => { + // Focus the global feed around a stable “core” of demo drivers + const coreDrivers = faker.helpers.shuffle(drivers).slice(0, 16); + + coreDrivers.forEach((driver, index) => { const league = pickOne(leagues); const race = completedRaces[index % Math.max(1, completedRaces.length)]; - const minutesAgo = 15 + index * 10; - + const minutesAgo = 10 + index * 5; const baseTimestamp = new Date(now.getTime() - minutesAgo * 60 * 1000); + const actorFriendId = driver.id; + + // Joined league events.push({ id: `friend-joined-league:${driver.id}:${minutesAgo}`, type: 'friend-joined-league', timestamp: baseTimestamp, actorDriverId: driver.id, + actorFriendId, leagueId: league.id, headline: `${driver.name} joined ${league.name}`, body: 'They are now registered for the full season.', @@ -367,24 +372,66 @@ function createFeedEvents( ctaHref: `/leagues/${league.id}`, }); + // Finished race / podium highlight + const finishingPosition = (index % 5) + 1; events.push({ id: `friend-finished-race:${driver.id}:${minutesAgo}`, type: 'friend-finished-race', - timestamp: new Date(baseTimestamp.getTime() - 10 * 60 * 1000), + timestamp: new Date(baseTimestamp.getTime() - 8 * 60 * 1000), actorDriverId: driver.id, + actorFriendId, leagueId: race.leagueId, raceId: race.id, - position: (index % 5) + 1, - headline: `${driver.name} finished P${(index % 5) + 1} at ${race.track}`, - body: `${driver.name} secured a strong result in ${race.car}.`, + position: finishingPosition, + headline: `${driver.name} finished P${finishingPosition} at ${race.track}`, + body: + finishingPosition <= 3 + ? `${driver.name} scored a podium in ${race.car}.` + : `${driver.name} secured a strong result in ${race.car}.`, ctaLabel: 'View results', ctaHref: `/races/${race.id}/results`, }); + // New personal best + events.push({ + id: `friend-new-personal-best:${driver.id}:${minutesAgo}`, + type: 'friend-new-personal-best', + timestamp: new Date(baseTimestamp.getTime() - 20 * 60 * 1000), + actorDriverId: driver.id, + actorFriendId, + leagueId: race.leagueId, + raceId: race.id, + headline: `${driver.name} set a new personal best at ${race.track}`, + body: 'Consistency and pace are trending up this season.', + ctaLabel: 'View lap chart', + ctaHref: `/races/${race.id}/analysis`, + }); + + // Joined team (where applicable) + const driverFriendships = friendships.filter((f) => f.driverId === driver.id); + if (driverFriendships.length > 0) { + const friend = pickOne(driverFriendships); + const teammate = drivers.find((d) => d.id === friend.friendId); + if (teammate) { + events.push({ + id: `friend-joined-team:${driver.id}:${minutesAgo}`, + type: 'friend-joined-team', + timestamp: new Date(baseTimestamp.getTime() - 30 * 60 * 1000), + actorDriverId: driver.id, + actorFriendId, + headline: `${driver.name} and ${teammate.name} are now teammates`, + body: 'They will be sharing strategy and setups this season.', + ctaLabel: 'View team', + ctaHref: '/teams', + }); + } + } + + // League highlight events.push({ id: `league-highlight:${league.id}:${minutesAgo}`, type: 'league-highlight', - timestamp: new Date(baseTimestamp.getTime() - 30 * 60 * 1000), + timestamp: new Date(baseTimestamp.getTime() - 45 * 60 * 1000), leagueId: league.id, headline: `${league.name} active with ${drivers.length}+ drivers`, body: 'Participation is growing. Perfect time to join the grid.', @@ -393,6 +440,40 @@ function createFeedEvents( }); }); + // Global “system” events: new race scheduled and results posted + const upcomingRaces = races.filter((race) => race.status === 'scheduled').slice(0, 8); + upcomingRaces.forEach((race, index) => { + const minutesAgo = 60 + index * 15; + const timestamp = new Date(now.getTime() - minutesAgo * 60 * 1000); + events.push({ + id: `new-race-scheduled:${race.id}`, + type: 'new-race-scheduled', + timestamp, + leagueId: race.leagueId, + raceId: race.id, + headline: `New race scheduled at ${race.track}`, + body: `${race.car} • ${race.scheduledAt.toLocaleString()}`, + ctaLabel: 'View schedule', + ctaHref: `/races/${race.id}`, + }); + }); + + completedRaces.slice(0, 8).forEach((race, index) => { + const minutesAgo = 180 + index * 20; + const timestamp = new Date(now.getTime() - minutesAgo * 60 * 1000); + events.push({ + id: `new-result-posted:${race.id}`, + type: 'new-result-posted', + timestamp, + leagueId: race.leagueId, + raceId: race.id, + headline: `Results posted for ${race.track}`, + body: 'Standings and stats updated across the grid.', + ctaLabel: 'View classification', + ctaHref: `/races/${race.id}/results`, + }); + }); + const sorted = events .slice() .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); @@ -428,7 +509,15 @@ export function createStaticRacingSeed(seed: number): RacingSeedData { /** * Singleton seed used by website demo helpers. - * This mirrors the previous apps/website/lib/demo-data/index.ts behavior. + * + * Alpha demo dataset (deterministic, in-memory only): + * - 90+ drivers across multiple leagues + * - Leagues with precomputed races, results and standings + * - Team memberships and friendships forming social “circles” + * - Feed events referencing real driver, league, race and team IDs + * + * This mirrors the previous apps/website/lib/demo-data/index.ts behavior while + * keeping a stable shape for the website alpha experience. */ const staticSeed = createStaticRacingSeed(42);