wip
This commit is contained in:
@@ -344,8 +344,8 @@ export default function DriverDetailPage({
|
||||
backLink = `/leagues/${leagueId}`;
|
||||
} else if (from === 'league-members' && leagueId) {
|
||||
backLink = `/leagues/${leagueId}`;
|
||||
} else if (from === 'league-race' && leagueId && raceId) {
|
||||
backLink = `/leagues/${leagueId}/races/${raceId}`;
|
||||
} else if (from === 'league-race' && raceId) {
|
||||
backLink = `/races/${raceId}`;
|
||||
} else {
|
||||
backLink = null;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,71 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import LeagueHeader from '@/components/leagues/LeagueHeader';
|
||||
import { getLeagueRepository, getDriverRepository } from '@/lib/di-container';
|
||||
import { getLeagueRepository, getDriverRepository, getLeagueMembershipRepository } from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
|
||||
export default async function LeagueLayout(props: {
|
||||
export default function LeagueLayout({
|
||||
children,
|
||||
}: {
|
||||
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);
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [ownerName, setOwnerName] = useState<string>('');
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadLeague() {
|
||||
try {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
|
||||
const leagueData = await leagueRepo.findById(leagueId);
|
||||
|
||||
if (!leagueData) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLeague(leagueData);
|
||||
|
||||
const owner = await driverRepo.findById(leagueData.ownerId);
|
||||
setOwnerName(owner ? owner.name : `${leagueData.ownerId.slice(0, 8)}...`);
|
||||
|
||||
// Check if current user is admin
|
||||
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load league:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadLeague();
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading league...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!league) {
|
||||
return (
|
||||
@@ -23,8 +77,25 @@ export default async function LeagueLayout(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const owner = await driverRepo.findById(league.ownerId);
|
||||
const ownerName = owner ? owner.name : `${league.ownerId.slice(0, 8)}...`;
|
||||
// Define tab configuration
|
||||
const baseTabs = [
|
||||
{ label: 'Overview', href: `/leagues/${leagueId}`, exact: true },
|
||||
{ label: 'Schedule', href: `/leagues/${leagueId}/schedule`, exact: false },
|
||||
{ label: 'Standings', href: `/leagues/${leagueId}/standings`, exact: false },
|
||||
{ label: 'Rulebook', href: `/leagues/${leagueId}/rulebook`, exact: false },
|
||||
];
|
||||
|
||||
const adminTabs = [
|
||||
{ label: 'Stewarding', href: `/leagues/${leagueId}/stewarding`, exact: false },
|
||||
{ label: 'Settings', href: `/leagues/${leagueId}/settings`, exact: false },
|
||||
];
|
||||
|
||||
const tabs = isAdmin ? [...baseTabs, ...adminTabs] : baseTabs;
|
||||
|
||||
// Determine active tab
|
||||
const activeTab = tabs.find(tab =>
|
||||
tab.exact ? pathname === tab.href : pathname.startsWith(tab.href)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
@@ -45,6 +116,25 @@ export default async function LeagueLayout(props: {
|
||||
ownerName={ownerName}
|
||||
/>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="mb-6 border-b border-charcoal-outline">
|
||||
<div className="flex gap-6 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.href}
|
||||
onClick={() => router.push(tab.href)}
|
||||
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
|
||||
(tab.exact ? pathname === tab.href : pathname.startsWith(tab.href))
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRouter, useParams, useSearchParams } from 'next/navigation';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
|
||||
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 LeagueScoringTab from '@/components/leagues/LeagueScoringTab';
|
||||
import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed';
|
||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
||||
@@ -18,22 +13,21 @@ import {
|
||||
Driver,
|
||||
EntityMappers,
|
||||
type DriverDTO,
|
||||
type LeagueDriverSeasonStatsDTO,
|
||||
type LeagueScoringConfigDTO,
|
||||
} from '@gridpilot/racing';
|
||||
import {
|
||||
getLeagueRepository,
|
||||
getRaceRepository,
|
||||
getDriverRepository,
|
||||
getGetLeagueDriverSeasonStatsQuery,
|
||||
getGetLeagueScoringConfigQuery,
|
||||
getDriverStats,
|
||||
getAllDriverRankings,
|
||||
getGetLeagueStatsQuery,
|
||||
} from '@/lib/di-container';
|
||||
import { Zap, Users, Trophy, Calendar } from 'lucide-react';
|
||||
import { getMembership, getLeagueMembers, isOwnerOrAdmin } from '@/lib/leagueMembership';
|
||||
import { getMembership, getLeagueMembers } from '@/lib/leagueMembership';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { getLeagueRoleDisplay } from '@/lib/leagueRoles';
|
||||
|
||||
export default function LeagueDetailPage() {
|
||||
const router = useRouter();
|
||||
@@ -42,22 +36,16 @@ export default function LeagueDetailPage() {
|
||||
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [owner, setOwner] = useState<Driver | null>(null);
|
||||
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
|
||||
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
|
||||
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
|
||||
const [averageSOF, setAverageSOF] = useState<number | null>(null);
|
||||
const [completedRacesCount, setCompletedRacesCount] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'overview' | 'schedule' | 'standings' | 'scoring' | 'admin'
|
||||
>('overview');
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const membership = getMembership(leagueId, currentDriverId);
|
||||
const isAdmin = isOwnerOrAdmin(leagueId, currentDriverId);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const loadLeagueData = async () => {
|
||||
try {
|
||||
@@ -80,11 +68,6 @@ export default function LeagueDetailPage() {
|
||||
const ownerData = await driverRepo.findById(leagueData.ownerId);
|
||||
setOwner(ownerData);
|
||||
|
||||
// Load standings via rich season stats query
|
||||
const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery();
|
||||
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
|
||||
setStandings(leagueStandings);
|
||||
|
||||
// Load scoring configuration for the active season
|
||||
const getLeagueScoringConfigQuery = getGetLeagueScoringConfigQuery();
|
||||
const scoring = await getLeagueScoringConfigQuery.execute({ leagueId });
|
||||
@@ -121,23 +104,6 @@ export default function LeagueDetailPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leagueId]);
|
||||
|
||||
useEffect(() => {
|
||||
const initialTab = searchParams?.get('tab');
|
||||
if (!initialTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
initialTab === 'overview' ||
|
||||
initialTab === 'schedule' ||
|
||||
initialTab === 'standings' ||
|
||||
initialTab === 'scoring' ||
|
||||
initialTab === 'admin'
|
||||
) {
|
||||
setActiveTab(initialTab as typeof activeTab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleMembershipChange = () => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
loadLeagueData();
|
||||
@@ -154,6 +120,7 @@ export default function LeagueDetailPage() {
|
||||
const leagueMemberships = getLeagueMembers(leagueId);
|
||||
const ownerMembership = leagueMemberships.find((m) => m.role === 'owner') ?? null;
|
||||
const adminMemberships = leagueMemberships.filter((m) => m.role === 'admin');
|
||||
const stewardMemberships = leagueMemberships.filter((m) => m.role === 'steward');
|
||||
|
||||
const buildDriverSummary = (driverId: string) => {
|
||||
const driverDto = driversById[driverId];
|
||||
@@ -230,312 +197,181 @@ export default function LeagueDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Overview section switcher (in-page, not primary tabs) */}
|
||||
<div className="mb-6">
|
||||
<div className="inline-flex flex-wrap gap-2 rounded-full bg-iron-gray/60 px-2 py-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'overview'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'schedule'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('standings')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'standings'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Standings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('scoring')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'scoring'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Scoring
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setActiveTab('admin')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'admin'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Admin
|
||||
</button>
|
||||
{/* League Overview - Activity Center with Info Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Center - Activity Feed */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Recent Activity</h2>
|
||||
<LeagueActivityFeed leagueId={leagueId} limit={20} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - League Info */}
|
||||
<div className="space-y-6">
|
||||
{/* League Info - Combined */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
|
||||
<div className="text-xl font-bold text-white">{leagueMemberships.length}</div>
|
||||
<div className="text-xs text-gray-500">Members</div>
|
||||
</div>
|
||||
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
|
||||
<div className="text-xl font-bold text-white">{completedRacesCount}</div>
|
||||
<div className="text-xs text-gray-500">Races</div>
|
||||
</div>
|
||||
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
|
||||
<div className="text-xl font-bold text-warning-amber">{averageSOF ?? '—'}</div>
|
||||
<div className="text-xs text-gray-500">Avg SOF</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-500">Structure</span>
|
||||
<span className="text-white">Solo • {league.settings.maxDrivers ?? 32} max</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-500">Scoring</span>
|
||||
<span className="text-white">{scoringConfig?.scoringPresetName ?? 'Standard'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-gray-500">Created</span>
|
||||
<span className="text-white">
|
||||
{new Date(league.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{league.socialLinks && (
|
||||
<div className="mt-4 pt-4 border-t border-charcoal-outline">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{league.socialLinks.discordUrl && (
|
||||
<a
|
||||
href={league.socialLinks.discordUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-2 py-1 text-xs text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
)}
|
||||
{league.socialLinks.youtubeUrl && (
|
||||
<a
|
||||
href={league.socialLinks.youtubeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-2 py-1 text-xs text-red-400 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
YouTube
|
||||
</a>
|
||||
)}
|
||||
{league.socialLinks.websiteUrl && (
|
||||
<a
|
||||
href={league.socialLinks.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-2 py-1 text-xs text-gray-100 hover:bg-iron-gray transition-colors"
|
||||
>
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Management */}
|
||||
{(ownerMembership || adminMemberships.length > 0 || stewardMemberships.length > 0) && (
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Management</h3>
|
||||
<div className="space-y-2">
|
||||
{ownerMembership && (() => {
|
||||
const driverDto = driversById[ownerMembership.driverId];
|
||||
const summary = buildDriverSummary(ownerMembership.driverId);
|
||||
const roleDisplay = getLeagueRoleDisplay('owner');
|
||||
const meta = summary && summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return driverDto ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DriverIdentity
|
||||
driver={driverDto}
|
||||
href={`/drivers/${ownerMembership.driverId}?from=league-management&leagueId=${leagueId}`}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{adminMemberships.slice(0, 3).map((membership) => {
|
||||
const driverDto = driversById[membership.driverId];
|
||||
const summary = buildDriverSummary(membership.driverId);
|
||||
const roleDisplay = getLeagueRoleDisplay('admin');
|
||||
const meta = summary && summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return driverDto ? (
|
||||
<div key={membership.driverId} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DriverIdentity
|
||||
driver={driverDto}
|
||||
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
|
||||
{stewardMemberships.slice(0, 3).map((membership) => {
|
||||
const driverDto = driversById[membership.driverId];
|
||||
const summary = buildDriverSummary(membership.driverId);
|
||||
const roleDisplay = getLeagueRoleDisplay('steward');
|
||||
const meta = summary && summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return driverDto ? (
|
||||
<div key={membership.driverId} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DriverIdentity
|
||||
driver={driverDto}
|
||||
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* League Info */}
|
||||
<Card className="lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">League Information</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Created</label>
|
||||
<p className="text-white">
|
||||
{new Date(league.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<h3 className="text-white font-medium mb-3">At a glance</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||
Structure
|
||||
</h4>
|
||||
<p className="text-gray-200 flex items-center gap-1.5">
|
||||
<Users className="w-4 h-4 text-gray-500" />
|
||||
Solo • {league.settings.maxDrivers ?? 32} drivers
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||
Schedule
|
||||
</h4>
|
||||
<p className="text-gray-200 flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
{completedRacesCount > 0 ? `${completedRacesCount} races completed` : 'Season upcoming'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||
Scoring & drops
|
||||
</h4>
|
||||
<p className="text-gray-200 flex items-center gap-1.5">
|
||||
<Trophy className="w-4 h-4 text-gray-500" />
|
||||
{scoringConfig?.scoringPresetName ?? scoringConfig?.scoringPresetId ?? 'Standard'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||
Avg. Strength of Field
|
||||
</h4>
|
||||
<p className="text-warning-amber font-medium flex items-center gap-1.5">
|
||||
<Zap className="w-4 h-4" />
|
||||
{averageSOF ? averageSOF : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{league.socialLinks && (
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<h3 className="text-white font-medium mb-3">Community & Social</h3>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
{league.socialLinks.discordUrl && (
|
||||
<a
|
||||
href={league.socialLinks.discordUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-3 py-1 text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
)}
|
||||
{league.socialLinks.youtubeUrl && (
|
||||
<a
|
||||
href={league.socialLinks.youtubeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-3 py-1 text-red-400 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
<span>YouTube</span>
|
||||
</a>
|
||||
)}
|
||||
{league.socialLinks.websiteUrl && (
|
||||
<a
|
||||
href={league.socialLinks.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-3 py-1 text-gray-100 hover:bg-iron-gray transition-colors"
|
||||
>
|
||||
<span>Website</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<h3 className="text-white font-medium mb-3">Management</h3>
|
||||
<div className="space-y-4">
|
||||
{ownerMembership && (
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-2">Owner</label>
|
||||
{buildDriverSummary(ownerMembership.driverId) ? (
|
||||
<DriverSummaryPill
|
||||
driver={buildDriverSummary(ownerMembership.driverId)!.driver}
|
||||
rating={buildDriverSummary(ownerMembership.driverId)!.rating}
|
||||
rank={buildDriverSummary(ownerMembership.driverId)!.rank}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adminMemberships.length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-2">Admins</label>
|
||||
<div className="space-y-2">
|
||||
{adminMemberships.map((membership) => {
|
||||
const driverDto = driversById[membership.driverId];
|
||||
const summary = buildDriverSummary(membership.driverId);
|
||||
const meta =
|
||||
summary && summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={membership.driverId}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
{driverDto ? (
|
||||
<DriverIdentity
|
||||
driver={driverDto}
|
||||
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
|
||||
contextLabel="Admin"
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">Unknown admin</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adminMemberships.length === 0 && !ownerMembership && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Management roles have not been configured for this league yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Sidebar Container */}
|
||||
<div className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{membership ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
>
|
||||
View Schedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => setActiveTab('standings')}
|
||||
>
|
||||
View Standings
|
||||
</Button>
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Recent Activity</h2>
|
||||
<LeagueActivityFeed leagueId={leagueId} limit={8} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'schedule' && (
|
||||
<Card>
|
||||
<LeagueSchedule leagueId={leagueId} key={refreshKey} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'standings' && (
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
|
||||
<StandingsTable standings={standings} drivers={drivers} leagueId={leagueId} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'scoring' && (
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Scoring</h2>
|
||||
<LeagueScoringTab
|
||||
scoringConfig={scoringConfig}
|
||||
practiceMinutes={20}
|
||||
qualifyingMinutes={30}
|
||||
sprintRaceMinutes={20}
|
||||
mainRaceMinutes={
|
||||
typeof league.settings.sessionDuration === 'number'
|
||||
? league.settings.sessionDuration
|
||||
: 40
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'admin' && isAdmin && (
|
||||
<LeagueAdmin
|
||||
league={league}
|
||||
onLeagueUpdate={handleMembershipChange}
|
||||
key={refreshKey}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
'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,
|
||||
getGetRaceRegistrationsQuery,
|
||||
getIsDriverRegisteredForRaceQuery,
|
||||
getRegisterForRaceUseCase,
|
||||
getWithdrawFromRaceUseCase,
|
||||
} from '@/lib/di-container';
|
||||
import { getMembership } from '@/lib/leagueMembership';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
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<Race | null>(null);
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const [entryList, setEntryList] = useState<Driver[]>([]);
|
||||
const [isUserRegistered, setIsUserRegistered] = useState(false);
|
||||
const [canRegister, setCanRegister] = useState(false);
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
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 raceRegistrationsQuery = getGetRaceRegistrationsQuery();
|
||||
const registeredDriverIds = await raceRegistrationsQuery.execute({ raceId: 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 isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
|
||||
const userIsRegistered = await isRegisteredQuery.execute({
|
||||
raceId: raceIdValue,
|
||||
driverId: 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 {
|
||||
const useCase = getRegisterForRaceUseCase();
|
||||
await useCase.execute({
|
||||
raceId: race.id,
|
||||
leagueId: league.id,
|
||||
driverId: currentDriverId,
|
||||
});
|
||||
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 {
|
||||
const useCase = getWithdrawFromRaceUseCase();
|
||||
await useCase.execute({
|
||||
raceId: race.id,
|
||||
driverId: 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 (
|
||||
<div className="text-center text-gray-400">Loading race details...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !race) {
|
||||
return (
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-warning-amber mb-4">
|
||||
{error || 'Race not found'}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push(`/leagues/${leagueId}`)}
|
||||
>
|
||||
Back to League
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => router.push(`/leagues/${leagueId}`)}
|
||||
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to League
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FeatureLimitationTooltip message="Companion automation available in production">
|
||||
<div className="mb-6">
|
||||
<CompanionStatus />
|
||||
</div>
|
||||
</FeatureLimitationTooltip>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{race.track}</h1>
|
||||
{league && (
|
||||
<p className="text-gray-400">{league.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 text-sm font-medium rounded border ${
|
||||
statusColors[race.status as keyof typeof statusColors]
|
||||
}`}
|
||||
>
|
||||
{race.status.charAt(0).toUpperCase() + race.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{race.status === 'scheduled' && (
|
||||
<div className="mb-6">
|
||||
<CompanionInstructions race={race} leagueName={league?.name} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Race Details</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-1">Scheduled Date & Time</label>
|
||||
<p className="text-white text-lg font-medium">
|
||||
{formatDateTime(race.scheduledAt)}
|
||||
</p>
|
||||
<div className="flex gap-4 mt-2 text-sm">
|
||||
<span className="text-gray-400">{formatDate(race.scheduledAt)}</span>
|
||||
<span className="text-gray-400">{formatTime(race.scheduledAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<label className="text-sm text-gray-500 block mb-1">Track</label>
|
||||
<p className="text-white">{race.track}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-1">Car</label>
|
||||
<p className="text-white">{race.car}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-1">Session Type</label>
|
||||
<p className="text-white capitalize">{race.sessionType}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{race.status === 'scheduled' && canRegister && !isUserRegistered && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleRegister}
|
||||
disabled={registering}
|
||||
>
|
||||
{registering ? 'Registering...' : 'Register for Race'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{race.status === 'scheduled' && isUserRegistered && (
|
||||
<div className="space-y-2">
|
||||
<div className="px-3 py-2 bg-green-500/10 border border-green-500/30 rounded text-green-400 text-sm text-center">
|
||||
✓ Registered
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={handleWithdraw}
|
||||
disabled={registering}
|
||||
>
|
||||
{registering ? 'Withdrawing...' : 'Withdraw'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{race.status === 'completed' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/races/${race.id}/results`)}
|
||||
>
|
||||
View Results
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{race.status === 'scheduled' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={handleCancelRace}
|
||||
disabled={cancelling}
|
||||
>
|
||||
{cancelling ? 'Cancelling...' : 'Cancel Race'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/leagues/${leagueId}`)}
|
||||
>
|
||||
Back to League
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{race.status === 'scheduled' && (
|
||||
<Card className="mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">Entry List</h2>
|
||||
<span className="text-sm text-gray-400">
|
||||
{entryList.length} {entryList.length === 1 ? 'driver' : 'drivers'} registered
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{entryList.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p className="mb-2">No drivers registered yet</p>
|
||||
<p className="text-sm text-gray-500">Be the first to register!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{entryList.map((driver, index) => (
|
||||
<div
|
||||
key={driver.id}
|
||||
className="flex items-center gap-4 p-3 bg-iron-gray/50 rounded-lg border border-charcoal-outline hover:border-primary-blue/50 transition-colors cursor-pointer"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/drivers/${driver.id}?from=league&leagueId=${leagueId}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="w-8 text-center text-gray-400 font-mono text-sm">
|
||||
#{index + 1}
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-charcoal-outline rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-lg font-bold text-gray-500">
|
||||
{driver.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium">{driver.name}</p>
|
||||
<p className="text-sm text-gray-400">{driver.country}</p>
|
||||
</div>
|
||||
{driver.id === currentDriverId && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/20 text-primary-blue rounded">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
314
apps/website/app/leagues/[id]/rulebook/page.tsx
Normal file
314
apps/website/app/leagues/[id]/rulebook/page.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import {
|
||||
getLeagueRepository,
|
||||
getGetLeagueScoringConfigQuery
|
||||
} from '@/lib/di-container';
|
||||
import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
|
||||
type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties';
|
||||
|
||||
export default function LeagueRulebookPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const scoringQuery = getGetLeagueScoringConfigQuery();
|
||||
|
||||
const leagueData = await leagueRepo.findById(leagueId);
|
||||
if (!leagueData) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLeague(leagueData);
|
||||
|
||||
const scoring = await scoringQuery.execute({ leagueId });
|
||||
setScoringConfig(scoring);
|
||||
} catch (err) {
|
||||
console.error('Failed to load scoring config:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
}, [leagueId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12 text-gray-400">Loading rulebook...</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!league || !scoringConfig) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12 text-gray-400">Unable to load rulebook</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryChampionship = scoringConfig.championships.find(c => c.type === 'driver') ?? scoringConfig.championships[0];
|
||||
const positionPoints = primaryChampionship?.pointsPreview
|
||||
.filter(p => p.sessionType === primaryChampionship.sessionTypes[0])
|
||||
.map(p => ({ position: p.position, points: p.points }))
|
||||
.sort((a, b) => a.position - b.position) || [];
|
||||
|
||||
const sections: { id: RulebookSection; label: string }[] = [
|
||||
{ id: 'scoring', label: 'Scoring' },
|
||||
{ id: 'conduct', label: 'Conduct' },
|
||||
{ id: 'protests', label: 'Protests' },
|
||||
{ id: 'penalties', label: 'Penalties' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Rulebook</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
|
||||
<span className="text-sm font-medium text-primary-blue">{scoringConfig.scoringPresetName || 'Custom Rules'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-1 p-1 bg-deep-graphite rounded-lg border border-charcoal-outline">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeSection === section.id
|
||||
? 'bg-iron-gray text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray/50'
|
||||
}`}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Sections */}
|
||||
{activeSection === 'scoring' && (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p>
|
||||
<p className="text-lg font-semibold text-white">{scoringConfig.gameName}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Championships</p>
|
||||
<p className="text-lg font-semibold text-white">{scoringConfig.championships.length}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Sessions Scored</p>
|
||||
<p className="text-lg font-semibold text-white capitalize">
|
||||
{primaryChampionship?.sessionTypes.join(', ') || 'Main'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Drop Policy</p>
|
||||
<p className="text-lg font-semibold text-white truncate" title={scoringConfig.dropPolicySummary}>
|
||||
{scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Points Table */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Points Distribution</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-400">Position</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-400">Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positionPoints.map(({ position, points }) => (
|
||||
<tr
|
||||
key={position}
|
||||
className={`border-b border-charcoal-outline/50 transition-colors hover:bg-iron-gray/30 ${
|
||||
position <= 3 ? 'bg-iron-gray/20' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
position === 1 ? 'bg-yellow-500 text-black' :
|
||||
position === 2 ? 'bg-gray-400 text-black' :
|
||||
position === 3 ? 'bg-amber-600 text-white' :
|
||||
'bg-charcoal-outline text-white'
|
||||
}`}>
|
||||
{position}
|
||||
</div>
|
||||
<span className="text-white font-medium">
|
||||
{position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<span className="text-white font-semibold tabular-nums">{points}</span>
|
||||
<span className="text-gray-500 ml-1">pts</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bonus Points */}
|
||||
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2>
|
||||
<div className="space-y-2">
|
||||
{primaryChampionship.bonusSummary.map((bonus, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-4 p-3 bg-deep-graphite rounded-lg border border-charcoal-outline"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-performance-green text-sm font-bold">+</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{bonus}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Drop Policy */}
|
||||
{!scoringConfig.dropPolicySummary.includes('All results count') && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2>
|
||||
<p className="text-sm text-gray-300">{scoringConfig.dropPolicySummary}</p>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Drop rules are applied automatically when calculating championship standings.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'conduct' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Driver Conduct</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">1. Respect</h3>
|
||||
<p>All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">2. Clean Racing</h3>
|
||||
<p>Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">3. Track Limits</h3>
|
||||
<p>Drivers must stay within track limits. Gaining a lasting advantage by exceeding track limits may result in penalties.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">4. Blue Flags</h3>
|
||||
<p>Lapped cars must yield to faster traffic within a reasonable time. Failure to do so may result in penalties.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">5. Communication</h3>
|
||||
<p>Drivers are expected to communicate respectfully in voice and text chat during sessions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === 'protests' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Protest Process</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Filing a Protest</h3>
|
||||
<p>Protests can be filed within 48 hours of the race conclusion. Include the lap number, drivers involved, and a clear description of the incident.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Evidence</h3>
|
||||
<p>Video evidence is highly recommended but not required. Stewards will review available replay data.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Review Process</h3>
|
||||
<p>League stewards will review protests and make decisions within 72 hours. Decisions are final unless new evidence is presented.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Outcomes</h3>
|
||||
<p>Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === 'penalties' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Penalty Guidelines</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-2 font-medium text-gray-400">Infraction</th>
|
||||
<th className="text-left py-2 font-medium text-gray-400">Typical Penalty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-charcoal-outline/50">
|
||||
<tr>
|
||||
<td className="py-3">Causing avoidable contact</td>
|
||||
<td className="py-3 text-warning-amber">5-10 second time penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Unsafe rejoin</td>
|
||||
<td className="py-3 text-warning-amber">5 second time penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Blocking</td>
|
||||
<td className="py-3 text-warning-amber">Warning or 3 second penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Repeated track limit violations</td>
|
||||
<td className="py-3 text-warning-amber">5 second penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Intentional wrecking</td>
|
||||
<td className="py-3 text-red-400">Disqualification</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Unsportsmanlike conduct</td>
|
||||
<td className="py-3 text-red-400">Points deduction or ban</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Penalties are applied at steward discretion based on incident severity and driver history.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/website/app/leagues/[id]/schedule/page.tsx
Normal file
38
apps/website/app/leagues/[id]/schedule/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
|
||||
import ScheduleRaceForm from '@/components/leagues/ScheduleRaceForm';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getLeagueMembershipRepository } from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
|
||||
export default function LeagueSchedulePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAdmin() {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
}
|
||||
checkAdmin();
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Schedule</h2>
|
||||
<LeagueSchedule leagueId={leagueId} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
apps/website/app/leagues/[id]/scoring/page.tsx
Normal file
6
apps/website/app/leagues/[id]/scoring/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function ScoringPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
redirect(`/leagues/${id}/rulebook`);
|
||||
}
|
||||
299
apps/website/app/leagues/[id]/settings/page.tsx
Normal file
299
apps/website/app/leagues/[id]/settings/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
getLeagueRepository,
|
||||
getDriverRepository,
|
||||
getGetLeagueFullConfigQuery,
|
||||
getLeagueMembershipRepository,
|
||||
getDriverStats,
|
||||
getAllDriverRankings,
|
||||
getListLeagueScoringPresetsQuery,
|
||||
getTransferLeagueOwnershipUseCase
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import { ScoringPatternSection, ChampionshipsSection } from '@/components/leagues/LeagueScoringSection';
|
||||
import { LeagueDropSection } from '@/components/leagues/LeagueDropSection';
|
||||
import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
||||
import { AlertTriangle, Settings, Trophy, Calendar, TrendingDown, Edit, Users, UserCog } from 'lucide-react';
|
||||
|
||||
export default function LeagueSettingsPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [configForm, setConfigForm] = useState<LeagueConfigFormModel | null>(null);
|
||||
const [presets, setPresets] = useState<LeagueScoringPresetDTO[]>([]);
|
||||
const [ownerDriver, setOwnerDriver] = useState<DriverDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [showTransferDialog, setShowTransferDialog] = useState(false);
|
||||
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('');
|
||||
const [transferring, setTransferring] = useState(false);
|
||||
const [allMembers, setAllMembers] = useState<DriverDTO[]>([]);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAdmin() {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
}
|
||||
checkAdmin();
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
const query = getGetLeagueFullConfigQuery();
|
||||
const presetsQuery = getListLeagueScoringPresetsQuery();
|
||||
|
||||
const leagueData = await leagueRepo.findById(leagueId);
|
||||
if (!leagueData) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLeague(leagueData);
|
||||
|
||||
const form = await query.execute({ leagueId });
|
||||
setConfigForm(form);
|
||||
|
||||
const presetsData = await presetsQuery.execute();
|
||||
setPresets(presetsData);
|
||||
|
||||
const entity = await driverRepo.findById(leagueData.ownerId);
|
||||
if (entity) {
|
||||
setOwnerDriver(EntityMappers.toDriverDTO(entity));
|
||||
}
|
||||
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const memberships = await membershipRepo.getLeagueMembers(leagueId);
|
||||
const memberDrivers: DriverDTO[] = [];
|
||||
for (const m of memberships) {
|
||||
if (m.driverId !== leagueData.ownerId && m.status === 'active') {
|
||||
const d = await driverRepo.findById(m.driverId);
|
||||
if (d) {
|
||||
const dto = EntityMappers.toDriverDTO(d);
|
||||
if (dto) {
|
||||
memberDrivers.push(dto);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setAllMembers(memberDrivers);
|
||||
} catch (err) {
|
||||
console.error('Failed to load league settings:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
loadSettings();
|
||||
}
|
||||
}, [leagueId, isAdmin]);
|
||||
|
||||
const ownerSummary = useMemo(() => {
|
||||
if (!ownerDriver) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = getDriverStats(ownerDriver.id);
|
||||
const allRankings = getAllDriverRankings();
|
||||
|
||||
let rating: number | null = stats?.rating ?? null;
|
||||
let rank: number | null = null;
|
||||
|
||||
if (stats) {
|
||||
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
|
||||
rank = stats.overallRank;
|
||||
} else {
|
||||
const indexInGlobal = allRankings.findIndex(
|
||||
(stat) => stat.driverId === stats.driverId,
|
||||
);
|
||||
if (indexInGlobal !== -1) {
|
||||
rank = indexInGlobal + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (rating === null) {
|
||||
const globalEntry = allRankings.find(
|
||||
(stat) => stat.driverId === stats.driverId,
|
||||
);
|
||||
if (globalEntry) {
|
||||
rating = globalEntry.rating;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
driver: ownerDriver,
|
||||
rating,
|
||||
rank,
|
||||
};
|
||||
}, [ownerDriver]);
|
||||
|
||||
const handleTransferOwnership = async () => {
|
||||
if (!selectedNewOwner || !league) return;
|
||||
|
||||
setTransferring(true);
|
||||
try {
|
||||
const useCase = getTransferLeagueOwnershipUseCase();
|
||||
await useCase.execute({
|
||||
leagueId,
|
||||
currentOwnerId: currentDriverId,
|
||||
newOwnerId: selectedNewOwner,
|
||||
});
|
||||
|
||||
setShowTransferDialog(false);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error('Failed to transfer ownership:', err);
|
||||
alert(err instanceof Error ? err.message : 'Failed to transfer ownership');
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Only league admins can access settings.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="py-6 text-sm text-gray-400">Loading configuration…</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!configForm || !league) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="py-6 text-sm text-gray-500">
|
||||
Unable to load league configuration for this demo league.
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
|
||||
<Settings className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">League Settings</h1>
|
||||
<p className="text-sm text-gray-400">Manage your league configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* READONLY INFORMATION SECTION - Compact */}
|
||||
<div className="space-y-4">
|
||||
<ReadonlyLeagueInfo league={league} configForm={configForm} />
|
||||
|
||||
{/* League Owner - Compact */}
|
||||
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-3">League Owner</h3>
|
||||
{ownerSummary ? (
|
||||
<DriverSummaryPill
|
||||
driver={ownerSummary.driver}
|
||||
rating={ownerSummary.rating}
|
||||
rank={ownerSummary.rank}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Loading owner details...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transfer Ownership - Owner Only */}
|
||||
{league.ownerId === currentDriverId && allMembers.length > 0 && (
|
||||
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<UserCog className="w-4 h-4 text-gray-400" />
|
||||
<h3 className="text-sm font-semibold text-gray-400">Transfer Ownership</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Transfer league ownership to another active member. You will become an admin.
|
||||
</p>
|
||||
|
||||
{!showTransferDialog ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowTransferDialog(true)}
|
||||
>
|
||||
Transfer Ownership
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedNewOwner}
|
||||
onChange={(e) => setSelectedNewOwner(e.target.value)}
|
||||
className="w-full rounded-lg border border-charcoal-outline bg-charcoal-card px-3 py-2 text-sm text-white focus:border-primary-blue focus:outline-none"
|
||||
>
|
||||
<option value="">Select new owner...</option>
|
||||
{allMembers.map((member) => (
|
||||
<option key={member.id} value={member.id}>
|
||||
{member.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleTransferOwnership}
|
||||
disabled={!selectedNewOwner || transferring}
|
||||
>
|
||||
{transferring ? 'Transferring...' : 'Confirm Transfer'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowTransferDialog(false);
|
||||
setSelectedNewOwner('');
|
||||
}}
|
||||
disabled={transferring}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import StandingsTable from '@/components/leagues/StandingsTable';
|
||||
import {
|
||||
@@ -8,20 +9,32 @@ import {
|
||||
type DriverDTO,
|
||||
type LeagueDriverSeasonStatsDTO,
|
||||
} from '@gridpilot/racing';
|
||||
import { getGetLeagueDriverSeasonStatsQuery, getDriverRepository } from '@/lib/di-container';
|
||||
import {
|
||||
getGetLeagueDriverSeasonStatsQuery,
|
||||
getDriverRepository,
|
||||
getLeagueMembershipRepository
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import type { MembershipRole, LeagueMembership } from '@/lib/leagueMembership';
|
||||
|
||||
export default function LeagueStandingsPage({ params }: any) {
|
||||
const leagueId = params.id;
|
||||
export default function LeagueStandingsPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
|
||||
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
|
||||
const [memberships, setMemberships] = useState<LeagueMembership[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery();
|
||||
const driverRepo = getDriverRepository();
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
|
||||
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
|
||||
setStandings(leagueStandings);
|
||||
@@ -31,17 +44,86 @@ export default function LeagueStandingsPage({ params }: any) {
|
||||
.map((driver) => EntityMappers.toDriverDTO(driver))
|
||||
.filter((dto): dto is DriverDTO => dto !== null);
|
||||
setDrivers(driverDtos);
|
||||
|
||||
// Load league memberships from repository (consistent with other data)
|
||||
const allMemberships = await membershipRepo.getLeagueMembers(leagueId);
|
||||
// Convert to the format expected by StandingsTable
|
||||
const membershipData: LeagueMembership[] = allMemberships.map(m => ({
|
||||
leagueId: m.leagueId,
|
||||
driverId: m.driverId,
|
||||
role: m.role,
|
||||
status: m.status,
|
||||
joinedAt: m.joinedAt instanceof Date ? m.joinedAt.toISOString() : String(m.joinedAt),
|
||||
}));
|
||||
setMemberships(membershipData);
|
||||
|
||||
// Check if current user is admin
|
||||
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load standings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leagueId]);
|
||||
}, [loadData]);
|
||||
|
||||
const handleRemoveMember = async (driverId: string) => {
|
||||
if (!confirm('Are you sure you want to remove this member?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
|
||||
throw new Error('Only owners or admins can remove members');
|
||||
}
|
||||
|
||||
const membership = await membershipRepo.getMembership(leagueId, driverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot remove the league owner');
|
||||
}
|
||||
|
||||
await membershipRepo.removeMembership(leagueId, driverId);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove member');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (driverId: string, newRole: MembershipRole) => {
|
||||
try {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
if (!performer || performer.role !== 'owner') {
|
||||
throw new Error('Only the league owner can update roles');
|
||||
}
|
||||
|
||||
const membership = await membershipRepo.getMembership(leagueId, driverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot change the owner role');
|
||||
}
|
||||
|
||||
await membershipRepo.saveMembership({
|
||||
...membership,
|
||||
role: newRole,
|
||||
});
|
||||
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update role');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -59,10 +141,68 @@ export default function LeagueStandingsPage({ params }: any) {
|
||||
);
|
||||
}
|
||||
|
||||
const leader = standings[0];
|
||||
const totalRaces = Math.max(...standings.map(s => s.racesStarted), 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
|
||||
<StandingsTable standings={standings} drivers={drivers} leagueId={leagueId} />
|
||||
</Card>
|
||||
<div className="space-y-6">
|
||||
{/* Championship Stats */}
|
||||
{standings.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-yellow-500/10 flex items-center justify-center">
|
||||
<span className="text-2xl">🏆</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Championship Leader</p>
|
||||
<p className="font-bold text-white">{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}</p>
|
||||
<p className="text-sm text-yellow-400 font-medium">{leader?.totalPoints || 0} points</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-blue/10 flex items-center justify-center">
|
||||
<span className="text-2xl">🏁</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Races Completed</p>
|
||||
<p className="text-2xl font-bold text-white">{totalRaces}</p>
|
||||
<p className="text-sm text-gray-400">Season in progress</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<span className="text-2xl">👥</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Active Drivers</p>
|
||||
<p className="text-2xl font-bold text-white">{standings.length}</p>
|
||||
<p className="text-sm text-gray-400">Competing for points</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
|
||||
<StandingsTable
|
||||
standings={standings}
|
||||
drivers={drivers}
|
||||
leagueId={leagueId}
|
||||
memberships={memberships}
|
||||
currentDriverId={currentDriverId}
|
||||
isAdmin={isAdmin}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onUpdateRole={handleUpdateRole}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
508
apps/website/app/leagues/[id]/stewarding/page.tsx
Normal file
508
apps/website/app/leagues/[id]/stewarding/page.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
||||
import {
|
||||
getRaceRepository,
|
||||
getProtestRepository,
|
||||
getDriverRepository,
|
||||
getLeagueMembershipRepository,
|
||||
getReviewProtestUseCase,
|
||||
getApplyPenaltyUseCase,
|
||||
getPenaltyRepository
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import type { Penalty, PenaltyType } from '@gridpilot/racing/domain/entities/Penalty';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
AlertTriangle, Clock, CheckCircle, Flag, ChevronRight,
|
||||
Calendar, MapPin, AlertCircle, Video, Gavel
|
||||
} from 'lucide-react';
|
||||
|
||||
interface RaceWithProtests {
|
||||
race: Race;
|
||||
pendingProtests: Protest[];
|
||||
resolvedProtests: Protest[];
|
||||
penalties: Penalty[];
|
||||
}
|
||||
|
||||
export default function LeagueStewardingPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [protestsByRace, setProtestsByRace] = useState<Record<string, Protest[]>>({});
|
||||
const [penaltiesByRace, setPenaltiesByRace] = useState<Record<string, Penalty[]>>({});
|
||||
const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
||||
const [selectedProtest, setSelectedProtest] = useState<Protest | null>(null);
|
||||
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAdmin() {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
}
|
||||
checkAdmin();
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const protestRepo = getProtestRepository();
|
||||
const penaltyRepo = getPenaltyRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
// Get all races for this league
|
||||
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
|
||||
setRaces(leagueRaces);
|
||||
|
||||
// Get protests and penalties for each race
|
||||
const protestsMap: Record<string, Protest[]> = {};
|
||||
const penaltiesMap: Record<string, Penalty[]> = {};
|
||||
const driverIds = new Set<string>();
|
||||
|
||||
for (const race of leagueRaces) {
|
||||
const raceProtests = await protestRepo.findByRaceId(race.id);
|
||||
const racePenalties = await penaltyRepo.findByRaceId(race.id);
|
||||
|
||||
protestsMap[race.id] = raceProtests;
|
||||
penaltiesMap[race.id] = racePenalties;
|
||||
|
||||
// Collect driver IDs
|
||||
raceProtests.forEach((p) => {
|
||||
driverIds.add(p.protestingDriverId);
|
||||
driverIds.add(p.accusedDriverId);
|
||||
});
|
||||
racePenalties.forEach((p) => {
|
||||
driverIds.add(p.driverId);
|
||||
});
|
||||
}
|
||||
|
||||
setProtestsByRace(protestsMap);
|
||||
setPenaltiesByRace(penaltiesMap);
|
||||
|
||||
// Load driver info
|
||||
const driverEntities = await Promise.all(
|
||||
Array.from(driverIds).map((id) => driverRepo.findById(id)),
|
||||
);
|
||||
const byId: Record<string, DriverDTO> = {};
|
||||
driverEntities.forEach((driver) => {
|
||||
if (driver) {
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (dto) {
|
||||
byId[dto.id] = dto;
|
||||
}
|
||||
}
|
||||
});
|
||||
setDriversById(byId);
|
||||
|
||||
// Auto-expand races with pending protests
|
||||
const racesWithPending = new Set<string>();
|
||||
Object.entries(protestsMap).forEach(([raceId, protests]) => {
|
||||
if (protests.some(p => p.status === 'pending' || p.status === 'under_review')) {
|
||||
racesWithPending.add(raceId);
|
||||
}
|
||||
});
|
||||
setExpandedRaces(racesWithPending);
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
loadData();
|
||||
}
|
||||
}, [leagueId, isAdmin]);
|
||||
|
||||
// Compute race data with protest/penalty info
|
||||
const racesWithData = useMemo((): RaceWithProtests[] => {
|
||||
return races.map(race => {
|
||||
const protests = protestsByRace[race.id] || [];
|
||||
const penalties = penaltiesByRace[race.id] || [];
|
||||
return {
|
||||
race,
|
||||
pendingProtests: protests.filter(p => p.status === 'pending' || p.status === 'under_review'),
|
||||
resolvedProtests: protests.filter(p => p.status === 'upheld' || p.status === 'dismissed' || p.status === 'withdrawn'),
|
||||
penalties
|
||||
};
|
||||
}).sort((a, b) => b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime());
|
||||
}, [races, protestsByRace, penaltiesByRace]);
|
||||
|
||||
// Filter races based on active tab
|
||||
const filteredRaces = useMemo(() => {
|
||||
if (activeTab === 'pending') {
|
||||
return racesWithData.filter(r => r.pendingProtests.length > 0);
|
||||
}
|
||||
return racesWithData.filter(r => r.resolvedProtests.length > 0 || r.penalties.length > 0);
|
||||
}, [racesWithData, activeTab]);
|
||||
|
||||
// Stats
|
||||
const totalPending = racesWithData.reduce((sum, r) => sum + r.pendingProtests.length, 0);
|
||||
const totalResolved = racesWithData.reduce((sum, r) => sum + r.resolvedProtests.length, 0);
|
||||
const totalPenalties = racesWithData.reduce((sum, r) => sum + r.penalties.length, 0);
|
||||
|
||||
const handleAcceptProtest = async (
|
||||
protestId: string,
|
||||
penaltyType: PenaltyType,
|
||||
penaltyValue: number,
|
||||
stewardNotes: string
|
||||
) => {
|
||||
const reviewUseCase = getReviewProtestUseCase();
|
||||
const penaltyUseCase = getApplyPenaltyUseCase();
|
||||
|
||||
await reviewUseCase.execute({
|
||||
protestId,
|
||||
stewardId: currentDriverId,
|
||||
decision: 'uphold',
|
||||
decisionNotes: stewardNotes,
|
||||
});
|
||||
|
||||
// Find the protest
|
||||
let foundProtest: Protest | undefined;
|
||||
Object.values(protestsByRace).forEach(protests => {
|
||||
const p = protests.find(pr => pr.id === protestId);
|
||||
if (p) foundProtest = p;
|
||||
});
|
||||
|
||||
if (foundProtest) {
|
||||
await penaltyUseCase.execute({
|
||||
raceId: foundProtest.raceId,
|
||||
driverId: foundProtest.accusedDriverId,
|
||||
stewardId: currentDriverId,
|
||||
type: penaltyType,
|
||||
value: penaltyValue,
|
||||
reason: foundProtest.incident.description,
|
||||
protestId,
|
||||
notes: stewardNotes,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
|
||||
const reviewUseCase = getReviewProtestUseCase();
|
||||
|
||||
await reviewUseCase.execute({
|
||||
protestId,
|
||||
stewardId: currentDriverId,
|
||||
decision: 'dismiss',
|
||||
decisionNotes: stewardNotes,
|
||||
});
|
||||
};
|
||||
|
||||
const handleProtestReviewed = () => {
|
||||
setSelectedProtest(null);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const toggleRaceExpanded = (raceId: string) => {
|
||||
setExpandedRaces(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(raceId)) {
|
||||
next.delete(raceId);
|
||||
} else {
|
||||
next.add(raceId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
case 'under_review':
|
||||
return <span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</span>;
|
||||
case 'upheld':
|
||||
return <span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">Upheld</span>;
|
||||
case 'dismissed':
|
||||
return <span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">Dismissed</span>;
|
||||
case 'withdrawn':
|
||||
return <span className="px-2 py-0.5 text-xs font-medium bg-blue-500/20 text-blue-400 rounded-full">Withdrawn</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Only league admins can access stewarding functions.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Quick overview of protests and penalties across all races
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats summary */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-warning-amber mb-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Pending Review</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{totalPending}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-performance-green mb-1">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Resolved</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{totalResolved}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-red-400 mb-1">
|
||||
<Gavel className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Penalties</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{totalPenalties}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="border-b border-charcoal-outline mb-6">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('pending')}
|
||||
className={`pb-3 px-1 font-medium transition-colors ${
|
||||
activeTab === 'pending'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Pending Protests
|
||||
{totalPending > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{totalPending}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={`pb-3 px-1 font-medium transition-colors ${
|
||||
activeTab === 'history'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="animate-pulse">Loading stewarding data...</div>
|
||||
</div>
|
||||
) : filteredRaces.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<Flag className="w-8 h-8 text-performance-green" />
|
||||
</div>
|
||||
<p className="font-semibold text-lg text-white mb-2">
|
||||
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{activeTab === 'pending'
|
||||
? 'No pending protests to review'
|
||||
: 'No resolved protests or penalties'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
|
||||
const isExpanded = expandedRaces.has(race.id);
|
||||
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
|
||||
|
||||
return (
|
||||
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
|
||||
{/* Race Header */}
|
||||
<button
|
||||
onClick={() => toggleRaceExpanded(race.id)}
|
||||
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-medium text-white">{race.track}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{race.scheduledAt.toLocaleDateString()}</span>
|
||||
</div>
|
||||
{activeTab === 'pending' && pendingProtests.length > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{pendingProtests.length} pending
|
||||
</span>
|
||||
)}
|
||||
{activeTab === 'history' && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
|
||||
{resolvedProtests.length} protests, {penalties.length} penalties
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-3 bg-deep-graphite/50">
|
||||
{displayProtests.length === 0 && penalties.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
|
||||
) : (
|
||||
<>
|
||||
{displayProtests.map((protest) => {
|
||||
const protester = driversById[protest.protestingDriverId];
|
||||
const accused = driversById[protest.accusedDriverId];
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={protest.id}
|
||||
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
|
||||
<span className="font-medium text-white">
|
||||
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
|
||||
</span>
|
||||
{getStatusBadge(protest.status)}
|
||||
{isUrgent && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
|
||||
<span>Lap {protest.incident.lap}</span>
|
||||
<span>•</span>
|
||||
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
|
||||
{protest.proofVideoUrl && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1 text-primary-blue">
|
||||
<Video className="w-3 h-3" />
|
||||
Video
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 line-clamp-2">
|
||||
{protest.incident.description}
|
||||
</p>
|
||||
{protest.decisionNotes && (
|
||||
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-400">
|
||||
<span className="font-medium">Steward:</span> {protest.decisionNotes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(protest.status === 'pending' || protest.status === 'under_review') && (
|
||||
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
|
||||
<Button variant="primary">
|
||||
Review
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeTab === 'history' && penalties.map((penalty) => {
|
||||
const driver = driversById[penalty.driverId];
|
||||
return (
|
||||
<div
|
||||
key={penalty.id}
|
||||
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Gavel className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
{penalty.type.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{penalty.reason}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-red-400">
|
||||
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
|
||||
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
|
||||
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
|
||||
{penalty.type === 'disqualification' && 'DSQ'}
|
||||
{penalty.type === 'warning' && 'Warning'}
|
||||
{penalty.type === 'license_points' && `${penalty.value} LP`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{selectedProtest && (
|
||||
<ReviewProtestModal
|
||||
protest={selectedProtest}
|
||||
onClose={() => setSelectedProtest(null)}
|
||||
onAccept={handleAcceptProtest}
|
||||
onReject={handleRejectProtest}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
getProtestRepository,
|
||||
getRaceRepository,
|
||||
getDriverRepository,
|
||||
getLeagueMembershipRepository,
|
||||
getReviewProtestUseCase,
|
||||
getApplyPenaltyUseCase,
|
||||
getRequestProtestDefenseUseCase,
|
||||
getSendNotificationUseCase
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import type { PenaltyType } from '@gridpilot/racing/domain/entities/Penalty';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
AlertCircle,
|
||||
Video,
|
||||
Clock,
|
||||
Grid3x3,
|
||||
TrendingDown,
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
ArrowLeft,
|
||||
Flag,
|
||||
AlertTriangle,
|
||||
ShieldAlert,
|
||||
Ban,
|
||||
DollarSign,
|
||||
FileWarning,
|
||||
User,
|
||||
Calendar,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Shield,
|
||||
Gavel,
|
||||
Send,
|
||||
ChevronDown,
|
||||
ExternalLink
|
||||
} from 'lucide-react';
|
||||
|
||||
// Timeline event types
|
||||
interface TimelineEvent {
|
||||
id: string;
|
||||
type: 'protest_filed' | 'defense_requested' | 'defense_submitted' | 'steward_comment' | 'decision' | 'penalty_applied';
|
||||
timestamp: Date;
|
||||
actor: DriverDTO | null;
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const PENALTY_TYPES = [
|
||||
{
|
||||
type: 'time_penalty' as PenaltyType,
|
||||
label: 'Time Penalty',
|
||||
description: 'Add seconds to race result',
|
||||
icon: Clock,
|
||||
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
requiresValue: true,
|
||||
valueLabel: 'seconds',
|
||||
defaultValue: 5
|
||||
},
|
||||
{
|
||||
type: 'grid_penalty' as PenaltyType,
|
||||
label: 'Grid Penalty',
|
||||
description: 'Grid positions for next race',
|
||||
icon: Grid3x3,
|
||||
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
|
||||
requiresValue: true,
|
||||
valueLabel: 'positions',
|
||||
defaultValue: 3
|
||||
},
|
||||
{
|
||||
type: 'points_deduction' as PenaltyType,
|
||||
label: 'Points Deduction',
|
||||
description: 'Deduct championship points',
|
||||
icon: TrendingDown,
|
||||
color: 'text-red-400 bg-red-500/10 border-red-500/20',
|
||||
requiresValue: true,
|
||||
valueLabel: 'points',
|
||||
defaultValue: 5
|
||||
},
|
||||
{
|
||||
type: 'disqualification' as PenaltyType,
|
||||
label: 'Disqualification',
|
||||
description: 'Disqualify from race',
|
||||
icon: XCircle,
|
||||
color: 'text-red-500 bg-red-500/10 border-red-500/20',
|
||||
requiresValue: false,
|
||||
valueLabel: '',
|
||||
defaultValue: 0
|
||||
},
|
||||
{
|
||||
type: 'warning' as PenaltyType,
|
||||
label: 'Warning',
|
||||
description: 'Official warning only',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
|
||||
requiresValue: false,
|
||||
valueLabel: '',
|
||||
defaultValue: 0
|
||||
},
|
||||
{
|
||||
type: 'license_points' as PenaltyType,
|
||||
label: 'License Points',
|
||||
description: 'Safety rating penalty',
|
||||
icon: ShieldAlert,
|
||||
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
|
||||
requiresValue: true,
|
||||
valueLabel: 'points',
|
||||
defaultValue: 2
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProtestReviewPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const leagueId = params.id as string;
|
||||
const protestId = params.protestId as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const [protest, setProtest] = useState<Protest | null>(null);
|
||||
const [race, setRace] = useState<Race | null>(null);
|
||||
const [protestingDriver, setProtestingDriver] = useState<DriverDTO | null>(null);
|
||||
const [accusedDriver, setAccusedDriver] = useState<DriverDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
// Decision state
|
||||
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
|
||||
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
|
||||
const [penaltyType, setPenaltyType] = useState<PenaltyType>('time_penalty');
|
||||
const [penaltyValue, setPenaltyValue] = useState<number>(5);
|
||||
const [stewardNotes, setStewardNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Comment state
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAdmin() {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
}
|
||||
checkAdmin();
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadProtest() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const protestRepo = getProtestRepository();
|
||||
const raceRepo = getRaceRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
const protestEntity = await protestRepo.findById(protestId);
|
||||
if (!protestEntity) {
|
||||
throw new Error('Protest not found');
|
||||
}
|
||||
setProtest(protestEntity);
|
||||
|
||||
const raceEntity = await raceRepo.findById(protestEntity.raceId);
|
||||
if (!raceEntity) {
|
||||
throw new Error('Race not found');
|
||||
}
|
||||
setRace(raceEntity);
|
||||
|
||||
const protestingDriverEntity = await driverRepo.findById(protestEntity.protestingDriverId);
|
||||
const accusedDriverEntity = await driverRepo.findById(protestEntity.accusedDriverId);
|
||||
|
||||
setProtestingDriver(protestingDriverEntity ? EntityMappers.toDriverDTO(protestingDriverEntity) : null);
|
||||
setAccusedDriver(accusedDriverEntity ? EntityMappers.toDriverDTO(accusedDriverEntity) : null);
|
||||
} catch (err) {
|
||||
console.error('Failed to load protest:', err);
|
||||
alert('Failed to load protest details');
|
||||
router.push(`/leagues/${leagueId}/stewarding`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
loadProtest();
|
||||
}
|
||||
}, [protestId, leagueId, isAdmin, currentDriverId, router]);
|
||||
|
||||
// Build timeline from protest data
|
||||
const timeline = useMemo((): TimelineEvent[] => {
|
||||
if (!protest) return [];
|
||||
|
||||
const events: TimelineEvent[] = [
|
||||
{
|
||||
id: 'filed',
|
||||
type: 'protest_filed',
|
||||
timestamp: new Date(protest.filedAt),
|
||||
actor: protestingDriver,
|
||||
content: protest.incident.description,
|
||||
metadata: {
|
||||
lap: protest.incident.lap,
|
||||
comment: protest.comment
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Add decision event if resolved
|
||||
if (protest.status === 'upheld' || protest.status === 'dismissed') {
|
||||
events.push({
|
||||
id: 'decision',
|
||||
type: 'decision',
|
||||
timestamp: protest.reviewedAt ? new Date(protest.reviewedAt) : new Date(),
|
||||
actor: null, // Would need to load steward driver
|
||||
content: protest.decisionNotes || (protest.status === 'upheld' ? 'Protest upheld' : 'Protest dismissed'),
|
||||
metadata: {
|
||||
decision: protest.status
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return events.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
}, [protest, protestingDriver]);
|
||||
|
||||
const handleSubmitDecision = async () => {
|
||||
if (!decision || !stewardNotes.trim() || !protest) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const reviewUseCase = getReviewProtestUseCase();
|
||||
const penaltyUseCase = getApplyPenaltyUseCase();
|
||||
|
||||
if (decision === 'uphold') {
|
||||
await reviewUseCase.execute({
|
||||
protestId: protest.id,
|
||||
stewardId: currentDriverId,
|
||||
decision: 'uphold',
|
||||
decisionNotes: stewardNotes,
|
||||
});
|
||||
|
||||
const selectedPenalty = PENALTY_TYPES.find(p => p.type === penaltyType);
|
||||
await penaltyUseCase.execute({
|
||||
raceId: protest.raceId,
|
||||
driverId: protest.accusedDriverId,
|
||||
stewardId: currentDriverId,
|
||||
type: penaltyType,
|
||||
value: selectedPenalty?.requiresValue ? penaltyValue : undefined,
|
||||
reason: protest.incident.description,
|
||||
protestId: protest.id,
|
||||
notes: stewardNotes,
|
||||
});
|
||||
} else {
|
||||
await reviewUseCase.execute({
|
||||
protestId: protest.id,
|
||||
stewardId: currentDriverId,
|
||||
decision: 'dismiss',
|
||||
decisionNotes: stewardNotes,
|
||||
});
|
||||
}
|
||||
|
||||
router.push(`/leagues/${leagueId}/stewarding`);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to submit decision');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestDefense = async () => {
|
||||
if (!protest) return;
|
||||
|
||||
try {
|
||||
const requestDefenseUseCase = getRequestProtestDefenseUseCase();
|
||||
const sendNotificationUseCase = getSendNotificationUseCase();
|
||||
|
||||
// Request defense
|
||||
const result = await requestDefenseUseCase.execute({
|
||||
protestId: protest.id,
|
||||
stewardId: currentDriverId,
|
||||
});
|
||||
|
||||
// Send notification to accused driver
|
||||
await sendNotificationUseCase.execute({
|
||||
recipientId: result.accusedDriverId,
|
||||
type: 'protest_filed',
|
||||
title: 'Defense Requested',
|
||||
body: `A steward has requested your defense for a protest filed against you.`,
|
||||
actionUrl: `/leagues/${leagueId}/stewarding/protests/${protest.id}`,
|
||||
data: {
|
||||
protestId: protest.id,
|
||||
raceId: protest.raceId,
|
||||
leagueId,
|
||||
},
|
||||
});
|
||||
|
||||
// Reload page to show updated status
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to request defense');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { label: 'Pending Review', color: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30', icon: Clock };
|
||||
case 'under_review':
|
||||
return { label: 'Under Review', color: 'bg-blue-500/20 text-blue-400 border-blue-500/30', icon: Shield };
|
||||
case 'awaiting_defense':
|
||||
return { label: 'Awaiting Defense', color: 'bg-purple-500/20 text-purple-400 border-purple-500/30', icon: MessageCircle };
|
||||
case 'upheld':
|
||||
return { label: 'Upheld', color: 'bg-red-500/20 text-red-400 border-red-500/30', icon: CheckCircle };
|
||||
case 'dismissed':
|
||||
return { label: 'Dismissed', color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: XCircle };
|
||||
default:
|
||||
return { label: status, color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: AlertCircle };
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Only league admins can review protests.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !protest || !race) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-pulse text-gray-400">Loading protest details...</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = getStatusConfig(protest.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const isPending = protest.status === 'pending' || protest.status === 'under_review' || protest.status === 'awaiting_defense';
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Compact Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-white">Protest Review</h1>
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
{daysSinceFiled > 2 && isPending && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Layout: Feed + Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* Left Sidebar - Incident Info */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{/* Drivers Involved */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Protesting Driver */}
|
||||
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-blue-400 font-medium">Protesting</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Accused Driver */}
|
||||
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-orange-400 font-medium">Accused</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Race Info */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
|
||||
|
||||
<Link
|
||||
href={`/races/${race.id}`}
|
||||
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white">{race.track}</span>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.track}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.scheduledAt.toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Flag className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">Lap {protest.incident.lap}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Evidence */}
|
||||
{protest.proofVideoUrl && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
|
||||
<a
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
<span className="text-sm font-medium flex-1">Watch Video</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Filed</span>
|
||||
<span className="text-gray-300">{new Date(protest.filedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Age</span>
|
||||
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
|
||||
</div>
|
||||
{protest.reviewedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Resolved</span>
|
||||
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Center - Discussion Feed */}
|
||||
<div className="lg:col-span-6 space-y-4">
|
||||
{/* Timeline / Feed */}
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
|
||||
<h2 className="text-sm font-semibold text-white">Discussion</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{/* Initial Protest Filing */}
|
||||
<div className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertCircle className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
|
||||
<span className="text-xs text-blue-400 font-medium">filed protest</span>
|
||||
<span className="text-xs text-gray-500">•</span>
|
||||
<span className="text-xs text-gray-500">{new Date(protest.filedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-sm text-gray-300 mb-3">{protest.incident.description}</p>
|
||||
|
||||
{protest.comment && (
|
||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
|
||||
<p className="text-sm text-gray-400">{protest.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defense placeholder - will be populated when defense system is implemented */}
|
||||
{protest.status === 'awaiting_defense' && (
|
||||
<div className="p-4 bg-purple-500/5">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<MessageCircle className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
|
||||
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision (if resolved) */}
|
||||
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
|
||||
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
|
||||
<div className="flex gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
|
||||
}`}>
|
||||
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-white text-sm">Steward Decision</span>
|
||||
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
|
||||
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
|
||||
</span>
|
||||
{protest.reviewedAt && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">•</span>
|
||||
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`rounded-lg p-4 border ${
|
||||
protest.status === 'upheld'
|
||||
? 'bg-red-500/10 border-red-500/20'
|
||||
: 'bg-gray-500/10 border-gray-500/20'
|
||||
}`}>
|
||||
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Comment (future feature) */}
|
||||
{isPending && (
|
||||
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment or request more information..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button variant="secondary" disabled={!newComment.trim()}>
|
||||
<Send className="w-3 h-3 mr-1" />
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Actions */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{isPending && (
|
||||
<>
|
||||
{/* Quick Actions */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full justify-start"
|
||||
onClick={handleRequestDefense}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
Request Defense
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
|
||||
>
|
||||
<Gavel className="w-4 h-4 mr-2" />
|
||||
Make Decision
|
||||
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Decision Panel */}
|
||||
{showDecisionPanel && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
|
||||
|
||||
{/* Decision Selection */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setDecision('uphold')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
decision === 'uphold'
|
||||
? 'border-red-500 bg-red-500/10'
|
||||
: 'border-charcoal-outline hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
|
||||
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDecision('dismiss')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
decision === 'dismiss'
|
||||
? 'border-gray-500 bg-gray-500/10'
|
||||
: 'border-charcoal-outline hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
|
||||
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Penalty Selection (if upholding) */}
|
||||
{decision === 'uphold' && (
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{PENALTY_TYPES.map((penalty) => {
|
||||
const Icon = penalty.icon;
|
||||
const isSelected = penaltyType === penalty.type;
|
||||
return (
|
||||
<button
|
||||
key={penalty.type}
|
||||
onClick={() => {
|
||||
setPenaltyType(penalty.type);
|
||||
setPenaltyValue(penalty.defaultValue);
|
||||
}}
|
||||
className={`p-2 rounded-lg border transition-all text-left ${
|
||||
isSelected
|
||||
? `${penalty.color} border`
|
||||
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
|
||||
}`}
|
||||
title={penalty.description}
|
||||
>
|
||||
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
|
||||
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
|
||||
{penalty.label}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{PENALTY_TYPES.find(p => p.type === penaltyType)?.requiresValue && (
|
||||
<div className="mt-3">
|
||||
<label className="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Value ({PENALTY_TYPES.find(p => p.type === penaltyType)?.valueLabel})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={penaltyValue}
|
||||
onChange={(e) => setPenaltyValue(Number(e.target.value))}
|
||||
min="1"
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steward Notes */}
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
|
||||
<textarea
|
||||
value={stewardNotes}
|
||||
onChange={(e) => setStewardNotes(e.target.value)}
|
||||
placeholder="Explain your decision..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleSubmitDecision}
|
||||
disabled={!decision || !stewardNotes.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decision'}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Already Resolved Info */}
|
||||
{!isPending && (
|
||||
<Card className="p-4">
|
||||
<div className={`text-center py-4 ${
|
||||
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
|
||||
}`}>
|
||||
<Gavel className="w-8 h-8 mx-auto mb-2" />
|
||||
<p className="font-semibold">Case Closed</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
apps/website/components/leagues/BonusPointsCard.tsx
Normal file
34
apps/website/components/leagues/BonusPointsCard.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
interface BonusPointsCardProps {
|
||||
bonusSummary: string[];
|
||||
}
|
||||
|
||||
export function BonusPointsCard({ bonusSummary }: BonusPointsCardProps) {
|
||||
if (!bonusSummary || bonusSummary.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">Bonus Points</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">Additional points for special achievements</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{bonusSummary.map((bonus, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-4 p-4 bg-deep-graphite rounded-lg border border-charcoal-outline transition-colors hover:border-primary-blue/30"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-performance-green text-lg font-bold">+</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 flex-1">{bonus}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
95
apps/website/components/leagues/ChampionshipCard.tsx
Normal file
95
apps/website/components/leagues/ChampionshipCard.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { LeagueScoringChampionshipDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
|
||||
|
||||
interface ChampionshipCardProps {
|
||||
championship: LeagueScoringChampionshipDTO;
|
||||
}
|
||||
|
||||
export function ChampionshipCard({ championship }: ChampionshipCardProps) {
|
||||
const getTypeLabel = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'driver':
|
||||
return 'Driver Championship';
|
||||
case 'team':
|
||||
return 'Team Championship';
|
||||
case 'nations':
|
||||
return 'Nations Championship';
|
||||
case 'trophy':
|
||||
return 'Trophy Championship';
|
||||
default:
|
||||
return 'Championship';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeBadgeStyle = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'driver':
|
||||
return 'bg-primary-blue/10 text-primary-blue border-primary-blue/20';
|
||||
case 'team':
|
||||
return 'bg-purple-500/10 text-purple-400 border-purple-500/20';
|
||||
case 'nations':
|
||||
return 'bg-performance-green/10 text-performance-green border-performance-green/20';
|
||||
case 'trophy':
|
||||
return 'bg-warning-amber/10 text-warning-amber border-warning-amber/20';
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-400 border-gray-500/20';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{championship.name}</h3>
|
||||
<span className={`inline-block mt-2 px-2.5 py-1 text-xs font-medium rounded border ${getTypeBadgeStyle(championship.type)}`}>
|
||||
{getTypeLabel(championship.type)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Session Types */}
|
||||
{championship.sessionTypes.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Scored Sessions</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{championship.sessionTypes.map((session, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-3 py-1.5 rounded bg-deep-graphite text-gray-300 text-sm font-medium border border-charcoal-outline capitalize"
|
||||
>
|
||||
{session}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Points Preview */}
|
||||
{championship.pointsPreview.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Points Distribution</h4>
|
||||
<div className="bg-deep-graphite rounded-lg border border-charcoal-outline p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
|
||||
{championship.pointsPreview.slice(0, 6).map((preview, idx) => (
|
||||
<div key={idx} className="text-center">
|
||||
<div className="text-xs text-gray-500 mb-1">P{preview.position}</div>
|
||||
<div className="text-lg font-bold text-white tabular-nums">{preview.points}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop Policy */}
|
||||
<div className="p-4 bg-deep-graphite rounded-lg border border-charcoal-outline">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Drop Policy</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{championship.dropPolicyDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
31
apps/website/components/leagues/DropRulesExplanation.tsx
Normal file
31
apps/website/components/leagues/DropRulesExplanation.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
interface DropRulesExplanationProps {
|
||||
dropPolicyDescription: string;
|
||||
}
|
||||
|
||||
export function DropRulesExplanation({ dropPolicyDescription }: DropRulesExplanationProps) {
|
||||
// Don't show if all results count
|
||||
const hasDropRules = !dropPolicyDescription.toLowerCase().includes('all results count');
|
||||
|
||||
if (!hasDropRules) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">Drop Score Rules</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">How your worst results are handled</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-deep-graphite rounded-lg border border-charcoal-outline">
|
||||
<p className="text-sm text-gray-300">{dropPolicyDescription}</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-gray-500">
|
||||
Drop rules are applied automatically when calculating championship standings. Focus on racing — the system handles the rest.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -407,18 +407,13 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('protests')}
|
||||
className={`pb-3 px-1 font-medium transition-colors flex items-center gap-2 ${
|
||||
className={`pb-3 px-1 font-medium transition-colors ${
|
||||
activeTab === 'protests'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Protests
|
||||
{protests.length > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{protests.filter(p => p.status === 'pending').length || protests.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
@@ -526,7 +521,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
<ScheduleRaceForm
|
||||
preSelectedLeagueId={league.id}
|
||||
onSuccess={(race) => {
|
||||
router.push(`/leagues/${league.id}/races/${race.id}`);
|
||||
router.push(`/races/${race.id}`);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
@@ -604,6 +599,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
||||
const statusConfig = {
|
||||
pending: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', border: 'border-warning-amber/30', icon: Clock, label: 'Pending Review' },
|
||||
under_review: { color: 'text-primary-blue', bg: 'bg-primary-blue/10', border: 'border-primary-blue/30', icon: Flag, label: 'Under Review' },
|
||||
awaiting_defense: { color: 'text-purple-400', bg: 'bg-purple-500/10', border: 'border-purple-500/30', icon: Clock, label: 'Awaiting Defense' },
|
||||
upheld: { color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/30', icon: AlertTriangle, label: 'Upheld' },
|
||||
dismissed: { color: 'text-gray-400', bg: 'bg-gray-500/10', border: 'border-gray-500/30', icon: XCircle, label: 'Dismissed' },
|
||||
withdrawn: { color: 'text-gray-500', bg: 'bg-gray-600/10', border: 'border-gray-600/30', icon: XCircle, label: 'Withdrawn' },
|
||||
|
||||
@@ -120,30 +120,6 @@ export default function LeagueHeader({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FeatureLimitationTooltip message="Multi-league memberships coming in production">
|
||||
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
|
||||
Alpha: Single League
|
||||
</span>
|
||||
</FeatureLimitationTooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-400">Owner:</span>
|
||||
{ownerSummary ? (
|
||||
<DriverSummaryPill
|
||||
driver={ownerSummary.driver}
|
||||
rating={ownerSummary.rating}
|
||||
rank={ownerSummary.rank}
|
||||
href={`/drivers/${ownerSummary.driver.id}?from=league&leagueId=${leagueId}`}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
href={`/drivers/${ownerId}?from=league&leagueId=${leagueId}`}
|
||||
className="text-sm text-primary-blue hover:underline"
|
||||
>
|
||||
{ownerName}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -201,7 +201,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(`/leagues/${leagueId}/races/${race.id}`)}
|
||||
onClick={() => router.push(`/races/${race.id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
|
||||
import { Trophy, Clock, Target, Zap, Info } from 'lucide-react';
|
||||
|
||||
interface LeagueScoringTabProps {
|
||||
scoringConfig: LeagueScoringConfigDTO | null;
|
||||
@@ -19,8 +20,14 @@ export default function LeagueScoringTab({
|
||||
}: LeagueScoringTabProps) {
|
||||
if (!scoringConfig) {
|
||||
return (
|
||||
<div className="text-sm text-gray-400 py-6">
|
||||
Scoring configuration is not available for this league yet.
|
||||
<div className="p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-purple-500/10 flex items-center justify-center">
|
||||
<Target className="w-8 h-8 text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">No Scoring System</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Scoring configuration is not available for this league yet
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,49 +44,63 @@ export default function LeagueScoringTab({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-charcoal-outline pb-4 space-y-3">
|
||||
<h2 className="text-xl font-semibold text-white mb-1">
|
||||
Scoring overview
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
{scoringConfig.gameName}{' '}
|
||||
{scoringConfig.scoringPresetName
|
||||
? `• ${scoringConfig.scoringPresetName}`
|
||||
: '• Custom scoring'}{' '}
|
||||
• {scoringConfig.dropPolicySummary}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-blue/10 flex items-center justify-center">
|
||||
<Trophy className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
Scoring overview
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
{scoringConfig.gameName}{' '}
|
||||
{scoringConfig.scoringPresetName
|
||||
? `• ${scoringConfig.scoringPresetName}`
|
||||
: '• Custom scoring'}{' '}
|
||||
• {scoringConfig.dropPolicySummary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{primaryChampionship && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-200">
|
||||
Weekend structure & timings
|
||||
</h3>
|
||||
<div className="space-y-3 pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
<h3 className="text-sm font-medium text-gray-200">
|
||||
Weekend structure & timings
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{primaryChampionship.sessionTypes.map((session) => (
|
||||
<span
|
||||
key={session}
|
||||
className="px-2 py-0.5 rounded-full bg-charcoal-outline/60 text-xs text-gray-200"
|
||||
className="px-3 py-1 rounded-full bg-primary-blue/10 text-primary-blue border border-primary-blue/20 font-medium"
|
||||
>
|
||||
{session}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs text-gray-300">
|
||||
<p>
|
||||
<span className="text-gray-400">Practice:</span>{' '}
|
||||
{resolvedPractice ? `${resolvedPractice} min` : '—'}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-400">Qualifying:</span>{' '}
|
||||
{resolvedQualifying} min
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-400">Sprint:</span>{' '}
|
||||
{resolvedSprint ? `${resolvedSprint} min` : '—'}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-400">Main race:</span>{' '}
|
||||
{resolvedMain} min
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-400 mb-1">Practice</p>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{resolvedPractice ? `${resolvedPractice} min` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-400 mb-1">Qualifying</p>
|
||||
<p className="text-sm font-medium text-white">{resolvedQualifying} min</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-400 mb-1">Sprint</p>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{resolvedSprint ? `${resolvedSprint} min` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-400 mb-1">Main race</p>
|
||||
<p className="text-sm font-medium text-white">{resolvedMain} min</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -163,10 +184,13 @@ export default function LeagueScoringTab({
|
||||
)}
|
||||
|
||||
{championship.bonusSummary.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 mb-1">
|
||||
Bonus points
|
||||
</h4>
|
||||
<div className="p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="w-4 h-4 text-yellow-400" />
|
||||
<h4 className="text-xs font-semibold text-yellow-400">
|
||||
Bonus points
|
||||
</h4>
|
||||
</div>
|
||||
<ul className="list-disc list-inside text-xs text-gray-300 space-y-1">
|
||||
{championship.bonusSummary.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
@@ -175,10 +199,13 @@ export default function LeagueScoringTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 mb-1">
|
||||
Drop score policy
|
||||
</h4>
|
||||
<div className="p-3 bg-primary-blue/5 border border-primary-blue/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info className="w-4 h-4 text-primary-blue" />
|
||||
<h4 className="text-xs font-semibold text-primary-blue">
|
||||
Drop score policy
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-300">
|
||||
{championship.dropPolicyDescription}
|
||||
</p>
|
||||
|
||||
112
apps/website/components/leagues/PenaltyHistoryList.tsx
Normal file
112
apps/website/components/leagues/PenaltyHistoryList.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Protest } from "@gridpilot/racing/domain/entities/Protest";
|
||||
import { Race } from "@gridpilot/racing/domain/entities/Race";
|
||||
import { DriverDTO } from "@gridpilot/racing/application/dto/DriverDTO";
|
||||
import Card from "../ui/Card";
|
||||
import Button from "../ui/Button";
|
||||
import { Clock, Grid3x3, TrendingDown, AlertCircle, Filter, Flag } from "lucide-react";
|
||||
|
||||
type PenaltyType = "time_penalty" | "grid_penalty" | "points_deduction" | "disqualification" | "warning" | "license_points";
|
||||
|
||||
interface PenaltyHistoryListProps {
|
||||
protests: Protest[];
|
||||
races: Record<string, Race>;
|
||||
drivers: Record<string, DriverDTO>;
|
||||
}
|
||||
|
||||
export function PenaltyHistoryList({
|
||||
protests,
|
||||
races,
|
||||
drivers,
|
||||
}: PenaltyHistoryListProps) {
|
||||
const [filteredProtests, setFilteredProtests] = useState<Protest[]>([]);
|
||||
const [filterType, setFilterType] = useState<"all">("all");
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredProtests(protests);
|
||||
}, [protests]);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "upheld":
|
||||
return "text-red-400 bg-red-500/20";
|
||||
case "dismissed":
|
||||
return "text-gray-400 bg-gray-500/20";
|
||||
case "withdrawn":
|
||||
return "text-blue-400 bg-blue-500/20";
|
||||
default:
|
||||
return "text-orange-400 bg-orange-500/20";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{filteredProtests.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<div className="flex flex-col items-center gap-4 text-gray-400">
|
||||
<AlertCircle className="h-12 w-12 opacity-50" />
|
||||
<div>
|
||||
<p className="font-medium text-lg">No Resolved Protests</p>
|
||||
<p className="text-sm mt-1">
|
||||
No protests have been resolved in this league
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredProtests.map((protest) => {
|
||||
const race = races[protest.raceId];
|
||||
const protester = drivers[protest.protestingDriverId];
|
||||
const accused = drivers[protest.accusedDriverId];
|
||||
|
||||
return (
|
||||
<Card key={protest.id} className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`h-10 w-10 rounded-full flex items-center justify-center flex-shrink-0 ${getStatusColor(protest.status)}`}>
|
||||
<Flag className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">
|
||||
Protest #{protest.id.substring(0, 8)}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Resolved {new Date(protest.reviewedAt || protest.filedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium flex-shrink-0 ${getStatusColor(protest.status)}`}>
|
||||
{protest.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="text-gray-400">
|
||||
<span className="font-medium">{protester?.name || 'Unknown'}</span> vs <span className="font-medium">{accused?.name || 'Unknown'}</span>
|
||||
</p>
|
||||
{race && (
|
||||
<p className="text-gray-500">
|
||||
{race.track} ({race.car}) - Lap {protest.incident.lap}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm">{protest.incident.description}</p>
|
||||
{protest.decisionNotes && (
|
||||
<div className="mt-2 p-2 rounded bg-iron-gray/30 border border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-400">
|
||||
<span className="font-medium">Steward Notes:</span> {protest.decisionNotes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
apps/website/components/leagues/PendingProtestsList.tsx
Normal file
115
apps/website/components/leagues/PendingProtestsList.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { Protest } from "@gridpilot/racing/domain/entities/Protest";
|
||||
import { Race } from "@gridpilot/racing/domain/entities/Race";
|
||||
import { DriverDTO } from "@gridpilot/racing/application/dto/DriverDTO";
|
||||
import Card from "../ui/Card";
|
||||
import Button from "../ui/Button";
|
||||
import Link from "next/link";
|
||||
import { AlertCircle, Video, ChevronRight, Flag, Clock, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface PendingProtestsListProps {
|
||||
protests: Protest[];
|
||||
races: Record<string, Race>;
|
||||
drivers: Record<string, DriverDTO>;
|
||||
leagueId: string;
|
||||
onReviewProtest: (protest: Protest) => void;
|
||||
onProtestReviewed: () => void;
|
||||
}
|
||||
|
||||
export function PendingProtestsList({
|
||||
protests,
|
||||
races,
|
||||
drivers,
|
||||
leagueId,
|
||||
onReviewProtest,
|
||||
onProtestReviewed,
|
||||
}: PendingProtestsListProps) {
|
||||
|
||||
if (protests.length === 0) {
|
||||
return (
|
||||
<Card className="p-12 text-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<Flag className="h-8 w-8 text-performance-green" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-lg text-white mb-2">All Clear! 🏁</p>
|
||||
<p className="text-sm text-gray-400">No pending protests to review</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{protests.map((protest) => {
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
const isUrgent = daysSinceFiled > 2;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={protest.id}
|
||||
className={`p-6 hover:border-warning-amber/40 transition-all ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="h-10 w-10 rounded-full bg-warning-amber/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-warning-amber" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-white">
|
||||
Protest #{protest.id.substring(0, 8)}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Filed {new Date(protest.filedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-1 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Pending
|
||||
</span>
|
||||
{isUrgent && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Flag className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-gray-400">Lap {protest.incident.lap}</span>
|
||||
</div>
|
||||
<p className="text-gray-300 line-clamp-2 leading-relaxed">
|
||||
{protest.incident.description}
|
||||
</p>
|
||||
{protest.proofVideoUrl && (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-primary-blue/10 text-primary-blue rounded-lg border border-primary-blue/20">
|
||||
<Video className="h-4 w-4" />
|
||||
<span>Video evidence attached</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
apps/website/components/leagues/PointsBreakdownTable.tsx
Normal file
73
apps/website/components/leagues/PointsBreakdownTable.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
interface PointsBreakdownTableProps {
|
||||
positionPoints: Array<{ position: number; points: number }>;
|
||||
}
|
||||
|
||||
export function PointsBreakdownTable({ positionPoints }: PointsBreakdownTableProps) {
|
||||
const getPositionStyle = (position: number): string => {
|
||||
if (position === 1) return 'bg-yellow-500 text-black';
|
||||
if (position === 2) return 'bg-gray-400 text-black';
|
||||
if (position === 3) return 'bg-amber-600 text-white';
|
||||
return 'bg-charcoal-outline text-white';
|
||||
};
|
||||
|
||||
const getRowHighlight = (position: number): string => {
|
||||
if (position === 1) return 'bg-yellow-500/5 border-l-2 border-l-yellow-500';
|
||||
if (position === 2) return 'bg-gray-400/5 border-l-2 border-l-gray-400';
|
||||
if (position === 3) return 'bg-amber-600/5 border-l-2 border-l-amber-600';
|
||||
return 'border-l-2 border-l-transparent';
|
||||
};
|
||||
|
||||
const formatPosition = (position: number): string => {
|
||||
if (position === 1) return '1st';
|
||||
if (position === 2) return '2nd';
|
||||
if (position === 3) return '3rd';
|
||||
return `${position}th`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">Position Points</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">Points awarded by finishing position</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto -mx-6 -mb-6">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline bg-deep-graphite">
|
||||
<th className="text-left py-3 px-6 font-medium text-gray-400 uppercase text-xs tracking-wider">
|
||||
Position
|
||||
</th>
|
||||
<th className="text-right py-3 px-6 font-medium text-gray-400 uppercase text-xs tracking-wider">
|
||||
Points
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positionPoints.map(({ position, points }) => (
|
||||
<tr
|
||||
key={position}
|
||||
className={`border-b border-charcoal-outline/50 transition-colors hover:bg-iron-gray/30 ${getRowHighlight(position)}`}
|
||||
>
|
||||
<td className="py-3 px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${getPositionStyle(position)}`}>
|
||||
{position}
|
||||
</div>
|
||||
<span className="text-white font-medium">{formatPosition(position)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-6 text-right">
|
||||
<span className="text-white font-semibold tabular-nums">{points}</span>
|
||||
<span className="text-gray-500 ml-1">pts</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
86
apps/website/components/leagues/ReadonlyLeagueInfo.tsx
Normal file
86
apps/website/components/leagues/ReadonlyLeagueInfo.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { Calendar, Users, Trophy, Gamepad2, Eye, Hash, Award } from 'lucide-react';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
|
||||
interface ReadonlyLeagueInfoProps {
|
||||
league: League;
|
||||
configForm: LeagueConfigFormModel;
|
||||
}
|
||||
|
||||
export function ReadonlyLeagueInfo({ league, configForm }: ReadonlyLeagueInfoProps) {
|
||||
const basics = configForm.basics;
|
||||
const structure = configForm.structure;
|
||||
const timings = configForm.timings;
|
||||
const scoring = configForm.scoring;
|
||||
|
||||
const infoItems = [
|
||||
{
|
||||
icon: Hash,
|
||||
label: 'League Name',
|
||||
value: basics.name,
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
label: 'Visibility',
|
||||
value: basics.visibility === 'ranked' || basics.visibility === 'public' ? 'Ranked' : 'Unranked',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
label: 'Structure',
|
||||
value: structure.mode === 'solo'
|
||||
? `Solo • ${structure.maxDrivers} drivers`
|
||||
: `Teams • ${structure.maxTeams} × ${structure.driversPerTeam} drivers`,
|
||||
},
|
||||
{
|
||||
icon: Gamepad2,
|
||||
label: 'Platform',
|
||||
value: 'iRacing',
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
label: 'Scoring',
|
||||
value: scoring.patternId ?? 'Standard',
|
||||
},
|
||||
{
|
||||
icon: Calendar,
|
||||
label: 'Created',
|
||||
value: new Date(league.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}),
|
||||
},
|
||||
{
|
||||
icon: Trophy,
|
||||
label: 'Season',
|
||||
value: `${timings.roundsPlanned ?? '—'} rounds`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-4">League Information</h3>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
{infoItems.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={index} className="flex items-start gap-2.5">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-iron-gray/60 shrink-0">
|
||||
<Icon className="w-3.5 h-3.5 text-gray-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] text-gray-500 mb-0.5">{item.label}</div>
|
||||
<div className="text-xs font-medium text-gray-300 truncate">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
387
apps/website/components/leagues/ReviewProtestModal.tsx
Normal file
387
apps/website/components/leagues/ReviewProtestModal.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Protest } from "@gridpilot/racing/domain/entities/Protest";
|
||||
import { PenaltyType } from "@gridpilot/racing/domain/entities/Penalty";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import Card from "../ui/Card";
|
||||
import {
|
||||
AlertCircle,
|
||||
Video,
|
||||
Clock,
|
||||
Grid3x3,
|
||||
TrendingDown,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
ShieldAlert,
|
||||
Ban,
|
||||
DollarSign,
|
||||
FileWarning,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ReviewProtestModalProps {
|
||||
protest: Protest | null;
|
||||
onClose: () => void;
|
||||
onAccept: (
|
||||
protestId: string,
|
||||
penaltyType: PenaltyType,
|
||||
penaltyValue: number,
|
||||
stewardNotes: string
|
||||
) => Promise<void>;
|
||||
onReject: (protestId: string, stewardNotes: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ReviewProtestModal({
|
||||
protest,
|
||||
onClose,
|
||||
onAccept,
|
||||
onReject,
|
||||
}: ReviewProtestModalProps) {
|
||||
const [decision, setDecision] = useState<"accept" | "reject" | null>(null);
|
||||
const [penaltyType, setPenaltyType] = useState<PenaltyType>("time_penalty");
|
||||
const [penaltyValue, setPenaltyValue] = useState<number>(5);
|
||||
const [stewardNotes, setStewardNotes] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
|
||||
if (!protest) return null;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!decision || !stewardNotes.trim()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (decision === "accept") {
|
||||
await onAccept(protest.id, penaltyType, penaltyValue, stewardNotes);
|
||||
} else {
|
||||
await onReject(protest.id, stewardNotes);
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to submit decision");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setShowConfirmation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPenaltyIcon = (type: PenaltyType) => {
|
||||
switch (type) {
|
||||
case "time_penalty":
|
||||
return Clock;
|
||||
case "grid_penalty":
|
||||
return Grid3x3;
|
||||
case "points_deduction":
|
||||
return TrendingDown;
|
||||
case "disqualification":
|
||||
return XCircle;
|
||||
case "warning":
|
||||
return AlertTriangle;
|
||||
case "license_points":
|
||||
return ShieldAlert;
|
||||
case "probation":
|
||||
return FileWarning;
|
||||
case "fine":
|
||||
return DollarSign;
|
||||
case "race_ban":
|
||||
return Ban;
|
||||
default:
|
||||
return AlertCircle;
|
||||
}
|
||||
};
|
||||
|
||||
const getPenaltyLabel = (type: PenaltyType) => {
|
||||
switch (type) {
|
||||
case "time_penalty":
|
||||
return "seconds";
|
||||
case "grid_penalty":
|
||||
return "grid positions";
|
||||
case "points_deduction":
|
||||
return "points";
|
||||
case "license_points":
|
||||
return "points";
|
||||
case "fine":
|
||||
return "points";
|
||||
case "race_ban":
|
||||
return "races";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getPenaltyColor = (type: PenaltyType) => {
|
||||
switch (type) {
|
||||
case "time_penalty":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
|
||||
case "grid_penalty":
|
||||
return "text-purple-400 bg-purple-500/10 border-purple-500/30";
|
||||
case "points_deduction":
|
||||
return "text-red-400 bg-red-500/10 border-red-500/30";
|
||||
case "disqualification":
|
||||
return "text-red-500 bg-red-500/10 border-red-500/30";
|
||||
case "warning":
|
||||
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/30";
|
||||
case "license_points":
|
||||
return "text-orange-400 bg-orange-500/10 border-orange-500/30";
|
||||
case "probation":
|
||||
return "text-amber-400 bg-amber-500/10 border-amber-500/30";
|
||||
case "fine":
|
||||
return "text-green-400 bg-green-500/10 border-green-500/30";
|
||||
case "race_ban":
|
||||
return "text-red-600 bg-red-600/10 border-red-600/30";
|
||||
default:
|
||||
return "text-warning-amber bg-warning-amber/10 border-warning-amber/30";
|
||||
}
|
||||
};
|
||||
|
||||
if (showConfirmation) {
|
||||
return (
|
||||
<Modal title="Confirm Decision" isOpen={true} onOpenChange={() => setShowConfirmation(false)}>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="text-center space-y-4">
|
||||
{decision === "accept" ? (
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-orange-500/20 flex items-center justify-center">
|
||||
<AlertCircle className="h-8 w-8 text-orange-400" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-gray-500/20 flex items-center justify-center">
|
||||
<XCircle className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white">Confirm Decision</h3>
|
||||
<p className="text-gray-400 mt-2">
|
||||
{decision === "accept"
|
||||
? `Issue ${penaltyValue} ${getPenaltyLabel(penaltyType)} penalty?`
|
||||
: "Reject this protest?"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="p-4 bg-gray-800/50">
|
||||
<p className="text-sm text-gray-300">{stewardNotes}</p>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Submitting..." : "Confirm Decision"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Review Protest" isOpen={true} onOpenChange={onClose}>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-12 w-12 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertCircle className="h-6 w-6 text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold text-white">Review Protest</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Protest #{protest.id.substring(0, 8)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="p-4 bg-gray-800/50">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Filed Date</span>
|
||||
<span className="text-white font-medium">
|
||||
{new Date(protest.filedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Incident Lap</span>
|
||||
<span className="text-white font-medium">
|
||||
Lap {protest.incident.lap}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Status</span>
|
||||
<span className="px-2 py-1 rounded text-xs font-medium bg-orange-500/20 text-orange-400">
|
||||
{protest.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400 mb-2 block">
|
||||
Description
|
||||
</label>
|
||||
<Card className="p-4 bg-gray-800/50">
|
||||
<p className="text-gray-300">{protest.incident.description}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{protest.comment && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400 mb-2 block">
|
||||
Additional Comment
|
||||
</label>
|
||||
<Card className="p-4 bg-gray-800/50">
|
||||
<p className="text-gray-300">{protest.comment}</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{protest.proofVideoUrl && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400 mb-2 block">
|
||||
Evidence
|
||||
</label>
|
||||
<Card className="p-4 bg-gray-800/50">
|
||||
<a
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-orange-400 hover:text-orange-300 transition-colors"
|
||||
>
|
||||
<Video className="h-4 w-4" />
|
||||
<span className="text-sm">View video evidence</span>
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 pt-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-white">Stewarding Decision</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant={decision === "accept" ? "primary" : "secondary"}
|
||||
className="flex items-center justify-center gap-2"
|
||||
onClick={() => setDecision("accept")}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Accept Protest
|
||||
</Button>
|
||||
<Button
|
||||
variant={decision === "reject" ? "primary" : "secondary"}
|
||||
className="flex items-center justify-center gap-2"
|
||||
onClick={() => setDecision("reject")}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Reject Protest
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{decision === "accept" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400 mb-2 block">
|
||||
Penalty Type
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ type: "time_penalty" as PenaltyType, label: "Time Penalty" },
|
||||
{ type: "grid_penalty" as PenaltyType, label: "Grid Penalty" },
|
||||
{ type: "points_deduction" as PenaltyType, label: "Points Deduction" },
|
||||
{ type: "disqualification" as PenaltyType, label: "Disqualification" },
|
||||
{ type: "warning" as PenaltyType, label: "Warning" },
|
||||
{ type: "license_points" as PenaltyType, label: "License Points" },
|
||||
{ type: "probation" as PenaltyType, label: "Probation" },
|
||||
{ type: "fine" as PenaltyType, label: "Fine" },
|
||||
{ type: "race_ban" as PenaltyType, label: "Race Ban" },
|
||||
].map(({ type, label }) => {
|
||||
const Icon = getPenaltyIcon(type);
|
||||
const colorClass = getPenaltyColor(type);
|
||||
const isSelected = penaltyType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setPenaltyType(type)}
|
||||
className={`p-3 rounded-lg border transition-all ${
|
||||
isSelected
|
||||
? `${colorClass} border-2`
|
||||
: "border-charcoal-outline hover:border-gray-600 bg-iron-gray/50"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 mx-auto mb-1 ${isSelected ? '' : 'text-gray-400'}`} />
|
||||
<p className={`text-xs font-medium ${isSelected ? '' : 'text-gray-400'}`}>{label}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(penaltyType) && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400 mb-2 block">
|
||||
Penalty Value ({getPenaltyLabel(penaltyType)})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={penaltyValue}
|
||||
onChange={(e) => setPenaltyValue(Number(e.target.value))}
|
||||
min="1"
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-400 mb-2 block">
|
||||
Steward Notes *
|
||||
</label>
|
||||
<textarea
|
||||
value={stewardNotes}
|
||||
onChange={(e) => setStewardNotes(e.target.value)}
|
||||
placeholder="Explain your decision and reasoning..."
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-orange-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-800">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
onClick={() => setShowConfirmation(true)}
|
||||
disabled={!decision || !stewardNotes.trim() || submitting}
|
||||
>
|
||||
Submit Decision
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
54
apps/website/components/leagues/ScoringOverviewCard.tsx
Normal file
54
apps/website/components/leagues/ScoringOverviewCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
|
||||
interface ScoringOverviewCardProps {
|
||||
gameName: string;
|
||||
scoringPresetName?: string;
|
||||
dropPolicySummary: string;
|
||||
totalChampionships: number;
|
||||
}
|
||||
|
||||
export function ScoringOverviewCard({
|
||||
gameName,
|
||||
scoringPresetName,
|
||||
dropPolicySummary,
|
||||
totalChampionships
|
||||
}: ScoringOverviewCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Scoring System</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">Points allocation and championship rules</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
|
||||
<span className="text-sm font-medium text-primary-blue">{scoringPresetName || 'Custom'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1 font-medium">Platform</p>
|
||||
<p className="text-lg font-semibold text-white">{gameName}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1 font-medium">Championships</p>
|
||||
<p className="text-lg font-semibold text-white">{totalChampionships}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1 font-medium">Drop Policy</p>
|
||||
<p className="text-lg font-semibold text-white truncate" title={dropPolicySummary}>
|
||||
{dropPolicySummary.includes('Best') ? dropPolicySummary.split(' ').slice(0, 3).join(' ') :
|
||||
dropPolicySummary.includes('Worst') ? dropPolicySummary.split(' ').slice(0, 3).join(' ') :
|
||||
'All count'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-deep-graphite rounded-lg border border-charcoal-outline">
|
||||
<p className="text-sm text-gray-400">{dropPolicySummary}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
87
apps/website/components/leagues/SeasonStatsCard.tsx
Normal file
87
apps/website/components/leagues/SeasonStatsCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
interface SeasonStatistics {
|
||||
racesCompleted: number;
|
||||
totalRaces: number;
|
||||
averagePoints: number;
|
||||
highestScore: number;
|
||||
totalPoints: number;
|
||||
}
|
||||
|
||||
interface SeasonStatsCardProps {
|
||||
stats: SeasonStatistics;
|
||||
}
|
||||
|
||||
export function SeasonStatsCard({ stats }: SeasonStatsCardProps) {
|
||||
const completionPercentage = stats.totalRaces > 0
|
||||
? Math.round((stats.racesCompleted / stats.totalRaces) * 100)
|
||||
: 0;
|
||||
|
||||
if (stats.racesCompleted === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
📈 Season Statistics
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Your performance this season
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-950/20 dark:to-cyan-950/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 uppercase tracking-wider mb-1 font-medium">
|
||||
Races Completed
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.racesCompleted}
|
||||
<span className="text-lg text-gray-500 dark:text-gray-400">/{stats.totalRaces}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
||||
<p className="text-xs text-green-600 dark:text-green-400 uppercase tracking-wider mb-1 font-medium">
|
||||
Average Points
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.averagePoints.toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400 uppercase tracking-wider mb-1 font-medium">
|
||||
Highest Score
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.highestScore}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-orange-50 to-red-50 dark:from-orange-950/20 dark:to-red-950/20 rounded-lg p-4 border border-orange-200 dark:border-orange-800">
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400 uppercase tracking-wider mb-1 font-medium">
|
||||
Total Points
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.totalPoints}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Season Progress</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{completionPercentage}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-500 h-3 rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${completionPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Star } from 'lucide-react';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
|
||||
import type { LeagueMembership, MembershipRole } from '@/lib/leagueMembership';
|
||||
import { getLeagueRoleDisplay } from '@/lib/leagueRoles';
|
||||
import { getDriverStats } from '@/lib/di-container';
|
||||
import { getImageService } from '@/lib/di-container';
|
||||
import CountryFlag from '@/components/ui/CountryFlag';
|
||||
|
||||
// Position background colors
|
||||
const getPositionBgColor = (position: number): string => {
|
||||
switch (position) {
|
||||
case 1: return 'bg-yellow-500/10 border-l-4 border-l-yellow-500';
|
||||
case 2: return 'bg-gray-300/10 border-l-4 border-l-gray-400';
|
||||
case 3: return 'bg-amber-600/10 border-l-4 border-l-amber-600';
|
||||
default: return 'border-l-4 border-l-transparent';
|
||||
}
|
||||
};
|
||||
|
||||
interface StandingsTableProps {
|
||||
standings: LeagueDriverSeasonStatsDTO[];
|
||||
drivers: DriverDTO[];
|
||||
leagueId: string;
|
||||
memberships?: LeagueMembership[];
|
||||
currentDriverId?: string;
|
||||
isAdmin?: boolean;
|
||||
onRemoveMember?: (driverId: string) => void;
|
||||
onUpdateRole?: (driverId: string, role: MembershipRole) => void;
|
||||
}
|
||||
|
||||
export default function StandingsTable({ standings, drivers, leagueId }: StandingsTableProps) {
|
||||
export default function StandingsTable({
|
||||
standings,
|
||||
drivers,
|
||||
leagueId,
|
||||
memberships = [],
|
||||
currentDriverId,
|
||||
isAdmin = false,
|
||||
onRemoveMember,
|
||||
onUpdateRole
|
||||
}: StandingsTableProps) {
|
||||
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
||||
const [activeMenu, setActiveMenu] = useState<{ driverId: string; type: 'member' | 'points' } | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setActiveMenu(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const getDriver = (driverId: string): DriverDTO | undefined => {
|
||||
return drivers.find((d) => d.id === driverId);
|
||||
};
|
||||
|
||||
const getMembership = (driverId: string): LeagueMembership | undefined => {
|
||||
return memberships.find((m) => m.driverId === driverId);
|
||||
};
|
||||
|
||||
const canModifyMember = (driverId: string): boolean => {
|
||||
if (!isAdmin) return false;
|
||||
if (driverId === currentDriverId) return false;
|
||||
const membership = getMembership(driverId);
|
||||
// Allow managing drivers even without formal membership (they have standings = they're participating)
|
||||
// But don't allow modifying the owner
|
||||
if (membership && membership.role === 'owner') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const isCurrentUser = (driverId: string): boolean => {
|
||||
return driverId === currentDriverId;
|
||||
};
|
||||
|
||||
const handleRoleChange = (driverId: string, newRole: MembershipRole) => {
|
||||
if (!onUpdateRole) return;
|
||||
const membership = getMembership(driverId);
|
||||
if (!membership) return;
|
||||
|
||||
const confirmationMessages: Record<MembershipRole, string> = {
|
||||
owner: 'Cannot promote to owner',
|
||||
admin: 'Promote this member to Admin? They will have full management permissions.',
|
||||
steward: 'Assign Steward role? They will be able to manage protests and penalties.',
|
||||
member: 'Demote to regular Member? They will lose elevated permissions.'
|
||||
};
|
||||
|
||||
if (newRole === 'owner') {
|
||||
alert(confirmationMessages.owner);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newRole !== membership.role && confirm(confirmationMessages[newRole])) {
|
||||
onUpdateRole(driverId, newRole);
|
||||
setActiveMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (driverId: string) => {
|
||||
if (!onRemoveMember) return;
|
||||
const driver = getDriver(driverId);
|
||||
const driverName = driver?.name || 'this member';
|
||||
|
||||
if (confirm(`Remove ${driverName} from the league? This action cannot be undone.`)) {
|
||||
onRemoveMember(driverId);
|
||||
setActiveMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
const MemberActionMenu = ({ driverId }: { driverId: string }) => {
|
||||
const membership = getMembership(driverId);
|
||||
// For drivers without membership, show limited options (add as member, remove from standings)
|
||||
const hasMembership = !!membership;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute right-0 top-full mt-1 z-50 bg-deep-graphite border border-charcoal-outline rounded-lg shadow-xl p-2 min-w-[200px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-xs text-gray-400 px-2 py-1 mb-1">
|
||||
Member Management
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{hasMembership ? (
|
||||
<>
|
||||
{/* Role Management for existing members */}
|
||||
{membership!.role !== 'admin' && membership!.role !== 'owner' && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'admin'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-purple-500/10 rounded flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span>🛡️</span>
|
||||
<span>Promote to Admin</span>
|
||||
</button>
|
||||
)}
|
||||
{membership!.role === 'admin' && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span>⬇️</span>
|
||||
<span>Demote to Member</span>
|
||||
</button>
|
||||
)}
|
||||
{membership!.role === 'member' && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'steward'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-blue-500/10 rounded flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span>🏁</span>
|
||||
<span>Make Steward</span>
|
||||
</button>
|
||||
)}
|
||||
{membership!.role === 'steward' && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span>🏁</span>
|
||||
<span>Remove Steward</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="border-t border-charcoal-outline my-1"></div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleRemove(driverId); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 rounded flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span>🚫</span>
|
||||
<span>Remove from League</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Options for drivers without membership (participating but not formal members) */}
|
||||
<div className="text-xs text-yellow-400/80 px-2 py-1 mb-1 bg-yellow-500/10 rounded">
|
||||
Driver not a formal member
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); alert('Add as member - feature coming soon'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-green-500/10 rounded flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span>➕</span>
|
||||
<span>Add as Member</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleRemove(driverId); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 rounded flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span>🚫</span>
|
||||
<span>Remove from Standings</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PointsActionMenu = ({ driverId }: { driverId: string }) => {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute right-0 top-full mt-1 z-50 bg-deep-graphite border border-charcoal-outline rounded-lg shadow-xl p-2 min-w-[180px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-xs text-gray-400 px-2 py-1 mb-1">
|
||||
Score Actions
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); alert('View detailed stats - feature coming soon'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span>📊</span>
|
||||
<span>View Details</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); alert('Manual adjustment - feature coming soon'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span>⚠️</span>
|
||||
<span>Adjust Points</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); alert('Race history - feature coming soon'); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span>📝</span>
|
||||
<span>Race History</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (standings.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
@@ -24,128 +250,191 @@ export default function StandingsTable({ standings, drivers, leagueId }: Standin
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto overflow-y-visible">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Pos</th>
|
||||
<th className="text-center py-3 px-3 font-semibold text-gray-400 w-14">Pos</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Driver</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Team</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Total Pts</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Pts / Race</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Started</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Finished</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">DNF</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">No‑Shows</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Penalty</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Bonus</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Avg Finish</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-400">Rating Δ</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-400">Points</th>
|
||||
<th className="text-center py-3 px-4 font-semibold text-gray-400">Races</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-400">Avg Finish</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-400">Penalty</th>
|
||||
<th className="text-right py-3 px-4 font-semibold text-gray-400">Bonus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{standings.map((row) => {
|
||||
const isLeader = row.position === 1;
|
||||
const driver = getDriver(row.driverId);
|
||||
const membership = getMembership(row.driverId);
|
||||
const roleDisplay = membership ? getLeagueRoleDisplay(membership.role) : null;
|
||||
const canModify = canModifyMember(row.driverId);
|
||||
const driverStatsData = getDriverStats(row.driverId);
|
||||
const isRowHovered = hoveredRow === row.driverId;
|
||||
const isMemberMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'member';
|
||||
const isPointsMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'points';
|
||||
|
||||
const totalPointsLine =
|
||||
row.penaltyPoints > 0
|
||||
? `Total Points: ${row.totalPoints} (-${row.penaltyPoints} penalty)`
|
||||
: `Total Points: ${row.totalPoints}`;
|
||||
|
||||
const ratingDelta =
|
||||
row.ratingChange === null || row.ratingChange === 0
|
||||
? '—'
|
||||
: row.ratingChange > 0
|
||||
? `+${row.ratingChange}`
|
||||
: `${row.ratingChange}`;
|
||||
const isMe = isCurrentUser(row.driverId);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={`${row.leagueId}-${row.driverId}`}
|
||||
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
|
||||
className={`border-b border-charcoal-outline/50 transition-all duration-200 ${getPositionBgColor(row.position)} ${isRowHovered ? 'bg-iron-gray/10' : ''} ${isMe ? 'ring-2 ring-primary-blue/50 ring-inset bg-primary-blue/5' : ''}`}
|
||||
onMouseEnter={() => setHoveredRow(row.driverId)}
|
||||
onMouseLeave={() => {
|
||||
setHoveredRow(null);
|
||||
if (!isMemberMenuOpen && !isPointsMenuOpen) {
|
||||
setActiveMenu(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`font-semibold ${isLeader ? 'text-yellow-500' : 'text-white'}`}>
|
||||
{/* Position */}
|
||||
<td className="py-3 px-3 text-center">
|
||||
<div className={`inline-flex items-center justify-center w-8 h-8 rounded-full font-bold ${
|
||||
row.position === 1 ? 'bg-yellow-500 text-black' :
|
||||
row.position === 2 ? 'bg-gray-400 text-black' :
|
||||
row.position === 3 ? 'bg-amber-600 text-white' :
|
||||
'bg-charcoal-outline text-white'
|
||||
}`}>
|
||||
{row.position}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{driver ? (
|
||||
<DriverIdentity
|
||||
driver={driver}
|
||||
href={`/drivers/${row.driverId}?from=league-standings&leagueId=${leagueId}`}
|
||||
contextLabel={`P${row.position}`}
|
||||
size="sm"
|
||||
meta={totalPointsLine}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white">Unknown Driver</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-gray-300">
|
||||
{row.teamName ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white font-medium">{row.totalPoints}</span>
|
||||
{row.penaltyPoints > 0 || row.bonusPoints !== 0 ? (
|
||||
<span className="text-xs text-gray-400">
|
||||
base {row.basePoints}
|
||||
{row.penaltyPoints > 0 && (
|
||||
<span className="text-red-400"> −{row.penaltyPoints}</span>
|
||||
)}
|
||||
{row.bonusPoints !== 0 && (
|
||||
<span className="text-green-400"> +{row.bonusPoints}</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Driver with Rating and Nationality */}
|
||||
<td className="py-3 px-4 relative">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0">
|
||||
{driver && (
|
||||
<Image
|
||||
src={getImageService().getDriverAvatar(driver.id)}
|
||||
alt={driver.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Nationality flag */}
|
||||
{driver && driver.country && (
|
||||
<div className="absolute -bottom-1 -right-1">
|
||||
<CountryFlag countryCode={driver.country} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name and Rating */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/drivers/${row.driverId}`}
|
||||
className="font-medium text-white truncate hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{driver?.name || 'Unknown Driver'}
|
||||
</Link>
|
||||
{isMe && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-primary-blue/20 text-primary-blue border border-primary-blue/30">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
{roleDisplay && roleDisplay.text !== 'Member' && (
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs flex items-center gap-1">
|
||||
{driverStatsData && (
|
||||
<span className="inline-flex items-center gap-1 text-amber-300">
|
||||
<Star className="h-3 w-3" />
|
||||
<span className="tabular-nums font-medium">{driverStatsData.rating}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover Actions for Member Management */}
|
||||
{isAdmin && canModify && (
|
||||
<div className="flex items-center gap-1" style={{ opacity: isRowHovered || isMemberMenuOpen ? 1 : 0, transition: 'opacity 0.2s', visibility: isRowHovered || isMemberMenuOpen ? 'visible' : 'hidden' }}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setActiveMenu(isMemberMenuOpen ? null : { driverId: row.driverId, type: 'member' }); }}
|
||||
className={`p-1.5 rounded transition-colors ${isMemberMenuOpen ? 'bg-primary-blue/20 text-primary-blue' : 'text-gray-400 hover:text-white hover:bg-iron-gray/30'}`}
|
||||
title="Manage member"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isMemberMenuOpen && <MemberActionMenu driverId={row.driverId} />}
|
||||
</td>
|
||||
|
||||
{/* Team */}
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white">
|
||||
{row.pointsPerRace.toFixed(2)}
|
||||
<span className="text-gray-300">{row.teamName ?? '—'}</span>
|
||||
</td>
|
||||
|
||||
{/* Total Points with Hover Action */}
|
||||
<td className="py-3 px-4 text-right relative">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-white font-bold text-lg">{row.totalPoints}</span>
|
||||
{isAdmin && canModify && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setActiveMenu(isPointsMenuOpen ? null : { driverId: row.driverId, type: 'points' }); }}
|
||||
className={`p-1 rounded transition-colors ${isPointsMenuOpen ? 'bg-primary-blue/20 text-primary-blue' : 'text-gray-400 hover:text-white hover:bg-iron-gray/30'}`}
|
||||
style={{ opacity: isRowHovered || isPointsMenuOpen ? 1 : 0, transition: 'opacity 0.2s', visibility: isRowHovered || isPointsMenuOpen ? 'visible' : 'hidden' }}
|
||||
title="Score actions"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPointsMenuOpen && <PointsActionMenu driverId={row.driverId} />}
|
||||
</td>
|
||||
|
||||
{/* Races (Finished/Started) */}
|
||||
<td className="py-3 px-4 text-center">
|
||||
<span className="text-white">{row.racesFinished}</span>
|
||||
<span className="text-gray-500">/{row.racesStarted}</span>
|
||||
</td>
|
||||
|
||||
{/* Avg Finish */}
|
||||
<td className="py-3 px-4 text-right">
|
||||
<span className="text-gray-300">
|
||||
{row.avgFinish !== null ? row.avgFinish.toFixed(1) : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white">{row.racesStarted}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white">{row.racesFinished}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white">{row.dnfs}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white">{row.noShows}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={row.penaltyPoints > 0 ? 'text-red-400' : 'text-gray-300'}>
|
||||
|
||||
{/* Penalty */}
|
||||
<td className="py-3 px-4 text-right">
|
||||
<span className={row.penaltyPoints > 0 ? 'text-red-400 font-medium' : 'text-gray-500'}>
|
||||
{row.penaltyPoints > 0 ? `-${row.penaltyPoints}` : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={row.bonusPoints !== 0 ? 'text-green-400' : 'text-gray-300'}>
|
||||
|
||||
{/* Bonus */}
|
||||
<td className="py-3 px-4 text-right">
|
||||
<span className={row.bonusPoints !== 0 ? 'text-green-400 font-medium' : 'text-gray-500'}>
|
||||
{row.bonusPoints !== 0 ? `+${row.bonusPoints}` : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white">
|
||||
{row.avgFinish !== null ? row.avgFinish.toFixed(2) : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={row.ratingChange && row.ratingChange > 0 ? 'text-green-400' : row.ratingChange && row.ratingChange < 0 ? 'text-red-400' : 'text-gray-300'}>
|
||||
{ratingDelta}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import Button from '../ui/Button';
|
||||
import { getImageService } from '@/lib/di-container';
|
||||
import DriverRatingPill from '@/components/profile/DriverRatingPill';
|
||||
import CountryFlag from '@/components/ui/CountryFlag';
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
driver: DriverDTO;
|
||||
@@ -41,9 +42,7 @@ export default function ProfileHeader({
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-white">{driver.name}</h1>
|
||||
<span className="text-3xl" aria-label={`Country: ${driver.country}`}>
|
||||
{getCountryFlag(driver.country)}
|
||||
</span>
|
||||
<CountryFlag countryCode={driver.country} size="lg" />
|
||||
{teamTag && (
|
||||
<span className="px-3 py-1 bg-primary-blue/20 text-primary-blue rounded-full text-sm font-medium">
|
||||
{teamTag}
|
||||
@@ -78,17 +77,4 @@ export default function ProfileHeader({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 '🏁';
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export default function Button({
|
||||
as = 'button',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles = 'min-h-[44px] rounded-full px-6 py-3 text-sm font-semibold transition-all duration-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 hover:scale-[1.03] active:scale-95';
|
||||
const baseStyles = 'inline-flex items-center min-h-[44px] rounded-full px-6 py-3 text-sm font-semibold transition-all duration-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 hover:scale-[1.03] active:scale-95';
|
||||
|
||||
const variantStyles = {
|
||||
primary: 'bg-primary-blue text-white shadow-[0_0_15px_rgba(25,140,255,0.4)] hover:shadow-[0_0_25px_rgba(25,140,255,0.6)] active:ring-2 active:ring-primary-blue focus-visible:outline-primary-blue',
|
||||
|
||||
130
apps/website/components/ui/CountryFlag.tsx
Normal file
130
apps/website/components/ui/CountryFlag.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
// ISO 3166-1 alpha-2 country code to full country name mapping
|
||||
const countryNames: Record<string, string> = {
|
||||
'US': 'United States',
|
||||
'GB': 'United Kingdom',
|
||||
'CA': 'Canada',
|
||||
'AU': 'Australia',
|
||||
'NZ': 'New Zealand',
|
||||
'DE': 'Germany',
|
||||
'FR': 'France',
|
||||
'IT': 'Italy',
|
||||
'ES': 'Spain',
|
||||
'NL': 'Netherlands',
|
||||
'BE': 'Belgium',
|
||||
'SE': 'Sweden',
|
||||
'NO': 'Norway',
|
||||
'DK': 'Denmark',
|
||||
'FI': 'Finland',
|
||||
'PL': 'Poland',
|
||||
'CZ': 'Czech Republic',
|
||||
'AT': 'Austria',
|
||||
'CH': 'Switzerland',
|
||||
'PT': 'Portugal',
|
||||
'IE': 'Ireland',
|
||||
'BR': 'Brazil',
|
||||
'MX': 'Mexico',
|
||||
'AR': 'Argentina',
|
||||
'JP': 'Japan',
|
||||
'CN': 'China',
|
||||
'KR': 'South Korea',
|
||||
'IN': 'India',
|
||||
'SG': 'Singapore',
|
||||
'TH': 'Thailand',
|
||||
'MY': 'Malaysia',
|
||||
'ID': 'Indonesia',
|
||||
'PH': 'Philippines',
|
||||
'ZA': 'South Africa',
|
||||
'RU': 'Russia',
|
||||
'MC': 'Monaco',
|
||||
'TR': 'Turkey',
|
||||
'GR': 'Greece',
|
||||
'HU': 'Hungary',
|
||||
'RO': 'Romania',
|
||||
'BG': 'Bulgaria',
|
||||
'HR': 'Croatia',
|
||||
'SI': 'Slovenia',
|
||||
'SK': 'Slovakia',
|
||||
'LT': 'Lithuania',
|
||||
'LV': 'Latvia',
|
||||
'EE': 'Estonia',
|
||||
};
|
||||
|
||||
// ISO 3166-1 alpha-2 country code to flag emoji conversion
|
||||
const countryCodeToFlag = (countryCode: string): string => {
|
||||
if (!countryCode || countryCode.length !== 2) return '🏁';
|
||||
|
||||
// Convert ISO 3166-1 alpha-2 to regional indicator symbols
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
interface CountryFlagProps {
|
||||
/**
|
||||
* ISO 3166-1 alpha-2 country code (e.g., 'US', 'GB', 'DE')
|
||||
*/
|
||||
countryCode: string;
|
||||
/**
|
||||
* Size of the flag emoji
|
||||
* @default 'md'
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Whether to show tooltip with country name
|
||||
* @default true
|
||||
*/
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable component for displaying country flags with tooltips
|
||||
*
|
||||
* @example
|
||||
* <CountryFlag countryCode="US" />
|
||||
* <CountryFlag countryCode="GB" size="lg" />
|
||||
* <CountryFlag countryCode="DE" showTooltip={false} />
|
||||
*/
|
||||
export default function CountryFlag({
|
||||
countryCode,
|
||||
size = 'md',
|
||||
className = '',
|
||||
showTooltip = true
|
||||
}: CountryFlagProps) {
|
||||
const [showTooltipState, setShowTooltipState] = useState(false);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base'
|
||||
};
|
||||
|
||||
const flag = countryCodeToFlag(countryCode);
|
||||
const countryName = countryNames[countryCode.toUpperCase()] || countryCode;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center relative ${sizeClasses[size]} ${className}`}
|
||||
onMouseEnter={() => setShowTooltipState(true)}
|
||||
onMouseLeave={() => setShowTooltipState(false)}
|
||||
title={showTooltip ? countryName : undefined}
|
||||
>
|
||||
<span className="select-none">{flag}</span>
|
||||
{showTooltip && showTooltipState && (
|
||||
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs font-medium text-white bg-deep-graphite border border-charcoal-outline rounded shadow-lg whitespace-nowrap z-50">
|
||||
{countryName}
|
||||
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-charcoal-outline"></span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Globe, Search, ChevronDown, Check } from 'lucide-react';
|
||||
import CountryFlag from './CountryFlag';
|
||||
|
||||
export interface Country {
|
||||
code: string;
|
||||
@@ -51,14 +52,6 @@ export const COUNTRIES: Country[] = [
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
];
|
||||
|
||||
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 '🏁';
|
||||
}
|
||||
|
||||
interface CountrySelectProps {
|
||||
value: string;
|
||||
@@ -130,7 +123,7 @@ export default function CountrySelect({
|
||||
<Globe className="w-4 h-4 text-gray-500" />
|
||||
{selectedCountry ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-lg">{getCountryFlag(selectedCountry.code)}</span>
|
||||
<CountryFlag countryCode={selectedCountry.code} size="md" showTooltip={false} />
|
||||
<span>{selectedCountry.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
@@ -173,7 +166,7 @@ export default function CountrySelect({
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="text-lg">{getCountryFlag(country.code)}</span>
|
||||
<CountryFlag countryCode={country.code} size="md" showTooltip={false} />
|
||||
<span>{country.name}</span>
|
||||
</span>
|
||||
{value === country.code && (
|
||||
|
||||
@@ -40,6 +40,20 @@ import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFee
|
||||
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
|
||||
import type { ImageServicePort } from '@gridpilot/media';
|
||||
|
||||
// Notifications package imports
|
||||
import type { INotificationRepository, INotificationPreferenceRepository } from '@gridpilot/notifications/application';
|
||||
import {
|
||||
SendNotificationUseCase,
|
||||
MarkNotificationReadUseCase,
|
||||
GetUnreadNotificationsQuery
|
||||
} from '@gridpilot/notifications/application';
|
||||
import {
|
||||
InMemoryNotificationRepository,
|
||||
InMemoryNotificationPreferenceRepository,
|
||||
NotificationGatewayRegistry,
|
||||
InAppNotificationAdapter,
|
||||
} from '@gridpilot/notifications/infrastructure';
|
||||
|
||||
import { InMemoryDriverRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryDriverRepository';
|
||||
import { InMemoryLeagueRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueRepository';
|
||||
import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository';
|
||||
@@ -92,7 +106,10 @@ import {
|
||||
ApplyPenaltyUseCase,
|
||||
GetRaceProtestsQuery,
|
||||
GetRacePenaltiesQuery,
|
||||
RequestProtestDefenseUseCase,
|
||||
SubmitProtestDefenseUseCase,
|
||||
} from '@gridpilot/racing/application';
|
||||
import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase';
|
||||
import type { DriverRatingProvider } from '@gridpilot/racing/application';
|
||||
import {
|
||||
createStaticRacingSeed,
|
||||
@@ -194,6 +211,14 @@ class DIContainer {
|
||||
private _trackRepository: ITrackRepository;
|
||||
private _carRepository: ICarRepository;
|
||||
|
||||
// Notifications
|
||||
private _notificationRepository: INotificationRepository;
|
||||
private _notificationPreferenceRepository: INotificationPreferenceRepository;
|
||||
private _notificationGatewayRegistry: NotificationGatewayRegistry;
|
||||
private _sendNotificationUseCase: SendNotificationUseCase;
|
||||
private _markNotificationReadUseCase: MarkNotificationReadUseCase;
|
||||
private _getUnreadNotificationsQuery: GetUnreadNotificationsQuery;
|
||||
|
||||
// Racing application use-cases / queries
|
||||
private _joinLeagueUseCase: JoinLeagueUseCase;
|
||||
private _registerForRaceUseCase: RegisterForRaceUseCase;
|
||||
@@ -219,6 +244,8 @@ class DIContainer {
|
||||
private _applyPenaltyUseCase: ApplyPenaltyUseCase;
|
||||
private _getRaceProtestsQuery: GetRaceProtestsQuery;
|
||||
private _getRacePenaltiesQuery: GetRacePenaltiesQuery;
|
||||
private _requestProtestDefenseUseCase: RequestProtestDefenseUseCase;
|
||||
private _submitProtestDefenseUseCase: SubmitProtestDefenseUseCase;
|
||||
|
||||
private _createTeamUseCase: CreateTeamUseCase;
|
||||
private _joinTeamUseCase: JoinTeamUseCase;
|
||||
@@ -231,6 +258,7 @@ class DIContainer {
|
||||
private _getTeamMembersQuery: GetTeamMembersQuery;
|
||||
private _getTeamJoinRequestsQuery: GetTeamJoinRequestsQuery;
|
||||
private _getDriverTeamQuery: GetDriverTeamQuery;
|
||||
private _transferLeagueOwnershipUseCase: TransferLeagueOwnershipUseCase;
|
||||
|
||||
private constructor() {
|
||||
// Create seed data
|
||||
@@ -435,8 +463,7 @@ class DIContainer {
|
||||
|
||||
for (const league of seedData.leagues) {
|
||||
const archetype = getDemoLeagueArchetypeByName(league.name);
|
||||
if (!archetype) continue;
|
||||
|
||||
|
||||
const season = Season.create({
|
||||
id: `season-${league.id}-demo`,
|
||||
leagueId: league.id,
|
||||
@@ -450,15 +477,14 @@ class DIContainer {
|
||||
});
|
||||
seededSeasons.push(season);
|
||||
|
||||
const infraPreset = getLeagueScoringPresetById(
|
||||
archetype.scoringPresetId,
|
||||
);
|
||||
if (!infraPreset) {
|
||||
// If a preset is missing, skip scoring config for this league in alpha seed.
|
||||
continue;
|
||||
// Use archetype preset if available, otherwise fall back to default club preset
|
||||
const presetId = archetype?.scoringPresetId ?? 'club-default';
|
||||
const infraPreset = getLeagueScoringPresetById(presetId);
|
||||
|
||||
if (infraPreset) {
|
||||
const config = infraPreset.createConfig({ seasonId: season.id });
|
||||
seededScoringConfigs.push(config);
|
||||
}
|
||||
const config = infraPreset.createConfig({ seasonId: season.id });
|
||||
seededScoringConfigs.push(config);
|
||||
}
|
||||
|
||||
this._gameRepository = new InMemoryGameRepository([game]);
|
||||
@@ -550,6 +576,33 @@ class DIContainer {
|
||||
});
|
||||
}
|
||||
|
||||
// Seed sample league stewards for the primary driver's league (alpha demo)
|
||||
if (primaryLeagueForAdmins) {
|
||||
const stewardCandidates = seedData.drivers
|
||||
.filter((d) => d.id !== primaryLeagueForAdmins.ownerId)
|
||||
.slice(2, 5);
|
||||
|
||||
stewardCandidates.forEach((driver) => {
|
||||
const existing = seededMemberships.find(
|
||||
(m) =>
|
||||
m.leagueId === primaryLeagueForAdmins.id && m.driverId === driver.id,
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.role !== 'owner' && existing.role !== 'admin') {
|
||||
existing.role = 'steward';
|
||||
}
|
||||
} else {
|
||||
seededMemberships.push({
|
||||
leagueId: primaryLeagueForAdmins.id,
|
||||
driverId: driver.id,
|
||||
role: 'steward',
|
||||
status: 'active',
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Seed a few pending join requests for demo leagues (expanded to more leagues)
|
||||
const seededJoinRequests: JoinRequest[] = [];
|
||||
const demoLeagues = seedData.leagues.slice(0, 6); // Expanded from 2 to 6 leagues
|
||||
@@ -762,6 +815,11 @@ class DIContainer {
|
||||
this._teamMembershipRepository,
|
||||
);
|
||||
|
||||
this._transferLeagueOwnershipUseCase = new TransferLeagueOwnershipUseCase(
|
||||
this._leagueRepository,
|
||||
this._leagueMembershipRepository,
|
||||
);
|
||||
|
||||
// Stewarding use cases and queries
|
||||
this._fileProtestUseCase = new FileProtestUseCase(
|
||||
this._protestRepository,
|
||||
@@ -787,6 +845,14 @@ class DIContainer {
|
||||
this._penaltyRepository,
|
||||
this._driverRepository,
|
||||
);
|
||||
this._requestProtestDefenseUseCase = new RequestProtestDefenseUseCase(
|
||||
this._protestRepository,
|
||||
this._raceRepository,
|
||||
this._leagueMembershipRepository,
|
||||
);
|
||||
this._submitProtestDefenseUseCase = new SubmitProtestDefenseUseCase(
|
||||
this._protestRepository,
|
||||
);
|
||||
|
||||
// Social and feed adapters backed by static seed
|
||||
this._feedRepository = new InMemoryFeedRepository(seedData);
|
||||
@@ -972,6 +1038,29 @@ class DIContainer {
|
||||
|
||||
this._trackRepository = new InMemoryTrackRepository(seedTracks);
|
||||
this._carRepository = new InMemoryCarRepository(seedCars);
|
||||
|
||||
// Initialize notifications
|
||||
this._notificationRepository = new InMemoryNotificationRepository();
|
||||
this._notificationPreferenceRepository = new InMemoryNotificationPreferenceRepository();
|
||||
|
||||
// Set up gateway registry with adapters
|
||||
this._notificationGatewayRegistry = new NotificationGatewayRegistry([
|
||||
new InAppNotificationAdapter(),
|
||||
// Future: DiscordNotificationAdapter, EmailNotificationAdapter
|
||||
]);
|
||||
|
||||
// Notification use cases
|
||||
this._sendNotificationUseCase = new SendNotificationUseCase(
|
||||
this._notificationRepository,
|
||||
this._notificationPreferenceRepository,
|
||||
this._notificationGatewayRegistry,
|
||||
);
|
||||
this._markNotificationReadUseCase = new MarkNotificationReadUseCase(
|
||||
this._notificationRepository,
|
||||
);
|
||||
this._getUnreadNotificationsQuery = new GetUnreadNotificationsQuery(
|
||||
this._notificationRepository,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1187,6 +1276,26 @@ class DIContainer {
|
||||
return this._carRepository;
|
||||
}
|
||||
|
||||
get notificationRepository(): INotificationRepository {
|
||||
return this._notificationRepository;
|
||||
}
|
||||
|
||||
get notificationPreferenceRepository(): INotificationPreferenceRepository {
|
||||
return this._notificationPreferenceRepository;
|
||||
}
|
||||
|
||||
get sendNotificationUseCase(): SendNotificationUseCase {
|
||||
return this._sendNotificationUseCase;
|
||||
}
|
||||
|
||||
get markNotificationReadUseCase(): MarkNotificationReadUseCase {
|
||||
return this._markNotificationReadUseCase;
|
||||
}
|
||||
|
||||
get getUnreadNotificationsQuery(): GetUnreadNotificationsQuery {
|
||||
return this._getUnreadNotificationsQuery;
|
||||
}
|
||||
|
||||
get fileProtestUseCase(): FileProtestUseCase {
|
||||
return this._fileProtestUseCase;
|
||||
}
|
||||
@@ -1206,6 +1315,18 @@ class DIContainer {
|
||||
get getRacePenaltiesQuery(): GetRacePenaltiesQuery {
|
||||
return this._getRacePenaltiesQuery;
|
||||
}
|
||||
|
||||
get requestProtestDefenseUseCase(): RequestProtestDefenseUseCase {
|
||||
return this._requestProtestDefenseUseCase;
|
||||
}
|
||||
|
||||
get submitProtestDefenseUseCase(): SubmitProtestDefenseUseCase {
|
||||
return this._submitProtestDefenseUseCase;
|
||||
}
|
||||
|
||||
get transferLeagueOwnershipUseCase(): TransferLeagueOwnershipUseCase {
|
||||
return this._transferLeagueOwnershipUseCase;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1388,6 +1509,26 @@ export function getCarRepository(): ICarRepository {
|
||||
return DIContainer.getInstance().carRepository;
|
||||
}
|
||||
|
||||
export function getNotificationRepository(): INotificationRepository {
|
||||
return DIContainer.getInstance().notificationRepository;
|
||||
}
|
||||
|
||||
export function getNotificationPreferenceRepository(): INotificationPreferenceRepository {
|
||||
return DIContainer.getInstance().notificationPreferenceRepository;
|
||||
}
|
||||
|
||||
export function getSendNotificationUseCase(): SendNotificationUseCase {
|
||||
return DIContainer.getInstance().sendNotificationUseCase;
|
||||
}
|
||||
|
||||
export function getMarkNotificationReadUseCase(): MarkNotificationReadUseCase {
|
||||
return DIContainer.getInstance().markNotificationReadUseCase;
|
||||
}
|
||||
|
||||
export function getGetUnreadNotificationsQuery(): GetUnreadNotificationsQuery {
|
||||
return DIContainer.getInstance().getUnreadNotificationsQuery;
|
||||
}
|
||||
|
||||
export function getFileProtestUseCase(): FileProtestUseCase {
|
||||
return DIContainer.getInstance().fileProtestUseCase;
|
||||
}
|
||||
@@ -1408,6 +1549,18 @@ export function getGetRacePenaltiesQuery(): GetRacePenaltiesQuery {
|
||||
return DIContainer.getInstance().getRacePenaltiesQuery;
|
||||
}
|
||||
|
||||
export function getRequestProtestDefenseUseCase(): RequestProtestDefenseUseCase {
|
||||
return DIContainer.getInstance().requestProtestDefenseUseCase;
|
||||
}
|
||||
|
||||
export function getSubmitProtestDefenseUseCase(): SubmitProtestDefenseUseCase {
|
||||
return DIContainer.getInstance().submitProtestDefenseUseCase;
|
||||
}
|
||||
|
||||
export function getTransferLeagueOwnershipUseCase(): TransferLeagueOwnershipUseCase {
|
||||
return DIContainer.getInstance().transferLeagueOwnershipUseCase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset function for testing
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
MembershipRole,
|
||||
MembershipStatus,
|
||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
import { leagues, memberships as seedMemberships } from '@gridpilot/testing-support';
|
||||
import { leagues, memberships as seedMemberships, drivers } from '@gridpilot/testing-support';
|
||||
|
||||
/**
|
||||
* Lightweight league membership model mirroring the domain type but with
|
||||
@@ -65,6 +65,63 @@ const leagueMemberships = new Map<string, LeagueMembership[]>();
|
||||
byLeague.set(league.id, list);
|
||||
}
|
||||
|
||||
// Seed sample league admins for the primary driver's league (alpha demo)
|
||||
const primaryDriverId = drivers[0]?.id ?? 'driver-1';
|
||||
const primaryLeagueForAdmins = leagues.find((l) => l.ownerId === primaryDriverId) ?? leagues[0];
|
||||
|
||||
if (primaryLeagueForAdmins) {
|
||||
const adminCandidates = drivers
|
||||
.filter((d) => d.id !== primaryLeagueForAdmins.ownerId)
|
||||
.slice(0, 2);
|
||||
|
||||
adminCandidates.forEach((driver) => {
|
||||
const list = byLeague.get(primaryLeagueForAdmins.id) ?? [];
|
||||
const existing = list.find((m) => m.driverId === driver.id);
|
||||
if (existing) {
|
||||
if (existing.role !== 'owner') {
|
||||
existing.role = 'admin';
|
||||
}
|
||||
} else {
|
||||
const joinedAt = new Date().toISOString();
|
||||
list.push({
|
||||
leagueId: primaryLeagueForAdmins.id,
|
||||
driverId: driver.id,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
joinedAt,
|
||||
});
|
||||
}
|
||||
byLeague.set(primaryLeagueForAdmins.id, list);
|
||||
});
|
||||
}
|
||||
|
||||
// Seed sample league stewards for the primary driver's league (alpha demo)
|
||||
if (primaryLeagueForAdmins) {
|
||||
const stewardCandidates = drivers
|
||||
.filter((d) => d.id !== primaryLeagueForAdmins.ownerId)
|
||||
.slice(2, 5);
|
||||
|
||||
stewardCandidates.forEach((driver) => {
|
||||
const list = byLeague.get(primaryLeagueForAdmins.id) ?? [];
|
||||
const existing = list.find((m) => m.driverId === driver.id);
|
||||
if (existing) {
|
||||
if (existing.role !== 'owner' && existing.role !== 'admin') {
|
||||
existing.role = 'steward';
|
||||
}
|
||||
} else {
|
||||
const joinedAt = new Date().toISOString();
|
||||
list.push({
|
||||
leagueId: primaryLeagueForAdmins.id,
|
||||
driverId: driver.id,
|
||||
role: 'steward',
|
||||
status: 'active',
|
||||
joinedAt,
|
||||
});
|
||||
}
|
||||
byLeague.set(primaryLeagueForAdmins.id, list);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [leagueId, list] of byLeague.entries()) {
|
||||
leagueMemberships.set(leagueId, list);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "^9.2.0",
|
||||
"@gridpilot/identity": "file:../../packages/identity",
|
||||
"@gridpilot/notifications": "file:../../packages/notifications",
|
||||
"@gridpilot/racing": "file:../../packages/racing",
|
||||
"@gridpilot/social": "file:../../packages/social",
|
||||
"@gridpilot/testing-support": "file:../../packages/testing-support",
|
||||
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -141,6 +141,7 @@
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "^9.2.0",
|
||||
"@gridpilot/identity": "file:../../packages/identity",
|
||||
"@gridpilot/notifications": "file:../../packages/notifications",
|
||||
"@gridpilot/racing": "file:../../packages/racing",
|
||||
"@gridpilot/social": "file:../../packages/social",
|
||||
"@gridpilot/testing-support": "file:../../packages/testing-support",
|
||||
@@ -1547,6 +1548,10 @@
|
||||
"resolved": "packages/media",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@gridpilot/notifications": {
|
||||
"resolved": "packages/notifications",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@gridpilot/racing": {
|
||||
"resolved": "packages/racing",
|
||||
"link": true
|
||||
@@ -13474,6 +13479,16 @@
|
||||
"name": "@gridpilot/media",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"packages/notifications": {
|
||||
"name": "@gridpilot/notifications",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"packages/racing": {
|
||||
"name": "@gridpilot/racing",
|
||||
"version": "0.1.0",
|
||||
|
||||
26
packages/notifications/application/index.ts
Normal file
26
packages/notifications/application/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Notifications Application Layer
|
||||
*
|
||||
* Exports all use cases, queries, and ports.
|
||||
*/
|
||||
|
||||
// Use Cases
|
||||
export * from './use-cases/SendNotificationUseCase';
|
||||
export * from './use-cases/MarkNotificationReadUseCase';
|
||||
export * from './use-cases/GetUnreadNotificationsQuery';
|
||||
export * from './use-cases/NotificationPreferencesUseCases';
|
||||
|
||||
// Ports
|
||||
export * from './ports/INotificationGateway';
|
||||
|
||||
// Re-export domain types for convenience
|
||||
export type { Notification, NotificationProps, NotificationStatus, NotificationData } from '../domain/entities/Notification';
|
||||
export type { NotificationPreference, NotificationPreferenceProps, ChannelPreference, TypePreference } from '../domain/entities/NotificationPreference';
|
||||
export type { NotificationType } from '../domain/value-objects/NotificationType';
|
||||
export type { NotificationChannel } from '../domain/value-objects/NotificationChannel';
|
||||
export { getNotificationTypeTitle, getNotificationTypePriority } from '../domain/value-objects/NotificationType';
|
||||
export { getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/value-objects/NotificationChannel';
|
||||
|
||||
// Re-export repository interfaces
|
||||
export type { INotificationRepository } from '../domain/repositories/INotificationRepository';
|
||||
export type { INotificationPreferenceRepository } from '../domain/repositories/INotificationPreferenceRepository';
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Application Port: INotificationGateway
|
||||
*
|
||||
* Defines the contract for sending notifications through external channels.
|
||||
* Implementations (adapters) handle the actual delivery mechanism.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
|
||||
export interface NotificationDeliveryResult {
|
||||
success: boolean;
|
||||
channel: NotificationChannel;
|
||||
/** External message ID (e.g., Discord message ID, email ID) */
|
||||
externalId?: string;
|
||||
/** Error message if delivery failed */
|
||||
error?: string;
|
||||
/** Timestamp of delivery attempt */
|
||||
attemptedAt: Date;
|
||||
}
|
||||
|
||||
export interface INotificationGateway {
|
||||
/**
|
||||
* Send a notification through this gateway's channel
|
||||
*/
|
||||
send(notification: Notification): Promise<NotificationDeliveryResult>;
|
||||
|
||||
/**
|
||||
* Check if this gateway supports the given channel
|
||||
*/
|
||||
supportsChannel(channel: NotificationChannel): boolean;
|
||||
|
||||
/**
|
||||
* Check if the gateway is configured and ready to send
|
||||
*/
|
||||
isConfigured(): boolean;
|
||||
|
||||
/**
|
||||
* Get the channel this gateway handles
|
||||
*/
|
||||
getChannel(): NotificationChannel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry for notification gateways
|
||||
* Allows routing notifications to the appropriate gateway based on channel
|
||||
*/
|
||||
export interface INotificationGatewayRegistry {
|
||||
/**
|
||||
* Register a gateway for a channel
|
||||
*/
|
||||
register(gateway: INotificationGateway): void;
|
||||
|
||||
/**
|
||||
* Get gateway for a specific channel
|
||||
*/
|
||||
getGateway(channel: NotificationChannel): INotificationGateway | null;
|
||||
|
||||
/**
|
||||
* Get all registered gateways
|
||||
*/
|
||||
getAllGateways(): INotificationGateway[];
|
||||
|
||||
/**
|
||||
* Send notification through appropriate gateway
|
||||
*/
|
||||
send(notification: Notification): Promise<NotificationDeliveryResult>;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Application Query: GetUnreadNotificationsQuery
|
||||
*
|
||||
* Retrieves unread notifications for a recipient.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
|
||||
export interface UnreadNotificationsResult {
|
||||
notifications: Notification[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export class GetUnreadNotificationsQuery {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
|
||||
async execute(recipientId: string): Promise<UnreadNotificationsResult> {
|
||||
const notifications = await this.notificationRepository.findUnreadByRecipientId(recipientId);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
totalCount: notifications.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Application Query: GetNotificationsQuery
|
||||
*
|
||||
* Retrieves all notifications for a recipient with optional filtering.
|
||||
*/
|
||||
export interface GetNotificationsOptions {
|
||||
includeRead?: boolean;
|
||||
includeDismissed?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class GetNotificationsQuery {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
|
||||
async execute(recipientId: string, options: GetNotificationsOptions = {}): Promise<Notification[]> {
|
||||
const allNotifications = await this.notificationRepository.findByRecipientId(recipientId);
|
||||
|
||||
let filtered = allNotifications;
|
||||
|
||||
// Filter by status
|
||||
if (!options.includeRead && !options.includeDismissed) {
|
||||
filtered = filtered.filter(n => n.isUnread());
|
||||
} else if (!options.includeDismissed) {
|
||||
filtered = filtered.filter(n => !n.isDismissed());
|
||||
} else if (!options.includeRead) {
|
||||
filtered = filtered.filter(n => n.isUnread() || n.isDismissed());
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
filtered.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
// Apply pagination
|
||||
if (options.offset !== undefined) {
|
||||
filtered = filtered.slice(options.offset);
|
||||
}
|
||||
if (options.limit !== undefined) {
|
||||
filtered = filtered.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Application Query: GetUnreadCountQuery
|
||||
*
|
||||
* Gets the count of unread notifications for a recipient.
|
||||
*/
|
||||
export class GetUnreadCountQuery {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
|
||||
async execute(recipientId: string): Promise<number> {
|
||||
return this.notificationRepository.countUnreadByRecipientId(recipientId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Application Use Case: MarkNotificationReadUseCase
|
||||
*
|
||||
* Marks a notification as read.
|
||||
*/
|
||||
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
|
||||
export interface MarkNotificationReadCommand {
|
||||
notificationId: string;
|
||||
recipientId: string; // For validation
|
||||
}
|
||||
|
||||
export class MarkNotificationReadUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: MarkNotificationReadCommand): Promise<void> {
|
||||
const notification = await this.notificationRepository.findById(command.notificationId);
|
||||
|
||||
if (!notification) {
|
||||
throw new Error('Notification not found');
|
||||
}
|
||||
|
||||
if (notification.recipientId !== command.recipientId) {
|
||||
throw new Error('Cannot mark another user\'s notification as read');
|
||||
}
|
||||
|
||||
if (!notification.isUnread()) {
|
||||
return; // Already read, nothing to do
|
||||
}
|
||||
|
||||
const updatedNotification = notification.markAsRead();
|
||||
await this.notificationRepository.update(updatedNotification);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Application Use Case: MarkAllNotificationsReadUseCase
|
||||
*
|
||||
* Marks all notifications as read for a recipient.
|
||||
*/
|
||||
export class MarkAllNotificationsReadUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
|
||||
async execute(recipientId: string): Promise<void> {
|
||||
await this.notificationRepository.markAllAsReadByRecipientId(recipientId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Application Use Case: DismissNotificationUseCase
|
||||
*
|
||||
* Dismisses a notification.
|
||||
*/
|
||||
export interface DismissNotificationCommand {
|
||||
notificationId: string;
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
export class DismissNotificationUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: DismissNotificationCommand): Promise<void> {
|
||||
const notification = await this.notificationRepository.findById(command.notificationId);
|
||||
|
||||
if (!notification) {
|
||||
throw new Error('Notification not found');
|
||||
}
|
||||
|
||||
if (notification.recipientId !== command.recipientId) {
|
||||
throw new Error('Cannot dismiss another user\'s notification');
|
||||
}
|
||||
|
||||
if (notification.isDismissed()) {
|
||||
return; // Already dismissed
|
||||
}
|
||||
|
||||
const updatedNotification = notification.dismiss();
|
||||
await this.notificationRepository.update(updatedNotification);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Application Use Cases: Notification Preferences
|
||||
*
|
||||
* Manages user notification preferences.
|
||||
*/
|
||||
|
||||
import { NotificationPreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
import type { NotificationType } from '../../domain/value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
|
||||
/**
|
||||
* Query: GetNotificationPreferencesQuery
|
||||
*/
|
||||
export class GetNotificationPreferencesQuery {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
|
||||
async execute(driverId: string): Promise<NotificationPreference> {
|
||||
return this.preferenceRepository.getOrCreateDefault(driverId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Case: UpdateChannelPreferenceUseCase
|
||||
*/
|
||||
export interface UpdateChannelPreferenceCommand {
|
||||
driverId: string;
|
||||
channel: NotificationChannel;
|
||||
preference: ChannelPreference;
|
||||
}
|
||||
|
||||
export class UpdateChannelPreferenceUseCase {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateChannelPreferenceCommand): Promise<void> {
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
const updated = preferences.updateChannel(command.channel, command.preference);
|
||||
await this.preferenceRepository.save(updated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Case: UpdateTypePreferenceUseCase
|
||||
*/
|
||||
export interface UpdateTypePreferenceCommand {
|
||||
driverId: string;
|
||||
type: NotificationType;
|
||||
preference: TypePreference;
|
||||
}
|
||||
|
||||
export class UpdateTypePreferenceUseCase {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateTypePreferenceCommand): Promise<void> {
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
const updated = preferences.updateTypePreference(command.type, command.preference);
|
||||
await this.preferenceRepository.save(updated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Case: UpdateQuietHoursUseCase
|
||||
*/
|
||||
export interface UpdateQuietHoursCommand {
|
||||
driverId: string;
|
||||
startHour: number | undefined;
|
||||
endHour: number | undefined;
|
||||
}
|
||||
|
||||
export class UpdateQuietHoursUseCase {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateQuietHoursCommand): Promise<void> {
|
||||
// Validate hours if provided
|
||||
if (command.startHour !== undefined && (command.startHour < 0 || command.startHour > 23)) {
|
||||
throw new Error('Start hour must be between 0 and 23');
|
||||
}
|
||||
if (command.endHour !== undefined && (command.endHour < 0 || command.endHour > 23)) {
|
||||
throw new Error('End hour must be between 0 and 23');
|
||||
}
|
||||
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
const updated = preferences.updateQuietHours(command.startHour, command.endHour);
|
||||
await this.preferenceRepository.save(updated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Case: SetDigestModeUseCase
|
||||
*/
|
||||
export interface SetDigestModeCommand {
|
||||
driverId: string;
|
||||
enabled: boolean;
|
||||
frequencyHours?: number;
|
||||
}
|
||||
|
||||
export class SetDigestModeUseCase {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: SetDigestModeCommand): Promise<void> {
|
||||
if (command.frequencyHours !== undefined && command.frequencyHours < 1) {
|
||||
throw new Error('Digest frequency must be at least 1 hour');
|
||||
}
|
||||
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
const updated = preferences.setDigestMode(command.enabled, command.frequencyHours);
|
||||
await this.preferenceRepository.save(updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Application Use Case: SendNotificationUseCase
|
||||
*
|
||||
* Sends a notification to a recipient through the appropriate channels
|
||||
* based on their preferences.
|
||||
*/
|
||||
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
import type { NotificationData } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
import type { INotificationGatewayRegistry, NotificationDeliveryResult } from '../ports/INotificationGateway';
|
||||
import type { NotificationType } from '../../domain/value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
|
||||
export interface SendNotificationCommand {
|
||||
recipientId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
data?: NotificationData;
|
||||
actionUrl?: string;
|
||||
/** Override channels (skip preference check) */
|
||||
forceChannels?: NotificationChannel[];
|
||||
}
|
||||
|
||||
export interface SendNotificationResult {
|
||||
/** The created notification */
|
||||
notification: Notification;
|
||||
/** Delivery results for each channel */
|
||||
deliveryResults: NotificationDeliveryResult[];
|
||||
}
|
||||
|
||||
export class SendNotificationUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly gatewayRegistry: INotificationGatewayRegistry,
|
||||
) {}
|
||||
|
||||
async execute(command: SendNotificationCommand): Promise<SendNotificationResult> {
|
||||
// Get recipient's preferences
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.recipientId);
|
||||
|
||||
// Check if this notification type is enabled
|
||||
if (!preferences.isTypeEnabled(command.type)) {
|
||||
// User has disabled this type - create but don't deliver
|
||||
const notification = Notification.create({
|
||||
id: uuid(),
|
||||
recipientId: command.recipientId,
|
||||
type: command.type,
|
||||
title: command.title,
|
||||
body: command.body,
|
||||
channel: 'in_app',
|
||||
data: command.data,
|
||||
actionUrl: command.actionUrl,
|
||||
status: 'dismissed', // Auto-dismiss since user doesn't want these
|
||||
});
|
||||
|
||||
await this.notificationRepository.create(notification);
|
||||
|
||||
return {
|
||||
notification,
|
||||
deliveryResults: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Determine which channels to use
|
||||
const channels = command.forceChannels ?? preferences.getEnabledChannelsForType(command.type);
|
||||
|
||||
// Check quiet hours (skip external channels during quiet hours)
|
||||
const effectiveChannels = preferences.isInQuietHours()
|
||||
? channels.filter(ch => ch === 'in_app')
|
||||
: channels;
|
||||
|
||||
// Ensure at least in_app is used
|
||||
if (!effectiveChannels.includes('in_app')) {
|
||||
effectiveChannels.unshift('in_app');
|
||||
}
|
||||
|
||||
const deliveryResults: NotificationDeliveryResult[] = [];
|
||||
let primaryNotification: Notification | null = null;
|
||||
|
||||
// Send through each channel
|
||||
for (const channel of effectiveChannels) {
|
||||
const notification = Notification.create({
|
||||
id: uuid(),
|
||||
recipientId: command.recipientId,
|
||||
type: command.type,
|
||||
title: command.title,
|
||||
body: command.body,
|
||||
channel,
|
||||
data: command.data,
|
||||
actionUrl: command.actionUrl,
|
||||
});
|
||||
|
||||
// Save to repository (in_app channel) or attempt delivery (external channels)
|
||||
if (channel === 'in_app') {
|
||||
await this.notificationRepository.create(notification);
|
||||
primaryNotification = notification;
|
||||
deliveryResults.push({
|
||||
success: true,
|
||||
channel,
|
||||
attemptedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
// Attempt external delivery
|
||||
const result = await this.gatewayRegistry.send(notification);
|
||||
deliveryResults.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
notification: primaryNotification!,
|
||||
deliveryResults,
|
||||
};
|
||||
}
|
||||
}
|
||||
135
packages/notifications/domain/entities/Notification.ts
Normal file
135
packages/notifications/domain/entities/Notification.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Domain Entity: Notification
|
||||
*
|
||||
* Represents a notification sent to a user.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import type { NotificationType } from '../value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../value-objects/NotificationChannel';
|
||||
|
||||
export type NotificationStatus = 'unread' | 'read' | 'dismissed';
|
||||
|
||||
export interface NotificationData {
|
||||
/** Reference to related protest */
|
||||
protestId?: string;
|
||||
/** Reference to related race */
|
||||
raceId?: string;
|
||||
/** Reference to related league */
|
||||
leagueId?: string;
|
||||
/** Reference to related driver (e.g., who filed protest) */
|
||||
actorDriverId?: string;
|
||||
/** Reference to related penalty */
|
||||
penaltyId?: string;
|
||||
/** Reference to related team */
|
||||
teamId?: string;
|
||||
/** Deadline for action (e.g., defense deadline) */
|
||||
deadline?: Date;
|
||||
/** Any additional context data */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface NotificationProps {
|
||||
id: string;
|
||||
/** Driver who receives this notification */
|
||||
recipientId: string;
|
||||
/** Type of notification */
|
||||
type: NotificationType;
|
||||
/** Human-readable title */
|
||||
title: string;
|
||||
/** Notification body/message */
|
||||
body: string;
|
||||
/** Channel this notification was/will be sent through */
|
||||
channel: NotificationChannel;
|
||||
/** Current status */
|
||||
status: NotificationStatus;
|
||||
/** Structured data for linking/context */
|
||||
data?: NotificationData;
|
||||
/** Optional action URL */
|
||||
actionUrl?: string;
|
||||
/** When the notification was created */
|
||||
createdAt: Date;
|
||||
/** When the notification was read (if applicable) */
|
||||
readAt?: Date;
|
||||
}
|
||||
|
||||
export class Notification {
|
||||
private constructor(private readonly props: NotificationProps) {}
|
||||
|
||||
static create(props: Omit<NotificationProps, 'status' | 'createdAt'> & {
|
||||
status?: NotificationStatus;
|
||||
createdAt?: Date;
|
||||
}): Notification {
|
||||
if (!props.id) throw new Error('Notification ID is required');
|
||||
if (!props.recipientId) throw new Error('Recipient ID is required');
|
||||
if (!props.type) throw new Error('Notification type is required');
|
||||
if (!props.title?.trim()) throw new Error('Notification title is required');
|
||||
if (!props.body?.trim()) throw new Error('Notification body is required');
|
||||
if (!props.channel) throw new Error('Notification channel is required');
|
||||
|
||||
return new Notification({
|
||||
...props,
|
||||
status: props.status ?? 'unread',
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
get id(): string { return this.props.id; }
|
||||
get recipientId(): string { return this.props.recipientId; }
|
||||
get type(): NotificationType { return this.props.type; }
|
||||
get title(): string { return this.props.title; }
|
||||
get body(): string { return this.props.body; }
|
||||
get channel(): NotificationChannel { return this.props.channel; }
|
||||
get status(): NotificationStatus { return this.props.status; }
|
||||
get data(): NotificationData | undefined { return this.props.data ? { ...this.props.data } : undefined; }
|
||||
get actionUrl(): string | undefined { return this.props.actionUrl; }
|
||||
get createdAt(): Date { return this.props.createdAt; }
|
||||
get readAt(): Date | undefined { return this.props.readAt; }
|
||||
|
||||
isUnread(): boolean {
|
||||
return this.props.status === 'unread';
|
||||
}
|
||||
|
||||
isRead(): boolean {
|
||||
return this.props.status === 'read';
|
||||
}
|
||||
|
||||
isDismissed(): boolean {
|
||||
return this.props.status === 'dismissed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the notification as read
|
||||
*/
|
||||
markAsRead(): Notification {
|
||||
if (this.props.status !== 'unread') {
|
||||
return this; // Already read or dismissed, no change
|
||||
}
|
||||
return new Notification({
|
||||
...this.props,
|
||||
status: 'read',
|
||||
readAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the notification
|
||||
*/
|
||||
dismiss(): Notification {
|
||||
if (this.props.status === 'dismissed') {
|
||||
return this; // Already dismissed
|
||||
}
|
||||
return new Notification({
|
||||
...this.props,
|
||||
status: 'dismissed',
|
||||
readAt: this.props.readAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to plain object for serialization
|
||||
*/
|
||||
toJSON(): NotificationProps {
|
||||
return { ...this.props };
|
||||
}
|
||||
}
|
||||
193
packages/notifications/domain/entities/NotificationPreference.ts
Normal file
193
packages/notifications/domain/entities/NotificationPreference.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Domain Entity: NotificationPreference
|
||||
*
|
||||
* Represents a user's notification preferences for different channels and types.
|
||||
*/
|
||||
|
||||
import type { NotificationType } from '../value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../value-objects/NotificationChannel';
|
||||
import { DEFAULT_ENABLED_CHANNELS } from '../value-objects/NotificationChannel';
|
||||
|
||||
export interface ChannelPreference {
|
||||
/** Whether this channel is enabled */
|
||||
enabled: boolean;
|
||||
/** Channel-specific settings (e.g., discord webhook URL, email address) */
|
||||
settings?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TypePreference {
|
||||
/** Whether notifications of this type are enabled */
|
||||
enabled: boolean;
|
||||
/** Which channels to use for this type (overrides global) */
|
||||
channels?: NotificationChannel[];
|
||||
}
|
||||
|
||||
export interface NotificationPreferenceProps {
|
||||
/** Driver ID this preference belongs to */
|
||||
driverId: string;
|
||||
/** Global channel preferences */
|
||||
channels: Record<NotificationChannel, ChannelPreference>;
|
||||
/** Per-type preferences (optional overrides) */
|
||||
typePreferences?: Partial<Record<NotificationType, TypePreference>>;
|
||||
/** Whether to receive digest emails instead of individual notifications */
|
||||
digestMode?: boolean;
|
||||
/** Digest frequency in hours (e.g., 24 for daily) */
|
||||
digestFrequencyHours?: number;
|
||||
/** Quiet hours start (0-23) */
|
||||
quietHoursStart?: number;
|
||||
/** Quiet hours end (0-23) */
|
||||
quietHoursEnd?: number;
|
||||
/** Last updated timestamp */
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class NotificationPreference {
|
||||
private constructor(private readonly props: NotificationPreferenceProps) {}
|
||||
|
||||
static create(props: Omit<NotificationPreferenceProps, 'updatedAt'> & { updatedAt?: Date }): NotificationPreference {
|
||||
if (!props.driverId) throw new Error('Driver ID is required');
|
||||
if (!props.channels) throw new Error('Channel preferences are required');
|
||||
|
||||
return new NotificationPreference({
|
||||
...props,
|
||||
updatedAt: props.updatedAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default preferences for a new user
|
||||
*/
|
||||
static createDefault(driverId: string): NotificationPreference {
|
||||
return new NotificationPreference({
|
||||
driverId,
|
||||
channels: {
|
||||
in_app: { enabled: true },
|
||||
email: { enabled: false },
|
||||
discord: { enabled: false },
|
||||
push: { enabled: false },
|
||||
},
|
||||
digestMode: false,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
get driverId(): string { return this.props.driverId; }
|
||||
get channels(): Record<NotificationChannel, ChannelPreference> { return { ...this.props.channels }; }
|
||||
get typePreferences(): Partial<Record<NotificationType, TypePreference>> | undefined {
|
||||
return this.props.typePreferences ? { ...this.props.typePreferences } : undefined;
|
||||
}
|
||||
get digestMode(): boolean { return this.props.digestMode ?? false; }
|
||||
get digestFrequencyHours(): number { return this.props.digestFrequencyHours ?? 24; }
|
||||
get quietHoursStart(): number | undefined { return this.props.quietHoursStart; }
|
||||
get quietHoursEnd(): number | undefined { return this.props.quietHoursEnd; }
|
||||
get updatedAt(): Date { return this.props.updatedAt; }
|
||||
|
||||
/**
|
||||
* Check if a specific channel is enabled
|
||||
*/
|
||||
isChannelEnabled(channel: NotificationChannel): boolean {
|
||||
return this.props.channels[channel]?.enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific notification type is enabled
|
||||
*/
|
||||
isTypeEnabled(type: NotificationType): boolean {
|
||||
const typePref = this.props.typePreferences?.[type];
|
||||
return typePref?.enabled ?? true; // Default to enabled if not specified
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled channels for a specific notification type
|
||||
*/
|
||||
getEnabledChannelsForType(type: NotificationType): NotificationChannel[] {
|
||||
// Check type-specific channel overrides
|
||||
const typePref = this.props.typePreferences?.[type];
|
||||
if (typePref?.channels && typePref.channels.length > 0) {
|
||||
return typePref.channels.filter(ch => this.isChannelEnabled(ch));
|
||||
}
|
||||
|
||||
// Fall back to globally enabled channels
|
||||
return (Object.keys(this.props.channels) as NotificationChannel[])
|
||||
.filter(ch => this.isChannelEnabled(ch));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current time is in quiet hours
|
||||
*/
|
||||
isInQuietHours(): boolean {
|
||||
if (this.props.quietHoursStart === undefined || this.props.quietHoursEnd === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
|
||||
if (this.props.quietHoursStart < this.props.quietHoursEnd) {
|
||||
// Normal range (e.g., 22:00 to 07:00 next day is NOT this case)
|
||||
return currentHour >= this.props.quietHoursStart && currentHour < this.props.quietHoursEnd;
|
||||
} else {
|
||||
// Overnight range (e.g., 22:00 to 07:00)
|
||||
return currentHour >= this.props.quietHoursStart || currentHour < this.props.quietHoursEnd;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update channel preference
|
||||
*/
|
||||
updateChannel(channel: NotificationChannel, preference: ChannelPreference): NotificationPreference {
|
||||
return new NotificationPreference({
|
||||
...this.props,
|
||||
channels: {
|
||||
...this.props.channels,
|
||||
[channel]: preference,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update type preference
|
||||
*/
|
||||
updateTypePreference(type: NotificationType, preference: TypePreference): NotificationPreference {
|
||||
return new NotificationPreference({
|
||||
...this.props,
|
||||
typePreferences: {
|
||||
...this.props.typePreferences,
|
||||
[type]: preference,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update quiet hours
|
||||
*/
|
||||
updateQuietHours(start: number | undefined, end: number | undefined): NotificationPreference {
|
||||
return new NotificationPreference({
|
||||
...this.props,
|
||||
quietHoursStart: start,
|
||||
quietHoursEnd: end,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle digest mode
|
||||
*/
|
||||
setDigestMode(enabled: boolean, frequencyHours?: number): NotificationPreference {
|
||||
return new NotificationPreference({
|
||||
...this.props,
|
||||
digestMode: enabled,
|
||||
digestFrequencyHours: frequencyHours ?? this.props.digestFrequencyHours,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to plain object for serialization
|
||||
*/
|
||||
toJSON(): NotificationPreferenceProps {
|
||||
return { ...this.props };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Repository Interface: INotificationPreferenceRepository
|
||||
*
|
||||
* Defines the contract for persisting and retrieving NotificationPreference entities.
|
||||
*/
|
||||
|
||||
import type { NotificationPreference } from '../entities/NotificationPreference';
|
||||
|
||||
export interface INotificationPreferenceRepository {
|
||||
/**
|
||||
* Find preferences for a driver
|
||||
*/
|
||||
findByDriverId(driverId: string): Promise<NotificationPreference | null>;
|
||||
|
||||
/**
|
||||
* Save preferences (create or update)
|
||||
*/
|
||||
save(preference: NotificationPreference): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete preferences for a driver
|
||||
*/
|
||||
delete(driverId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get or create default preferences for a driver
|
||||
*/
|
||||
getOrCreateDefault(driverId: string): Promise<NotificationPreference>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Repository Interface: INotificationRepository
|
||||
*
|
||||
* Defines the contract for persisting and retrieving Notification entities.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../entities/Notification';
|
||||
import type { NotificationType } from '../value-objects/NotificationType';
|
||||
|
||||
export interface INotificationRepository {
|
||||
/**
|
||||
* Find a notification by ID
|
||||
*/
|
||||
findById(id: string): Promise<Notification | null>;
|
||||
|
||||
/**
|
||||
* Find all notifications for a recipient
|
||||
*/
|
||||
findByRecipientId(recipientId: string): Promise<Notification[]>;
|
||||
|
||||
/**
|
||||
* Find unread notifications for a recipient
|
||||
*/
|
||||
findUnreadByRecipientId(recipientId: string): Promise<Notification[]>;
|
||||
|
||||
/**
|
||||
* Find notifications by type for a recipient
|
||||
*/
|
||||
findByRecipientIdAndType(recipientId: string, type: NotificationType): Promise<Notification[]>;
|
||||
|
||||
/**
|
||||
* Count unread notifications for a recipient
|
||||
*/
|
||||
countUnreadByRecipientId(recipientId: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Save a new notification
|
||||
*/
|
||||
create(notification: Notification): Promise<void>;
|
||||
|
||||
/**
|
||||
* Update an existing notification
|
||||
*/
|
||||
update(notification: Notification): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a notification
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete all notifications for a recipient
|
||||
*/
|
||||
deleteAllByRecipientId(recipientId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a recipient
|
||||
*/
|
||||
markAllAsReadByRecipientId(recipientId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Value Object: NotificationChannel
|
||||
*
|
||||
* Defines the delivery channels for notifications.
|
||||
*/
|
||||
|
||||
export type NotificationChannel =
|
||||
| 'in_app' // In-app notification (stored in database, shown in UI)
|
||||
| 'email' // Email notification
|
||||
| 'discord' // Discord webhook notification
|
||||
| 'push'; // Push notification (future: mobile/browser)
|
||||
|
||||
/**
|
||||
* Get human-readable name for channel
|
||||
*/
|
||||
export function getChannelDisplayName(channel: NotificationChannel): string {
|
||||
const names: Record<NotificationChannel, string> = {
|
||||
in_app: 'In-App',
|
||||
email: 'Email',
|
||||
discord: 'Discord',
|
||||
push: 'Push Notification',
|
||||
};
|
||||
return names[channel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if channel requires external integration
|
||||
*/
|
||||
export function isExternalChannel(channel: NotificationChannel): boolean {
|
||||
return channel !== 'in_app';
|
||||
}
|
||||
|
||||
/**
|
||||
* Default channels that are always enabled
|
||||
*/
|
||||
export const DEFAULT_ENABLED_CHANNELS: NotificationChannel[] = ['in_app'];
|
||||
|
||||
/**
|
||||
* All available channels
|
||||
*/
|
||||
export const ALL_CHANNELS: NotificationChannel[] = ['in_app', 'email', 'discord', 'push'];
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Value Object: NotificationType
|
||||
*
|
||||
* Defines the types of notifications that can be sent in the system.
|
||||
*/
|
||||
|
||||
export type NotificationType =
|
||||
// Protest-related
|
||||
| 'protest_filed' // A protest was filed against you
|
||||
| 'protest_defense_requested' // Steward requests your defense
|
||||
| 'protest_defense_submitted' // Accused submitted their defense
|
||||
| 'protest_comment_added' // New comment on a protest you're involved in
|
||||
| 'protest_vote_required' // You need to vote on a protest
|
||||
| 'protest_vote_cast' // Someone voted on a protest
|
||||
| 'protest_resolved' // Protest has been resolved
|
||||
// Penalty-related
|
||||
| 'penalty_issued' // A penalty was issued to you
|
||||
| 'penalty_appealed' // Penalty appeal submitted
|
||||
| 'penalty_appeal_resolved' // Appeal was resolved
|
||||
// Race-related
|
||||
| 'race_registration_open' // Race registration is now open
|
||||
| 'race_reminder' // Race starting soon reminder
|
||||
| 'race_results_posted' // Race results are available
|
||||
// League-related
|
||||
| 'league_invite' // You were invited to a league
|
||||
| 'league_join_request' // Someone requested to join your league
|
||||
| 'league_join_approved' // Your join request was approved
|
||||
| 'league_join_rejected' // Your join request was rejected
|
||||
| 'league_role_changed' // Your role in a league changed
|
||||
// Team-related
|
||||
| 'team_invite' // You were invited to a team
|
||||
| 'team_join_request' // Someone requested to join your team
|
||||
| 'team_join_approved' // Your team join request was approved
|
||||
// System
|
||||
| 'system_announcement'; // System-wide announcement
|
||||
|
||||
/**
|
||||
* Get human-readable title for notification type
|
||||
*/
|
||||
export function getNotificationTypeTitle(type: NotificationType): string {
|
||||
const titles: Record<NotificationType, string> = {
|
||||
protest_filed: 'Protest Filed',
|
||||
protest_defense_requested: 'Defense Requested',
|
||||
protest_defense_submitted: 'Defense Submitted',
|
||||
protest_comment_added: 'New Comment',
|
||||
protest_vote_required: 'Vote Required',
|
||||
protest_vote_cast: 'Vote Cast',
|
||||
protest_resolved: 'Protest Resolved',
|
||||
penalty_issued: 'Penalty Issued',
|
||||
penalty_appealed: 'Penalty Appealed',
|
||||
penalty_appeal_resolved: 'Appeal Resolved',
|
||||
race_registration_open: 'Registration Open',
|
||||
race_reminder: 'Race Reminder',
|
||||
race_results_posted: 'Results Posted',
|
||||
league_invite: 'League Invitation',
|
||||
league_join_request: 'Join Request',
|
||||
league_join_approved: 'Request Approved',
|
||||
league_join_rejected: 'Request Rejected',
|
||||
league_role_changed: 'Role Changed',
|
||||
team_invite: 'Team Invitation',
|
||||
team_join_request: 'Team Join Request',
|
||||
team_join_approved: 'Team Request Approved',
|
||||
system_announcement: 'Announcement',
|
||||
};
|
||||
return titles[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority level for notification type (higher = more urgent)
|
||||
*/
|
||||
export function getNotificationTypePriority(type: NotificationType): number {
|
||||
const priorities: Record<NotificationType, number> = {
|
||||
protest_filed: 8,
|
||||
protest_defense_requested: 9,
|
||||
protest_defense_submitted: 6,
|
||||
protest_comment_added: 4,
|
||||
protest_vote_required: 8,
|
||||
protest_vote_cast: 3,
|
||||
protest_resolved: 7,
|
||||
penalty_issued: 9,
|
||||
penalty_appealed: 7,
|
||||
penalty_appeal_resolved: 7,
|
||||
race_registration_open: 5,
|
||||
race_reminder: 8,
|
||||
race_results_posted: 5,
|
||||
league_invite: 6,
|
||||
league_join_request: 5,
|
||||
league_join_approved: 7,
|
||||
league_join_rejected: 7,
|
||||
league_role_changed: 6,
|
||||
team_invite: 5,
|
||||
team_join_request: 4,
|
||||
team_join_approved: 6,
|
||||
system_announcement: 10,
|
||||
};
|
||||
return priorities[type];
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Infrastructure Adapter: DiscordNotificationAdapter (Stub)
|
||||
*
|
||||
* Handles Discord webhook notifications.
|
||||
* Currently a stub - to be implemented when Discord integration is needed.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type {
|
||||
INotificationGateway,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
|
||||
export interface DiscordAdapterConfig {
|
||||
webhookUrl?: string;
|
||||
}
|
||||
|
||||
export class DiscordNotificationAdapter implements INotificationGateway {
|
||||
private readonly channel: NotificationChannel = 'discord';
|
||||
private webhookUrl?: string;
|
||||
|
||||
constructor(config: DiscordAdapterConfig = {}) {
|
||||
this.webhookUrl = config.webhookUrl;
|
||||
}
|
||||
|
||||
async send(notification: Notification): Promise<NotificationDeliveryResult> {
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.channel,
|
||||
error: 'Discord webhook URL not configured',
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement actual Discord webhook call
|
||||
// For now, this is a stub that logs and returns success
|
||||
console.log(`[Discord Stub] Would send notification to ${this.webhookUrl}:`, {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
type: notification.type,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channel: this.channel,
|
||||
externalId: `discord-stub-${notification.id}`,
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
supportsChannel(channel: NotificationChannel): boolean {
|
||||
return channel === this.channel;
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!this.webhookUrl;
|
||||
}
|
||||
|
||||
getChannel(): NotificationChannel {
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the webhook URL
|
||||
*/
|
||||
setWebhookUrl(url: string): void {
|
||||
this.webhookUrl = url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Infrastructure Adapter: EmailNotificationAdapter (Stub)
|
||||
*
|
||||
* Handles email notifications.
|
||||
* Currently a stub - to be implemented when email integration is needed.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type {
|
||||
INotificationGateway,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
|
||||
export interface EmailAdapterConfig {
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpUser?: string;
|
||||
smtpPassword?: string;
|
||||
fromAddress?: string;
|
||||
}
|
||||
|
||||
export class EmailNotificationAdapter implements INotificationGateway {
|
||||
private readonly channel: NotificationChannel = 'email';
|
||||
private config: EmailAdapterConfig;
|
||||
|
||||
constructor(config: EmailAdapterConfig = {}) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async send(notification: Notification): Promise<NotificationDeliveryResult> {
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.channel,
|
||||
error: 'Email SMTP not configured',
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement actual email sending
|
||||
// For now, this is a stub that logs and returns success
|
||||
console.log(`[Email Stub] Would send email:`, {
|
||||
to: notification.recipientId, // Would need to resolve to actual email
|
||||
subject: notification.title,
|
||||
body: notification.body,
|
||||
type: notification.type,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channel: this.channel,
|
||||
externalId: `email-stub-${notification.id}`,
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
supportsChannel(channel: NotificationChannel): boolean {
|
||||
return channel === this.channel;
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!(this.config.smtpHost && this.config.fromAddress);
|
||||
}
|
||||
|
||||
getChannel(): NotificationChannel {
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update SMTP configuration
|
||||
*/
|
||||
configure(config: EmailAdapterConfig): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InAppNotificationAdapter
|
||||
*
|
||||
* Handles in-app notifications (stored in database, shown in UI).
|
||||
* This is the primary/default notification channel.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type {
|
||||
INotificationGateway,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
|
||||
export class InAppNotificationAdapter implements INotificationGateway {
|
||||
private readonly channel: NotificationChannel = 'in_app';
|
||||
|
||||
/**
|
||||
* For in_app, sending is essentially a no-op since the notification
|
||||
* is already persisted by the use case. This just confirms delivery.
|
||||
*/
|
||||
async send(notification: Notification): Promise<NotificationDeliveryResult> {
|
||||
// In-app notifications are stored directly in the repository
|
||||
// This adapter just confirms the "delivery" was successful
|
||||
return {
|
||||
success: true,
|
||||
channel: this.channel,
|
||||
externalId: notification.id,
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
supportsChannel(channel: NotificationChannel): boolean {
|
||||
return channel === this.channel;
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return true; // Always configured
|
||||
}
|
||||
|
||||
getChannel(): NotificationChannel {
|
||||
return this.channel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Infrastructure: NotificationGatewayRegistry
|
||||
*
|
||||
* Manages notification gateways and routes notifications to appropriate channels.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type {
|
||||
INotificationGateway,
|
||||
INotificationGatewayRegistry,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
|
||||
export class NotificationGatewayRegistry implements INotificationGatewayRegistry {
|
||||
private gateways: Map<NotificationChannel, INotificationGateway> = new Map();
|
||||
|
||||
constructor(initialGateways: INotificationGateway[] = []) {
|
||||
initialGateways.forEach(gateway => this.register(gateway));
|
||||
}
|
||||
|
||||
register(gateway: INotificationGateway): void {
|
||||
const channel = gateway.getChannel();
|
||||
this.gateways.set(channel, gateway);
|
||||
}
|
||||
|
||||
getGateway(channel: NotificationChannel): INotificationGateway | null {
|
||||
return this.gateways.get(channel) || null;
|
||||
}
|
||||
|
||||
getAllGateways(): INotificationGateway[] {
|
||||
return Array.from(this.gateways.values());
|
||||
}
|
||||
|
||||
async send(notification: Notification): Promise<NotificationDeliveryResult> {
|
||||
const gateway = this.gateways.get(notification.channel);
|
||||
|
||||
if (!gateway) {
|
||||
return {
|
||||
success: false,
|
||||
channel: notification.channel,
|
||||
error: `No gateway registered for channel: ${notification.channel}`,
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!gateway.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
channel: notification.channel,
|
||||
error: `Gateway for channel ${notification.channel} is not configured`,
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await gateway.send(notification);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
channel: notification.channel,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during delivery',
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/notifications/infrastructure/index.ts
Normal file
11
packages/notifications/infrastructure/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Infrastructure layer exports for notifications package
|
||||
*/
|
||||
|
||||
// Repositories
|
||||
export { InMemoryNotificationRepository } from './repositories/InMemoryNotificationRepository';
|
||||
export { InMemoryNotificationPreferenceRepository } from './repositories/InMemoryNotificationPreferenceRepository';
|
||||
|
||||
// Adapters
|
||||
export { InAppNotificationAdapter } from './adapters/InAppNotificationAdapter';
|
||||
export { NotificationGatewayRegistry } from './adapters/NotificationGatewayRegistry';
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* In-Memory Implementation: InMemoryNotificationPreferenceRepository
|
||||
*
|
||||
* Provides an in-memory storage implementation for notification preferences.
|
||||
*/
|
||||
|
||||
import { NotificationPreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
|
||||
export class InMemoryNotificationPreferenceRepository implements INotificationPreferenceRepository {
|
||||
private preferences: Map<string, NotificationPreference> = new Map();
|
||||
|
||||
constructor(initialPreferences: NotificationPreference[] = []) {
|
||||
initialPreferences.forEach(pref => {
|
||||
this.preferences.set(pref.driverId, pref);
|
||||
});
|
||||
}
|
||||
|
||||
async findByDriverId(driverId: string): Promise<NotificationPreference | null> {
|
||||
return this.preferences.get(driverId) || null;
|
||||
}
|
||||
|
||||
async save(preference: NotificationPreference): Promise<void> {
|
||||
this.preferences.set(preference.driverId, preference);
|
||||
}
|
||||
|
||||
async delete(driverId: string): Promise<void> {
|
||||
this.preferences.delete(driverId);
|
||||
}
|
||||
|
||||
async getOrCreateDefault(driverId: string): Promise<NotificationPreference> {
|
||||
const existing = this.preferences.get(driverId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const defaultPreference = NotificationPreference.createDefault(driverId);
|
||||
this.preferences.set(driverId, defaultPreference);
|
||||
return defaultPreference;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* In-Memory Implementation: InMemoryNotificationRepository
|
||||
*
|
||||
* Provides an in-memory storage implementation for notifications.
|
||||
*/
|
||||
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import type { NotificationType } from '../../domain/value-objects/NotificationType';
|
||||
|
||||
export class InMemoryNotificationRepository implements INotificationRepository {
|
||||
private notifications: Map<string, Notification> = new Map();
|
||||
|
||||
constructor(initialNotifications: Notification[] = []) {
|
||||
initialNotifications.forEach(notification => {
|
||||
this.notifications.set(notification.id, notification);
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Notification | null> {
|
||||
return this.notifications.get(id) || null;
|
||||
}
|
||||
|
||||
async findByRecipientId(recipientId: string): Promise<Notification[]> {
|
||||
return Array.from(this.notifications.values())
|
||||
.filter(n => n.recipientId === recipientId)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
async findUnreadByRecipientId(recipientId: string): Promise<Notification[]> {
|
||||
return Array.from(this.notifications.values())
|
||||
.filter(n => n.recipientId === recipientId && n.isUnread())
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
async findByRecipientIdAndType(recipientId: string, type: NotificationType): Promise<Notification[]> {
|
||||
return Array.from(this.notifications.values())
|
||||
.filter(n => n.recipientId === recipientId && n.type === type)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
async countUnreadByRecipientId(recipientId: string): Promise<number> {
|
||||
return Array.from(this.notifications.values())
|
||||
.filter(n => n.recipientId === recipientId && n.isUnread())
|
||||
.length;
|
||||
}
|
||||
|
||||
async create(notification: Notification): Promise<void> {
|
||||
if (this.notifications.has(notification.id)) {
|
||||
throw new Error(`Notification with ID ${notification.id} already exists`);
|
||||
}
|
||||
this.notifications.set(notification.id, notification);
|
||||
}
|
||||
|
||||
async update(notification: Notification): Promise<void> {
|
||||
if (!this.notifications.has(notification.id)) {
|
||||
throw new Error(`Notification with ID ${notification.id} not found`);
|
||||
}
|
||||
this.notifications.set(notification.id, notification);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.notifications.delete(id);
|
||||
}
|
||||
|
||||
async deleteAllByRecipientId(recipientId: string): Promise<void> {
|
||||
const toDelete = Array.from(this.notifications.values())
|
||||
.filter(n => n.recipientId === recipientId)
|
||||
.map(n => n.id);
|
||||
|
||||
toDelete.forEach(id => this.notifications.delete(id));
|
||||
}
|
||||
|
||||
async markAllAsReadByRecipientId(recipientId: string): Promise<void> {
|
||||
const toUpdate = Array.from(this.notifications.values())
|
||||
.filter(n => n.recipientId === recipientId && n.isUnread());
|
||||
|
||||
toUpdate.forEach(n => {
|
||||
const updated = n.markAsRead();
|
||||
this.notifications.set(updated.id, updated);
|
||||
});
|
||||
}
|
||||
}
|
||||
18
packages/notifications/package.json
Normal file
18
packages/notifications/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@gridpilot/notifications",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./domain/*": "./domain/*",
|
||||
"./application": "./application/index.ts",
|
||||
"./application/*": "./application/*",
|
||||
"./infrastructure": "./infrastructure/index.ts",
|
||||
"./infrastructure/*": "./infrastructure/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/uuid": "^10.0.0"
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export * from './use-cases/ReviewProtestUseCase';
|
||||
export * from './use-cases/ApplyPenaltyUseCase';
|
||||
export * from './use-cases/GetRaceProtestsQuery';
|
||||
export * from './use-cases/GetRacePenaltiesQuery';
|
||||
export * from './use-cases/RequestProtestDefenseUseCase';
|
||||
export * from './use-cases/SubmitProtestDefenseUseCase';
|
||||
|
||||
// Export ports
|
||||
export * from './ports/DriverRatingProvider';
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Application Use Case: RequestProtestDefenseUseCase
|
||||
*
|
||||
* Allows a steward to request defense from the accused driver before making a decision.
|
||||
* This will trigger a notification to the accused driver.
|
||||
*/
|
||||
|
||||
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import { isLeagueStewardOrHigherRole } from '../../domain/value-objects/LeagueRoles';
|
||||
|
||||
export interface RequestProtestDefenseCommand {
|
||||
protestId: string;
|
||||
stewardId: string;
|
||||
}
|
||||
|
||||
export interface RequestProtestDefenseResult {
|
||||
success: boolean;
|
||||
accusedDriverId: string;
|
||||
protestId: string;
|
||||
}
|
||||
|
||||
export class RequestProtestDefenseUseCase {
|
||||
constructor(
|
||||
private readonly protestRepository: IProtestRepository,
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly membershipRepository: ILeagueMembershipRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: RequestProtestDefenseCommand): Promise<RequestProtestDefenseResult> {
|
||||
// Get the protest
|
||||
const protest = await this.protestRepository.findById(command.protestId);
|
||||
if (!protest) {
|
||||
throw new Error('Protest not found');
|
||||
}
|
||||
|
||||
// Get the race to find the league
|
||||
const race = await this.raceRepository.findById(protest.raceId);
|
||||
if (!race) {
|
||||
throw new Error('Race not found');
|
||||
}
|
||||
|
||||
// Verify the steward has permission
|
||||
const membership = await this.membershipRepository.getMembership(race.leagueId, command.stewardId);
|
||||
if (!membership || !isLeagueStewardOrHigherRole(membership.role)) {
|
||||
throw new Error('Only stewards and admins can request defense');
|
||||
}
|
||||
|
||||
// Check if defense can be requested
|
||||
if (!protest.canRequestDefense()) {
|
||||
throw new Error('Defense cannot be requested for this protest');
|
||||
}
|
||||
|
||||
// Request defense
|
||||
const updatedProtest = protest.requestDefense(command.stewardId);
|
||||
await this.protestRepository.update(updatedProtest);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accusedDriverId: protest.accusedDriverId,
|
||||
protestId: protest.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Application Use Case: SubmitProtestDefenseUseCase
|
||||
*
|
||||
* Allows the accused driver to submit their defense statement for a protest.
|
||||
*/
|
||||
|
||||
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
|
||||
|
||||
export interface SubmitProtestDefenseCommand {
|
||||
protestId: string;
|
||||
driverId: string;
|
||||
statement: string;
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
export interface SubmitProtestDefenseResult {
|
||||
success: boolean;
|
||||
protestId: string;
|
||||
}
|
||||
|
||||
export class SubmitProtestDefenseUseCase {
|
||||
constructor(
|
||||
private readonly protestRepository: IProtestRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: SubmitProtestDefenseCommand): Promise<SubmitProtestDefenseResult> {
|
||||
// Get the protest
|
||||
const protest = await this.protestRepository.findById(command.protestId);
|
||||
if (!protest) {
|
||||
throw new Error('Protest not found');
|
||||
}
|
||||
|
||||
// Verify the submitter is the accused driver
|
||||
if (protest.accusedDriverId !== command.driverId) {
|
||||
throw new Error('Only the accused driver can submit a defense');
|
||||
}
|
||||
|
||||
// Check if defense can be submitted
|
||||
if (!protest.canSubmitDefense()) {
|
||||
throw new Error('Defense cannot be submitted for this protest');
|
||||
}
|
||||
|
||||
// Submit defense
|
||||
const updatedProtest = protest.submitDefense(command.statement, command.videoUrl);
|
||||
await this.protestRepository.update(updatedProtest);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
protestId: protest.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
ILeagueMembershipRepository,
|
||||
} from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
||||
import type {
|
||||
LeagueMembership,
|
||||
MembershipRole,
|
||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
|
||||
export interface TransferLeagueOwnershipCommandDTO {
|
||||
leagueId: string;
|
||||
currentOwnerId: string;
|
||||
newOwnerId: string;
|
||||
}
|
||||
|
||||
export class TransferLeagueOwnershipUseCase {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly membershipRepository: ILeagueMembershipRepository
|
||||
) {}
|
||||
|
||||
async execute(command: TransferLeagueOwnershipCommandDTO): Promise<void> {
|
||||
const { leagueId, currentOwnerId, newOwnerId } = command;
|
||||
|
||||
const league = await this.leagueRepository.findById(leagueId);
|
||||
if (!league) {
|
||||
throw new Error('League not found');
|
||||
}
|
||||
|
||||
if (league.ownerId !== currentOwnerId) {
|
||||
throw new Error('Only the current owner can transfer ownership');
|
||||
}
|
||||
|
||||
const newOwnerMembership = await this.membershipRepository.getMembership(leagueId, newOwnerId);
|
||||
if (!newOwnerMembership || newOwnerMembership.status !== 'active') {
|
||||
throw new Error('New owner must be an active member of the league');
|
||||
}
|
||||
|
||||
const currentOwnerMembership = await this.membershipRepository.getMembership(leagueId, currentOwnerId);
|
||||
|
||||
await this.membershipRepository.saveMembership({
|
||||
...newOwnerMembership,
|
||||
role: 'owner' as MembershipRole,
|
||||
});
|
||||
|
||||
if (currentOwnerMembership) {
|
||||
await this.membershipRepository.saveMembership({
|
||||
...currentOwnerMembership,
|
||||
role: 'admin' as MembershipRole,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedLeague = league.update({ ownerId: newOwnerId });
|
||||
await this.leagueRepository.update(updatedLeague);
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ export class League {
|
||||
update(props: Partial<{
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
settings: LeagueSettings;
|
||||
socialLinks: LeagueSocialLinks | undefined;
|
||||
}>): League {
|
||||
@@ -125,7 +126,7 @@ export class League {
|
||||
id: this.id,
|
||||
name: props.name ?? this.name,
|
||||
description: props.description ?? this.description,
|
||||
ownerId: this.ownerId,
|
||||
ownerId: props.ownerId ?? this.ownerId,
|
||||
settings: props.settings ?? this.settings,
|
||||
createdAt: this.createdAt,
|
||||
socialLinks: props.socialLinks ?? this.socialLinks,
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
* Penalties can be applied as a result of an upheld protest or directly by stewards.
|
||||
*/
|
||||
|
||||
export type PenaltyType =
|
||||
export type PenaltyType =
|
||||
| 'time_penalty' // Add time to race result (e.g., +5 seconds)
|
||||
| 'grid_penalty' // Grid position penalty for next race
|
||||
| 'points_deduction' // Deduct championship points
|
||||
| 'disqualification' // DSQ from the race
|
||||
| 'warning' // Official warning (no immediate consequence)
|
||||
| 'license_points'; // Add penalty points to license (future feature)
|
||||
| 'license_points' // Add penalty points to license (safety rating)
|
||||
| 'probation' // Conditional penalty
|
||||
| 'fine' // Monetary/points fine
|
||||
| 'race_ban'; // Multi-race suspension
|
||||
|
||||
export type PenaltyStatus = 'pending' | 'applied' | 'appealed' | 'overturned';
|
||||
|
||||
@@ -52,7 +55,7 @@ export class Penalty {
|
||||
if (!props.issuedBy) throw new Error('Penalty must be issued by a steward');
|
||||
|
||||
// Validate value based on type
|
||||
if (['time_penalty', 'grid_penalty', 'points_deduction'].includes(props.type)) {
|
||||
if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) {
|
||||
if (props.value === undefined || props.value <= 0) {
|
||||
throw new Error(`${props.type} requires a positive value`);
|
||||
}
|
||||
@@ -135,6 +138,12 @@ export class Penalty {
|
||||
return 'Official warning';
|
||||
case 'license_points':
|
||||
return `${this.props.value} license penalty points`;
|
||||
case 'probation':
|
||||
return 'Probationary period';
|
||||
case 'fine':
|
||||
return `${this.props.value} points fine`;
|
||||
case 'race_ban':
|
||||
return `${this.props.value} race suspension`;
|
||||
default:
|
||||
return 'Penalty';
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
/**
|
||||
* Domain Entity: Protest
|
||||
*
|
||||
*
|
||||
* Represents a protest filed by a driver against another driver for an incident during a race.
|
||||
*
|
||||
* Workflow states:
|
||||
* - pending: Initial state when protest is filed
|
||||
* - awaiting_defense: Defense has been requested from the accused driver
|
||||
* - under_review: Steward is actively reviewing the protest
|
||||
* - upheld: Protest was upheld (penalty will be applied)
|
||||
* - dismissed: Protest was dismissed (no action taken)
|
||||
* - withdrawn: Protesting driver withdrew the protest
|
||||
*/
|
||||
|
||||
export type ProtestStatus = 'pending' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
|
||||
export type ProtestStatus = 'pending' | 'awaiting_defense' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
|
||||
|
||||
export interface ProtestIncident {
|
||||
/** Lap number where the incident occurred */
|
||||
@@ -15,6 +23,15 @@ export interface ProtestIncident {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ProtestDefense {
|
||||
/** The accused driver's statement/defense */
|
||||
statement: string;
|
||||
/** URL to defense video clip (optional) */
|
||||
videoUrl?: string;
|
||||
/** Timestamp when defense was submitted */
|
||||
submittedAt: Date;
|
||||
}
|
||||
|
||||
export interface ProtestProps {
|
||||
id: string;
|
||||
raceId: string;
|
||||
@@ -38,6 +55,12 @@ export interface ProtestProps {
|
||||
filedAt: Date;
|
||||
/** Timestamp when the protest was reviewed */
|
||||
reviewedAt?: Date;
|
||||
/** Defense from the accused driver (if requested and submitted) */
|
||||
defense?: ProtestDefense;
|
||||
/** Timestamp when defense was requested */
|
||||
defenseRequestedAt?: Date;
|
||||
/** ID of the steward who requested defense */
|
||||
defenseRequestedBy?: string;
|
||||
}
|
||||
|
||||
export class Protest {
|
||||
@@ -71,11 +94,18 @@ export class Protest {
|
||||
get decisionNotes(): string | undefined { return this.props.decisionNotes; }
|
||||
get filedAt(): Date { return this.props.filedAt; }
|
||||
get reviewedAt(): Date | undefined { return this.props.reviewedAt; }
|
||||
get defense(): ProtestDefense | undefined { return this.props.defense ? { ...this.props.defense } : undefined; }
|
||||
get defenseRequestedAt(): Date | undefined { return this.props.defenseRequestedAt; }
|
||||
get defenseRequestedBy(): string | undefined { return this.props.defenseRequestedBy; }
|
||||
|
||||
isPending(): boolean {
|
||||
return this.props.status === 'pending';
|
||||
}
|
||||
|
||||
isAwaitingDefense(): boolean {
|
||||
return this.props.status === 'awaiting_defense';
|
||||
}
|
||||
|
||||
isUnderReview(): boolean {
|
||||
return this.props.status === 'under_review';
|
||||
}
|
||||
@@ -84,12 +114,60 @@ export class Protest {
|
||||
return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status);
|
||||
}
|
||||
|
||||
hasDefense(): boolean {
|
||||
return this.props.defense !== undefined;
|
||||
}
|
||||
|
||||
canRequestDefense(): boolean {
|
||||
return this.isPending() && !this.hasDefense() && !this.props.defenseRequestedAt;
|
||||
}
|
||||
|
||||
canSubmitDefense(): boolean {
|
||||
return this.isAwaitingDefense() && !this.hasDefense();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start reviewing the protest
|
||||
* Request defense from the accused driver
|
||||
*/
|
||||
requestDefense(stewardId: string): Protest {
|
||||
if (!this.canRequestDefense()) {
|
||||
throw new Error('Defense can only be requested for pending protests without existing defense');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'awaiting_defense',
|
||||
defenseRequestedAt: new Date(),
|
||||
defenseRequestedBy: stewardId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit defense from the accused driver
|
||||
*/
|
||||
submitDefense(statement: string, videoUrl?: string): Protest {
|
||||
if (!this.canSubmitDefense()) {
|
||||
throw new Error('Defense can only be submitted when protest is awaiting defense');
|
||||
}
|
||||
if (!statement?.trim()) {
|
||||
throw new Error('Defense statement is required');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'under_review',
|
||||
defense: {
|
||||
statement: statement.trim(),
|
||||
videoUrl,
|
||||
submittedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start reviewing the protest (without requiring defense)
|
||||
*/
|
||||
startReview(stewardId: string): Protest {
|
||||
if (!this.isPending()) {
|
||||
throw new Error('Only pending protests can be put under review');
|
||||
if (!this.isPending() && !this.isAwaitingDefense()) {
|
||||
throw new Error('Only pending or awaiting-defense protests can be put under review');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
@@ -102,8 +180,8 @@ export class Protest {
|
||||
* Uphold the protest (finding the accused guilty)
|
||||
*/
|
||||
uphold(stewardId: string, decisionNotes: string): Protest {
|
||||
if (!this.isPending() && !this.isUnderReview()) {
|
||||
throw new Error('Only pending or under-review protests can be upheld');
|
||||
if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) {
|
||||
throw new Error('Only pending, awaiting-defense, or under-review protests can be upheld');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
@@ -118,8 +196,8 @@ export class Protest {
|
||||
* Dismiss the protest (finding no fault)
|
||||
*/
|
||||
dismiss(stewardId: string, decisionNotes: string): Protest {
|
||||
if (!this.isPending() && !this.isUnderReview()) {
|
||||
throw new Error('Only pending or under-review protests can be dismissed');
|
||||
if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) {
|
||||
throw new Error('Only pending, awaiting-defense, or under-review protests can be dismissed');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
|
||||
73
packages/racing/domain/value-objects/LeagueRoles.ts
Normal file
73
packages/racing/domain/value-objects/LeagueRoles.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Domain Value Object: LeagueRoles
|
||||
*
|
||||
* Utility functions for working with league membership roles.
|
||||
*/
|
||||
|
||||
import type { MembershipRole } from '../entities/LeagueMembership';
|
||||
|
||||
/**
|
||||
* Role hierarchy (higher number = more authority)
|
||||
*/
|
||||
const ROLE_HIERARCHY: Record<MembershipRole, number> = {
|
||||
member: 0,
|
||||
steward: 1,
|
||||
admin: 2,
|
||||
owner: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a role is at least steward level
|
||||
*/
|
||||
export function isLeagueStewardOrHigherRole(role: MembershipRole): boolean {
|
||||
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.steward;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role is at least admin level
|
||||
*/
|
||||
export function isLeagueAdminOrHigherRole(role: MembershipRole): boolean {
|
||||
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role is owner
|
||||
*/
|
||||
export function isLeagueOwnerRole(role: MembershipRole): boolean {
|
||||
return role === 'owner';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two roles
|
||||
* Returns positive if role1 > role2, negative if role1 < role2, 0 if equal
|
||||
*/
|
||||
export function compareRoles(role1: MembershipRole, role2: MembershipRole): number {
|
||||
return ROLE_HIERARCHY[role1] - ROLE_HIERARCHY[role2];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role display name
|
||||
*/
|
||||
export function getRoleDisplayName(role: MembershipRole): string {
|
||||
const names: Record<MembershipRole, string> = {
|
||||
member: 'Member',
|
||||
steward: 'Steward',
|
||||
admin: 'Admin',
|
||||
owner: 'Owner',
|
||||
};
|
||||
return names[role];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all roles in order of hierarchy
|
||||
*/
|
||||
export function getAllRolesOrdered(): MembershipRole[] {
|
||||
return ['member', 'steward', 'admin', 'owner'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get roles that can be assigned (excludes owner as it's transferred, not assigned)
|
||||
*/
|
||||
export function getAssignableRoles(): MembershipRole[] {
|
||||
return ['member', 'steward', 'admin'];
|
||||
}
|
||||
Reference in New Issue
Block a user