From 4a3087ae353286f6172b26e4453c4ef73c61715e Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Thu, 18 Dec 2025 19:14:50 +0100 Subject: [PATCH] remove core from pages --- apps/website/app/leagues/[id]/page.tsx | 12 +- .../app/leagues/[id]/rulebook/page.tsx | 31 +++-- .../app/leagues/[id]/schedule/page.tsx | 8 +- .../website/app/leagues/[id]/scoring/page.tsx | 6 - .../app/leagues/[id]/settings/page.tsx | 5 +- .../app/leagues/[id]/sponsorships/page.tsx | 21 ++-- .../app/leagues/[id]/standings/page.tsx | 43 +------ .../app/leagues/[id]/stewarding/page.tsx | 106 ++++++++--------- .../stewarding/protests/[protestId]/page.tsx | 56 ++++----- apps/website/app/leagues/page.tsx | 1 - apps/website/app/profile/leagues/page.tsx | 18 +-- apps/website/app/profile/page.tsx | 108 +++++++----------- .../app/profile/sponsorship-requests/page.tsx | 80 ++++++------- .../lib/api/drivers/DriversApiClient.ts | 5 + .../lib/api/leagues/LeaguesApiClient.ts | 10 ++ .../lib/api/penalties/PenaltiesApiClient.ts | 18 +++ .../lib/api/protests/ProtestsApiClient.ts | 10 ++ .../lib/api/sponsors/SponsorsApiClient.ts | 15 +++ apps/website/lib/services/ServiceFactory.ts | 11 ++ apps/website/lib/services/ServiceProvider.tsx | 3 + .../lib/services/drivers/DriverService.ts | 28 ++++- .../lib/services/leagues/LeagueService.ts | 10 +- .../services/leagues/LeagueSettingsService.ts | 8 +- .../lib/services/media/MediaService.ts | 7 ++ .../lib/services/penalties/PenaltyService.ts | 28 +++++ .../lib/services/protests/ProtestService.ts | 29 ++++- .../website/lib/services/races/RaceService.ts | 68 ++++++----- .../services/sponsors/SponsorshipService.ts | 24 +++- .../view-models/LeagueDetailPageViewModel.ts | 7 +- .../view-models/LeagueSettingsViewModel.ts | 5 +- .../view-models/LeagueStandingsViewModel.ts | 13 ++- .../lib/view-models/ProtestDriverViewModel.ts | 13 +++ .../lib/view-models/ProtestViewModel.ts | 11 +- apps/website/lib/view-models/RaceViewModel.ts | 34 ++++++ .../SponsorshipRequestViewModel.ts | 54 +++++++++ 35 files changed, 552 insertions(+), 354 deletions(-) delete mode 100644 apps/website/app/leagues/[id]/scoring/page.tsx create mode 100644 apps/website/lib/api/penalties/PenaltiesApiClient.ts create mode 100644 apps/website/lib/services/penalties/PenaltyService.ts create mode 100644 apps/website/lib/view-models/ProtestDriverViewModel.ts create mode 100644 apps/website/lib/view-models/RaceViewModel.ts create mode 100644 apps/website/lib/view-models/SponsorshipRequestViewModel.ts diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index e28100bf2..ba7cadaf9 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -25,7 +25,7 @@ export default function LeagueDetailPage() { const params = useParams(); const leagueId = params.id as string; const isSponsor = useSponsorMode(); - const { leagueService, leagueMembershipService } = useServices(); + const { leagueService, leagueMembershipService, raceService } = useServices(); const [viewModel, setViewModel] = useState(null); const [loading, setLoading] = useState(true); @@ -168,8 +168,7 @@ export default function LeagueDetailPage() { Started {new Date(race.date).toLocaleDateString()} - {/* TODO: Add registeredCount and strengthOfField to RaceDTO */} - {/* {race.registeredCount && ( + {race.registeredCount && (
{race.registeredCount} drivers registered @@ -180,7 +179,7 @@ export default function LeagueDetailPage() { SOF: {race.strengthOfField}
- )} */} + )} ))} @@ -482,10 +481,7 @@ export default function LeagueDetailPage() { raceName={race.name} onConfirm={async () => { try { - // TODO: Use service to complete race - // const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_URL || ''); - // const raceService = serviceFactory.createRaceService(); - // await raceService.completeRace(race.id); + await raceService.completeRace(race.id); await loadLeagueData(); setEndRaceModalRaceId(null); } catch (err) { diff --git a/apps/website/app/leagues/[id]/rulebook/page.tsx b/apps/website/app/leagues/[id]/rulebook/page.tsx index 64f8f39bf..72295a49a 100644 --- a/apps/website/app/leagues/[id]/rulebook/page.tsx +++ b/apps/website/app/leagues/[id]/rulebook/page.tsx @@ -3,9 +3,8 @@ import { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; import Card from '@/components/ui/Card'; -import type { LeagueScoringConfigDTO } from '@core/racing/application/dto/LeagueScoringConfigDTO'; import { useServices } from '@/lib/services/ServiceProvider'; -import type { LeagueWithCapacityDTO } from '@/lib/types/generated/LeagueWithCapacityDTO'; +import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel'; type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties'; @@ -13,8 +12,7 @@ export default function LeagueRulebookPage() { const params = useParams(); const leagueId = params.id as string; - const [league, setLeague] = useState(null); - const [scoringConfig, setScoringConfig] = useState(null); + const [viewModel, setViewModel] = useState(null); const [loading, setLoading] = useState(true); const [activeSection, setActiveSection] = useState('scoring'); @@ -22,14 +20,13 @@ export default function LeagueRulebookPage() { async function loadData() { try { const { leagueService } = useServices(); - const viewModel = await leagueService.getLeagueDetailPageData(leagueId); - if (!viewModel) { + const data = await leagueService.getLeagueDetailPageData(leagueId); + if (!data) { setLoading(false); return; } - setLeague(viewModel.league); - setScoringConfig(viewModel.scoringConfig); + setViewModel(data); } catch (err) { console.error('Failed to load scoring config:', err); } finally { @@ -48,7 +45,7 @@ export default function LeagueRulebookPage() { ); } - if (!league || !scoringConfig) { + if (!viewModel || !viewModel.scoringConfig) { return (
Unable to load rulebook
@@ -56,7 +53,7 @@ export default function LeagueRulebookPage() { ); } - const primaryChampionship = scoringConfig.championships.find(c => c.type === 'driver') ?? scoringConfig.championships[0]; + const primaryChampionship = viewModel.scoringConfig.championships.find(c => c.type === 'driver') ?? viewModel.scoringConfig.championships[0]; const positionPoints = primaryChampionship?.pointsPreview .filter(p => p.sessionType === primaryChampionship.sessionTypes[0]) .map(p => ({ position: p.position, points: p.points })) @@ -78,7 +75,7 @@ export default function LeagueRulebookPage() {

Official rules and regulations

- {scoringConfig.scoringPresetName || 'Custom Rules'} + {viewModel.scoringConfig.scoringPresetName || 'Custom Rules'}
@@ -106,11 +103,11 @@ export default function LeagueRulebookPage() {

Platform

-

{scoringConfig.gameName}

+

{viewModel.scoringConfig.gameName}

Championships

-

{scoringConfig.championships.length}

+

{viewModel.scoringConfig.championships.length}

Sessions Scored

@@ -120,8 +117,8 @@ export default function LeagueRulebookPage() {

Drop Policy

-

- {scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'} +

+ {viewModel.scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'}

@@ -192,10 +189,10 @@ export default function LeagueRulebookPage() { )} {/* Drop Policy */} - {!scoringConfig.dropPolicySummary.includes('All results count') && ( + {!viewModel.scoringConfig.dropPolicySummary.includes('All results count') && (

Drop Policy

-

{scoringConfig.dropPolicySummary}

+

{viewModel.scoringConfig.dropPolicySummary}

Drop rules are applied automatically when calculating championship standings.

diff --git a/apps/website/app/leagues/[id]/schedule/page.tsx b/apps/website/app/leagues/[id]/schedule/page.tsx index 737584436..21dd71596 100644 --- a/apps/website/app/leagues/[id]/schedule/page.tsx +++ b/apps/website/app/leagues/[id]/schedule/page.tsx @@ -4,6 +4,7 @@ import LeagueSchedule from '@/components/leagues/LeagueSchedule'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; +import { useServices } from '@/lib/services/ServiceProvider'; import { useParams, useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; @@ -12,17 +13,18 @@ export default function LeagueSchedulePage() { const router = useRouter(); const leagueId = params.id as string; const currentDriverId = useEffectiveDriverId(); + const { leagueMembershipService } = useServices(); const [isAdmin, setIsAdmin] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false); useEffect(() => { async function checkAdmin() { - const membershipRepo = getLeagueMembershipRepository(); - const membership = await membershipRepo.getMembership(leagueId, currentDriverId); + await leagueMembershipService.fetchLeagueMemberships(leagueId); + const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); } checkAdmin(); - }, [leagueId, currentDriverId]); + }, [leagueId, currentDriverId, leagueMembershipService]); return (
diff --git a/apps/website/app/leagues/[id]/scoring/page.tsx b/apps/website/app/leagues/[id]/scoring/page.tsx deleted file mode 100644 index 348ef15d7..000000000 --- a/apps/website/app/leagues/[id]/scoring/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default async function ScoringPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - redirect(`/leagues/${id}/rulebook`); -} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/settings/page.tsx b/apps/website/app/leagues/[id]/settings/page.tsx index 6826d4332..e1a41183c 100644 --- a/apps/website/app/leagues/[id]/settings/page.tsx +++ b/apps/website/app/leagues/[id]/settings/page.tsx @@ -8,7 +8,6 @@ import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; import { useServices } from '@/lib/services/ServiceProvider'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; -import type { DriverDTO } from '@/lib/types/DriverDTO'; import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel'; import { AlertTriangle, Settings, UserCog } from 'lucide-react'; import { useParams, useRouter } from 'next/navigation'; @@ -170,8 +169,8 @@ export default function LeagueSettingsPage() { > {settings.members.map((member) => ( - ))} diff --git a/apps/website/app/leagues/[id]/sponsorships/page.tsx b/apps/website/app/leagues/[id]/sponsorships/page.tsx index 3812561f1..7f7b69fed 100644 --- a/apps/website/app/leagues/[id]/sponsorships/page.tsx +++ b/apps/website/app/leagues/[id]/sponsorships/page.tsx @@ -4,7 +4,8 @@ import { LeagueSponsorshipsSection } from '@/components/leagues/LeagueSponsorshi import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; -import type { League } from '@core/racing/domain/entities/League'; +import { useServices } from '@/lib/services/ServiceProvider'; +import { LeagueDetailViewModel } from '@/lib/view-models/LeagueDetailViewModel'; import { AlertTriangle, Building } from 'lucide-react'; import { useParams } from 'next/navigation'; import { useEffect, useState } from 'react'; @@ -13,23 +14,23 @@ export default function LeagueSponsorshipsPage() { const params = useParams(); const leagueId = params.id as string; const currentDriverId = useEffectiveDriverId(); + const { leagueService, leagueMembershipService } = useServices(); - const [league, setLeague] = useState(null); + const [league, setLeague] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [loading, setLoading] = useState(true); useEffect(() => { async function loadData() { try { - const leagueRepo = getLeagueRepository(); - const membershipRepo = getLeagueMembershipRepository(); - - const [leagueData, membership] = await Promise.all([ - leagueRepo.findById(leagueId), - membershipRepo.getMembership(leagueId, currentDriverId), + const [leagueDetail, memberships] = await Promise.all([ + leagueService.getLeagueDetail(leagueId, currentDriverId), + leagueMembershipService.fetchLeagueMemberships(leagueId), ]); - setLeague(leagueData); + const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); + + setLeague(leagueDetail); setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); } catch (err) { console.error('Failed to load league:', err); @@ -39,7 +40,7 @@ export default function LeagueSponsorshipsPage() { } loadData(); - }, [leagueId, currentDriverId]); + }, [leagueId, currentDriverId, leagueService, leagueMembershipService]); if (loading) { return ( diff --git a/apps/website/app/leagues/[id]/standings/page.tsx b/apps/website/app/leagues/[id]/standings/page.tsx index d5f77c535..771f52301 100644 --- a/apps/website/app/leagues/[id]/standings/page.tsx +++ b/apps/website/app/leagues/[id]/standings/page.tsx @@ -3,9 +3,10 @@ import StandingsTable from '@/components/leagues/StandingsTable'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import type { DriverDto, LeagueMembership } from '@/lib/dtos'; +import type { LeagueMembership, MembershipRole } from '@/lib/types'; import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; import { useServices } from '@/lib/services/ServiceProvider'; +import { DriverViewModel } from '@/lib/view-models'; import type { LeagueStandingsViewModel } from '@/lib/view-models'; import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel'; import { useParams } from 'next/navigation'; @@ -18,7 +19,7 @@ export default function LeagueStandingsPage() { const { leagueService } = useServices(); const [standings, setStandings] = useState([]); - const [drivers, setDrivers] = useState([]); + const [drivers, setDrivers] = useState([]); const [memberships, setMemberships] = useState([]); const [viewModel, setViewModel] = useState(null); const [loading, setLoading] = useState(true); @@ -30,7 +31,7 @@ export default function LeagueStandingsPage() { const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId); setViewModel(vm); setStandings(vm.standings); - setDrivers(vm.drivers); + setDrivers(vm.drivers.map(d => new DriverViewModel(d))); setMemberships(vm.memberships); // Check if current user is admin @@ -53,21 +54,7 @@ export default function LeagueStandingsPage() { } 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 leagueService.removeMember(leagueId, currentDriverId, driverId); await loadData(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to remove member'); @@ -76,25 +63,7 @@ export default function LeagueStandingsPage() { 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 leagueService.updateMemberRole(leagueId, currentDriverId, driverId, newRole); await loadData(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to update role'); diff --git a/apps/website/app/leagues/[id]/stewarding/page.tsx b/apps/website/app/leagues/[id]/stewarding/page.tsx index 95f3202ce..4d1e4b642 100644 --- a/apps/website/app/leagues/[id]/stewarding/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/page.tsx @@ -6,12 +6,8 @@ import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; -import type { DriverDTO } from '@core/racing/application/dto/DriverDTO'; -import { EntityMappers } from '@core/racing/application/mappers/EntityMappers'; -import type { Penalty, PenaltyType } from '@core/racing/domain/entities/Penalty'; -import type { Protest } from '@core/racing/domain/entities/Protest'; -import type { Race } from '@core/racing/domain/entities/Race'; +import { useServices } from '@/lib/services/ServiceProvider'; +import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { AlertCircle, AlertTriangle, @@ -28,97 +24,98 @@ import Link from 'next/link'; import { useParams } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; +// Local type definitions to replace core imports +type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points'; + +type DriverDTO = { + id: string; + name: string; + avatarUrl?: string; + iracingId?: string; + rating?: number; +}; + interface RaceWithProtests { - race: Race; - pendingProtests: Protest[]; - resolvedProtests: Protest[]; - penalties: Penalty[]; + race: any; + pendingProtests: any[]; + resolvedProtests: any[]; + penalties: any[]; } export default function LeagueStewardingPage() { const params = useParams(); const leagueId = params.id as string; const currentDriverId = useEffectiveDriverId(); + const { raceService, protestService, driverService, leagueMembershipService, penaltyService } = useServices(); - const [races, setRaces] = useState([]); - const [protestsByRace, setProtestsByRace] = useState>({}); - const [penaltiesByRace, setPenaltiesByRace] = useState>({}); + const [races, setRaces] = useState([]); + const [protestsByRace, setProtestsByRace] = useState>({}); + const [penaltiesByRace, setPenaltiesByRace] = useState>({}); const [driversById, setDriversById] = useState>({}); const [allDrivers, setAllDrivers] = useState([]); const [loading, setLoading] = useState(true); const [isAdmin, setIsAdmin] = useState(false); const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending'); - const [selectedProtest, setSelectedProtest] = useState(null); + const [selectedProtest, setSelectedProtest] = useState(null); const [expandedRaces, setExpandedRaces] = useState>(new Set()); const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false); useEffect(() => { async function checkAdmin() { - const membershipRepo = getLeagueMembershipRepository(); - const membership = await membershipRepo.getMembership(leagueId, currentDriverId); - setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); + const membership = await leagueMembershipService.getMembership(leagueId, currentDriverId); + setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false); } checkAdmin(); - }, [leagueId, currentDriverId]); + }, [leagueId, currentDriverId, leagueMembershipService]); 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); + const leagueRaces = await raceService.findByLeagueId(leagueId); setRaces(leagueRaces); - + // Get protests and penalties for each race - const protestsMap: Record = {}; - const penaltiesMap: Record = {}; + const protestsMap: Record = {}; + const penaltiesMap: Record = {}; const driverIds = new Set(); - + for (const race of leagueRaces) { - const raceProtests = await protestRepo.findByRaceId(race.id); - const racePenalties = await penaltyRepo.findByRaceId(race.id); - + const raceProtests = await protestService.findByRaceId(race.id); + const racePenalties = await penaltyService.findByRaceId(race.id); + protestsMap[race.id] = raceProtests; penaltiesMap[race.id] = racePenalties; - + // Collect driver IDs - raceProtests.forEach((p) => { + raceProtests.forEach((p: any) => { driverIds.add(p.protestingDriverId); driverIds.add(p.accusedDriverId); }); - racePenalties.forEach((p) => { + racePenalties.forEach((p: any) => { 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 = {}; + const driverEntities = await driverService.findByIds(Array.from(driverIds)); + const byId: Record = {}; driverEntities.forEach((driver) => { if (driver) { - const dto = EntityMappers.toDriverDTO(driver); - if (dto) { - byId[dto.id] = dto; - } + byId[driver.id] = driver; } }); setDriversById(byId); setAllDrivers(Object.values(byId)); - + // Auto-expand races with pending protests const racesWithPending = new Set(); Object.entries(protestsMap).forEach(([raceId, protests]) => { - if (protests.some(p => p.status === 'pending' || p.status === 'under_review')) { + if (protests.some((p: any) => p.status === 'pending' || p.status === 'under_review')) { racesWithPending.add(raceId); } }); @@ -133,7 +130,7 @@ export default function LeagueStewardingPage() { if (isAdmin) { loadData(); } - }, [leagueId, isAdmin]); + }, [leagueId, isAdmin, raceService, protestService, driverService, penaltyService]); // Compute race data with protest/penalty info const racesWithData = useMemo((): RaceWithProtests[] => { @@ -168,10 +165,7 @@ export default function LeagueStewardingPage() { penaltyValue: number, stewardNotes: string ) => { - const reviewUseCase = getReviewProtestUseCase(); - const penaltyUseCase = getApplyPenaltyUseCase(); - - await reviewUseCase.execute({ + await protestService.reviewProtest({ protestId, stewardId: currentDriverId, decision: 'uphold', @@ -179,14 +173,14 @@ export default function LeagueStewardingPage() { }); // Find the protest - let foundProtest: Protest | undefined; + let foundProtest: any | undefined; Object.values(protestsByRace).forEach(protests => { const p = protests.find(pr => pr.id === protestId); if (p) foundProtest = p; }); if (foundProtest) { - await penaltyUseCase.execute({ + await penaltyService.applyPenalty({ raceId: foundProtest.raceId, driverId: foundProtest.accusedDriverId, stewardId: currentDriverId, @@ -200,9 +194,7 @@ export default function LeagueStewardingPage() { }; const handleRejectProtest = async (protestId: string, stewardNotes: string) => { - const reviewUseCase = getReviewProtestUseCase(); - - await reviewUseCase.execute({ + await protestService.reviewProtest({ protestId, stewardId: currentDriverId, decision: 'dismiss', @@ -210,10 +202,6 @@ export default function LeagueStewardingPage() { }); }; - const handleProtestReviewed = () => { - setSelectedProtest(null); - window.location.reload(); - }; const toggleRaceExpanded = (raceId: string) => { setExpandedRaces(prev => { diff --git a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx index 7ef2367dd..2e0d22058 100644 --- a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx @@ -3,12 +3,12 @@ import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; +import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { useServices } from '@/lib/services/ServiceProvider'; import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel'; +import { RaceViewModel } from '@/lib/view-models/RaceViewModel'; +import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel'; import { ProtestDecisionCommandModel, type PenaltyType } from '@/lib/command-models/protests/ProtestDecisionCommandModel'; -import type { DriverSummaryDTO } from '@/lib/types/generated/LeagueAdminProtestsDTO'; -import type { RaceDTO } from '@/lib/types/generated/RaceDTO'; import { AlertCircle, AlertTriangle, @@ -40,7 +40,7 @@ interface TimelineEvent { id: string; type: 'protest_filed' | 'defense_requested' | 'defense_submitted' | 'steward_comment' | 'decision' | 'penalty_applied'; timestamp: Date; - actor: DriverDTO | null; + actor: ProtestDriverViewModel | null; content: string; metadata?: Record; } @@ -114,12 +114,12 @@ export default function ProtestReviewPage() { const leagueId = params.id as string; const protestId = params.protestId as string; const currentDriverId = useEffectiveDriverId(); - const { protestService } = useServices(); + const { protestService, leagueMembershipService } = useServices(); const [protest, setProtest] = useState(null); - const [race, setRace] = useState(null); - const [protestingDriver, setProtestingDriver] = useState(null); - const [accusedDriver, setAccusedDriver] = useState(null); + const [race, setRace] = useState(null); + const [protestingDriver, setProtestingDriver] = useState(null); + const [accusedDriver, setAccusedDriver] = useState(null); const [loading, setLoading] = useState(true); const [isAdmin, setIsAdmin] = useState(false); @@ -136,12 +136,12 @@ export default function ProtestReviewPage() { useEffect(() => { async function checkAdmin() { - const membershipRepo = getLeagueMembershipRepository(); - const membership = await membershipRepo.getMembership(leagueId, currentDriverId); - setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); + await leagueMembershipService.fetchLeagueMemberships(leagueId); + const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); + setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false); } checkAdmin(); - }, [leagueId, currentDriverId]); + }, [leagueId, currentDriverId, leagueMembershipService]); useEffect(() => { async function loadProtest() { @@ -188,19 +188,19 @@ export default function ProtestReviewPage() { } ]; - // TODO: Add decision event when status/decisions are available in DTO - // 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 - // } - // }); - // } + // Add decision event when status/decisions are available in view model + 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]); @@ -315,9 +315,9 @@ export default function ProtestReviewPage() { ); } - const statusConfig = getStatusConfig('pending'); // TODO: Update when status is available + const statusConfig = getStatusConfig(protest.status); const StatusIcon = statusConfig.icon; - const isPending = true; // TODO: Update when status is available + const isPending = protest.status === 'pending'; const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24)); return ( @@ -404,7 +404,7 @@ export default function ProtestReviewPage() {
- {new Date(race.date).toLocaleDateString()} + {race.formattedDate}
{/* TODO: Add lap info when available */} {/*
diff --git a/apps/website/app/leagues/page.tsx b/apps/website/app/leagues/page.tsx index db7ad3515..398034ab7 100644 --- a/apps/website/app/leagues/page.tsx +++ b/apps/website/app/leagues/page.tsx @@ -400,7 +400,6 @@ export default function LeaguesPage() { } }; - // Use only real leagues from repository const leagues = realLeagues; const handleLeagueClick = (leagueId: string) => { diff --git a/apps/website/app/profile/leagues/page.tsx b/apps/website/app/profile/leagues/page.tsx index d7a7ac257..cd172b841 100644 --- a/apps/website/app/profile/leagues/page.tsx +++ b/apps/website/app/profile/leagues/page.tsx @@ -3,13 +3,14 @@ import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import type { League } from '@core/racing/domain/entities/League'; -import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; +import { useServices } from '@/lib/services/ServiceProvider'; +import type { LeagueMembership } from '@/lib/types/LeagueMembership'; +import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; import Link from 'next/link'; import { useEffect, useState } from 'react'; interface LeagueWithRole { - league: League; + league: LeagueSummaryViewModel; membership: LeagueMembership; } @@ -19,6 +20,7 @@ export default function ManageLeaguesPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const effectiveDriverId = useEffectiveDriverId(); + const { leagueService, leagueMembershipService } = useServices(); useEffect(() => { let cancelled = false; @@ -26,14 +28,12 @@ export default function ManageLeaguesPage() { const load = async () => { setLoading(true); try { - const leagueRepo = getLeagueRepository(); - const membershipRepo = getLeagueMembershipRepository(); - - const leagues = await leagueRepo.findAll(); + const leagues = await leagueService.getAllLeagues(); const memberships = await Promise.all( leagues.map(async (league) => { - const membership = await membershipRepo.getMembership(league.id, effectiveDriverId); + await leagueMembershipService.fetchLeagueMemberships(league.id); + const membership = leagueMembershipService.getMembership(league.id, effectiveDriverId); return { league, membership }; }), ); @@ -76,7 +76,7 @@ export default function ManageLeaguesPage() { return () => { cancelled = true; }; - }, [effectiveDriverId]); + }, [effectiveDriverId, leagueService, leagueMembershipService]); if (loading) { return ( diff --git a/apps/website/app/profile/page.tsx b/apps/website/app/profile/page.tsx index aee040c92..4107361c4 100644 --- a/apps/website/app/profile/page.tsx +++ b/apps/website/app/profile/page.tsx @@ -7,12 +7,12 @@ import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import Heading from '@/components/ui/Heading'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import type { DriverDTO } from '@core/racing/application/dto/DriverDTO'; +import { useServices } from '@/lib/services/ServiceProvider'; import type { - ProfileOverviewAchievementViewModel, - ProfileOverviewSocialHandleViewModel, - ProfileOverviewViewModel -} from '@/lib/view-models/ProfileOverviewViewModel'; + DriverProfileAchievementViewModel, + DriverProfileSocialHandleViewModel, + DriverProfileViewModel +} from '@/lib/view-models/DriverProfileViewModel'; import { Activity, Award, @@ -67,7 +67,7 @@ function getCountryFlag(countryCode: string): string { return '🏁'; } -function getRarityColor(rarity: ProfileOverviewAchievementViewModel['rarity']) { +function getRarityColor(rarity: DriverProfileAchievementViewModel['rarity']) { switch (rarity) { case 'common': return 'text-gray-400 bg-gray-400/10 border-gray-400/30'; @@ -80,7 +80,7 @@ function getRarityColor(rarity: ProfileOverviewAchievementViewModel['rarity']) { } } -function getAchievementIcon(icon: ProfileOverviewAchievementViewModel['icon']) { +function getAchievementIcon(icon: DriverProfileAchievementViewModel['icon']) { switch (icon) { case 'trophy': return Trophy; @@ -97,7 +97,7 @@ function getAchievementIcon(icon: ProfileOverviewAchievementViewModel['icon']) { } } -function getSocialIcon(platform: ProfileOverviewSocialHandleViewModel['platform']) { +function getSocialIcon(platform: DriverProfileSocialHandleViewModel['platform']) { switch (platform) { case 'twitter': return Twitter; @@ -110,7 +110,7 @@ function getSocialIcon(platform: ProfileOverviewSocialHandleViewModel['platform' } } -function getSocialColor(platform: ProfileOverviewSocialHandleViewModel['platform']) { +function getSocialColor(platform: DriverProfileSocialHandleViewModel['platform']) { switch (platform) { case 'twitter': return 'hover:text-sky-400 hover:bg-sky-400/10'; @@ -256,12 +256,13 @@ export default function ProfilePage() { const router = useRouter(); const searchParams = useSearchParams(); const tabParam = searchParams.get('tab') as ProfileTab | null; - - const [driver, setDriver] = useState(null); + + const { driverService, mediaService } = useServices(); + + const [profileData, setProfileData] = useState(null); const [loading, setLoading] = useState(true); const [editMode, setEditMode] = useState(false); const [activeTab, setActiveTab] = useState(tabParam || 'overview'); - const [profileData, setProfileData] = useState(null); const [friendRequestSent, setFriendRequestSent] = useState(false); const effectiveDriverId = useEffectiveDriverId(); @@ -271,24 +272,8 @@ export default function ProfilePage() { const loadData = async () => { try { const currentDriverId = effectiveDriverId; - - // Use GetProfileOverviewUseCase to load all profile data - const profileUseCase = getGetProfileOverviewUseCase(); - const profileViewModel = await profileUseCase.execute({ driverId: currentDriverId }); - - if (profileViewModel && profileViewModel.currentDriver) { - // Set driver from ViewModel instead of direct repository access - const driverData: DriverDTO = { - id: profileViewModel.currentDriver.id, - name: profileViewModel.currentDriver.name, - iracingId: profileViewModel.currentDriver.iracingId ?? '', - country: profileViewModel.currentDriver.country, - bio: profileViewModel.currentDriver.bio || '', - joinedAt: profileViewModel.currentDriver.joinedAt, - }; - setDriver(driverData); - setProfileData(profileViewModel); - } + const profileViewModel = await driverService.getDriverProfile(currentDriverId); + setProfileData(profileViewModel); } catch (error) { console.error('Failed to load profile:', error); } finally { @@ -296,7 +281,7 @@ export default function ProfilePage() { } }; void loadData(); - }, [effectiveDriverId]); + }, [effectiveDriverId, driverService]); // Update URL when tab changes useEffect(() => { @@ -319,24 +304,13 @@ export default function ProfilePage() { } }, [tabParam]); - const handleSaveSettings = async (updates: Partial) => { - if (!driver) return; + const handleSaveSettings = async (updates: { bio?: string; country?: string }) => { + if (!profileData?.currentDriver) return; try { - const updateProfileUseCase = getUpdateDriverProfileUseCase(); - const input: { driverId: string; bio?: string; country?: string } = { driverId: driver.id }; - if (typeof updates.bio === 'string') { - input.bio = updates.bio; - } - if (typeof updates.country === 'string') { - input.country = updates.country; - } - const updatedDto = await updateProfileUseCase.execute(input); - - if (updatedDto) { - setDriver(updatedDto); - setEditMode(false); - } + const updatedProfile = await driverService.updateProfile(updates); + setProfileData(updatedProfile); + setEditMode(false); } catch (error) { console.error('Failed to update profile:', error); } @@ -360,7 +334,7 @@ export default function ProfilePage() { ); } - if (!driver) { + if (!profileData?.currentDriver) { return (
@@ -387,12 +361,12 @@ export default function ProfilePage() { } // Extract data from profileData ViewModel - const currentDriver = profileData?.currentDriver || null; - const stats = profileData?.stats || null; - const finishDistribution = profileData?.finishDistribution || null; - const teamMemberships = profileData?.teamMemberships || []; - const socialSummary = profileData?.socialSummary || { friendsCount: 0, friends: [] }; - const extendedProfile = profileData?.extendedProfile; + const currentDriver = profileData.currentDriver; + const stats = profileData.stats; + const finishDistribution = profileData.finishDistribution; + const teamMemberships = profileData.teamMemberships; + const socialSummary = profileData.socialSummary; + const extendedProfile = profileData.extendedProfile; const globalRank = currentDriver?.globalRank || null; // Show edit mode @@ -405,7 +379,7 @@ export default function ProfilePage() { Cancel
- +
); } @@ -428,8 +402,8 @@ export default function ProfilePage() {
{driver.name}
-

{driver.name}

- - {getCountryFlag(driver.country)} +

{currentDriver.name}

+ + {getCountryFlag(currentDriver.country)} {teamMemberships.length > 0 && teamMemberships[0] && ( @@ -488,11 +462,11 @@ export default function ProfilePage() {
- iRacing: {driver.iracingId} + iRacing: {currentDriver.iracingId} - Joined {new Date(driver.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} + Joined {new Date(currentDriver.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} {extendedProfile && ( @@ -564,13 +538,13 @@ export default function ProfilePage() {
{/* Bio Section */} - {driver.bio && ( + {currentDriver.bio && (

About

-

{driver.bio}

+

{currentDriver.bio}

)} @@ -910,7 +884,7 @@ export default function ProfilePage() { >
{friend.name} )} - {activeTab === 'history' && driver && ( + {activeTab === 'history' && currentDriver && (

Race History

- +
)} diff --git a/apps/website/app/profile/sponsorship-requests/page.tsx b/apps/website/app/profile/sponsorship-requests/page.tsx index 3aa45cbbd..8eeb7f730 100644 --- a/apps/website/app/profile/sponsorship-requests/page.tsx +++ b/apps/website/app/profile/sponsorship-requests/page.tsx @@ -1,15 +1,16 @@ 'use client'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; -import PendingSponsorshipRequests, { type PendingRequestDTO } from '@/components/sponsors/PendingSponsorshipRequests'; +import PendingSponsorshipRequests from '@/components/sponsors/PendingSponsorshipRequests'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; +import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { useServices } from '@/lib/services/ServiceProvider'; +import { SponsorshipRequestViewModel } from '@/lib/view-models/SponsorshipRequestViewModel'; import { AlertTriangle, Building, ChevronRight, Handshake, Trophy, User, Users } from 'lucide-react'; import Link from 'next/link'; @@ -17,7 +18,7 @@ interface EntitySection { entityType: 'driver' | 'team' | 'race' | 'season'; entityId: string; entityName: string; - requests: PendingRequestDTO[]; + requests: SponsorshipRequestViewModel[]; } export default function SponsorshipRequestsPage() { @@ -33,50 +34,45 @@ export default function SponsorshipRequestsPage() { setError(null); try { - const { sponsorshipService } = useServices(); - const driverRepo = getDriverRepository(); - const leagueRepo = getLeagueRepository(); - const teamRepo = getTeamRepository(); - const leagueMembershipRepo = getLeagueMembershipRepository(); - const teamMembershipRepo = getTeamMembershipRepository(); - + const { sponsorshipService, driverService, leagueService, teamService, leagueMembershipService } = useServices(); + const allSections: EntitySection[] = []; - + // 1. Driver's own sponsorship requests - const driverResult = await sponsorshipService.getPendingSponsorshipRequests({ + const driverRequests = await sponsorshipService.getPendingSponsorshipRequests({ entityType: 'driver', entityId: currentDriverId, }); - - if (driverResult && driverResult.requests.length > 0) { - const driver = await driverRepo.findById(currentDriverId); + + if (driverRequests.length > 0) { + const driverProfile = await driverService.getDriverProfile(currentDriverId); allSections.push({ entityType: 'driver', entityId: currentDriverId, - entityName: driver?.name ?? 'Your Profile', - requests: driverResult.requests, + entityName: driverProfile?.currentDriver?.name ?? 'Your Profile', + requests: driverRequests, }); } - + // 2. Leagues where the user is admin/owner - const allLeagues = await leagueRepo.findAll(); + const allLeagues = await leagueService.getAllLeagues(); for (const league of allLeagues) { - const membership = await leagueMembershipRepo.getMembership(league.id, currentDriverId); - if (membership && isLeagueAdminOrHigherRole(membership.role)) { + const membership = await leagueMembershipService.getMembership(league.id, currentDriverId); + if (membership && LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role)) { // Load sponsorship requests for this league's active season try { // For simplicity, we'll query by season entityType - in production you'd get the active season ID - const leagueResult = await sponsorshipService.getPendingSponsorshipRequests({ + const leagueRequests = await sponsorshipService.getPendingSponsorshipRequests({ entityType: 'season', entityId: league.id, // Using league ID as a proxy for now }); - - if (leagueResult && leagueResult.requests.length > 0) { + + if (leagueRequests.length > 0) { allSections.push({ entityType: 'season', entityId: league.id, entityName: league.name, - requests: leagueResult.requests, + requests: leagueRequests, }); } } catch (err) { @@ -84,28 +80,28 @@ export default function SponsorshipRequestsPage() { } } } - + // 3. Teams where the user is owner/manager - const allTeams = await teamRepo.findAll(); + const allTeams = await teamService.getAllTeams(); for (const team of allTeams) { - const membership = await teamMembershipRepo.getMembership(team.id, currentDriverId); + const membership = await teamService.getMembership(team.id, currentDriverId); if (membership && (membership.role === 'owner' || membership.role === 'manager')) { - const teamResult = await sponsorshipService.getPendingSponsorshipRequests({ + const teamRequests = await sponsorshipService.getPendingSponsorshipRequests({ entityType: 'team', entityId: team.id, }); - - if (teamResult && teamResult.requests.length > 0) { + + if (teamRequests.length > 0) { allSections.push({ entityType: 'team', entityId: team.id, entityName: team.name, - requests: teamResult.requests, + requests: teamRequests, }); } } } - + setSections(allSections); } catch (err) { console.error('Failed to load sponsorship requests:', err); @@ -120,24 +116,14 @@ export default function SponsorshipRequestsPage() { }, [loadAllRequests]); const handleAccept = async (requestId: string) => { - const useCase = getAcceptSponsorshipRequestUseCase(); - await useCase.execute({ - requestId, - respondedBy: currentDriverId, - }); + const { sponsorshipService } = useServices(); + await sponsorshipService.acceptSponsorshipRequest(requestId, currentDriverId); await loadAllRequests(); }; const handleReject = async (requestId: string, reason?: string) => { - const useCase = getRejectSponsorshipRequestUseCase(); - const input: { requestId: string; respondedBy: string; reason?: string } = { - requestId, - respondedBy: currentDriverId, - }; - if (typeof reason === 'string') { - input.reason = reason; - } - await useCase.execute(input); + const { sponsorshipService } = useServices(); + await sponsorshipService.rejectSponsorshipRequest(requestId, currentDriverId, reason); await loadAllRequests(); }; diff --git a/apps/website/lib/api/drivers/DriversApiClient.ts b/apps/website/lib/api/drivers/DriversApiClient.ts index 3399b9c8f..05439127d 100644 --- a/apps/website/lib/api/drivers/DriversApiClient.ts +++ b/apps/website/lib/api/drivers/DriversApiClient.ts @@ -50,4 +50,9 @@ export class DriversApiClient extends BaseApiClient { getDriverProfile(driverId: string): Promise { return this.get(`/drivers/${driverId}/profile`); } + + /** Update current driver profile */ + updateProfile(updates: { bio?: string; country?: string }): Promise { + return this.put('/drivers/profile', updates); + } } \ No newline at end of file diff --git a/apps/website/lib/api/leagues/LeaguesApiClient.ts b/apps/website/lib/api/leagues/LeaguesApiClient.ts index c82005807..830a0f512 100644 --- a/apps/website/lib/api/leagues/LeaguesApiClient.ts +++ b/apps/website/lib/api/leagues/LeaguesApiClient.ts @@ -50,6 +50,11 @@ export class LeaguesApiClient extends BaseApiClient { return this.patch<{ success: boolean }>(`/leagues/${leagueId}/members/${targetDriverId}/remove`, { performerDriverId }); } + /** Update a member's role in league */ + updateMemberRole(leagueId: string, performerDriverId: string, targetDriverId: string, newRole: string): Promise<{ success: boolean }> { + return this.patch<{ success: boolean }>(`/leagues/${leagueId}/members/${targetDriverId}/role`, { performerDriverId, newRole }); + } + /** Get league seasons */ getSeasons(leagueId: string): Promise<{ seasons: Array<{ id: string; status: string }> }> { return this.get<{ seasons: Array<{ id: string; status: string }> }>(`/leagues/${leagueId}/seasons`); @@ -77,4 +82,9 @@ export class LeaguesApiClient extends BaseApiClient { newOwnerId, }); } + + /** Get races for a league */ + getRaces(leagueId: string): Promise<{ races: any[] }> { + return this.get<{ races: any[] }>(`/leagues/${leagueId}/races`); + } } \ No newline at end of file diff --git a/apps/website/lib/api/penalties/PenaltiesApiClient.ts b/apps/website/lib/api/penalties/PenaltiesApiClient.ts new file mode 100644 index 000000000..4b2916e1a --- /dev/null +++ b/apps/website/lib/api/penalties/PenaltiesApiClient.ts @@ -0,0 +1,18 @@ +import { BaseApiClient } from '../base/BaseApiClient'; + +/** + * Penalties API Client + * + * Handles all penalty-related API operations. + */ +export class PenaltiesApiClient extends BaseApiClient { + /** Get penalties for a race */ + getRacePenalties(raceId: string): Promise<{ penalties: any[] }> { + return this.get<{ penalties: any[] }>(`/races/${raceId}/penalties`); + } + + /** Apply a penalty */ + applyPenalty(input: any): Promise { + return this.post('/races/penalties/apply', input); + } +} \ No newline at end of file diff --git a/apps/website/lib/api/protests/ProtestsApiClient.ts b/apps/website/lib/api/protests/ProtestsApiClient.ts index c9da9836f..8b048a041 100644 --- a/apps/website/lib/api/protests/ProtestsApiClient.ts +++ b/apps/website/lib/api/protests/ProtestsApiClient.ts @@ -30,4 +30,14 @@ export class ProtestsApiClient extends BaseApiClient { requestDefense(input: RequestProtestDefenseCommandDTO): Promise { return this.post('/races/protests/defense/request', input); } + + /** Review protest */ + reviewProtest(input: { protestId: string; stewardId: string; decision: string; decisionNotes: string }): Promise { + return this.post(`/protests/${input.protestId}/review`, input); + } + + /** Get protests for a race */ + getRaceProtests(raceId: string): Promise<{ protests: any[] }> { + return this.get<{ protests: any[] }>(`/races/${raceId}/protests`); + } } \ No newline at end of file diff --git a/apps/website/lib/api/sponsors/SponsorsApiClient.ts b/apps/website/lib/api/sponsors/SponsorsApiClient.ts index a8de775f0..32e51fef4 100644 --- a/apps/website/lib/api/sponsors/SponsorsApiClient.ts +++ b/apps/website/lib/api/sponsors/SponsorsApiClient.ts @@ -44,4 +44,19 @@ export class SponsorsApiClient extends BaseApiClient { getSponsor(sponsorId: string): Promise { return this.get(`/sponsors/${sponsorId}`); } + + /** Get pending sponsorship requests for an entity */ + getPendingSponsorshipRequests(params: { entityType: string; entityId: string }): Promise<{ requests: any[] }> { + return this.get<{ requests: any[] }>(`/sponsors/requests?entityType=${params.entityType}&entityId=${params.entityId}`); + } + + /** Accept a sponsorship request */ + acceptSponsorshipRequest(requestId: string, respondedBy: string): Promise { + return this.post(`/sponsors/requests/${requestId}/accept`, { respondedBy }); + } + + /** Reject a sponsorship request */ + rejectSponsorshipRequest(requestId: string, respondedBy: string, reason?: string): Promise { + return this.post(`/sponsors/requests/${requestId}/reject`, { respondedBy, reason }); + } } \ No newline at end of file diff --git a/apps/website/lib/services/ServiceFactory.ts b/apps/website/lib/services/ServiceFactory.ts index 8199dbe75..49c28c938 100644 --- a/apps/website/lib/services/ServiceFactory.ts +++ b/apps/website/lib/services/ServiceFactory.ts @@ -9,6 +9,8 @@ import { AnalyticsApiClient } from '../api/analytics/AnalyticsApiClient'; import { MediaApiClient } from '../api/media/MediaApiClient'; import { DashboardApiClient } from '../api/dashboard/DashboardApiClient'; import { ProtestsApiClient } from '../api/protests/ProtestsApiClient'; +import { PenaltiesApiClient } from '../api/penalties/PenaltiesApiClient'; +import { PenaltyService } from './penalties/PenaltyService'; import { ConsoleErrorReporter } from '../infrastructure/logging/ConsoleErrorReporter'; import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger'; @@ -59,6 +61,7 @@ export class ServiceFactory { media: MediaApiClient; dashboard: DashboardApiClient; protests: ProtestsApiClient; + penalties: PenaltiesApiClient; }; constructor(baseUrl: string) { @@ -75,6 +78,7 @@ export class ServiceFactory { media: new MediaApiClient(baseUrl, this.errorReporter, this.logger), dashboard: new DashboardApiClient(baseUrl, this.errorReporter, this.logger), protests: new ProtestsApiClient(baseUrl, this.errorReporter, this.logger), + penalties: new PenaltiesApiClient(baseUrl, this.errorReporter, this.logger), }; } @@ -231,4 +235,11 @@ export class ServiceFactory { createProtestService(): ProtestService { return new ProtestService(this.apiClients.protests); } + + /** + * Create PenaltyService instance + */ + createPenaltyService(): PenaltyService { + return new PenaltyService(this.apiClients.penalties); + } } \ No newline at end of file diff --git a/apps/website/lib/services/ServiceProvider.tsx b/apps/website/lib/services/ServiceProvider.tsx index c2e0634bb..bea838148 100644 --- a/apps/website/lib/services/ServiceProvider.tsx +++ b/apps/website/lib/services/ServiceProvider.tsx @@ -25,6 +25,7 @@ import { MembershipFeeService } from './payments/MembershipFeeService'; import { AuthService } from './auth/AuthService'; import { SessionService } from './auth/SessionService'; import { ProtestService } from './protests/ProtestService'; +import { PenaltyService } from './penalties/PenaltyService'; export interface Services { raceService: RaceService; @@ -48,6 +49,7 @@ export interface Services { authService: AuthService; sessionService: SessionService; protestService: ProtestService; + penaltyService: PenaltyService; } const ServicesContext = createContext(null); @@ -82,6 +84,7 @@ export function ServiceProvider({ children }: ServiceProviderProps) { authService: serviceFactory.createAuthService(), sessionService: serviceFactory.createSessionService(), protestService: serviceFactory.createProtestService(), + penaltyService: serviceFactory.createPenaltyService(), }; }, []); diff --git a/apps/website/lib/services/drivers/DriverService.ts b/apps/website/lib/services/drivers/DriverService.ts index 6fb684f2f..38f738382 100644 --- a/apps/website/lib/services/drivers/DriverService.ts +++ b/apps/website/lib/services/drivers/DriverService.ts @@ -54,10 +54,34 @@ export class DriverService { } /** - * Get driver profile with full details and view model transformation - */ + * Get driver profile with full details and view model transformation + */ async getDriverProfile(driverId: string): Promise { const dto = await this.apiClient.getDriverProfile(driverId); return new DriverProfileViewModel(dto); } + + /** + * Update current driver profile with view model transformation + */ + async updateProfile(updates: { bio?: string; country?: string }): Promise { + const dto = await this.apiClient.updateProfile(updates); + // After updating, get the full profile again to return updated view model + return this.getDriverProfile(dto.id); + } + + /** + * Find driver by ID + */ + async findById(id: string): Promise { + return this.apiClient.getDriver(id); + } + + /** + * Find multiple drivers by IDs + */ + async findByIds(ids: string[]): Promise { + const drivers = await Promise.all(ids.map(id => this.apiClient.getDriver(id))); + return drivers.filter((d): d is DriverDTO => d !== null); + } } \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueService.ts b/apps/website/lib/services/leagues/LeagueService.ts index 3a73a82ff..a6bcbb5cb 100644 --- a/apps/website/lib/services/leagues/LeagueService.ts +++ b/apps/website/lib/services/leagues/LeagueService.ts @@ -13,6 +13,7 @@ import { LeagueSummaryViewModel } from "@/lib/view-models/LeagueSummaryViewModel import { RemoveMemberViewModel } from "@/lib/view-models/RemoveMemberViewModel"; import { LeagueDetailViewModel } from "@/lib/view-models/LeagueDetailViewModel"; import { LeagueDetailPageViewModel, SponsorInfo } from "@/lib/view-models/LeagueDetailPageViewModel"; +import { RaceViewModel } from "@/lib/view-models/RaceViewModel"; import { SubmitBlocker, ThrottleBlocker } from "@/lib/blockers"; import { DriverDTO } from "@/lib/types/DriverDTO"; import { RaceDTO } from "@/lib/types/generated/RaceDTO"; @@ -107,6 +108,13 @@ export class LeagueService { return new RemoveMemberViewModel(dto); } + /** + * Update a member's role in league + */ + async updateMemberRole(leagueId: string, performerDriverId: string, targetDriverId: string, newRole: string): Promise<{ success: boolean }> { + return this.apiClient.updateMemberRole(leagueId, performerDriverId, targetDriverId, newRole); + } + /** * Get league detail with owner, membership, and sponsor info */ @@ -192,7 +200,7 @@ export class LeagueService { const memberships = await this.apiClient.getMemberships(leagueId); // Get all races for this league - TODO: implement API endpoint - const allRaces: RaceDTO[] = []; // TODO: fetch from API + const allRaces: RaceViewModel[] = []; // TODO: fetch from API and map to RaceViewModel // Get league stats const leagueStats = await this.apiClient.getTotal(); // TODO: get stats for specific league diff --git a/apps/website/lib/services/leagues/LeagueSettingsService.ts b/apps/website/lib/services/leagues/LeagueSettingsService.ts index f10048796..0cd7e7fb0 100644 --- a/apps/website/lib/services/leagues/LeagueSettingsService.ts +++ b/apps/website/lib/services/leagues/LeagueSettingsService.ts @@ -57,12 +57,16 @@ export class LeagueSettingsService { // Get members const membershipsDto = await this.leaguesApiClient.getMemberships(leagueId); - const members: DriverDTO[] = []; + const members: DriverSummaryViewModel[] = []; for (const member of membershipsDto.members) { if (member.driverId !== league.ownerId && member.role !== 'owner') { const driver = await this.driversApiClient.getDriver(member.driverId); if (driver) { - members.push(driver); + members.push(new DriverSummaryViewModel({ + driver, + rating: driver.rating ?? null, + rank: null, // TODO: get from API + })); } } } diff --git a/apps/website/lib/services/media/MediaService.ts b/apps/website/lib/services/media/MediaService.ts index 103f4257c..81c84fd6f 100644 --- a/apps/website/lib/services/media/MediaService.ts +++ b/apps/website/lib/services/media/MediaService.ts @@ -47,4 +47,11 @@ export class MediaService { getTeamLogo(teamId: string): string { return `/api/media/teams/${teamId}/logo`; } + + /** + * Get driver avatar URL + */ + getDriverAvatar(driverId: string): string { + return `/api/media/avatar/${driverId}`; + } } \ No newline at end of file diff --git a/apps/website/lib/services/penalties/PenaltyService.ts b/apps/website/lib/services/penalties/PenaltyService.ts new file mode 100644 index 000000000..839754ec8 --- /dev/null +++ b/apps/website/lib/services/penalties/PenaltyService.ts @@ -0,0 +1,28 @@ +import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient'; + +/** + * Penalty Service + * + * Orchestrates penalty operations by coordinating API calls and view model creation. + * All dependencies are injected via constructor. + */ +export class PenaltyService { + constructor( + private readonly apiClient: PenaltiesApiClient + ) {} + + /** + * Find penalties by race ID + */ + async findByRaceId(raceId: string): Promise { + const dto = await this.apiClient.getRacePenalties(raceId); + return dto.penalties; + } + + /** + * Apply a penalty + */ + async applyPenalty(input: any): Promise { + await this.apiClient.applyPenalty(input); + } +} \ No newline at end of file diff --git a/apps/website/lib/services/protests/ProtestService.ts b/apps/website/lib/services/protests/ProtestService.ts index 53186e3df..84f092261 100644 --- a/apps/website/lib/services/protests/ProtestService.ts +++ b/apps/website/lib/services/protests/ProtestService.ts @@ -1,5 +1,7 @@ import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient'; import { ProtestViewModel } from '../../view-models/ProtestViewModel'; +import { RaceViewModel } from '../../view-models/RaceViewModel'; +import { ProtestDriverViewModel } from '../../view-models/ProtestDriverViewModel'; import type { LeagueAdminProtestsDTO, ApplyPenaltyCommandDTO, RequestProtestDefenseCommandDTO, DriverSummaryDTO } from '../../types'; /** @@ -34,9 +36,9 @@ export class ProtestService { */ async getProtestById(leagueId: string, protestId: string): Promise<{ protest: ProtestViewModel; - race: LeagueAdminProtestsDTO['racesById'][string]; - protestingDriver: DriverSummaryDTO; - accusedDriver: DriverSummaryDTO; + race: RaceViewModel; + protestingDriver: ProtestDriverViewModel; + accusedDriver: ProtestDriverViewModel; } | null> { const dto = await this.apiClient.getLeagueProtest(leagueId, protestId); const protest = dto.protests[0]; @@ -48,9 +50,9 @@ export class ProtestService { return { protest: new ProtestViewModel(protest), - race, - protestingDriver, - accusedDriver, + race: new RaceViewModel(race), + protestingDriver: new ProtestDriverViewModel(protestingDriver), + accusedDriver: new ProtestDriverViewModel(accusedDriver), }; } @@ -67,4 +69,19 @@ export class ProtestService { async requestDefense(input: RequestProtestDefenseCommandDTO): Promise { await this.apiClient.requestDefense(input); } + + /** + * Review protest + */ + async reviewProtest(input: { protestId: string; stewardId: string; decision: string; decisionNotes: string }): Promise { + await this.apiClient.reviewProtest(input); + } + + /** + * Find protests by race ID + */ + async findByRaceId(raceId: string): Promise { + const dto = await this.apiClient.getRaceProtests(raceId); + return dto.protests; + } } \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceService.ts b/apps/website/lib/services/races/RaceService.ts index 99362e95d..bb6ef7724 100644 --- a/apps/website/lib/services/races/RaceService.ts +++ b/apps/website/lib/services/races/RaceService.ts @@ -83,35 +83,45 @@ export class RaceService { } /** - * Transform API races page data to view model format + * Transform API races page data to view model format + */ + private transformRacesPageData(dto: RacesPageDataDto): { + upcomingRaces: Array<{ id: string; title: string; scheduledTime: string; status: string }>; + completedRaces: Array<{ id: string; title: string; scheduledTime: string; status: string }>; + totalCount: number; + } { + const upcomingRaces = dto.races + .filter(race => race.status !== 'completed') + .map(race => ({ + id: race.id, + title: `${race.track} - ${race.car}`, + scheduledTime: race.scheduledAt, + status: race.status, + })); + + const completedRaces = dto.races + .filter(race => race.status === 'completed') + .map(race => ({ + id: race.id, + title: `${race.track} - ${race.car}`, + scheduledTime: race.scheduledAt, + status: race.status, + })); + + return { + upcomingRaces, + completedRaces, + totalCount: dto.races.length, + }; + } + + /** + * Find races by league ID */ - private transformRacesPageData(dto: RacesPageDataDto): { - upcomingRaces: Array<{ id: string; title: string; scheduledTime: string; status: string }>; - completedRaces: Array<{ id: string; title: string; scheduledTime: string; status: string }>; - totalCount: number; - } { - const upcomingRaces = dto.races - .filter(race => race.status !== 'completed') - .map(race => ({ - id: race.id, - title: `${race.track} - ${race.car}`, - scheduledTime: race.scheduledAt, - status: race.status, - })); - - const completedRaces = dto.races - .filter(race => race.status === 'completed') - .map(race => ({ - id: race.id, - title: `${race.track} - ${race.car}`, - scheduledTime: race.scheduledAt, - status: race.status, - })); - - return { - upcomingRaces, - completedRaces, - totalCount: dto.races.length, - }; + async findByLeagueId(leagueId: string): Promise { + // Assuming the API has /races?leagueId=... + // TODO: Update when API is implemented + const dto = await this.apiClient.get('/races?leagueId=' + leagueId) as { races: any[] }; + return dto.races; } } \ No newline at end of file diff --git a/apps/website/lib/services/sponsors/SponsorshipService.ts b/apps/website/lib/services/sponsors/SponsorshipService.ts index ea48b2302..76356faa8 100644 --- a/apps/website/lib/services/sponsors/SponsorshipService.ts +++ b/apps/website/lib/services/sponsors/SponsorshipService.ts @@ -2,7 +2,8 @@ import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient'; import type { GetEntitySponsorshipPricingResultDto } from '../../api/sponsors/SponsorsApiClient'; import { SponsorshipPricingViewModel, - SponsorSponsorshipsViewModel + SponsorSponsorshipsViewModel, + SponsorshipRequestViewModel } from '../../view-models'; import type { SponsorSponsorshipsDTO } from '../../types/generated'; @@ -39,9 +40,22 @@ export class SponsorshipService { /** * Get pending sponsorship requests for an entity */ - async getPendingSponsorshipRequests(params: { entityType: string; entityId: string }): Promise<{ requests: any[] }> { - // TODO: Implement API call - // For now, return empty - return { requests: [] }; + async getPendingSponsorshipRequests(params: { entityType: string; entityId: string }): Promise { + const dto = await this.apiClient.getPendingSponsorshipRequests(params); + return dto.requests.map(dto => new SponsorshipRequestViewModel(dto)); + } + + /** + * Accept a sponsorship request + */ + async acceptSponsorshipRequest(requestId: string, respondedBy: string): Promise { + await this.apiClient.acceptSponsorshipRequest(requestId, respondedBy); + } + + /** + * Reject a sponsorship request + */ + async rejectSponsorshipRequest(requestId: string, respondedBy: string, reason?: string): Promise { + await this.apiClient.rejectSponsorshipRequest(requestId, respondedBy, reason); } } \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueDetailPageViewModel.ts b/apps/website/lib/view-models/LeagueDetailPageViewModel.ts index aacb329a2..6ddd23f5c 100644 --- a/apps/website/lib/view-models/LeagueDetailPageViewModel.ts +++ b/apps/website/lib/view-models/LeagueDetailPageViewModel.ts @@ -6,6 +6,7 @@ import { LeagueStandingsDTO } from '../types/generated/LeagueStandingsDTO'; import { DriverDTO } from '../types/DriverDTO'; import { RaceDTO } from '../types/generated/RaceDTO'; import { LeagueScoringConfigDTO } from '../types/LeagueScoringConfigDTO'; +import { RaceViewModel } from './RaceViewModel'; // Sponsor info type export interface SponsorInfo { @@ -59,8 +60,8 @@ export class LeagueDetailPageViewModel { memberships: LeagueMembershipWithRole[]; // Races - allRaces: RaceDTO[]; - runningRaces: RaceDTO[]; + allRaces: RaceViewModel[]; + runningRaces: RaceViewModel[]; // Stats averageSOF: number | null; @@ -96,7 +97,7 @@ export class LeagueDetailPageViewModel { scoringConfig: LeagueScoringConfigDTO | null, drivers: DriverDTO[], memberships: LeagueMembershipsDTO, - allRaces: RaceDTO[], + allRaces: RaceViewModel[], leagueStats: LeagueStatsDTO, sponsors: SponsorInfo[] ) { diff --git a/apps/website/lib/view-models/LeagueSettingsViewModel.ts b/apps/website/lib/view-models/LeagueSettingsViewModel.ts index 688ed28fe..d74d19be6 100644 --- a/apps/website/lib/view-models/LeagueSettingsViewModel.ts +++ b/apps/website/lib/view-models/LeagueSettingsViewModel.ts @@ -1,6 +1,5 @@ import type { LeagueConfigFormModel } from '../types/LeagueConfigFormModel'; import type { LeagueScoringPresetDTO } from '../types/LeagueScoringPresetDTO'; -import type { DriverDTO } from '../types/DriverDTO'; import { LeagueScoringPresetsViewModel } from './LeagueScoringPresetsViewModel'; import { DriverSummaryViewModel } from './DriverSummaryViewModel'; @@ -17,7 +16,7 @@ export class LeagueSettingsViewModel { config: LeagueConfigFormModel; presets: LeagueScoringPresetDTO[]; owner: DriverSummaryViewModel | null; - members: DriverDTO[]; + members: DriverSummaryViewModel[]; constructor(dto: { league: { @@ -28,7 +27,7 @@ export class LeagueSettingsViewModel { config: LeagueConfigFormModel; presets: LeagueScoringPresetDTO[]; owner: DriverSummaryViewModel | null; - members: DriverDTO[]; + members: DriverSummaryViewModel[]; }) { this.league = dto.league; this.config = dto.config; diff --git a/apps/website/lib/view-models/LeagueStandingsViewModel.ts b/apps/website/lib/view-models/LeagueStandingsViewModel.ts index 5a5de1724..2e5c2e550 100644 --- a/apps/website/lib/view-models/LeagueStandingsViewModel.ts +++ b/apps/website/lib/view-models/LeagueStandingsViewModel.ts @@ -1,20 +1,21 @@ import { LeagueStandingDTO } from '../types/generated/LeagueStandingDTO'; import { StandingEntryViewModel } from './StandingEntryViewModel'; +import { DriverDTO } from '../types/DriverDTO'; +import { LeagueMembership } from '../types/LeagueMembership'; export class LeagueStandingsViewModel { standings: StandingEntryViewModel[]; + drivers: DriverDTO[]; + memberships: LeagueMembership[]; - constructor(dto: { standings: LeagueStandingDTO[] }, currentUserId: string, previousStandings?: LeagueStandingDTO[]) { + constructor(dto: { standings: LeagueStandingDTO[]; drivers: DriverDTO[]; memberships: LeagueMembership[] }, currentUserId: string, previousStandings?: LeagueStandingDTO[]) { const leaderPoints = dto.standings[0]?.points || 0; this.standings = dto.standings.map((entry, index) => { const nextPoints = dto.standings[index + 1]?.points || entry.points; const previousPosition = previousStandings?.find(p => p.driverId === entry.driverId)?.position; return new StandingEntryViewModel(entry, leaderPoints, nextPoints, currentUserId, previousPosition); }); + this.drivers = dto.drivers; + this.memberships = dto.memberships; } - - // Note: The generated DTO doesn't have these fields - // These will need to be added when the OpenAPI spec is updated - drivers: any[] = []; - memberships: any[] = []; } \ No newline at end of file diff --git a/apps/website/lib/view-models/ProtestDriverViewModel.ts b/apps/website/lib/view-models/ProtestDriverViewModel.ts new file mode 100644 index 000000000..10fe8af7c --- /dev/null +++ b/apps/website/lib/view-models/ProtestDriverViewModel.ts @@ -0,0 +1,13 @@ +import { DriverSummaryDTO } from '../types/generated/LeagueAdminProtestsDTO'; + +export class ProtestDriverViewModel { + constructor(private readonly dto: DriverSummaryDTO) {} + + get id(): string { + return this.dto.id; + } + + get name(): string { + return this.dto.name; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/ProtestViewModel.ts b/apps/website/lib/view-models/ProtestViewModel.ts index 0ff9ec499..2f27c8b9a 100644 --- a/apps/website/lib/view-models/ProtestViewModel.ts +++ b/apps/website/lib/view-models/ProtestViewModel.ts @@ -11,6 +11,9 @@ export class ProtestViewModel { accusedDriverId: string; description: string; submittedAt: string; + status: string; + reviewedAt?: string; + decisionNotes?: string; constructor(dto: ProtestDTO) { this.id = dto.id; @@ -19,6 +22,10 @@ export class ProtestViewModel { this.accusedDriverId = dto.accusedDriverId; this.description = dto.description; this.submittedAt = dto.submittedAt; + // TODO: Add these fields to DTO when available + this.status = 'pending'; + this.reviewedAt = undefined; + this.decisionNotes = undefined; } /** UI-specific: Formatted submitted date */ @@ -26,8 +33,8 @@ export class ProtestViewModel { return new Date(this.submittedAt).toLocaleString(); } - /** UI-specific: Status display - placeholder since status not in current DTO */ + /** UI-specific: Status display */ get statusDisplay(): string { - return 'Pending'; // TODO: Update when status is added to DTO + return 'Pending'; } } \ No newline at end of file diff --git a/apps/website/lib/view-models/RaceViewModel.ts b/apps/website/lib/view-models/RaceViewModel.ts new file mode 100644 index 000000000..43244feb1 --- /dev/null +++ b/apps/website/lib/view-models/RaceViewModel.ts @@ -0,0 +1,34 @@ +import { RaceDTO } from '../types/generated/RaceDTO'; + +export class RaceViewModel { + constructor(private readonly dto: RaceDTO, private readonly _status?: string, private readonly _registeredCount?: number, private readonly _strengthOfField?: number) {} + + get id(): string { + return this.dto.id; + } + + get name(): string { + return this.dto.name; + } + + get date(): string { + return this.dto.date; + } + + get status(): string | undefined { + return this._status; + } + + get registeredCount(): number | undefined { + return this._registeredCount; + } + + get strengthOfField(): number | undefined { + return this._strengthOfField; + } + + /** UI-specific: Formatted date */ + get formattedDate(): string { + return new Date(this.date).toLocaleDateString(); + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/SponsorshipRequestViewModel.ts b/apps/website/lib/view-models/SponsorshipRequestViewModel.ts new file mode 100644 index 000000000..664da0629 --- /dev/null +++ b/apps/website/lib/view-models/SponsorshipRequestViewModel.ts @@ -0,0 +1,54 @@ +import { PendingRequestDTO } from '@/components/sponsors/PendingSponsorshipRequests'; + +export class SponsorshipRequestViewModel { + id: string; + sponsorId: string; + sponsorName: string; + sponsorLogo?: string; + tier: 'main' | 'secondary'; + offeredAmount: number; + currency: string; + formattedAmount: string; + message?: string; + createdAt: Date; + platformFee: number; + netAmount: number; + + constructor(dto: PendingRequestDTO) { + this.id = dto.id; + this.sponsorId = dto.sponsorId; + this.sponsorName = dto.sponsorName; + this.sponsorLogo = dto.sponsorLogo; + this.tier = dto.tier; + this.offeredAmount = dto.offeredAmount; + this.currency = dto.currency; + this.formattedAmount = dto.formattedAmount; + this.message = dto.message; + this.createdAt = dto.createdAt; + this.platformFee = dto.platformFee; + this.netAmount = dto.netAmount; + } + + /** UI-specific: Formatted date */ + get formattedDate(): string { + return this.createdAt.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + } + + /** UI-specific: Net amount in dollars */ + get netAmountDollars(): string { + return `$${(this.netAmount / 100).toFixed(2)}`; + } + + /** UI-specific: Tier display */ + get tierDisplay(): string { + return this.tier === 'main' ? 'Main Sponsor' : 'Secondary'; + } + + /** UI-specific: Tier badge variant */ + get tierBadgeVariant(): 'primary' | 'secondary' { + return this.tier === 'main' ? 'primary' : 'secondary'; + } +} \ No newline at end of file