From 75ffe0798ea201ae8a9e7205e65468f1d8e5e999 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sat, 17 Jan 2026 01:04:36 +0100 Subject: [PATCH] website refactor --- .dockerignore | 1 + README.docker.md | 3 + apps/api/src/domain/league/LeagueService.ts | 9 +- apps/api/src/domain/race/RaceProviders.ts | 2 +- apps/api/src/domain/race/RaceService.ts | 2 +- .../race/presenters/RaceDetailPresenter.ts | 7 +- apps/website/Dockerfile.e2e | 2 +- .../website/app/auth/forgot-password/page.tsx | 2 +- apps/website/app/auth/login/page.tsx | 2 +- apps/website/app/auth/reset-password/page.tsx | 2 +- apps/website/app/auth/signup/page.tsx | 2 +- apps/website/app/drivers/[id]/page.tsx | 5 +- apps/website/app/leagues/[id]/layout.tsx | 4 +- apps/website/app/leagues/[id]/page.tsx | 5 +- .../app/leagues/[id]/rulebook/page.tsx | 4 +- .../app/leagues/[id]/schedule/page.tsx | 4 +- .../app/leagues/[id]/settings/page.tsx | 4 +- .../app/leagues/[id]/sponsorships/page.tsx | 4 +- .../app/leagues/[id]/standings/page.tsx | 4 +- .../app/leagues/[id]/stewarding/page.tsx | 4 +- .../app/media/avatar/[driverId]/route.ts | 4 +- .../categories/[categoryId]/icon/route.ts | 4 +- .../media/leagues/[leagueId]/cover/route.ts | 4 +- .../media/leagues/[leagueId]/logo/route.ts | 4 +- .../media/sponsors/[sponsorId]/logo/route.ts | 4 +- .../app/media/teams/[teamId]/logo/route.ts | 4 +- .../app/media/tracks/[trackId]/image/route.ts | 4 +- .../app/races/[id]/RaceDetailPageClient.tsx | 124 +++++++++--------- apps/website/app/races/[id]/page.tsx | 101 +++----------- .../[id]/results/RaceResultsPageClient.tsx | 57 ++++++++ apps/website/app/races/[id]/results/page.tsx | 96 +++----------- .../app/races/[id]/stewarding/page.tsx | 8 +- .../website/app/sponsor/leagues/[id]/page.tsx | 5 +- apps/website/app/teams/[id]/page.tsx | 5 +- .../auth/ForgotPasswordPageQuery.ts | 6 +- .../lib/page-queries/auth/LoginPageQuery.ts | 6 +- .../auth/ResetPasswordPageQuery.ts | 6 +- .../lib/page-queries/auth/SignupPageQuery.ts | 6 +- .../search-params/SearchParamParser.ts | 67 ++++++---- package.json | 1 + 40 files changed, 267 insertions(+), 321 deletions(-) create mode 100644 apps/website/app/races/[id]/results/RaceResultsPageClient.tsx diff --git a/.dockerignore b/.dockerignore index 266bf5742..6acb0e73a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -78,4 +78,5 @@ userData # Development files .prettierrc .eslintrc* +! .eslintrc.json tsconfig.tsbuildinfo \ No newline at end of file diff --git a/README.docker.md b/README.docker.md index 64a734204..e271d15e4 100644 --- a/README.docker.md +++ b/README.docker.md @@ -100,6 +100,9 @@ The new unified e2e test environment runs **everything in Docker** - website, AP # Run complete e2e test suite npm run test:e2e:website +# Run specific test file (fast, no rebuild) +npm run test:e2e:run -- tests/e2e/website/website-pages.e2e.test.ts + # Or step-by-step: npm run docker:e2e:up # Start all services (fast, uses cache) npm run docker:e2e:build # Force rebuild website image diff --git a/apps/api/src/domain/league/LeagueService.ts b/apps/api/src/domain/league/LeagueService.ts index a39ec2963..67bf23336 100644 --- a/apps/api/src/domain/league/LeagueService.ts +++ b/apps/api/src/domain/league/LeagueService.ts @@ -777,7 +777,14 @@ export class LeagueService { throw new Error(fullConfigResult.unwrapErr().code); } - await this.getLeagueOwnerSummaryUseCase.execute({ leagueId }); + // Present the full config result + this.leagueConfigPresenter.present(fullConfigResult.unwrap()); + + const ownerSummaryResult = await this.getLeagueOwnerSummaryUseCase.execute({ leagueId }); + if (ownerSummaryResult.isErr()) { + throw new Error(ownerSummaryResult.unwrapErr().code); + } + this.getLeagueOwnerSummaryPresenter.present(ownerSummaryResult.unwrap()); const ownerSummary = this.getLeagueOwnerSummaryPresenter.getViewModel()!; const configForm = this.leagueConfigPresenter.getViewModel(); diff --git a/apps/api/src/domain/race/RaceProviders.ts b/apps/api/src/domain/race/RaceProviders.ts index 92be8a051..054ff0f06 100644 --- a/apps/api/src/domain/race/RaceProviders.ts +++ b/apps/api/src/domain/race/RaceProviders.ts @@ -113,7 +113,7 @@ export const RaceProviders: Provider[] = [ { provide: RACE_DETAIL_PRESENTER_TOKEN, useFactory: (driverRatingProvider: DriverRatingProvider, imageService: InMemoryImageServiceAdapter) => - new RaceDetailPresenter(driverRatingProvider, imageService, { raceId: '', driverId: '' }), + new RaceDetailPresenter(driverRatingProvider, imageService), inject: [DRIVER_RATING_PROVIDER_TOKEN, IMAGE_SERVICE_TOKEN], }, { diff --git a/apps/api/src/domain/race/RaceService.ts b/apps/api/src/domain/race/RaceService.ts index 4f01d6ee3..c3dc3146e 100644 --- a/apps/api/src/domain/race/RaceService.ts +++ b/apps/api/src/domain/race/RaceService.ts @@ -166,7 +166,7 @@ export class RaceService { } const value = result.unwrap(); - this.raceDetailPresenter.present(value); + this.raceDetailPresenter.present(value, params); return this.raceDetailPresenter; } diff --git a/apps/api/src/domain/race/presenters/RaceDetailPresenter.ts b/apps/api/src/domain/race/presenters/RaceDetailPresenter.ts index d225d3c36..75dbe0a32 100644 --- a/apps/api/src/domain/race/presenters/RaceDetailPresenter.ts +++ b/apps/api/src/domain/race/presenters/RaceDetailPresenter.ts @@ -13,19 +13,20 @@ export type GetRaceDetailResponseModel = RaceDetailDTO; export class RaceDetailPresenter { private result: GetRaceDetailResult | null = null; + private params: GetRaceDetailParamsDTO | null = null; constructor( private readonly driverRatingProvider: DriverRatingProvider, private readonly imageService: ImageServicePort, - private readonly params: GetRaceDetailParamsDTO, ) {} - present(result: GetRaceDetailResult): void { + present(result: GetRaceDetailResult, params: GetRaceDetailParamsDTO): void { this.result = result; + this.params = params; } async getResponseModel(): Promise { - if (!this.result) { + if (!this.result || !this.params) { return null; } diff --git a/apps/website/Dockerfile.e2e b/apps/website/Dockerfile.e2e index d0aa6d9c7..28ee10f3a 100644 --- a/apps/website/Dockerfile.e2e +++ b/apps/website/Dockerfile.e2e @@ -42,7 +42,7 @@ COPY core ./core COPY adapters ./adapters COPY apps/website ./apps/website COPY scripts ./scripts -COPY *.json *.js *.ts *.md ./ +COPY tsconfig.json tsconfig.base.json .eslintrc.json ./ # Set environment variables for build ENV NODE_ENV=${NODE_ENV} diff --git a/apps/website/app/auth/forgot-password/page.tsx b/apps/website/app/auth/forgot-password/page.tsx index 8aff8cde0..02c9d63d1 100644 --- a/apps/website/app/auth/forgot-password/page.tsx +++ b/apps/website/app/auth/forgot-password/page.tsx @@ -13,7 +13,7 @@ import { AuthError } from '@/ui/AuthError'; export default async function ForgotPasswordPage({ searchParams, }: { - searchParams: Promise; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { // Execute PageQuery const params = await searchParams; diff --git a/apps/website/app/auth/login/page.tsx b/apps/website/app/auth/login/page.tsx index 2a1badc18..a1cf3381f 100644 --- a/apps/website/app/auth/login/page.tsx +++ b/apps/website/app/auth/login/page.tsx @@ -13,7 +13,7 @@ import { AuthError } from '@/ui/AuthError'; export default async function LoginPage({ searchParams, }: { - searchParams: Promise; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { // Execute PageQuery const params = await searchParams; diff --git a/apps/website/app/auth/reset-password/page.tsx b/apps/website/app/auth/reset-password/page.tsx index 6892d8c75..816c3553c 100644 --- a/apps/website/app/auth/reset-password/page.tsx +++ b/apps/website/app/auth/reset-password/page.tsx @@ -13,7 +13,7 @@ import { AuthError } from '@/ui/AuthError'; export default async function ResetPasswordPage({ searchParams, }: { - searchParams: Promise; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { // Execute PageQuery const params = await searchParams; diff --git a/apps/website/app/auth/signup/page.tsx b/apps/website/app/auth/signup/page.tsx index 5645fe3a3..47901c558 100644 --- a/apps/website/app/auth/signup/page.tsx +++ b/apps/website/app/auth/signup/page.tsx @@ -13,7 +13,7 @@ import { AuthError } from '@/ui/AuthError'; export default async function SignupPage({ searchParams, }: { - searchParams: Promise; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { // Execute PageQuery const params = await searchParams; diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx index b9d45299e..92700b29e 100644 --- a/apps/website/app/drivers/[id]/page.tsx +++ b/apps/website/app/drivers/[id]/page.tsx @@ -3,8 +3,9 @@ import { routes } from '@/lib/routing/RouteConfig'; import { DriverProfilePageQuery } from '@/lib/page-queries/DriverProfilePageQuery'; import { DriverProfilePageClient } from './DriverProfilePageClient'; -export default async function DriverProfilePage({ params }: { params: { id: string } }) { - const result = await DriverProfilePageQuery.execute(params.id); +export default async function DriverProfilePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const result = await DriverProfilePageQuery.execute(id); if (result.isErr()) { const error = result.getError(); diff --git a/apps/website/app/leagues/[id]/layout.tsx b/apps/website/app/leagues/[id]/layout.tsx index 5e4331557..6e719fbc4 100644 --- a/apps/website/app/leagues/[id]/layout.tsx +++ b/apps/website/app/leagues/[id]/layout.tsx @@ -9,9 +9,9 @@ export default async function LeagueLayout({ params, }: { children: React.ReactNode; - params: { id: string }; + params: Promise<{ id: string }>; }) { - const leagueId = params.id; + const { id: leagueId } = await params; // Execute PageQuery to get league data const result = await LeagueDetailPageQuery.execute(leagueId); diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index fa9995848..242d94b2c 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -5,12 +5,13 @@ import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDeta import { ErrorBanner } from '@/ui/ErrorBanner'; interface Props { - params: { id: string }; + params: Promise<{ id: string }>; } export default async function Page({ params }: Props) { + const { id } = await params; // Execute the PageQuery - const result = await LeagueDetailPageQuery.execute(params.id); + const result = await LeagueDetailPageQuery.execute(id); // Handle different result types if (result.isErr()) { diff --git a/apps/website/app/leagues/[id]/rulebook/page.tsx b/apps/website/app/leagues/[id]/rulebook/page.tsx index 863b361d5..4cd4aa8aa 100644 --- a/apps/website/app/leagues/[id]/rulebook/page.tsx +++ b/apps/website/app/leagues/[id]/rulebook/page.tsx @@ -3,11 +3,11 @@ import { RulebookTemplate } from '@/templates/RulebookTemplate'; import { notFound } from 'next/navigation'; interface Props { - params: { id: string }; + params: Promise<{ id: string }>; } export default async function Page({ params }: Props) { - const leagueId = params.id; + const { id: leagueId } = await params; if (!leagueId) { notFound(); diff --git a/apps/website/app/leagues/[id]/schedule/page.tsx b/apps/website/app/leagues/[id]/schedule/page.tsx index 149ae86ab..88a05d3d1 100644 --- a/apps/website/app/leagues/[id]/schedule/page.tsx +++ b/apps/website/app/leagues/[id]/schedule/page.tsx @@ -3,11 +3,11 @@ import { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate'; import { notFound } from 'next/navigation'; interface Props { - params: { id: string }; + params: Promise<{ id: string }>; } export default async function LeagueSchedulePage({ params }: Props) { - const leagueId = params.id; + const { id: leagueId } = await params; if (!leagueId) { notFound(); diff --git a/apps/website/app/leagues/[id]/settings/page.tsx b/apps/website/app/leagues/[id]/settings/page.tsx index a7de6bc0d..f9e69c666 100644 --- a/apps/website/app/leagues/[id]/settings/page.tsx +++ b/apps/website/app/leagues/[id]/settings/page.tsx @@ -3,11 +3,11 @@ import { LeagueSettingsTemplate } from '@/templates/LeagueSettingsTemplate'; import { notFound } from 'next/navigation'; interface Props { - params: { id: string }; + params: Promise<{ id: string }>; } export default async function LeagueSettingsPage({ params }: Props) { - const leagueId = params.id; + const { id: leagueId } = await params; if (!leagueId) { notFound(); diff --git a/apps/website/app/leagues/[id]/sponsorships/page.tsx b/apps/website/app/leagues/[id]/sponsorships/page.tsx index 125fae27d..349dcceed 100644 --- a/apps/website/app/leagues/[id]/sponsorships/page.tsx +++ b/apps/website/app/leagues/[id]/sponsorships/page.tsx @@ -3,11 +3,11 @@ import { LeagueSponsorshipsTemplate } from '@/templates/LeagueSponsorshipsTempla import { notFound } from 'next/navigation'; interface Props { - params: { id: string }; + params: Promise<{ id: string }>; } export default async function LeagueSponsorshipsPage({ params }: Props) { - const leagueId = params.id; + const { id: leagueId } = await params; if (!leagueId) { notFound(); diff --git a/apps/website/app/leagues/[id]/standings/page.tsx b/apps/website/app/leagues/[id]/standings/page.tsx index 339f0f857..eebdf88c5 100644 --- a/apps/website/app/leagues/[id]/standings/page.tsx +++ b/apps/website/app/leagues/[id]/standings/page.tsx @@ -3,11 +3,11 @@ import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate'; import { notFound } from 'next/navigation'; interface Props { - params: { id: string }; + params: Promise<{ id: string }>; } export default async function Page({ params }: Props) { - const leagueId = params.id; + const { id: leagueId } = await params; if (!leagueId) { notFound(); diff --git a/apps/website/app/leagues/[id]/stewarding/page.tsx b/apps/website/app/leagues/[id]/stewarding/page.tsx index f151c5407..9b71fa107 100644 --- a/apps/website/app/leagues/[id]/stewarding/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/page.tsx @@ -3,11 +3,11 @@ import { StewardingPageClient } from './StewardingPageClient'; import { notFound } from 'next/navigation'; interface Props { - params: { id: string }; + params: Promise<{ id: string }>; } export default async function LeagueStewardingPage({ params }: Props) { - const leagueId = params.id; + const { id: leagueId } = await params; if (!leagueId) { notFound(); diff --git a/apps/website/app/media/avatar/[driverId]/route.ts b/apps/website/app/media/avatar/[driverId]/route.ts index 17db6994a..3dff1c971 100644 --- a/apps/website/app/media/avatar/[driverId]/route.ts +++ b/apps/website/app/media/avatar/[driverId]/route.ts @@ -3,9 +3,9 @@ import { GetAvatarPageQuery } from '@/lib/page-queries/media/GetAvatarPageQuery' export async function GET( request: NextRequest, - { params }: { params: { driverId: string } } + { params }: { params: Promise<{ driverId: string }> } ) { - const { driverId } = params; + const { driverId } = await params; const result = await GetAvatarPageQuery.execute({ driverId }); diff --git a/apps/website/app/media/categories/[categoryId]/icon/route.ts b/apps/website/app/media/categories/[categoryId]/icon/route.ts index 440d642ab..71d67eace 100644 --- a/apps/website/app/media/categories/[categoryId]/icon/route.ts +++ b/apps/website/app/media/categories/[categoryId]/icon/route.ts @@ -3,9 +3,9 @@ import { GetCategoryIconPageQuery } from '@/lib/page-queries/media/GetCategoryIc export async function GET( request: NextRequest, - { params }: { params: { categoryId: string } } + { params }: { params: Promise<{ categoryId: string }> } ) { - const { categoryId } = params; + const { categoryId } = await params; const result = await GetCategoryIconPageQuery.execute({ categoryId }); diff --git a/apps/website/app/media/leagues/[leagueId]/cover/route.ts b/apps/website/app/media/leagues/[leagueId]/cover/route.ts index f1b12c4d0..bd15f83fe 100644 --- a/apps/website/app/media/leagues/[leagueId]/cover/route.ts +++ b/apps/website/app/media/leagues/[leagueId]/cover/route.ts @@ -3,9 +3,9 @@ import { GetLeagueCoverPageQuery } from '@/lib/page-queries/media/GetLeagueCover export async function GET( request: NextRequest, - { params }: { params: { leagueId: string } } + { params }: { params: Promise<{ leagueId: string }> } ) { - const { leagueId } = params; + const { leagueId } = await params; const result = await GetLeagueCoverPageQuery.execute({ leagueId }); diff --git a/apps/website/app/media/leagues/[leagueId]/logo/route.ts b/apps/website/app/media/leagues/[leagueId]/logo/route.ts index 3537d5648..d0546d6da 100644 --- a/apps/website/app/media/leagues/[leagueId]/logo/route.ts +++ b/apps/website/app/media/leagues/[leagueId]/logo/route.ts @@ -3,9 +3,9 @@ import { GetLeagueLogoPageQuery } from '@/lib/page-queries/media/GetLeagueLogoPa export async function GET( request: NextRequest, - { params }: { params: { leagueId: string } } + { params }: { params: Promise<{ leagueId: string }> } ) { - const { leagueId } = params; + const { leagueId } = await params; const result = await GetLeagueLogoPageQuery.execute({ leagueId }); diff --git a/apps/website/app/media/sponsors/[sponsorId]/logo/route.ts b/apps/website/app/media/sponsors/[sponsorId]/logo/route.ts index fc281fb74..af3bb70a4 100644 --- a/apps/website/app/media/sponsors/[sponsorId]/logo/route.ts +++ b/apps/website/app/media/sponsors/[sponsorId]/logo/route.ts @@ -3,9 +3,9 @@ import { GetSponsorLogoPageQuery } from '@/lib/page-queries/media/GetSponsorLogo export async function GET( request: NextRequest, - { params }: { params: { sponsorId: string } } + { params }: { params: Promise<{ sponsorId: string }> } ) { - const { sponsorId } = params; + const { sponsorId } = await params; const result = await GetSponsorLogoPageQuery.execute({ sponsorId }); diff --git a/apps/website/app/media/teams/[teamId]/logo/route.ts b/apps/website/app/media/teams/[teamId]/logo/route.ts index 764881d8e..03af9ca7b 100644 --- a/apps/website/app/media/teams/[teamId]/logo/route.ts +++ b/apps/website/app/media/teams/[teamId]/logo/route.ts @@ -3,9 +3,9 @@ import { GetTeamLogoPageQuery } from '@/lib/page-queries/media/GetTeamLogoPageQu export async function GET( request: NextRequest, - { params }: { params: { teamId: string } } + { params }: { params: Promise<{ teamId: string }> } ) { - const { teamId } = params; + const { teamId } = await params; const result = await GetTeamLogoPageQuery.execute({ teamId }); diff --git a/apps/website/app/media/tracks/[trackId]/image/route.ts b/apps/website/app/media/tracks/[trackId]/image/route.ts index 7d1fc7057..1475085e3 100644 --- a/apps/website/app/media/tracks/[trackId]/image/route.ts +++ b/apps/website/app/media/tracks/[trackId]/image/route.ts @@ -3,9 +3,9 @@ import { GetTrackImagePageQuery } from '@/lib/page-queries/media/GetTrackImagePa export async function GET( request: NextRequest, - { params }: { params: { trackId: string } } + { params }: { params: Promise<{ trackId: string }> } ) { - const { trackId } = params; + const { trackId } = await params; const result = await GetTrackImagePageQuery.execute({ trackId }); diff --git a/apps/website/app/races/[id]/RaceDetailPageClient.tsx b/apps/website/app/races/[id]/RaceDetailPageClient.tsx index 5e3e8d895..ad1d7b2a3 100644 --- a/apps/website/app/races/[id]/RaceDetailPageClient.tsx +++ b/apps/website/app/races/[id]/RaceDetailPageClient.tsx @@ -1,83 +1,79 @@ 'use client'; -import React, { useState, useEffect } from 'react'; -import { RaceDetailTemplate, type RaceDetailViewData } from '@/templates/RaceDetailTemplate'; +import React, { useState, useCallback } from 'react'; +import { RaceDetailTemplate, RaceDetailViewData } from '@/templates/RaceDetailTemplate'; +import { useRouter } from 'next/navigation'; -interface RaceDetailPageClientProps { - viewData: RaceDetailViewData; - onBack: () => void; - onRegister: () => void; - onWithdraw: () => void; - onCancel: () => void; - onReopen: () => void; - onEndRace: () => void; - onFileProtest: () => void; - onResultsClick: () => void; - onStewardingClick: () => void; - onLeagueClick: (id: string) => void; - onDriverClick: (id: string) => void; - isOwnerOrAdmin: boolean; +interface Props { + data: RaceDetailViewData; } -export function RaceDetailPageClient({ - viewData, - onBack, - onRegister, - onWithdraw, - onCancel, - onReopen, - onEndRace, - onFileProtest, - onResultsClick, - onStewardingClick, - onLeagueClick, - onDriverClick, - isOwnerOrAdmin -}: RaceDetailPageClientProps) { - const [animatedRatingChange, setAnimatedRatingChange] = useState(0); +export default function RaceDetailPageClient({ data: viewData }: Props) { + const router = useRouter(); + const [animatedRatingChange] = useState(0); - const ratingChange = viewData.userResult?.ratingChange ?? null; + const handleBack = useCallback(() => { + router.back(); + }, [router]); - useEffect(() => { - if (ratingChange !== null) { - let start = 0; - const end = ratingChange; - const duration = 1000; - const startTime = performance.now(); + const handleRegister = useCallback(() => { + console.log('Register'); + }, []); - const animate = (currentTime: number) => { - const elapsed = currentTime - startTime; - const progress = Math.min(elapsed / duration, 1); - const eased = 1 - Math.pow(1 - progress, 3); - const current = Math.round(start + (end - start) * eased); - setAnimatedRatingChange(current); + const handleWithdraw = useCallback(() => { + console.log('Withdraw'); + }, []); - if (progress < 1) { - requestAnimationFrame(animate); - } - }; + const handleCancel = useCallback(() => { + console.log('Cancel'); + }, []); - requestAnimationFrame(animate); - } - }, [ratingChange]); + const handleReopen = useCallback(() => { + console.log('Reopen'); + }, []); + + const handleEndRace = useCallback(() => { + console.log('End Race'); + }, []); + + const handleFileProtest = useCallback(() => { + console.log('File Protest'); + }, []); + + const handleResultsClick = useCallback(() => { + router.push(`/races/${viewData.race.id}/results`); + }, [router, viewData.race.id]); + + const handleStewardingClick = useCallback(() => { + router.push(`/races/${viewData.race.id}/stewarding`); + }, [router, viewData.race.id]); + + const handleLeagueClick = useCallback((leagueId: string) => { + router.push(`/leagues/${leagueId}`); + }, [router]); + + const handleDriverClick = useCallback((driverId: string) => { + router.push(`/drivers/${driverId}`); + }, [router]); return ( ); } diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index 01475fa2b..de3464099 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -1,16 +1,16 @@ import { notFound } from 'next/navigation'; import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate'; import { RaceDetailPageQuery } from '@/lib/page-queries/races/RaceDetailPageQuery'; +import RaceDetailPageClient from './RaceDetailPageClient'; interface RaceDetailPageProps { - params: { + params: Promise<{ id: string; - }; + }>; } export default async function RaceDetailPage({ params }: RaceDetailPageProps) { - const raceId = params.id; + const { id: raceId } = await params; if (!raceId) { notFound(); @@ -22,54 +22,17 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) { if (result.isErr()) { const error = result.getError(); - switch (error) { - case 'notFound': - notFound(); - case 'redirect': - notFound(); - default: - // Pass error to template via PageWrapper - return ( - ( - {}} - onRegister={() => {}} - onWithdraw={() => {}} - onCancel={() => {}} - onReopen={() => {}} - onEndRace={() => {}} - onFileProtest={() => {}} - onResultsClick={() => {}} - onStewardingClick={() => {}} - onLeagueClick={() => {}} - onDriverClick={() => {}} - isOwnerOrAdmin={false} - animatedRatingChange={0} - mutationLoading={{ - register: false, - withdraw: false, - cancel: false, - reopen: false, - complete: false, - }} - /> - )} - loading={{ variant: 'skeleton', message: 'Loading race details...' }} - errorConfig={{ variant: 'full-screen' }} - empty={{ - icon: require('lucide-react').Flag, - title: 'Race not found', - description: 'The race may have been cancelled or deleted', - action: { label: 'Back to Races', onClick: () => {} } - }} - /> - ); + if (error === 'notFound') { + notFound(); } + // For other errors, let PageWrapper handle it + return ( + + ); } const viewData = result.unwrap(); @@ -77,41 +40,7 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) { return ( ( - {}} - onRegister={() => {}} - onWithdraw={() => {}} - onCancel={() => {}} - onReopen={() => {}} - onEndRace={() => {}} - onFileProtest={() => {}} - onResultsClick={() => {}} - onStewardingClick={() => {}} - onLeagueClick={() => {}} - onDriverClick={() => {}} - isOwnerOrAdmin={false} - animatedRatingChange={0} - mutationLoading={{ - register: false, - withdraw: false, - cancel: false, - reopen: false, - complete: false, - }} - /> - )} - loading={{ variant: 'skeleton', message: 'Loading race details...' }} - errorConfig={{ variant: 'full-screen' }} - empty={{ - icon: require('lucide-react').Flag, - title: 'Race not found', - description: 'The race may have been cancelled or deleted', - action: { label: 'Back to Races', onClick: () => {} } - }} + Template={RaceDetailPageClient} /> ); } \ No newline at end of file diff --git a/apps/website/app/races/[id]/results/RaceResultsPageClient.tsx b/apps/website/app/races/[id]/results/RaceResultsPageClient.tsx new file mode 100644 index 000000000..958a0df1c --- /dev/null +++ b/apps/website/app/races/[id]/results/RaceResultsPageClient.tsx @@ -0,0 +1,57 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate'; +import { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData'; +import { useRouter } from 'next/navigation'; + +interface Props { + data: RaceResultsViewData; +} + +export default function RaceResultsPageClient({ data: viewData }: Props) { + const router = useRouter(); + const [importing, setImporting] = useState(false); + const [importSuccess, setImportSuccess] = useState(false); + const [importError, setImportError] = useState(null); + const [showImportForm, setShowImportForm] = useState(false); + + const handleBack = useCallback(() => { + router.back(); + }, [router]); + + const handleImportResults = useCallback(async () => { + setImporting(true); + setImportError(null); + try { + // Mock import + await new Promise(resolve => setTimeout(resolve, 1000)); + setImportSuccess(true); + } catch (err) { + setImportError('Failed to import results'); + } finally { + setImporting(false); + } + }, []); + + const handlePenaltyClick = useCallback(() => { + console.log('Penalty click'); + }, []); + + return ( + + ); +} diff --git a/apps/website/app/races/[id]/results/page.tsx b/apps/website/app/races/[id]/results/page.tsx index c033bbec8..46569f8ec 100644 --- a/apps/website/app/races/[id]/results/page.tsx +++ b/apps/website/app/races/[id]/results/page.tsx @@ -1,17 +1,16 @@ import { notFound } from 'next/navigation'; import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; -import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate'; import { RaceResultsPageQuery } from '@/lib/page-queries/races/RaceResultsPageQuery'; -import { Trophy } from 'lucide-react'; +import RaceResultsPageClient from './RaceResultsPageClient'; interface RaceResultsPageProps { - params: { + params: Promise<{ id: string; - }; + }>; } export default async function RaceResultsPage({ params }: RaceResultsPageProps) { - const raceId = params.id; + const { id: raceId } = await params; if (!raceId) { notFound(); @@ -23,56 +22,18 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps) if (result.isErr()) { const error = result.getError(); - switch (error) { - case 'notFound': - notFound(); - case 'redirect': - notFound(); - default: - // Pass error to template via StatefulPageWrapper - return ( - Promise.resolve()} - Template={() => ( - {}} - onImportResults={() => Promise.resolve()} - onPenaltyClick={() => {}} - importing={false} - importSuccess={false} - importError={null} - showImportForm={false} - setShowImportForm={() => {}} - /> - )} - loading={{ variant: 'skeleton', message: 'Loading race results...' }} - errorConfig={{ variant: 'full-screen' }} - empty={{ - icon: Trophy, - title: 'No results available', - description: 'Race results will appear here once the race is completed', - action: { label: 'Back to Race', onClick: () => {} } - }} - /> - ); + if (error === 'notFound') { + notFound(); } + // For other errors, let StatefulPageWrapper handle it + return ( + Promise.resolve()} + /> + ); } const viewData = result.unwrap(); @@ -80,33 +41,8 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps) return ( Promise.resolve()} - Template={() => ( - {}} - onImportResults={() => Promise.resolve()} - onPenaltyClick={() => {}} - importing={false} - importSuccess={false} - importError={null} - showImportForm={false} - setShowImportForm={() => {}} - /> - )} - loading={{ variant: 'skeleton', message: 'Loading race results...' }} - errorConfig={{ variant: 'full-screen' }} - empty={{ - icon: Trophy, - title: 'No results available', - description: 'Race results will appear here once the race is completed', - action: { label: 'Back to Race', onClick: () => {} } - }} /> ); } diff --git a/apps/website/app/races/[id]/stewarding/page.tsx b/apps/website/app/races/[id]/stewarding/page.tsx index c8c06c141..f0b8ed925 100644 --- a/apps/website/app/races/[id]/stewarding/page.tsx +++ b/apps/website/app/races/[id]/stewarding/page.tsx @@ -6,16 +6,16 @@ import { RaceStewardingTemplate, type StewardingTab } from '@/templates/RaceStew import { RaceStewardingPageQuery } from '@/lib/page-queries/races/RaceStewardingPageQuery'; import { type RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData'; import { Gavel } from 'lucide-react'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, use } from 'react'; interface RaceStewardingPageProps { - params: { + params: Promise<{ id: string; - }; + }>; } export default function RaceStewardingPage({ params }: RaceStewardingPageProps) { - const raceId = params.id; + const { id: raceId } = use(params); const [activeTab, setActiveTab] = useState('pending'); if (!raceId) { diff --git a/apps/website/app/sponsor/leagues/[id]/page.tsx b/apps/website/app/sponsor/leagues/[id]/page.tsx index c82836a60..5de46d130 100644 --- a/apps/website/app/sponsor/leagues/[id]/page.tsx +++ b/apps/website/app/sponsor/leagues/[id]/page.tsx @@ -6,7 +6,8 @@ import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporte import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; -export default async function Page({ params }: { params: { id: string } }) { +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; // Manual wiring: create dependencies const baseUrl = getWebsiteApiBaseUrl(); const logger = new ConsoleLogger(); @@ -20,7 +21,7 @@ export default async function Page({ params }: { params: { id: string } }) { const apiClient = new SponsorsApiClient(baseUrl, errorReporter, logger); // Fetch data - const data = await apiClient.getLeagueDetail(params.id); + const data = await apiClient.getLeagueDetail(id); if (!data) notFound(); diff --git a/apps/website/app/teams/[id]/page.tsx b/apps/website/app/teams/[id]/page.tsx index c652ed70b..ac22cbcd0 100644 --- a/apps/website/app/teams/[id]/page.tsx +++ b/apps/website/app/teams/[id]/page.tsx @@ -2,8 +2,9 @@ import { notFound } from 'next/navigation'; import { TeamDetailPageQuery } from '@/lib/page-queries/TeamDetailPageQuery'; import { TeamDetailPageClient } from './TeamDetailPageClient'; -export default async function Page({ params }: { params: { id: string } }) { - const result = await TeamDetailPageQuery.execute(params.id); +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const result = await TeamDetailPageQuery.execute(id); if (result.isErr()) { const error = result.getError(); diff --git a/apps/website/lib/page-queries/auth/ForgotPasswordPageQuery.ts b/apps/website/lib/page-queries/auth/ForgotPasswordPageQuery.ts index 326b08b55..98daf89fa 100644 --- a/apps/website/lib/page-queries/auth/ForgotPasswordPageQuery.ts +++ b/apps/website/lib/page-queries/auth/ForgotPasswordPageQuery.ts @@ -5,8 +5,8 @@ import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPas import { AuthPageService } from '@/lib/services/auth/AuthPageService'; import { SearchParamParser } from '@/lib/routing/search-params/SearchParamParser'; -export class ForgotPasswordPageQuery implements PageQuery { - async execute(searchParams: URLSearchParams): Promise> { +export class ForgotPasswordPageQuery implements PageQuery> { + async execute(searchParams: URLSearchParams | Record): Promise> { // Parse and validate search parameters const parsedResult = SearchParamParser.parseAuth(searchParams); if (parsedResult.isErr()) { @@ -33,7 +33,7 @@ export class ForgotPasswordPageQuery implements PageQuery> { + static async execute(searchParams: URLSearchParams | Record): Promise> { const query = new ForgotPasswordPageQuery(); return query.execute(searchParams); } diff --git a/apps/website/lib/page-queries/auth/LoginPageQuery.ts b/apps/website/lib/page-queries/auth/LoginPageQuery.ts index cb5fe1557..b871e87b5 100644 --- a/apps/website/lib/page-queries/auth/LoginPageQuery.ts +++ b/apps/website/lib/page-queries/auth/LoginPageQuery.ts @@ -5,8 +5,8 @@ import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData'; import { AuthPageService } from '@/lib/services/auth/AuthPageService'; import { SearchParamParser } from '@/lib/routing/search-params/SearchParamParser'; -export class LoginPageQuery implements PageQuery { - async execute(searchParams: URLSearchParams): Promise> { +export class LoginPageQuery implements PageQuery> { + async execute(searchParams: URLSearchParams | Record): Promise> { // Parse and validate search parameters const parsedResult = SearchParamParser.parseAuth(searchParams); if (parsedResult.isErr()) { @@ -33,7 +33,7 @@ export class LoginPageQuery implements PageQuery } // Static factory method for convenience - static async execute(searchParams: URLSearchParams): Promise> { + static async execute(searchParams: URLSearchParams | Record): Promise> { const query = new LoginPageQuery(); return query.execute(searchParams); } diff --git a/apps/website/lib/page-queries/auth/ResetPasswordPageQuery.ts b/apps/website/lib/page-queries/auth/ResetPasswordPageQuery.ts index e0022742e..61e48bde6 100644 --- a/apps/website/lib/page-queries/auth/ResetPasswordPageQuery.ts +++ b/apps/website/lib/page-queries/auth/ResetPasswordPageQuery.ts @@ -5,8 +5,8 @@ import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPassw import { AuthPageService } from '@/lib/services/auth/AuthPageService'; import { SearchParamParser } from '@/lib/routing/search-params/SearchParamParser'; -export class ResetPasswordPageQuery implements PageQuery { - async execute(searchParams: URLSearchParams): Promise> { +export class ResetPasswordPageQuery implements PageQuery> { + async execute(searchParams: URLSearchParams | Record): Promise> { // Parse and validate search parameters const parsedResult = SearchParamParser.parseAuth(searchParams); if (parsedResult.isErr()) { @@ -33,7 +33,7 @@ export class ResetPasswordPageQuery implements PageQuery> { + static async execute(searchParams: URLSearchParams | Record): Promise> { const query = new ResetPasswordPageQuery(); return query.execute(searchParams); } diff --git a/apps/website/lib/page-queries/auth/SignupPageQuery.ts b/apps/website/lib/page-queries/auth/SignupPageQuery.ts index 209515574..51dabb446 100644 --- a/apps/website/lib/page-queries/auth/SignupPageQuery.ts +++ b/apps/website/lib/page-queries/auth/SignupPageQuery.ts @@ -5,8 +5,8 @@ import { PageQuery } from '@/lib/contracts/page-queries/PageQuery'; import { AuthPageService } from '@/lib/services/auth/AuthPageService'; import { SearchParamParser } from '@/lib/routing/search-params/SearchParamParser'; -export class SignupPageQuery implements PageQuery { - async execute(searchParams: URLSearchParams): Promise> { +export class SignupPageQuery implements PageQuery> { + async execute(searchParams: URLSearchParams | Record): Promise> { // Parse and validate search parameters const parsedResult = SearchParamParser.parseAuth(searchParams); if (parsedResult.isErr()) { @@ -33,7 +33,7 @@ export class SignupPageQuery implements PageQuery> { + static async execute(searchParams: URLSearchParams | Record): Promise> { const query = new SignupPageQuery(); return query.execute(searchParams); } diff --git a/apps/website/lib/routing/search-params/SearchParamParser.ts b/apps/website/lib/routing/search-params/SearchParamParser.ts index 6493ade47..1f91be6c4 100644 --- a/apps/website/lib/routing/search-params/SearchParamParser.ts +++ b/apps/website/lib/routing/search-params/SearchParamParser.ts @@ -42,11 +42,22 @@ export interface ParsedWizardParams { } export class SearchParamParser { + private static getParam(params: URLSearchParams | Record, key: string): string | null { + if (params instanceof URLSearchParams) { + return params.get(key); + } + const value = params[key]; + if (Array.isArray(value)) { + return value[0] ?? null; + } + return value ?? null; + } + // Parse auth parameters - static parseAuth(params: URLSearchParams): Result { + static parseAuth(params: URLSearchParams | Record): Result { const errors: string[] = []; - const returnTo = params.get('returnTo'); + const returnTo = this.getParam(params, 'returnTo'); if (returnTo !== null) { const validation = SearchParamValidators.validateReturnTo(returnTo); if (!validation.isValid) { @@ -54,7 +65,7 @@ export class SearchParamParser { } } - const token = params.get('token'); + const token = this.getParam(params, 'token'); if (token !== null) { const validation = SearchParamValidators.validateToken(token); if (!validation.isValid) { @@ -62,7 +73,7 @@ export class SearchParamParser { } } - const email = params.get('email'); + const email = this.getParam(params, 'email'); if (email !== null) { const validation = SearchParamValidators.validateEmail(email); if (!validation.isValid) { @@ -75,19 +86,19 @@ export class SearchParamParser { } return Result.ok({ - returnTo: params.get('returnTo'), - token: params.get('token'), - email: params.get('email'), - error: params.get('error'), - message: params.get('message'), + returnTo: this.getParam(params, 'returnTo'), + token: this.getParam(params, 'token'), + email: this.getParam(params, 'email'), + error: this.getParam(params, 'error'), + message: this.getParam(params, 'message'), }); } // Parse sponsor parameters - static parseSponsor(params: URLSearchParams): Result { + static parseSponsor(params: URLSearchParams | Record): Result { const errors: string[] = []; - const type = params.get('type'); + const type = this.getParam(params, 'type'); if (type !== null) { const validation = SearchParamValidators.validateCampaignType(type); if (!validation.isValid) { @@ -100,17 +111,17 @@ export class SearchParamParser { } return Result.ok({ - type: params.get('type'), - campaignId: params.get('campaignId'), + type: this.getParam(params, 'type'), + campaignId: this.getParam(params, 'campaignId'), }); } // Parse pagination parameters - static parsePagination(params: URLSearchParams): Result { + static parsePagination(params: URLSearchParams | Record): Result { const result: ParsedPaginationParams = {}; const errors: string[] = []; - const page = params.get('page'); + const page = this.getParam(params, 'page'); if (page !== null) { const validation = SearchParamValidators.validatePage(page); if (!validation.isValid) { @@ -120,7 +131,7 @@ export class SearchParamParser { } } - const limit = params.get('limit'); + const limit = this.getParam(params, 'limit'); if (limit !== null) { const validation = SearchParamValidators.validateLimit(limit); if (!validation.isValid) { @@ -130,7 +141,7 @@ export class SearchParamParser { } } - const offset = params.get('offset'); + const offset = this.getParam(params, 'offset'); if (offset !== null) { const num = parseInt(offset); if (!isNaN(num)) { @@ -146,10 +157,10 @@ export class SearchParamParser { } // Parse sorting parameters - static parseSorting(params: URLSearchParams): Result { + static parseSorting(params: URLSearchParams | Record): Result { const errors: string[] = []; - const order = params.get('order'); + const order = this.getParam(params, 'order'); if (order !== null) { const validation = SearchParamValidators.validateOrder(order); if (!validation.isValid) { @@ -162,29 +173,29 @@ export class SearchParamParser { } return Result.ok({ - sortBy: params.get('sortBy'), - order: (params.get('order') as 'asc' | 'desc') || undefined, + sortBy: this.getParam(params, 'sortBy'), + order: (this.getParam(params, 'order') as 'asc' | 'desc') || undefined, }); } // Parse filter parameters - static parseFilters(params: URLSearchParams): Result { + static parseFilters(params: URLSearchParams | Record): Result { return Result.ok({ - status: params.get('status'), - role: params.get('role'), - tier: params.get('tier'), + status: this.getParam(params, 'status'), + role: this.getParam(params, 'role'), + tier: this.getParam(params, 'tier'), }); } // Parse wizard parameters - static parseWizard(params: URLSearchParams): Result { + static parseWizard(params: URLSearchParams | Record): Result { return Result.ok({ - step: params.get('step'), + step: this.getParam(params, 'step'), }); } // Parse all parameters at once - static parseAll(params: URLSearchParams): Result< + static parseAll(params: URLSearchParams | Record): Result< { auth: ParsedAuthParams; sponsor: ParsedSponsorParams; diff --git a/package.json b/package.json index 5d67e3116..d11100500 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "test:companion-hosted": "vitest run --config vitest.e2e.config.ts tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts", "test:contract:compatibility": "tsx scripts/contract-compatibility.ts", "test:contracts": "tsx scripts/run-contract-tests.ts", + "test:e2e:run": "sh -lc \"npm run docker:e2e:up && echo '[e2e] Running Playwright tests...'; docker-compose -f docker-compose.e2e.yml run --rm playwright npx playwright test\"", "test:e2e:website": "sh -lc \"set -e; trap 'npm run docker:e2e:down' EXIT; npm run docker:e2e:up && echo '[e2e] Waiting for services...'; sleep 10 && echo '[e2e] Running Playwright tests...'; docker-compose -f docker-compose.e2e.yml run --rm playwright\"", "test:api:smoke": "sh -lc \"echo '🚀 Running API smoke tests...'; npx playwright test tests/e2e/api/api-smoke.test.ts --reporter=json,html\"", "test:api:smoke:docker": "sh -lc \"echo '🚀 Running API smoke tests in Docker...'; docker-compose -f docker-compose.e2e.yml run --rm playwright npx playwright test tests/e2e/api/api-smoke.test.ts --reporter=json,html\"",