website cleanup
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { Race } from '@core/racing/domain/entities/Race'; // TODO forbidden core import
|
||||
import { useState } from 'react';
|
||||
import Button from '../ui/Button';
|
||||
import Card from '../ui/Card';
|
||||
|
||||
type CompanionRace = {
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string | Date;
|
||||
sessionType: string;
|
||||
};
|
||||
|
||||
interface CompanionInstructionsProps {
|
||||
race: Race;
|
||||
race: CompanionRace;
|
||||
leagueName?: string;
|
||||
}
|
||||
|
||||
@@ -24,10 +30,12 @@ export default function CompanionInstructions({ race, leagueName }: CompanionIns
|
||||
});
|
||||
};
|
||||
|
||||
const scheduledAt = typeof race.scheduledAt === 'string' ? new Date(race.scheduledAt) : race.scheduledAt;
|
||||
|
||||
const raceDetails = `GridPilot Race: ${leagueName || 'League'}
|
||||
Track: ${race.track}
|
||||
Car: ${race.car}
|
||||
Date/Time: ${formatDateTime(race.scheduledAt)}
|
||||
Date/Time: ${formatDateTime(scheduledAt)}
|
||||
Session Type: ${race.sessionType.charAt(0).toUpperCase() + race.sessionType.slice(1)}`;
|
||||
|
||||
const handleCopyDetails = async () => {
|
||||
@@ -125,4 +133,4 @@ Session Type: ${race.sessionType.charAt(0).toUpperCase() + race.sessionType.slic
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useNotifications } from '@/components/notifications/NotificationProvider';
|
||||
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@@ -104,6 +106,7 @@ type LoginMode = 'none' | 'driver' | 'sponsor';
|
||||
|
||||
export default function DevToolbar() {
|
||||
const router = useRouter();
|
||||
const { addNotification } = useNotifications();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<DemoNotificationType>('protest_filed');
|
||||
@@ -180,6 +183,64 @@ export default function DevToolbar() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSendNotification = async () => {
|
||||
setSending(true);
|
||||
try {
|
||||
const actionUrlByType: Record<DemoNotificationType, string> = {
|
||||
protest_filed: '/races',
|
||||
defense_requested: '/races',
|
||||
vote_required: '/leagues',
|
||||
race_performance_summary: '/races',
|
||||
race_final_results: '/races',
|
||||
};
|
||||
|
||||
const titleByType: Record<DemoNotificationType, string> = {
|
||||
protest_filed: 'Protest Filed Against You',
|
||||
defense_requested: 'Defense Requested',
|
||||
vote_required: 'Vote Required',
|
||||
race_performance_summary: 'Race Performance Summary',
|
||||
race_final_results: 'Race Final Results',
|
||||
};
|
||||
|
||||
const messageByType: Record<DemoNotificationType, string> = {
|
||||
protest_filed: 'A protest has been filed against you. Please review the incident details.',
|
||||
defense_requested: 'A steward requests your defense. Please respond within the deadline.',
|
||||
vote_required: 'A protest vote is pending. Please review and vote.',
|
||||
race_performance_summary: 'Your race is complete. View your provisional results.',
|
||||
race_final_results: 'Stewarding is closed. Your final results are available.',
|
||||
};
|
||||
|
||||
const notificationTypeByDemoType: Record<DemoNotificationType, string> = {
|
||||
protest_filed: 'protest_filed',
|
||||
defense_requested: 'protest_defense_requested',
|
||||
vote_required: 'protest_vote_required',
|
||||
race_performance_summary: 'race_performance_summary',
|
||||
race_final_results: 'race_final_results',
|
||||
};
|
||||
|
||||
const variant: NotificationVariant = selectedUrgency === 'modal' ? 'modal' : 'toast';
|
||||
|
||||
addNotification({
|
||||
type: notificationTypeByDemoType[selectedType],
|
||||
title: titleByType[selectedType],
|
||||
message: messageByType[selectedType],
|
||||
variant,
|
||||
actionUrl: actionUrlByType[selectedType],
|
||||
data: {
|
||||
driverId: currentDriverId,
|
||||
demo: true,
|
||||
},
|
||||
});
|
||||
|
||||
setLastSent(`${selectedType}-${selectedUrgency}`);
|
||||
setTimeout(() => setLastSent(null), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to send demo notification:', error);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// const handleSendNotification = async () => {
|
||||
// setSending(true);
|
||||
// try {
|
||||
@@ -559,4 +620,4 @@ export default function DevToolbar() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Input from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import { Driver } from '@core/racing';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
|
||||
interface FormErrors {
|
||||
name?: string;
|
||||
@@ -16,12 +16,12 @@ interface FormErrors {
|
||||
|
||||
export default function CreateDriverForm() {
|
||||
const router = useRouter();
|
||||
const { driverService } = useServices();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
iracingId: '',
|
||||
country: '',
|
||||
bio: ''
|
||||
});
|
||||
@@ -33,16 +33,6 @@ export default function CreateDriverForm() {
|
||||
newErrors.name = 'Name is required';
|
||||
}
|
||||
|
||||
if (!formData.iracingId.trim()) {
|
||||
newErrors.iracingId = 'iRacing ID is required';
|
||||
} else {
|
||||
const driverRepo = getDriverRepository();
|
||||
const exists = await driverRepo.existsByIRacingId(formData.iracingId);
|
||||
if (exists) {
|
||||
newErrors.iracingId = 'This iRacing ID is already registered';
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.country.trim()) {
|
||||
newErrors.country = 'Country is required';
|
||||
} else if (!/^[A-Z]{2,3}$/i.test(formData.country)) {
|
||||
@@ -68,18 +58,21 @@ export default function CreateDriverForm() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const driverRepo = getDriverRepository();
|
||||
const bio = formData.bio.trim();
|
||||
|
||||
const driver = Driver.create({
|
||||
id: crypto.randomUUID(),
|
||||
iracingId: formData.iracingId.trim(),
|
||||
name: formData.name.trim(),
|
||||
|
||||
const displayName = formData.name.trim();
|
||||
const parts = displayName.split(' ').filter(Boolean);
|
||||
const firstName = parts[0] ?? displayName;
|
||||
const lastName = parts.slice(1).join(' ') || 'Driver';
|
||||
|
||||
await driverService.completeDriverOnboarding({
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
country: formData.country.trim().toUpperCase(),
|
||||
...(bio ? { bio } : {}),
|
||||
});
|
||||
|
||||
await driverRepo.create(driver);
|
||||
|
||||
router.push('/profile');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
@@ -111,16 +104,16 @@ export default function CreateDriverForm() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="iracingId" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
iRacing ID *
|
||||
Display Name *
|
||||
</label>
|
||||
<Input
|
||||
id="iracingId"
|
||||
id="name"
|
||||
type="text"
|
||||
value={formData.iracingId}
|
||||
onChange={(e) => setFormData({ ...formData, iracingId: e.target.value })}
|
||||
error={!!errors.iracingId}
|
||||
errorMessage={errors.iracingId}
|
||||
placeholder="123456"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
error={!!errors.name}
|
||||
errorMessage={errors.name}
|
||||
placeholder="Alex Vermeer"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
@@ -182,4 +175,4 @@ export default function CreateDriverForm() {
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import RaceResultCard from '../races/RaceResultCard';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
|
||||
interface RaceHistoryProps {
|
||||
driverId: string;
|
||||
@@ -13,35 +11,14 @@ interface RaceHistoryProps {
|
||||
export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
const [filter, setFilter] = useState<'all' | 'wins' | 'podiums'>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [results, setResults] = useState<Result[]>([]);
|
||||
const [leagues, setLeagues] = useState<Map<string, League>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const resultsPerPage = 10;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadRaceHistory() {
|
||||
try {
|
||||
const resultRepo = getResultRepository();
|
||||
const raceRepo = getRaceRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
|
||||
const driverResults = await resultRepo.findByDriverId(driverId);
|
||||
const allRaces = await raceRepo.findAll();
|
||||
const allLeagues = await leagueRepo.findAll();
|
||||
|
||||
// Filter races to only those where driver has results
|
||||
const raceIds = new Set(driverResults.map(r => r.raceId));
|
||||
const driverRaces = allRaces
|
||||
.filter(race => raceIds.has(race.id) && race.status === 'completed')
|
||||
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
|
||||
|
||||
const leagueMap = new Map<string, League>();
|
||||
allLeagues.forEach(league => leagueMap.set(league.id, league));
|
||||
|
||||
setRaces(driverRaces);
|
||||
setResults(driverResults);
|
||||
setLeagues(leagueMap);
|
||||
// Driver race history is not exposed via API yet.
|
||||
// Keep as placeholder until an endpoint exists.
|
||||
} catch (err) {
|
||||
console.error('Failed to load race history:', err);
|
||||
} finally {
|
||||
@@ -52,22 +29,7 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
loadRaceHistory();
|
||||
}, [driverId]);
|
||||
|
||||
const raceHistory = races.map(race => {
|
||||
const result = results.find(r => r.raceId === race.id);
|
||||
const league = leagues.get(race.leagueId);
|
||||
return {
|
||||
race,
|
||||
result,
|
||||
league,
|
||||
};
|
||||
}).filter(item => item.result);
|
||||
|
||||
const filteredResults = raceHistory.filter(item => {
|
||||
if (!item.result) return false;
|
||||
if (filter === 'wins') return item.result.position === 1;
|
||||
if (filter === 'podiums') return item.result.position <= 3;
|
||||
return true;
|
||||
});
|
||||
const filteredResults: Array<unknown> = [];
|
||||
|
||||
const totalPages = Math.ceil(filteredResults.length / resultsPerPage);
|
||||
const paginatedResults = filteredResults.slice(
|
||||
@@ -94,7 +56,7 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (raceHistory.length === 0) {
|
||||
if (filteredResults.length === 0) {
|
||||
return (
|
||||
<Card className="text-center py-12">
|
||||
<p className="text-gray-400 mb-2">No race history yet</p>
|
||||
@@ -131,19 +93,7 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
|
||||
<Card>
|
||||
<div className="space-y-2">
|
||||
{paginatedResults.map(({ race, result, league }) => {
|
||||
if (!result || !league) return null;
|
||||
|
||||
return (
|
||||
<RaceResultCard
|
||||
key={race.id}
|
||||
race={race}
|
||||
result={result}
|
||||
league={league}
|
||||
showLeague={true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* No results until API provides driver results */}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
@@ -172,4 +122,4 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import RankBadge from './RankBadge';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
|
||||
import { useMemo } from 'react';
|
||||
import { useDriverProfile } from '@/hooks/useDriverService';
|
||||
|
||||
interface ProfileStatsProps {
|
||||
driverId?: string;
|
||||
@@ -17,43 +17,36 @@ interface ProfileStatsProps {
|
||||
};
|
||||
}
|
||||
|
||||
type DriverProfileOverviewViewModel = ProfileOverviewOutputPort | null;
|
||||
|
||||
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||
const [profileData, setProfileData] = useState<DriverProfileOverviewViewModel>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (driverId) {
|
||||
const load = async () => {
|
||||
const profileUseCase = getGetProfileOverviewUseCase();
|
||||
const vm = await profileUseCase.execute({ driverId });
|
||||
setProfileData(vm);
|
||||
};
|
||||
void load();
|
||||
const { data: profileData } = useDriverProfile(driverId ?? '');
|
||||
|
||||
const driverStats = profileData?.stats ?? null;
|
||||
const totalDrivers = profileData?.currentDriver?.totalDrivers ?? 0;
|
||||
|
||||
// League rank widget needs a dedicated API contract; keep it disabled until provided.
|
||||
// (Leaving UI block out avoids `never` typing issues.)
|
||||
|
||||
const defaultStats = useMemo(() => {
|
||||
if (stats) {
|
||||
return stats;
|
||||
}
|
||||
}, [driverId]);
|
||||
|
||||
const driverStats = profileData?.stats || null;
|
||||
const totalDrivers = profileData?.driver?.totalDrivers ?? 0;
|
||||
const primaryLeagueId = driverId ? getPrimaryLeagueIdForDriver(driverId) : null;
|
||||
const leagueRank =
|
||||
driverId && primaryLeagueId ? getLeagueRankings(driverId, primaryLeagueId) : null;
|
||||
if (!driverStats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultStats =
|
||||
stats ||
|
||||
(driverStats
|
||||
? {
|
||||
totalRaces: driverStats.totalRaces,
|
||||
wins: driverStats.wins,
|
||||
podiums: driverStats.podiums,
|
||||
dnfs: driverStats.dnfs,
|
||||
avgFinish: driverStats.avgFinish ?? 0,
|
||||
completionRate:
|
||||
driverStats.totalRaces > 0
|
||||
? ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
|
||||
: 0,
|
||||
}
|
||||
: null);
|
||||
return {
|
||||
totalRaces: driverStats.totalRaces,
|
||||
wins: driverStats.wins,
|
||||
podiums: driverStats.podiums,
|
||||
dnfs: driverStats.dnfs,
|
||||
avgFinish: driverStats.avgFinish ?? 0,
|
||||
completionRate:
|
||||
driverStats.totalRaces > 0
|
||||
? ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
|
||||
: 0,
|
||||
};
|
||||
}, [stats, driverStats]);
|
||||
|
||||
const winRate =
|
||||
defaultStats && defaultStats.totalRaces > 0
|
||||
@@ -134,27 +127,7 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{leagueRank && leagueRank.totalDrivers > 0 && (
|
||||
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<RankBadge rank={leagueRank.rank} size="md" />
|
||||
<div>
|
||||
<div className="text-white font-medium">Primary League</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{leagueRank.rank} of {leagueRank.totalDrivers} drivers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-sm font-medium ${getPercentileColor(leagueRank.percentile)}`}>
|
||||
{getPercentileLabel(leagueRank.percentile)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">League Percentile</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Primary-league ranking removed until we have a dedicated API + view model for league ranks. */}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -264,4 +237,4 @@ function PerformanceRow({ label, races, wins, podiums, avgFinish }: {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Image from 'next/image';
|
||||
import type { FeedItemDTO } from '@core/social/application/dto/FeedItemDTO';
|
||||
import type { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
|
||||
|
||||
function timeAgo(timestamp: Date | string): string {
|
||||
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||
@@ -16,32 +16,14 @@ function timeAgo(timestamp: Date | string): string {
|
||||
return `${diffDays} d ago`;
|
||||
}
|
||||
|
||||
async function resolveActor(item: FeedItemDTO) {
|
||||
const driverRepo = getDriverRepository();
|
||||
const imageService = getImageService();
|
||||
|
||||
const actorId = item.actorFriendId ?? item.actorDriverId;
|
||||
if (!actorId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const driver = await driverRepo.findById(actorId);
|
||||
if (driver) {
|
||||
return {
|
||||
name: driver.name,
|
||||
avatarUrl: imageService.getDriverAvatar(driver.id),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall through to generic rendering
|
||||
}
|
||||
|
||||
async function resolveActor(_item: DashboardFeedItemSummaryViewModel) {
|
||||
// Actor resolution is not wired through the API in this build.
|
||||
// Keep rendering deterministic and decoupled (no core repos).
|
||||
return null;
|
||||
}
|
||||
|
||||
interface FeedItemCardProps {
|
||||
item: FeedItemDTO;
|
||||
item: DashboardFeedItemSummaryViewModel;
|
||||
}
|
||||
|
||||
export default function FeedItemCard({ item }: FeedItemCardProps) {
|
||||
@@ -110,4 +92,4 @@ export default function FeedItemCard({ item }: FeedItemCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { FeedItemDTO } from '@core/social/application/dto/FeedItemDTO';
|
||||
import type { Race } from '@core/racing/domain/entities/Race';
|
||||
import type { RaceWithResultsDTO } from '@core/testing-support';
|
||||
import type { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
|
||||
import FeedList from '@/components/feed/FeedList';
|
||||
import UpcomingRacesSidebar from '@/components/races/UpcomingRacesSidebar';
|
||||
import LatestResultsSidebar from '@/components/races/LatestResultsSidebar';
|
||||
|
||||
type FeedUpcomingRace = {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string | Date;
|
||||
};
|
||||
|
||||
type FeedLatestResult = {
|
||||
raceId: string;
|
||||
track: string;
|
||||
car: string;
|
||||
winnerName: string;
|
||||
scheduledAt: string | Date;
|
||||
};
|
||||
|
||||
interface FeedLayoutProps {
|
||||
feedItems: FeedItemDTO[];
|
||||
upcomingRaces: Race[];
|
||||
latestResults: RaceWithResultsDTO[];
|
||||
feedItems: DashboardFeedItemSummaryViewModel[];
|
||||
upcomingRaces: FeedUpcomingRace[];
|
||||
latestResults: FeedLatestResult[];
|
||||
}
|
||||
|
||||
export default function FeedLayout({
|
||||
@@ -40,4 +53,4 @@ export default function FeedLayout({
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import FeedEmptyState from '@/components/feed/FeedEmptyState';
|
||||
import FeedItemCard from '@/components/feed/FeedItemCard';
|
||||
import type { FeedItemDTO } from '@core/social/application/dto/FeedItemDTO';
|
||||
import type { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
|
||||
|
||||
interface FeedListProps {
|
||||
items: FeedItemDTO[];
|
||||
items: DashboardFeedItemSummaryViewModel[];
|
||||
}
|
||||
|
||||
export default function FeedList({ items }: FeedListProps) {
|
||||
@@ -18,4 +18,4 @@ export default function FeedList({ items }: FeedListProps) {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { LeagueScoringChampionshipDTO } from '@core/racing/application/dto/LeagueScoringConfigDTO';
|
||||
import type { LeagueScoringChampionshipDTO } from '@/lib/types/generated/LeagueScoringChampionshipDTO';
|
||||
|
||||
type PointsPreviewRow = {
|
||||
sessionType: string;
|
||||
position: number;
|
||||
points: number;
|
||||
};
|
||||
|
||||
interface ChampionshipCardProps {
|
||||
championship: LeagueScoringChampionshipDTO;
|
||||
}
|
||||
|
||||
export function ChampionshipCard({ championship }: ChampionshipCardProps) {
|
||||
const pointsPreview = (championship.pointsPreview as unknown as PointsPreviewRow[]) ?? [];
|
||||
const dropPolicyDescription = (championship as unknown as { dropPolicyDescription?: string }).dropPolicyDescription ?? '';
|
||||
const getTypeLabel = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'driver':
|
||||
@@ -66,12 +74,12 @@ export function ChampionshipCard({ championship }: ChampionshipCardProps) {
|
||||
)}
|
||||
|
||||
{/* Points Preview */}
|
||||
{championship.pointsPreview.length > 0 && (
|
||||
{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) => (
|
||||
{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>
|
||||
@@ -87,9 +95,9 @@ export function ChampionshipCard({ championship }: ChampionshipCardProps) {
|
||||
<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>
|
||||
<p className="text-sm text-gray-300">{dropPolicyDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { getMembership } from '@/lib/leagueMembership';
|
||||
import { useState } from 'react';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface JoinLeagueButtonProps {
|
||||
@@ -18,6 +19,7 @@ export default function JoinLeagueButton({
|
||||
}: JoinLeagueButtonProps) {
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const membership = getMembership(leagueId, currentDriverId);
|
||||
const { leagueMembershipService } = useServices();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -28,21 +30,13 @@ export default function JoinLeagueButton({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
|
||||
if (isInviteOnly) {
|
||||
const existing = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
if (existing) {
|
||||
throw new Error('Already a member or have a pending request');
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Requesting to join invite-only leagues is not available in this alpha build.',
|
||||
);
|
||||
}
|
||||
|
||||
const useCase = getJoinLeagueUseCase();
|
||||
await useCase.execute({ leagueId, driverId: currentDriverId });
|
||||
await leagueMembershipService.joinLeague(leagueId, currentDriverId);
|
||||
|
||||
onMembershipChange?.();
|
||||
setShowConfirmDialog(false);
|
||||
@@ -57,15 +51,11 @@ export default function JoinLeagueButton({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const existing = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
if (!existing) {
|
||||
throw new Error('Not a member of this league');
|
||||
}
|
||||
if (existing.role === 'owner') {
|
||||
if (membership?.role === 'owner') {
|
||||
throw new Error('League owner cannot leave the league');
|
||||
}
|
||||
await membershipRepo.removeMembership(leagueId, currentDriverId);
|
||||
|
||||
await leagueMembershipService.leaveLeague(leagueId, currentDriverId);
|
||||
|
||||
onMembershipChange?.();
|
||||
setShowConfirmDialog(false);
|
||||
@@ -171,4 +161,4 @@ export default function JoinLeagueButton({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Calendar, Award, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
|
||||
import { Race, Penalty } from '@core/racing';
|
||||
import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Driver } from '@core/racing';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import type { RaceListItemViewModel } from '@/lib/view-models/RaceListItemViewModel';
|
||||
|
||||
export type LeagueActivity =
|
||||
| { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date }
|
||||
@@ -33,65 +32,42 @@ function timeAgo(timestamp: Date): string {
|
||||
}
|
||||
|
||||
export default function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) {
|
||||
const { raceService, driverService } = useServices();
|
||||
const [activities, setActivities] = useState<LeagueActivity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadActivities() {
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const penaltyRepo = getPenaltyRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
const raceList = await raceService.findByLeagueId(leagueId);
|
||||
|
||||
const races = await raceRepo.findByLeagueId(leagueId);
|
||||
const drivers = await driverRepo.findAll();
|
||||
const driversMap = new Map(drivers.map(d => [d.id, d]));
|
||||
const completedRaces = raceList
|
||||
.filter((r) => r.status === 'completed')
|
||||
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
const upcomingRaces = raceList
|
||||
.filter((r) => r.status === 'scheduled')
|
||||
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
|
||||
.slice(0, 3);
|
||||
|
||||
const activityList: LeagueActivity[] = [];
|
||||
|
||||
// Add completed races
|
||||
const completedRaces = races.filter(r => r.status === 'completed')
|
||||
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
for (const race of completedRaces) {
|
||||
activityList.push({
|
||||
type: 'race_completed',
|
||||
raceId: race.id,
|
||||
raceName: `${race.track} - ${race.car}`,
|
||||
timestamp: race.scheduledAt,
|
||||
timestamp: new Date(race.scheduledAt),
|
||||
});
|
||||
|
||||
// Add penalties from this race
|
||||
const racePenalties = await penaltyRepo.findByRaceId(race.id);
|
||||
const appliedPenalties = racePenalties.filter(p => p.status === 'applied' && p.type === 'points_deduction');
|
||||
|
||||
for (const penalty of appliedPenalties) {
|
||||
const driver = driversMap.get(penalty.driverId);
|
||||
if (driver && penalty.value) {
|
||||
activityList.push({
|
||||
type: 'penalty_applied',
|
||||
penaltyId: penalty.id,
|
||||
driverName: driver.name,
|
||||
reason: penalty.reason,
|
||||
points: penalty.value,
|
||||
timestamp: penalty.appliedAt || penalty.issuedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add scheduled races
|
||||
const upcomingRaces = races.filter(r => r.status === 'scheduled')
|
||||
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
|
||||
.slice(0, 3);
|
||||
|
||||
for (const race of upcomingRaces) {
|
||||
activityList.push({
|
||||
type: 'race_scheduled',
|
||||
raceId: race.id,
|
||||
raceName: `${race.track} - ${race.car}`,
|
||||
timestamp: new Date(race.scheduledAt.getTime() - 7 * 24 * 60 * 60 * 1000), // Simulate schedule announcement
|
||||
timestamp: new Date(new Date(race.scheduledAt).getTime() - 7 * 24 * 60 * 60 * 1000), // Simulate schedule announcement
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,7 +83,7 @@ export default function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActiv
|
||||
}
|
||||
|
||||
loadActivities();
|
||||
}, [leagueId, limit]);
|
||||
}, [leagueId, limit, raceService, driverService]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -217,4 +193,4 @@ function ActivityItem({ activity }: { activity: LeagueActivity }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
|
||||
import { DriverViewModel } from '@/lib/view-models';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
|
||||
interface LeagueChampionshipStatsProps {
|
||||
standings: StandingEntryViewModel[];
|
||||
@@ -56,4 +56,4 @@ export default function LeagueChampionshipStats({ standings, drivers }: LeagueCh
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import MembershipStatus from '@/components/leagues/MembershipStatus';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import Image from 'next/image';
|
||||
|
||||
|
||||
@@ -27,8 +28,8 @@ export default function LeagueHeader({
|
||||
ownerId,
|
||||
mainSponsor,
|
||||
}: LeagueHeaderProps) {
|
||||
const imageService = getImageService();
|
||||
const logoUrl = imageService.getLeagueLogo(leagueId);
|
||||
const { mediaService } = useServices();
|
||||
const logoUrl = mediaService.getLeagueLogo(leagueId);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
@@ -76,4 +77,4 @@ export default function LeagueHeader({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import type { LeagueScheduleRaceItemViewModel } from '@/lib/presenters/LeagueSchedulePresenter';
|
||||
import { useLeagueSchedule } from '@/hooks/useLeagueService';
|
||||
import { useRegisterForRace, useWithdrawFromRace } from '@/hooks/useRaceService';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
interface LeagueScheduleProps {
|
||||
leagueId: string;
|
||||
@@ -11,69 +12,44 @@ interface LeagueScheduleProps {
|
||||
|
||||
export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
const router = useRouter();
|
||||
const [races, setRaces] = useState<LeagueScheduleRaceItemViewModel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
|
||||
const [registrationStates, setRegistrationStates] = useState<Record<string, boolean>>({});
|
||||
const [processingRace, setProcessingRace] = useState<string | null>(null);
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const loadRacesCallback = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const viewModel = await loadLeagueSchedule(leagueId, currentDriverId);
|
||||
setRaces(viewModel.races);
|
||||
const { data: schedule, isLoading } = useLeagueSchedule(leagueId);
|
||||
const registerMutation = useRegisterForRace();
|
||||
const withdrawMutation = useWithdrawFromRace();
|
||||
|
||||
const states: Record<string, boolean> = {};
|
||||
for (const race of viewModel.races) {
|
||||
states[race.id] = race.isRegistered;
|
||||
}
|
||||
setRegistrationStates(states);
|
||||
} catch (error) {
|
||||
console.error('Failed to load races:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [leagueId, currentDriverId]);
|
||||
const races = useMemo(() => {
|
||||
// Current contract uses `unknown[]` for races; treat as any until a proper schedule DTO/view-model is introduced.
|
||||
return (schedule?.races ?? []) as Array<any>;
|
||||
}, [schedule]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRacesCallback();
|
||||
}, [loadRacesCallback]);
|
||||
|
||||
const handleRegister = async (race: LeagueScheduleRaceItemViewModel, e: React.MouseEvent) => {
|
||||
const handleRegister = async (race: any, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const confirmed = window.confirm(`Register for ${race.track}?`);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setProcessingRace(race.id);
|
||||
try {
|
||||
await registerForRace(race.id, leagueId, currentDriverId);
|
||||
setRegistrationStates((prev) => ({ ...prev, [race.id]: true }));
|
||||
await registerMutation.mutateAsync({ raceId: race.id, leagueId, driverId: currentDriverId });
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to register');
|
||||
} finally {
|
||||
setProcessingRace(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async (race: LeagueScheduleRaceItemViewModel, e: React.MouseEvent) => {
|
||||
const handleWithdraw = async (race: any, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const confirmed = window.confirm('Withdraw from this race?');
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setProcessingRace(race.id);
|
||||
try {
|
||||
await withdrawFromRace(race.id, currentDriverId);
|
||||
setRegistrationStates((prev) => ({ ...prev, [race.id]: false }));
|
||||
await withdrawMutation.mutateAsync({ raceId: race.id, driverId: currentDriverId });
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to withdraw');
|
||||
} finally {
|
||||
setProcessingRace(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,7 +71,7 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
|
||||
const displayRaces = getDisplayRaces();
|
||||
|
||||
if (loading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
Loading schedule...
|
||||
@@ -157,6 +133,9 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
{displayRaces.map((race) => {
|
||||
const isPast = race.isPast;
|
||||
const isUpcoming = race.isUpcoming;
|
||||
const isRegistered = Boolean(race.isRegistered);
|
||||
const isProcessing =
|
||||
registerMutation.isPending || withdrawMutation.isPending;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -172,12 +151,12 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className="text-white font-medium">{race.track}</h3>
|
||||
{isUpcoming && !registrationStates[race.id] && (
|
||||
{isUpcoming && !isRegistered && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
|
||||
Upcoming
|
||||
</span>
|
||||
)}
|
||||
{isUpcoming && registrationStates[race.id] && (
|
||||
{isUpcoming && isRegistered && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-400 rounded border border-green-500/30">
|
||||
✓ Registered
|
||||
</span>
|
||||
@@ -217,21 +196,21 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
{/* Registration Actions */}
|
||||
{isUpcoming && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{!registrationStates[race.id] ? (
|
||||
{!isRegistered ? (
|
||||
<button
|
||||
onClick={(e) => handleRegister(race, e)}
|
||||
disabled={processingRace === race.id}
|
||||
disabled={isProcessing}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-primary-blue hover:bg-primary-blue/80 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{processingRace === race.id ? 'Registering...' : 'Register'}
|
||||
{registerMutation.isPending ? 'Registering...' : 'Register'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => handleWithdraw(race, e)}
|
||||
disabled={processingRace === race.id}
|
||||
disabled={isProcessing}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{processingRace === race.id ? 'Withdrawing...' : 'Withdraw'}
|
||||
{withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -245,4 +224,4 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { LeagueScoringPresetDTO } from '@/hooks/useLeagueScoringPresets';
|
||||
import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
|
||||
// ============================================================================
|
||||
@@ -1157,4 +1157,4 @@ export function ChampionshipsSection({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import type { LeagueScoringConfigDTO } from '@core/racing/application/dto/LeagueScoringConfigDTO';
|
||||
import type { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO';
|
||||
import { Trophy, Clock, Target, Zap, Info } from 'lucide-react';
|
||||
|
||||
type LeagueScoringConfigUi = LeagueScoringConfigDTO & {
|
||||
scoringPresetName?: string;
|
||||
dropPolicySummary?: string;
|
||||
championships?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'driver' | 'team' | 'nations' | 'trophy' | string;
|
||||
sessionTypes: string[];
|
||||
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
|
||||
bonusSummary: string[];
|
||||
dropPolicyDescription?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
interface LeagueScoringTabProps {
|
||||
scoringConfig: LeagueScoringConfigDTO | null;
|
||||
practiceMinutes?: number;
|
||||
@@ -32,9 +46,12 @@ export default function LeagueScoringTab({
|
||||
);
|
||||
}
|
||||
|
||||
const ui = scoringConfig as unknown as LeagueScoringConfigUi;
|
||||
const championships = ui.championships ?? [];
|
||||
|
||||
const primaryChampionship =
|
||||
scoringConfig.championships.find((c) => c.type === 'driver') ??
|
||||
scoringConfig.championships[0];
|
||||
championships.find((c) => c.type === 'driver') ??
|
||||
championships[0];
|
||||
|
||||
const resolvedPractice = practiceMinutes ?? 20;
|
||||
const resolvedQualifying = qualifyingMinutes ?? 30;
|
||||
@@ -54,10 +71,10 @@ export default function LeagueScoringTab({
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
{scoringConfig.gameName}{' '}
|
||||
{scoringConfig.scoringPresetName
|
||||
? `• ${scoringConfig.scoringPresetName}`
|
||||
{ui.scoringPresetName
|
||||
? `• ${ui.scoringPresetName}`
|
||||
: '• Custom scoring'}{' '}
|
||||
• {scoringConfig.dropPolicySummary}
|
||||
{ui.dropPolicySummary ? `• ${ui.dropPolicySummary}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,7 +88,7 @@ export default function LeagueScoringTab({
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{primaryChampionship.sessionTypes.map((session) => (
|
||||
{primaryChampionship.sessionTypes.map((session: string) => (
|
||||
<span
|
||||
key={session}
|
||||
className="px-3 py-1 rounded-full bg-primary-blue/10 text-primary-blue border border-primary-blue/20 font-medium"
|
||||
@@ -106,7 +123,7 @@ export default function LeagueScoringTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scoringConfig.championships.map((championship) => (
|
||||
{championships.map((championship) => (
|
||||
<div
|
||||
key={championship.id}
|
||||
className="border border-charcoal-outline rounded-lg bg-iron-gray/40 p-4 space-y-4"
|
||||
@@ -128,7 +145,7 @@ export default function LeagueScoringTab({
|
||||
</div>
|
||||
{championship.sessionTypes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 justify-end">
|
||||
{championship.sessionTypes.map((session) => (
|
||||
{championship.sessionTypes.map((session: string) => (
|
||||
<span
|
||||
key={session}
|
||||
className="px-2 py-0.5 rounded-full bg-charcoal-outline/60 text-xs text-gray-200"
|
||||
@@ -161,7 +178,7 @@ export default function LeagueScoringTab({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{championship.pointsPreview.map((row, index) => (
|
||||
{championship.pointsPreview.map((row, index: number) => (
|
||||
<tr
|
||||
key={`${row.sessionType}-${row.position}-${index}`}
|
||||
className="border-b border-charcoal-outline/30"
|
||||
@@ -192,7 +209,7 @@ export default function LeagueScoringTab({
|
||||
</h4>
|
||||
</div>
|
||||
<ul className="list-disc list-inside text-xs text-gray-300 space-y-1">
|
||||
{championship.bonusSummary.map((item, index) => (
|
||||
{championship.bonusSummary.map((item: string, index: number) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -207,11 +224,11 @@ export default function LeagueScoringTab({
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-300">
|
||||
{championship.dropPolicyDescription}
|
||||
{championship.dropPolicyDescription ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function LeagueSponsorshipsSection({
|
||||
readOnly = false
|
||||
}: LeagueSponsorshipsSectionProps) {
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { sponsorshipService } = useServices();
|
||||
const { sponsorshipService, leagueService } = useServices();
|
||||
const [slots, setSlots] = useState<SponsorshipSlot[]>([
|
||||
{ tier: 'main', price: 500, isOccupied: false },
|
||||
{ tier: 'secondary', price: 200, isOccupied: false },
|
||||
@@ -49,18 +49,15 @@ export function LeagueSponsorshipsSection({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const seasonRepo = getSeasonRepository();
|
||||
const seasons = await seasonRepo.findByLeagueId(leagueId);
|
||||
const activeSeason = seasons.find(s => s.status === 'active') ?? seasons[0];
|
||||
if (activeSeason) {
|
||||
setSeasonId(activeSeason.id);
|
||||
}
|
||||
const seasons = await leagueService.getLeagueSeasons(leagueId);
|
||||
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
|
||||
if (activeSeason) setSeasonId(activeSeason.seasonId);
|
||||
} catch (err) {
|
||||
console.error('Failed to load season:', err);
|
||||
}
|
||||
}
|
||||
loadSeasonId();
|
||||
}, [leagueId, propSeasonId]);
|
||||
}, [leagueId, propSeasonId, leagueService]);
|
||||
|
||||
// Load pending sponsorship requests
|
||||
const loadPendingRequests = useCallback(async () => {
|
||||
@@ -68,25 +65,36 @@ export function LeagueSponsorshipsSection({
|
||||
|
||||
setRequestsLoading(true);
|
||||
try {
|
||||
const useCase = getGetPendingSponsorshipRequestsUseCase();
|
||||
const presenter = new PendingSponsorshipRequestsPresenter();
|
||||
const requests = await sponsorshipService.getPendingSponsorshipRequests({
|
||||
entityType: 'season',
|
||||
entityId: seasonId,
|
||||
});
|
||||
|
||||
await useCase.execute(
|
||||
{
|
||||
entityType: 'season',
|
||||
entityId: seasonId,
|
||||
},
|
||||
presenter,
|
||||
// Convert service view-models to component DTO type (UI-only)
|
||||
setPendingRequests(
|
||||
requests.map(
|
||||
(r): PendingRequestDTO => ({
|
||||
id: r.id,
|
||||
sponsorId: r.sponsorId,
|
||||
sponsorName: r.sponsorName,
|
||||
sponsorLogo: r.sponsorLogo,
|
||||
tier: r.tier,
|
||||
offeredAmount: r.offeredAmount,
|
||||
currency: r.currency,
|
||||
formattedAmount: r.formattedAmount,
|
||||
message: r.message,
|
||||
createdAt: r.createdAt,
|
||||
platformFee: r.platformFee,
|
||||
netAmount: r.netAmount,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const viewModel = presenter.getViewModel();
|
||||
setPendingRequests(viewModel?.requests ?? []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load pending requests:', err);
|
||||
} finally {
|
||||
setRequestsLoading(false);
|
||||
}
|
||||
}, [seasonId]);
|
||||
}, [seasonId, sponsorshipService]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPendingRequests();
|
||||
@@ -94,11 +102,7 @@ export function LeagueSponsorshipsSection({
|
||||
|
||||
const handleAcceptRequest = async (requestId: string) => {
|
||||
try {
|
||||
const useCase = getAcceptSponsorshipRequestUseCase();
|
||||
await useCase.execute({
|
||||
requestId,
|
||||
respondedBy: currentDriverId,
|
||||
});
|
||||
await sponsorshipService.acceptSponsorshipRequest(requestId, currentDriverId);
|
||||
await loadPendingRequests();
|
||||
} catch (err) {
|
||||
console.error('Failed to accept request:', err);
|
||||
@@ -108,12 +112,7 @@ export function LeagueSponsorshipsSection({
|
||||
|
||||
const handleRejectRequest = async (requestId: string, reason?: string) => {
|
||||
try {
|
||||
const useCase = getRejectSponsorshipRequestUseCase();
|
||||
await useCase.execute({
|
||||
requestId,
|
||||
respondedBy: currentDriverId,
|
||||
...(reason ? { reason } : {}),
|
||||
});
|
||||
await sponsorshipService.rejectSponsorshipRequest(requestId, currentDriverId, reason);
|
||||
await loadPendingRequests();
|
||||
} catch (err) {
|
||||
console.error('Failed to reject request:', err);
|
||||
@@ -324,4 +323,4 @@ export function LeagueSponsorshipsSection({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Scale, Users, Clock, Bell, Shield, Vote, UserCheck, AlertTriangle } from 'lucide-react';
|
||||
import type { LeagueConfigFormModel, LeagueStewardingFormDTO } from '@core/racing/application';
|
||||
import type { StewardingDecisionMode } from '@core/racing/domain/entities/League';
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
|
||||
interface LeagueStewardingSectionProps {
|
||||
form: LeagueConfigFormModel;
|
||||
@@ -12,7 +11,7 @@ interface LeagueStewardingSectionProps {
|
||||
}
|
||||
|
||||
type DecisionModeOption = {
|
||||
value: StewardingDecisionMode;
|
||||
value: NonNullable<LeagueConfigFormModel['stewarding']>['decisionMode'];
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -21,40 +20,19 @@ type DecisionModeOption = {
|
||||
|
||||
const decisionModeOptions: DecisionModeOption[] = [
|
||||
{
|
||||
value: 'admin_only',
|
||||
label: 'Admin Decision',
|
||||
description: 'League admins make all penalty decisions',
|
||||
value: 'single_steward',
|
||||
label: 'Single Steward',
|
||||
description: 'A single steward/admin makes all penalty decisions',
|
||||
icon: <Shield className="w-5 h-5" />,
|
||||
requiresVotes: false,
|
||||
},
|
||||
{
|
||||
value: 'steward_vote',
|
||||
label: 'Steward Vote',
|
||||
description: 'Designated stewards vote to uphold protests',
|
||||
value: 'committee_vote',
|
||||
label: 'Committee Vote',
|
||||
description: 'A group votes to uphold/dismiss protests',
|
||||
icon: <Scale className="w-5 h-5" />,
|
||||
requiresVotes: true,
|
||||
},
|
||||
{
|
||||
value: 'member_vote',
|
||||
label: 'Member Vote',
|
||||
description: 'All league members vote on protests',
|
||||
icon: <Users className="w-5 h-5" />,
|
||||
requiresVotes: true,
|
||||
},
|
||||
{
|
||||
value: 'steward_veto',
|
||||
label: 'Steward Veto',
|
||||
description: 'Protests upheld unless stewards vote against',
|
||||
icon: <Vote className="w-5 h-5" />,
|
||||
requiresVotes: true,
|
||||
},
|
||||
{
|
||||
value: 'member_veto',
|
||||
label: 'Member Veto',
|
||||
description: 'Protests upheld unless members vote against',
|
||||
icon: <UserCheck className="w-5 h-5" />,
|
||||
requiresVotes: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function LeagueStewardingSection({
|
||||
@@ -64,7 +42,7 @@ export function LeagueStewardingSection({
|
||||
}: LeagueStewardingSectionProps) {
|
||||
// Provide default stewarding settings if not present
|
||||
const stewarding = form.stewarding ?? {
|
||||
decisionMode: 'admin_only' as const,
|
||||
decisionMode: 'single_steward' as const,
|
||||
requiredVotes: 2,
|
||||
requireDefense: false,
|
||||
defenseTimeLimit: 48,
|
||||
@@ -75,7 +53,7 @@ export function LeagueStewardingSection({
|
||||
notifyOnVoteRequired: true,
|
||||
};
|
||||
|
||||
const updateStewarding = (updates: Partial<LeagueStewardingFormDTO>) => {
|
||||
const updateStewarding = (updates: Partial<NonNullable<LeagueConfigFormModel['stewarding']>>) => {
|
||||
onChange({
|
||||
...form,
|
||||
stewarding: {
|
||||
@@ -147,7 +125,7 @@ export function LeagueStewardingSection({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5">
|
||||
Required votes to {stewarding.decisionMode.includes('veto') ? 'block' : 'uphold'}
|
||||
Required votes to uphold
|
||||
</label>
|
||||
<select
|
||||
value={stewarding.requiredVotes ?? 2}
|
||||
@@ -375,7 +353,7 @@ export function LeagueStewardingSection({
|
||||
</div>
|
||||
|
||||
{/* Warning about strict settings */}
|
||||
{stewarding.requireDefense && stewarding.decisionMode !== 'admin_only' && (
|
||||
{stewarding.requireDefense && stewarding.decisionMode !== 'single_steward' && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-warning-amber/10 border border-warning-amber/20">
|
||||
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
|
||||
<div>
|
||||
@@ -389,4 +367,4 @@ export function LeagueStewardingSection({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ 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 '@core/racing/application/dto/DriverDTO';
|
||||
import type { LeagueDriverSeasonStatsDTO } from '@core/racing/application/dto/LeagueDriverSeasonStatsDTO';
|
||||
import type { LeagueMembership, MembershipRole } from '@/lib/leagueMembership';
|
||||
import { getLeagueRoleDisplay } from '@/lib/leagueRoles';
|
||||
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||
import type { MembershipRoleDTO } from '@/lib/types/generated/MembershipRoleDTO';
|
||||
import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay';
|
||||
import CountryFlag from '@/components/ui/CountryFlag';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
|
||||
// Position background colors
|
||||
const getPositionBgColor = (position: number): string => {
|
||||
@@ -21,14 +22,25 @@ const getPositionBgColor = (position: number): string => {
|
||||
};
|
||||
|
||||
interface StandingsTableProps {
|
||||
standings: LeagueDriverSeasonStatsDTO[];
|
||||
standings: Array<{
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
totalPoints: number;
|
||||
racesFinished: number;
|
||||
racesStarted: number;
|
||||
avgFinish: number | null;
|
||||
penaltyPoints: number;
|
||||
bonusPoints: number;
|
||||
teamName?: string;
|
||||
}>;
|
||||
drivers: DriverDTO[];
|
||||
leagueId: string;
|
||||
memberships?: LeagueMembership[];
|
||||
currentDriverId?: string;
|
||||
isAdmin?: boolean;
|
||||
onRemoveMember?: (driverId: string) => void;
|
||||
onUpdateRole?: (driverId: string, role: MembershipRole) => void;
|
||||
onUpdateRole?: (driverId: string, role: MembershipRoleDTO['value']) => void;
|
||||
}
|
||||
|
||||
export default function StandingsTable({
|
||||
@@ -41,6 +53,7 @@ export default function StandingsTable({
|
||||
onRemoveMember,
|
||||
onUpdateRole
|
||||
}: StandingsTableProps) {
|
||||
const { mediaService } = useServices();
|
||||
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
||||
const [activeMenu, setActiveMenu] = useState<{ driverId: string; type: 'member' | 'points' } | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -78,12 +91,14 @@ export default function StandingsTable({
|
||||
return driverId === currentDriverId;
|
||||
};
|
||||
|
||||
type MembershipRole = MembershipRoleDTO['value'];
|
||||
|
||||
const handleRoleChange = (driverId: string, newRole: MembershipRole) => {
|
||||
if (!onUpdateRole) return;
|
||||
const membership = getMembership(driverId);
|
||||
if (!membership) return;
|
||||
|
||||
const confirmationMessages: Record<MembershipRole, string> = {
|
||||
const confirmationMessages: Record<string, 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.',
|
||||
@@ -96,7 +111,7 @@ export default function StandingsTable({
|
||||
}
|
||||
|
||||
if (newRole !== membership.role && confirm(confirmationMessages[newRole])) {
|
||||
onUpdateRole(driverId, newRole);
|
||||
onUpdateRole(driverId, newRole as MembershipRoleDTO['value']);
|
||||
setActiveMenu(null);
|
||||
}
|
||||
};
|
||||
@@ -266,9 +281,10 @@ export default function StandingsTable({
|
||||
{standings.map((row) => {
|
||||
const driver = getDriver(row.driverId);
|
||||
const membership = getMembership(row.driverId);
|
||||
const roleDisplay = membership ? getLeagueRoleDisplay(membership.role) : null;
|
||||
const roleDisplay = membership ? LeagueRoleDisplay.getLeagueRoleDisplay(membership.role) : null;
|
||||
const canModify = canModifyMember(row.driverId);
|
||||
const driverStatsData = getDriverStats(row.driverId);
|
||||
// TODO: Hook up real driver stats once API provides it
|
||||
const driverStatsData: null = null;
|
||||
const isRowHovered = hoveredRow === row.driverId;
|
||||
const isMemberMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'member';
|
||||
const isPointsMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'points';
|
||||
@@ -307,7 +323,7 @@ export default function StandingsTable({
|
||||
<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)}
|
||||
src={mediaService.getDriverAvatar(driver.id)}
|
||||
alt={driver.name}
|
||||
width={40}
|
||||
height={40}
|
||||
@@ -344,12 +360,7 @@ export default function StandingsTable({
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{/* Rating intentionally omitted until API provides driver stats */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -435,4 +446,4 @@ export default function StandingsTable({
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Notification, NotificationAction } from '@core/notifications/application';
|
||||
import type { Notification, NotificationAction } from './notificationTypes';
|
||||
import {
|
||||
Bell,
|
||||
AlertTriangle,
|
||||
@@ -107,7 +107,7 @@ export default function ModalNotification({
|
||||
}, [notification, onDismiss]);
|
||||
|
||||
const handleAction = (action: NotificationAction) => {
|
||||
onAction(notification, action.actionId);
|
||||
onAction(notification, action.id);
|
||||
if (action.href) {
|
||||
router.push(action.href);
|
||||
}
|
||||
@@ -128,15 +128,45 @@ export default function ModalNotification({
|
||||
glow: 'shadow-[0_0_60px_rgba(245,158,11,0.3)]',
|
||||
};
|
||||
|
||||
const data: Record<string, unknown> = notification.data ?? {};
|
||||
|
||||
const getNumber = (value: unknown): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getString = (value: unknown): string | null => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
return null;
|
||||
};
|
||||
|
||||
const isValidDate = (value: unknown): value is Date => value instanceof Date && !Number.isNaN(value.getTime());
|
||||
|
||||
// Check if there's a deadline
|
||||
const deadline = notification.data?.deadline;
|
||||
const hasDeadline = deadline instanceof Date;
|
||||
const deadlineValue = data.deadline;
|
||||
const deadline: Date | null =
|
||||
isValidDate(deadlineValue)
|
||||
? deadlineValue
|
||||
: typeof deadlineValue === 'string' || typeof deadlineValue === 'number'
|
||||
? new Date(deadlineValue)
|
||||
: null;
|
||||
const hasDeadline = !!deadline && !Number.isNaN(deadline.getTime());
|
||||
|
||||
// Special celebratory styling for race notifications
|
||||
const isRaceNotification = notification.type.startsWith('race_');
|
||||
const isPerformanceSummary = notification.type === 'race_performance_summary';
|
||||
const isFinalResults = notification.type === 'race_final_results';
|
||||
|
||||
const provisionalRatingChange = getNumber(data.provisionalRatingChange) ?? 0;
|
||||
const finalRatingChange = getNumber(data.finalRatingChange) ?? 0;
|
||||
const ratingChange = provisionalRatingChange || finalRatingChange;
|
||||
const protestId = getString(data.protestId);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
@@ -199,7 +229,7 @@ export default function ModalNotification({
|
||||
{/* Body */}
|
||||
<div className={`px-6 py-5 ${isRaceNotification ? 'bg-gradient-to-b from-transparent to-yellow-500/5' : ''}`}>
|
||||
<p className={`leading-relaxed ${isRaceNotification ? 'text-white text-lg font-medium' : 'text-gray-300'}`}>
|
||||
{notification.body}
|
||||
{notification.message}
|
||||
</p>
|
||||
|
||||
{/* Race performance stats */}
|
||||
@@ -213,9 +243,9 @@ export default function ModalNotification({
|
||||
</div>
|
||||
<div className="bg-black/20 rounded-lg p-3 border border-yellow-400/20">
|
||||
<div className="text-xs text-yellow-300 font-medium mb-1">RATING CHANGE</div>
|
||||
<div className={`text-2xl font-bold ${(notification.data?.provisionalRatingChange || notification.data?.finalRatingChange || 0) >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{(notification.data?.provisionalRatingChange || notification.data?.finalRatingChange || 0) >= 0 ? '+' : ''}
|
||||
{notification.data?.provisionalRatingChange || notification.data?.finalRatingChange || 0}
|
||||
<div className={`text-2xl font-bold ${ratingChange >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{ratingChange >= 0 ? '+' : ''}
|
||||
{ratingChange}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,18 +258,18 @@ export default function ModalNotification({
|
||||
<div>
|
||||
<p className="text-sm font-medium text-warning-amber">Response Required</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Please respond by {deadline.toLocaleDateString()} at {deadline.toLocaleTimeString()}
|
||||
Please respond by {deadline ? deadline.toLocaleDateString() : ''} at {deadline ? deadline.toLocaleTimeString() : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional context from data */}
|
||||
{notification.data?.protestId && (
|
||||
{protestId && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 mb-1">Related Protest</p>
|
||||
<p className="text-sm text-gray-300 font-mono">
|
||||
{notification.data.protestId}
|
||||
{protestId}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -267,14 +297,14 @@ export default function ModalNotification({
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onDismiss ? onDismiss(notification) : handleAction(notification, 'dismiss')}
|
||||
onClick={() => (onDismiss ? onDismiss(notification) : onAction(notification, 'dismiss'))}
|
||||
className="shadow-lg hover:shadow-yellow-400/30"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleAction({ label: 'Share Achievement', type: 'secondary', actionId: 'share' })}
|
||||
onClick={() => handleAction({ id: 'share', label: 'Share Achievement', type: 'secondary' })}
|
||||
className="shadow-lg hover:shadow-yellow-400/30"
|
||||
>
|
||||
🎉 Share
|
||||
@@ -307,4 +337,4 @@ export default function ModalNotification({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const notificationColors: Record<string, string> = {
|
||||
};
|
||||
|
||||
import { useNotifications } from './NotificationProvider';
|
||||
import type { Notification } from './NotificationProvider';
|
||||
import type { Notification } from './notificationTypes';
|
||||
|
||||
export default function NotificationCenter() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -208,4 +208,4 @@ export default function NotificationCenter() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,28 +6,7 @@ import { v4 as uuid } from 'uuid';
|
||||
import ModalNotification from './ModalNotification';
|
||||
import ToastNotification from './ToastNotification';
|
||||
|
||||
export type NotificationVariant = 'toast' | 'modal' | 'center';
|
||||
|
||||
export interface NotificationAction {
|
||||
id: string;
|
||||
label: string;
|
||||
type?: 'primary' | 'secondary' | 'danger';
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: string;
|
||||
title?: string;
|
||||
message: string;
|
||||
createdAt: Date;
|
||||
variant: NotificationVariant;
|
||||
actionUrl?: string;
|
||||
requiresResponse?: boolean;
|
||||
actions?: NotificationAction[];
|
||||
data?: Record<string, unknown>;
|
||||
read: boolean;
|
||||
}
|
||||
import type { Notification, NotificationAction, NotificationVariant } from './notificationTypes';
|
||||
|
||||
interface AddNotificationInput {
|
||||
id?: string;
|
||||
@@ -184,4 +163,4 @@ export default function NotificationProvider({ children }: NotificationProviderP
|
||||
)}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Notification } from '@core/notifications/application';
|
||||
import type { Notification } from './notificationTypes';
|
||||
import {
|
||||
Bell,
|
||||
AlertTriangle,
|
||||
@@ -120,8 +120,8 @@ export default function ToastNotification({
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-white truncate">
|
||||
{notification.title}
|
||||
<p className="text-sm font-semibold text-white truncate">
|
||||
{notification.title ?? 'Notification'}
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -134,7 +134,7 @@ export default function ToastNotification({
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 line-clamp-2 mt-1">
|
||||
{notification.body}
|
||||
{notification.message}
|
||||
</p>
|
||||
{notification.actionUrl && (
|
||||
<button
|
||||
@@ -151,4 +151,4 @@ export default function ToastNotification({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
22
apps/website/components/notifications/notificationTypes.ts
Normal file
22
apps/website/components/notifications/notificationTypes.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type NotificationVariant = 'toast' | 'modal' | 'center';
|
||||
|
||||
export interface NotificationAction {
|
||||
id: string;
|
||||
label: string;
|
||||
type?: 'primary' | 'secondary' | 'danger';
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: string;
|
||||
title?: string;
|
||||
message: string;
|
||||
createdAt: Date;
|
||||
variant: NotificationVariant;
|
||||
actionUrl?: string;
|
||||
requiresResponse?: boolean;
|
||||
actions?: NotificationAction[];
|
||||
data?: Record<string, unknown>;
|
||||
read: boolean;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||
import DriverRating from '@/components/profile/DriverRatingPill';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
|
||||
export interface DriverSummaryPillProps {
|
||||
driver: DriverDTO;
|
||||
@@ -17,8 +18,10 @@ export interface DriverSummaryPillProps {
|
||||
export default function DriverSummaryPill(props: DriverSummaryPillProps) {
|
||||
const { driver, rating, rank, avatarSrc, onClick, href } = props;
|
||||
|
||||
const { mediaService } = useServices();
|
||||
|
||||
const resolvedAvatar =
|
||||
avatarSrc ?? getImageService().getDriverAvatar(driver.id);
|
||||
avatarSrc ?? mediaService.getDriverAvatar(driver.id);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
@@ -70,4 +73,4 @@ export default function DriverSummaryPill(props: DriverSummaryPillProps) {
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDT
|
||||
import Button from '../ui/Button';
|
||||
import DriverRatingPill from '@/components/profile/DriverRatingPill';
|
||||
import CountryFlag from '@/components/ui/CountryFlag';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
driver: GetDriverOutputDTO;
|
||||
@@ -25,12 +26,14 @@ export default function ProfileHeader({
|
||||
teamName,
|
||||
teamTag,
|
||||
}: ProfileHeaderProps) {
|
||||
const { mediaService } = useServices();
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-blue to-purple-600 overflow-hidden flex items-center justify-center">
|
||||
<Image
|
||||
src={getImageService().getDriverAvatar(driver.id)}
|
||||
src={mediaService.getDriverAvatar(driver.id)}
|
||||
alt={driver.name}
|
||||
width={80}
|
||||
height={80}
|
||||
@@ -76,4 +79,4 @@ export default function ProfileHeader({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function UserPill() {
|
||||
|
||||
const dto = await driverService.findById(primaryDriverId);
|
||||
if (!cancelled) {
|
||||
setDriver(dto);
|
||||
setDriver(dto ? (dto as unknown as DriverDTO) : null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,36 +120,10 @@ export default function UserPill() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const driverStats = getDriverStats(primaryDriverId);
|
||||
const allRankings = getAllDriverRankings();
|
||||
|
||||
let rating: number | null = driverStats?.rating ?? null;
|
||||
let rank: number | null = null;
|
||||
let totalDrivers: number | null = null;
|
||||
|
||||
if (driverStats) {
|
||||
totalDrivers = allRankings.length || null;
|
||||
|
||||
if (typeof driverStats.overallRank === 'number' && driverStats.overallRank > 0) {
|
||||
rank = driverStats.overallRank;
|
||||
} else {
|
||||
const indexInGlobal = allRankings.findIndex(
|
||||
(stat) => stat.driverId === driverStats.driverId,
|
||||
);
|
||||
if (indexInGlobal !== -1) {
|
||||
rank = indexInGlobal + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (rating === null) {
|
||||
const globalEntry = allRankings.find(
|
||||
(stat) => stat.driverId === driverStats.driverId,
|
||||
);
|
||||
if (globalEntry) {
|
||||
rating = globalEntry.rating;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Driver rating + rank are not exposed by the current API contract for the lightweight
|
||||
// driver DTO used in the header. Keep it null until the API provides it.
|
||||
const rating: number | null = null;
|
||||
const rank: number | null = null;
|
||||
|
||||
const avatarSrc = mediaService.getDriverAvatar(primaryDriverId);
|
||||
|
||||
@@ -369,4 +343,4 @@ export default function UserPill() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useState } from 'react';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Button from '@/components/ui/Button';
|
||||
import type { ProtestIncident } from '@core/racing/domain/entities/Protest';
|
||||
import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO';
|
||||
import type { ProtestIncidentDTO } from '@/lib/types/generated/ProtestIncidentDTO';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Video,
|
||||
@@ -37,6 +39,7 @@ export default function FileProtestModal({
|
||||
protestingDriverId,
|
||||
participants,
|
||||
}: FileProtestModalProps) {
|
||||
const { raceService } = useServices();
|
||||
const [step, setStep] = useState<'form' | 'submitting' | 'success' | 'error'>('form');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
@@ -69,14 +72,10 @@ export default function FileProtestModal({
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const useCase = getFileProtestUseCase();
|
||||
|
||||
const incident: ProtestIncident = {
|
||||
const incident: ProtestIncidentDTO = {
|
||||
lap: parseInt(lap, 10),
|
||||
description: description.trim(),
|
||||
...(timeInRace
|
||||
? { timeInRace: parseInt(timeInRace, 10) }
|
||||
: {}),
|
||||
...(timeInRace ? { timeInRace: parseInt(timeInRace, 10) } : {}),
|
||||
};
|
||||
|
||||
const command = {
|
||||
@@ -84,15 +83,11 @@ export default function FileProtestModal({
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
incident,
|
||||
...(comment.trim()
|
||||
? { comment: comment.trim() }
|
||||
: {}),
|
||||
...(proofVideoUrl.trim()
|
||||
? { proofVideoUrl: proofVideoUrl.trim() }
|
||||
: {}),
|
||||
};
|
||||
...(comment.trim() ? { comment: comment.trim() } : {}),
|
||||
...(proofVideoUrl.trim() ? { proofVideoUrl: proofVideoUrl.trim() } : {}),
|
||||
} satisfies FileProtestCommandDTO;
|
||||
|
||||
await useCase.execute(command);
|
||||
await raceService.fileProtest(command);
|
||||
|
||||
setStep('success');
|
||||
} catch (err) {
|
||||
@@ -290,4 +285,4 @@ export default function FileProtestModal({
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { RaceWithResultsDTO } from '@core/testing-support';
|
||||
|
||||
type RaceWithResults = {
|
||||
raceId: string;
|
||||
track: string;
|
||||
car: string;
|
||||
winnerName: string;
|
||||
scheduledAt: string | Date;
|
||||
};
|
||||
|
||||
interface LatestResultsSidebarProps {
|
||||
results: RaceWithResultsDTO[];
|
||||
results: RaceWithResults[];
|
||||
}
|
||||
|
||||
export default function LatestResultsSidebar({ results }: LatestResultsSidebarProps) {
|
||||
@@ -14,7 +21,10 @@ export default function LatestResultsSidebar({ results }: LatestResultsSidebarPr
|
||||
<Card className="bg-iron-gray/80">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Latest results</h3>
|
||||
<ul className="space-y-3">
|
||||
{results.slice(0, 4).map(result => (
|
||||
{results.slice(0, 4).map((result) => {
|
||||
const scheduledAt = typeof result.scheduledAt === 'string' ? new Date(result.scheduledAt) : result.scheduledAt;
|
||||
|
||||
return (
|
||||
<li key={result.raceId} className="flex items-start justify-between gap-3 text-xs">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white truncate">{result.track}</p>
|
||||
@@ -23,14 +33,15 @@ export default function LatestResultsSidebar({ results }: LatestResultsSidebarPr
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-gray-500 whitespace-nowrap">
|
||||
{result.scheduledAt.toLocaleDateString(undefined, {
|
||||
{scheduledAt.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { Race } from '@core/racing/domain/entities/Race';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
type UpcomingRace = {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string | Date;
|
||||
};
|
||||
|
||||
interface UpcomingRacesSidebarProps {
|
||||
races: Race[];
|
||||
races: UpcomingRace[];
|
||||
}
|
||||
|
||||
export default function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProps) {
|
||||
@@ -25,21 +31,25 @@ export default function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProp
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
{races.slice(0, 4).map(race => (
|
||||
{races.slice(0, 4).map((race) => {
|
||||
const scheduledAt = typeof race.scheduledAt === 'string' ? new Date(race.scheduledAt) : race.scheduledAt;
|
||||
|
||||
return (
|
||||
<li key={race.id} className="flex items-start justify-between gap-3 text-xs">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white truncate">{race.track}</p>
|
||||
<p className="text-gray-400 truncate">{race.car}</p>
|
||||
</div>
|
||||
<div className="text-right text-gray-500 whitespace-nowrap">
|
||||
{race.scheduledAt.toLocaleDateString(undefined, {
|
||||
{scheduledAt.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ export interface PendingRequestDTO {
|
||||
id: string;
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
sponsorLogo?: string;
|
||||
sponsorLogo?: string | undefined;
|
||||
tier: 'main' | 'secondary';
|
||||
offeredAmount: number;
|
||||
currency: string;
|
||||
formattedAmount: string;
|
||||
message?: string;
|
||||
message?: string | undefined;
|
||||
createdAt: Date;
|
||||
platformFee: number;
|
||||
netAmount: number;
|
||||
@@ -238,4 +238,4 @@ export default function PendingSponsorshipRequests({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -13,6 +14,7 @@ interface CreateTeamFormProps {
|
||||
|
||||
export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormProps) {
|
||||
const router = useRouter();
|
||||
const { teamService } = useServices();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
tag: '',
|
||||
@@ -57,16 +59,13 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const useCase = getCreateTeamUseCase();
|
||||
const result = await useCase.execute({
|
||||
const result = await teamService.createTeam({
|
||||
name: formData.name,
|
||||
tag: formData.tag.toUpperCase(),
|
||||
description: formData.description,
|
||||
ownerId: currentDriverId,
|
||||
leagues: [],
|
||||
});
|
||||
|
||||
const teamId = result.team.id;
|
||||
const teamId = result.id;
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(teamId);
|
||||
@@ -169,4 +168,4 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewMode
|
||||
import TeamCard from './TeamCard';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
type TeamSpecialization = 'endurance' | 'sprint' | 'mixed';
|
||||
|
||||
interface SkillLevelConfig {
|
||||
id: SkillLevel;
|
||||
@@ -35,6 +36,13 @@ export default function SkillLevelSection({
|
||||
|
||||
if (teams.length === 0) return null;
|
||||
|
||||
const specialization = (teamSpecialization: string | undefined): TeamSpecialization | undefined => {
|
||||
if (teamSpecialization === 'endurance' || teamSpecialization === 'sprint' || teamSpecialization === 'mixed') {
|
||||
return teamSpecialization;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{/* Section Header */}
|
||||
@@ -81,12 +89,12 @@ export default function SkillLevelSection({
|
||||
name={team.name}
|
||||
description={team.description ?? ''}
|
||||
memberCount={team.memberCount}
|
||||
rating={team.rating}
|
||||
rating={null}
|
||||
totalWins={team.totalWins}
|
||||
totalRaces={team.totalRaces}
|
||||
performanceLevel={team.performanceLevel}
|
||||
performanceLevel={team.performanceLevel as SkillLevel}
|
||||
isRecruiting={team.isRecruiting}
|
||||
specialization={team.specialization}
|
||||
specialization={specialization(team.specialization)}
|
||||
region={team.region ?? ''}
|
||||
languages={team.languages}
|
||||
onClick={() => onTeamClick(team.id)}
|
||||
@@ -95,4 +103,4 @@ export default function SkillLevelSection({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,21 +5,19 @@ import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
|
||||
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||
import type { TeamJoinRequestViewModel } from '@/lib/view-models/TeamJoinRequestViewModel';
|
||||
import type { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
||||
import type { UpdateTeamViewModel } from '@/lib/view-models/UpdateTeamViewModel';
|
||||
|
||||
interface TeamAdminProps {
|
||||
team: {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
};
|
||||
team: Pick<TeamDetailsViewModel, 'id' | 'name' | 'tag' | 'description' | 'ownerId'>;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
const [joinRequests, setJoinRequests] = useState<TeamAdminJoinRequestViewModel[]>([]);
|
||||
const { teamJoinService, teamService } = useServices();
|
||||
const [joinRequests, setJoinRequests] = useState<TeamJoinRequestViewModel[]>([]);
|
||||
const [requestDrivers, setRequestDrivers] = useState<Record<string, DriverDTO>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
@@ -33,22 +31,13 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const viewModel = await loadTeamAdminViewModel({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
ownerId: team.ownerId,
|
||||
});
|
||||
setJoinRequests(viewModel.requests);
|
||||
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const request of viewModel.requests) {
|
||||
if (request.driver) {
|
||||
driversById[request.driverId] = request.driver;
|
||||
}
|
||||
}
|
||||
setRequestDrivers(driversById);
|
||||
// Current build only supports read-only join requests. Driver hydration is
|
||||
// not provided by the API response, so we only display driverId.
|
||||
const currentUserId = team.ownerId;
|
||||
const isOwner = true;
|
||||
const requests = await teamJoinService.getJoinRequests(team.id, currentUserId, isOwner);
|
||||
setJoinRequests(requests);
|
||||
setRequestDrivers({});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -59,16 +48,8 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
|
||||
const handleApprove = async (requestId: string) => {
|
||||
try {
|
||||
const updated = await approveTeamJoinRequestAndReload(requestId, team.id);
|
||||
setJoinRequests(updated);
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const request of updated) {
|
||||
if (request.driver) {
|
||||
driversById[request.driverId] = request.driver;
|
||||
}
|
||||
}
|
||||
setRequestDrivers(driversById);
|
||||
onUpdate();
|
||||
void requestId;
|
||||
await teamJoinService.approveJoinRequest();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to approve request');
|
||||
}
|
||||
@@ -76,15 +57,8 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
|
||||
const handleReject = async (requestId: string) => {
|
||||
try {
|
||||
const updated = await rejectTeamJoinRequestAndReload(requestId, team.id);
|
||||
setJoinRequests(updated);
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const request of updated) {
|
||||
if (request.driver) {
|
||||
driversById[request.driverId] = request.driver;
|
||||
}
|
||||
}
|
||||
setRequestDrivers(driversById);
|
||||
void requestId;
|
||||
await teamJoinService.rejectJoinRequest();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to reject request');
|
||||
}
|
||||
@@ -92,13 +66,16 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
try {
|
||||
await updateTeamDetails({
|
||||
teamId: team.id,
|
||||
const result: UpdateTeamViewModel = await teamService.updateTeam(team.id, {
|
||||
name: editedTeam.name,
|
||||
tag: editedTeam.tag,
|
||||
description: editedTeam.description,
|
||||
updatedByDriverId: team.ownerId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.successMessage);
|
||||
}
|
||||
|
||||
setEditMode(false);
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
@@ -201,40 +178,37 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
) : joinRequests.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{joinRequests.map((request) => {
|
||||
const driver = requestDrivers[request.driverId] ?? request.driver;
|
||||
if (!driver) return null;
|
||||
const driver = requestDrivers[request.driverId] ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={request.id}
|
||||
key={request.requestId}
|
||||
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-blue/20 flex items-center justify-center text-lg font-bold text-white">
|
||||
{driver.name.charAt(0)}
|
||||
{(driver?.name ?? request.driverId).charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-white font-medium">{driver.name}</h4>
|
||||
<h4 className="text-white font-medium">{driver?.name ?? request.driverId}</h4>
|
||||
<p className="text-sm text-gray-400">
|
||||
{driver.country} • Requested {new Date(request.requestedAt).toLocaleDateString()}
|
||||
{driver?.country ?? 'Unknown'} • Requested {new Date(request.requestedAt).toLocaleDateString()}
|
||||
</p>
|
||||
{request.message && (
|
||||
<p className="text-sm text-gray-300 mt-1 italic">
|
||||
"{request.message}"
|
||||
</p>
|
||||
)}
|
||||
{/* Request message is not part of current API contract */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleApprove(request.id)}
|
||||
onClick={() => handleApprove(request.requestId)}
|
||||
disabled
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleReject(request.id)}
|
||||
onClick={() => handleReject(request.requestId)}
|
||||
disabled
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
@@ -266,4 +240,4 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
Languages,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
|
||||
interface TeamCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -77,8 +79,8 @@ export default function TeamCard({
|
||||
languages,
|
||||
onClick,
|
||||
}: TeamCardProps) {
|
||||
const imageService = getImageService();
|
||||
const logoUrl = logo || imageService.getTeamLogo(id);
|
||||
const { mediaService } = useServices();
|
||||
const logoUrl = logo || mediaService.getTeamLogo(id);
|
||||
const performanceBadge = getPerformanceBadge(performanceLevel);
|
||||
const specializationBadge = getSpecializationBadge(specialization);
|
||||
|
||||
@@ -206,4 +208,4 @@ export default function TeamCard({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
|
||||
@@ -25,8 +26,8 @@ export default function TeamLadderRow({
|
||||
totalRaces,
|
||||
}: TeamLadderRowProps) {
|
||||
const router = useRouter();
|
||||
const imageService = getImageService();
|
||||
const logo = teamLogoUrl ?? imageService.getTeamLogo(teamId);
|
||||
const { mediaService } = useServices();
|
||||
const logo = teamLogoUrl ?? mediaService.getTeamLogo(teamId);
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(`/teams/${teamId}`);
|
||||
@@ -74,4 +75,4 @@ export default function TeamLadderRow({
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function TeamLeaderboardPreview({
|
||||
{/* Rating */}
|
||||
<div className="text-right">
|
||||
<p className="text-purple-400 font-mono font-semibold">
|
||||
{(team as any).rating?.toLocaleString() || '—'}
|
||||
{'—'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Rating</p>
|
||||
</div>
|
||||
@@ -172,4 +172,4 @@ export default function TeamLeaderboardPreview({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import { useState, useEffect } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import type { TeamRole } from '@core/racing/domain/types/TeamMembership';
|
||||
import type { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
||||
|
||||
interface TeamMembershipSummary {
|
||||
driverId: string;
|
||||
role: TeamRole;
|
||||
joinedAt: Date;
|
||||
}
|
||||
type TeamRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
type TeamMembershipSummary = Pick<TeamMemberViewModel, 'driverId' | 'role' | 'joinedAt'>;
|
||||
|
||||
interface TeamRosterProps {
|
||||
teamId: string;
|
||||
@@ -64,7 +62,7 @@ export default function TeamRoster({
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'bg-warning-amber/20 text-warning-amber';
|
||||
case 'manager':
|
||||
case 'admin':
|
||||
return 'bg-primary-blue/20 text-primary-blue';
|
||||
default:
|
||||
return 'bg-charcoal-outline text-gray-300';
|
||||
@@ -79,9 +77,9 @@ export default function TeamRoster({
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 0;
|
||||
case 'manager':
|
||||
case 'admin':
|
||||
return 1;
|
||||
case 'driver':
|
||||
case 'member':
|
||||
return 2;
|
||||
default:
|
||||
return 3;
|
||||
@@ -192,8 +190,8 @@ export default function TeamRoster({
|
||||
onChangeRole?.(driver.id, e.target.value as TeamRole)
|
||||
}
|
||||
>
|
||||
<option value="driver">Driver</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
@@ -214,4 +212,4 @@ export default function TeamRoster({
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps)
|
||||
<button
|
||||
key={team.id}
|
||||
type="button"
|
||||
onClick={() => onTeamClick(team.id)}
|
||||
onClick={() => onClick(team.id)}
|
||||
className="flex flex-col items-center group"
|
||||
>
|
||||
{/* Team card */}
|
||||
@@ -142,7 +142,7 @@ export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps)
|
||||
|
||||
{/* Rating */}
|
||||
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
|
||||
{(team as any).rating?.toLocaleString() || '—'}
|
||||
{'—'}
|
||||
</p>
|
||||
|
||||
{/* Stats row */}
|
||||
@@ -172,4 +172,4 @@ export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user