diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index c882b6c53..55f288899 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -17,12 +17,12 @@ export const viewport: Viewport = { maximumScale: 1, userScalable: false, viewportFit: 'cover', + themeColor: '#0a0a0a', }; export const metadata: Metadata = { title: 'GridPilot - SimRacing Platform', description: 'The dedicated home for serious sim racing leagues. Automatic results, standings, team racing, and professional race control.', - themeColor: '#0a0a0a', appleWebApp: { capable: true, statusBarStyle: 'black-translucent', diff --git a/apps/website/app/leagues/create/CreateLeagueWizard.tsx b/apps/website/app/leagues/create/CreateLeagueWizard.tsx index 2bd66f889..9d6e03d56 100644 --- a/apps/website/app/leagues/create/CreateLeagueWizard.tsx +++ b/apps/website/app/leagues/create/CreateLeagueWizard.tsx @@ -607,7 +607,7 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar Create a new league - We'll also set up your first season in {steps.length} easy steps. + We'll also set up your first season in {steps.length} easy steps. A league is your long-term brand. Each season is a block of races you can run again and again. diff --git a/apps/website/app/page.tsx b/apps/website/app/page.tsx index 3616fca44..dfdea5efc 100644 --- a/apps/website/app/page.tsx +++ b/apps/website/app/page.tsx @@ -1,19 +1,17 @@ import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { HomeTemplate, type HomeViewData } from '@/templates/HomeTemplate'; import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { HomeService } from '@/lib/services/home/HomeService'; // @server-safe +import { HomePageQuery } from '@/lib/page-queries/HomePageQuery'; import { notFound, redirect } from 'next/navigation'; import { routes } from '@/lib/routing/RouteConfig'; export default async function Page() { - const homeService = new HomeService(); - - if (await homeService.shouldRedirectToDashboard()) { + if (await HomePageQuery.shouldRedirectToDashboard()) { redirect(routes.protected.dashboard); } const data = await PageDataFetcher.fetchManual(async () => { - const result = await homeService.getHomeData(); + const result = await HomePageQuery.execute(); return result.isOk() ? result.unwrap() : null; }); diff --git a/apps/website/app/races/[id]/results/page.tsx b/apps/website/app/races/[id]/results/page.tsx index 46569f8ec..d4189ff5c 100644 --- a/apps/website/app/races/[id]/results/page.tsx +++ b/apps/website/app/races/[id]/results/page.tsx @@ -1,5 +1,5 @@ import { notFound } from 'next/navigation'; -import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; +import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { RaceResultsPageQuery } from '@/lib/page-queries/races/RaceResultsPageQuery'; import RaceResultsPageClient from './RaceResultsPageClient'; @@ -25,13 +25,12 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps) if (error === 'notFound') { notFound(); } - // For other errors, let StatefulPageWrapper handle it + // For other errors, let PageWrapper handle it return ( - Promise.resolve()} /> ); } @@ -39,10 +38,9 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps) const viewData = result.unwrap(); return ( - Promise.resolve()} /> ); } diff --git a/apps/website/lib/api/base/BaseApiClient.ts b/apps/website/lib/api/base/BaseApiClient.ts index eb0273739..1907d9dfa 100644 --- a/apps/website/lib/api/base/BaseApiClient.ts +++ b/apps/website/lib/api/base/BaseApiClient.ts @@ -16,6 +16,7 @@ export interface BaseApiClientOptions { timeout?: number; retry?: boolean; retryConfig?: typeof DEFAULT_RETRY_CONFIG; + allowUnauthenticated?: boolean; } export class BaseApiClient { diff --git a/apps/website/lib/api/drivers/DriversApiClient.ts b/apps/website/lib/api/drivers/DriversApiClient.ts index 150b1bfe2..29b7badae 100644 --- a/apps/website/lib/api/drivers/DriversApiClient.ts +++ b/apps/website/lib/api/drivers/DriversApiClient.ts @@ -28,7 +28,7 @@ export class DriversApiClient extends BaseApiClient { /** Get current driver (based on session) */ getCurrent(): Promise { - return this.get('/drivers/current'); + return this.get('/drivers/current', { allowUnauthenticated: true }); } /** Get driver registration status for a specific race */ diff --git a/apps/website/lib/builders/view-data/HomeViewDataBuilder.ts b/apps/website/lib/builders/view-data/HomeViewDataBuilder.ts new file mode 100644 index 000000000..241f360f7 --- /dev/null +++ b/apps/website/lib/builders/view-data/HomeViewDataBuilder.ts @@ -0,0 +1,24 @@ +import type { HomeViewData } from '@/templates/HomeTemplate'; +import type { HomeDataDTO } from '@/lib/types/dtos/HomeDataDTO'; + +/** + * HomeViewDataBuilder + * + * Transforms HomeDataDTO to HomeViewData. + */ +export class HomeViewDataBuilder { + /** + * Build HomeViewData from HomeDataDTO + * + * @param apiDto - The API DTO + * @returns HomeViewData + */ + static build(apiDto: HomeDataDTO): HomeViewData { + return { + isAlpha: apiDto.isAlpha, + upcomingRaces: apiDto.upcomingRaces, + topLeagues: apiDto.topLeagues, + teams: apiDto.teams, + }; + } +} diff --git a/apps/website/lib/config/apiBaseUrl.ts b/apps/website/lib/config/apiBaseUrl.ts index 5dd468bae..6417a6df7 100644 --- a/apps/website/lib/config/apiBaseUrl.ts +++ b/apps/website/lib/config/apiBaseUrl.ts @@ -30,8 +30,10 @@ export function getWebsiteApiBaseUrl(): string { ); } + const isDocker = process.env.DOCKER === 'true'; + const fallback = - process.env.NODE_ENV === 'development' + process.env.NODE_ENV === 'development' && !isDocker ? 'http://localhost:3001' : 'http://api:3000'; diff --git a/apps/website/lib/feature/FeatureFlagService.ts b/apps/website/lib/feature/FeatureFlagService.ts index 40e5b39fc..8f1ad3646 100644 --- a/apps/website/lib/feature/FeatureFlagService.ts +++ b/apps/website/lib/feature/FeatureFlagService.ts @@ -9,6 +9,8 @@ * Client: Reads from session context or provides mock implementation */ +import { getWebsiteApiBaseUrl } from '../config/apiBaseUrl'; + // Server-side implementation export class FeatureFlagService { private flags: Set; @@ -41,11 +43,10 @@ export class FeatureFlagService { /** * Factory method to create service by fetching from API - * Fetches from ${NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'}/features * On error, returns empty flags (secure by default) */ static async fromAPI(): Promise { - const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + const baseUrl = getWebsiteApiBaseUrl(); const url = `${baseUrl}/features`; try { diff --git a/apps/website/lib/page-queries/HomePageQuery.ts b/apps/website/lib/page-queries/HomePageQuery.ts new file mode 100644 index 000000000..281c8ef88 --- /dev/null +++ b/apps/website/lib/page-queries/HomePageQuery.ts @@ -0,0 +1,50 @@ +import { Result } from '@/lib/contracts/Result'; +import { HomeService } from '@/lib/services/home/HomeService'; +import type { HomeViewData } from '@/templates/HomeTemplate'; +import type { PageQuery } from '@/lib/contracts/page-queries/PageQuery'; +import { HomeViewDataBuilder } from '@/lib/builders/view-data/HomeViewDataBuilder'; + +/** + * HomePageQuery + * + * Server-side data fetcher for the home page. + * Returns Result + */ +export class HomePageQuery implements PageQuery { + /** + * Execute the home page query + * + * @returns Result with HomeViewData or error + */ + async execute(): Promise> { + try { + const service = new HomeService(); + const result = await service.getHomeData(); + + if (result.isErr()) { + return Result.err('Error'); + } + + const viewData = HomeViewDataBuilder.build(result.unwrap()); + return Result.ok(viewData); + } catch (error) { + console.error('HomePageQuery failed:', error); + return Result.err('Error'); + } + } + + /** + * Static execute for convenience + */ + static async execute(): Promise> { + return new HomePageQuery().execute(); + } + + /** + * Check if user should be redirected to dashboard + */ + static async shouldRedirectToDashboard(): Promise { + const service = new HomeService(); + return service.shouldRedirectToDashboard(); + } +} diff --git a/apps/website/lib/routing/search-params/SearchParamParser.ts b/apps/website/lib/routing/search-params/SearchParamParser.ts index 1f91be6c4..95b922ff3 100644 --- a/apps/website/lib/routing/search-params/SearchParamParser.ts +++ b/apps/website/lib/routing/search-params/SearchParamParser.ts @@ -42,7 +42,10 @@ export interface ParsedWizardParams { } export class SearchParamParser { - private static getParam(params: URLSearchParams | Record, key: string): string | null { + private static getParam(params: URLSearchParams | Record | undefined | null, key: string): string | null { + if (!params) { + return null; + } if (params instanceof URLSearchParams) { return params.get(key); } @@ -54,7 +57,7 @@ export class SearchParamParser { } // Parse auth parameters - static parseAuth(params: URLSearchParams | Record): Result { + static parseAuth(params: URLSearchParams | Record | undefined | null): Result { const errors: string[] = []; const returnTo = this.getParam(params, 'returnTo'); @@ -95,7 +98,7 @@ export class SearchParamParser { } // Parse sponsor parameters - static parseSponsor(params: URLSearchParams | Record): Result { + static parseSponsor(params: URLSearchParams | Record | undefined | null): Result { const errors: string[] = []; const type = this.getParam(params, 'type'); @@ -117,7 +120,7 @@ export class SearchParamParser { } // Parse pagination parameters - static parsePagination(params: URLSearchParams | Record): Result { + static parsePagination(params: URLSearchParams | Record | undefined | null): Result { const result: ParsedPaginationParams = {}; const errors: string[] = []; @@ -157,7 +160,7 @@ export class SearchParamParser { } // Parse sorting parameters - static parseSorting(params: URLSearchParams | Record): Result { + static parseSorting(params: URLSearchParams | Record | undefined | null): Result { const errors: string[] = []; const order = this.getParam(params, 'order'); @@ -179,7 +182,7 @@ export class SearchParamParser { } // Parse filter parameters - static parseFilters(params: URLSearchParams | Record): Result { + static parseFilters(params: URLSearchParams | Record | undefined | null): Result { return Result.ok({ status: this.getParam(params, 'status'), role: this.getParam(params, 'role'), @@ -188,14 +191,14 @@ export class SearchParamParser { } // Parse wizard parameters - static parseWizard(params: URLSearchParams | Record): Result { + static parseWizard(params: URLSearchParams | Record | undefined | null): Result { return Result.ok({ step: this.getParam(params, 'step'), }); } // Parse all parameters at once - static parseAll(params: URLSearchParams | Record): Result< + static parseAll(params: URLSearchParams | Record | undefined | null): Result< { auth: ParsedAuthParams; sponsor: ParsedSponsorParams; diff --git a/apps/website/lib/services/home/HomeService.ts b/apps/website/lib/services/home/HomeService.ts index af9e75348..ba5c2b6a3 100644 --- a/apps/website/lib/services/home/HomeService.ts +++ b/apps/website/lib/services/home/HomeService.ts @@ -14,11 +14,17 @@ import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorR import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; // DTO types -import type { HomeViewData } from '@/templates/HomeTemplate'; import { Result } from '@/lib/contracts/Result'; +import type { Service } from '@/lib/contracts/services/Service'; +import type { HomeDataDTO } from '@/lib/types/dtos/HomeDataDTO'; -export class HomeService { - async getHomeData(): Promise> { +/** + * HomeService + * + * @server-safe + */ +export class HomeService implements Service { + async getHomeData(): Promise> { try { // Manual wiring: construct dependencies explicitly const baseUrl = getWebsiteApiBaseUrl(); diff --git a/apps/website/lib/types/dtos/HomeDataDTO.ts b/apps/website/lib/types/dtos/HomeDataDTO.ts new file mode 100644 index 000000000..552ccf84b --- /dev/null +++ b/apps/website/lib/types/dtos/HomeDataDTO.ts @@ -0,0 +1,20 @@ +export interface HomeDataDTO { + isAlpha: boolean; + upcomingRaces: Array<{ + id: string; + track: string; + car: string; + formattedDate: string; + }>; + topLeagues: Array<{ + id: string; + name: string; + description: string; + }>; + teams: Array<{ + id: string; + name: string; + description: string; + logoUrl?: string; + }>; +} diff --git a/apps/website/tailwind.config.js b/apps/website/tailwind.config.js index f9c5cc67a..d2064aeb8 100644 --- a/apps/website/tailwind.config.js +++ b/apps/website/tailwind.config.js @@ -3,6 +3,10 @@ module.exports = { content: [ './app/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', + './templates/**/*.{js,ts,jsx,tsx,mdx}', + './ui/**/*.{js,ts,jsx,tsx,mdx}', + './lib/**/*.{js,ts,jsx,tsx,mdx}', + './hooks/**/*.{js,ts,jsx,tsx,mdx}', ], theme: { extend: { diff --git a/apps/website/templates/HomeTemplate.tsx b/apps/website/templates/HomeTemplate.tsx index 32eb82662..c9e27f7c6 100644 --- a/apps/website/templates/HomeTemplate.tsx +++ b/apps/website/templates/HomeTemplate.tsx @@ -1,31 +1,30 @@ 'use client'; -import React from 'react'; -import { LandingHero } from '@/components/landing/LandingHero'; import { AlternatingSection } from '@/components/landing/AlternatingSection'; -import { FeatureGrid } from '@/components/landing/FeatureGrid'; -import { DiscordCTA } from '@/ui/DiscordCTA'; import { FAQ } from '@/components/landing/FAQ'; -import { Footer } from '@/ui/Footer'; +import { FeatureGrid } from '@/components/landing/FeatureGrid'; +import { LandingHero } from '@/components/landing/LandingHero'; +import { FeatureItem, ResultItem, StepItem } from '@/components/landing/LandingItems'; import { CareerProgressionMockup } from '@/components/mockups/CareerProgressionMockup'; -import { RaceHistoryMockup } from '@/components/mockups/RaceHistoryMockup'; import { CompanionAutomationMockup } from '@/components/mockups/CompanionAutomationMockup'; +import { RaceHistoryMockup } from '@/components/mockups/RaceHistoryMockup'; import { SimPlatformMockup } from '@/components/mockups/SimPlatformMockup'; -import { Card } from '@/ui/Card'; -import { Button } from '@/ui/Button'; +import { ModeGuard } from '@/components/shared/ModeGuard'; +import { routes } from '@/lib/routing/RouteConfig'; +import { getMediaUrl } from '@/lib/utilities/media'; import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; +import { Button } from '@/ui/Button'; +import { Card } from '@/ui/Card'; +import { Container } from '@/ui/Container'; +import { DiscordCTA } from '@/ui/DiscordCTA'; +import { Footer } from '@/ui/Footer'; +import { Grid } from '@/ui/Grid'; import { Heading } from '@/ui/Heading'; import { Image } from '@/ui/Image'; import { Link } from '@/ui/Link'; -import { Container } from '@/ui/Container'; -import { Grid } from '@/ui/Grid'; +import { Stack } from '@/ui/Stack'; import { Surface } from '@/ui/Surface'; -import { getMediaUrl } from '@/lib/utilities/media'; -import { routes } from '@/lib/routing/RouteConfig'; -import { FeatureItem, ResultItem, StepItem } from '@/components/landing/LandingItems'; -import { ModeGuard } from '@/components/shared/ModeGuard'; +import { Text } from '@/ui/Text'; export interface HomeViewData { isAlpha: boolean; diff --git a/apps/website/ui/Box.tsx b/apps/website/ui/Box.tsx index 61812f402..4f4640e3a 100644 --- a/apps/website/ui/Box.tsx +++ b/apps/website/ui/Box.tsx @@ -89,6 +89,8 @@ export interface BoxProps { group?: boolean; groupHoverBorderColor?: string; groupHoverTextColor?: string; + groupHoverScale?: boolean; + groupHoverOpacity?: number; fontSize?: string; transform?: string; borderWidth?: string; @@ -111,6 +113,8 @@ export interface BoxProps { webkitMaskImage?: string; backgroundSize?: string; backgroundPosition?: string; + backgroundColor?: string; + insetY?: Spacing | string; } type ResponsiveValue = { @@ -136,6 +140,7 @@ export const Box = forwardRef(( flexDirection, alignItems, justifyContent, + flexWrap, position, top, bottom, @@ -183,6 +188,8 @@ export const Box = forwardRef(( group, groupHoverBorderColor, groupHoverTextColor, + groupHoverScale, + groupHoverOpacity, fontSize, transform, borderWidth, @@ -204,6 +211,8 @@ export const Box = forwardRef(( webkitMaskImage, backgroundSize, backgroundPosition, + backgroundColor, + insetY, ...props }: BoxProps & ComponentPropsWithoutRef, ref: ForwardedRef @@ -321,18 +330,20 @@ export const Box = forwardRef(( borderRight ? 'border-r' : '', borderColor ? borderColor : '', ring ? ring : '', - bg ? bg : '', + bg ? bg : (backgroundColor ? (backgroundColor.startsWith('bg-') ? backgroundColor : `bg-${backgroundColor}`) : ''), color ? color : '', hoverColor ? `hover:${hoverColor}` : '', shadow ? shadow : '', flexShrink !== undefined ? `flex-shrink-${flexShrink}` : '', flexGrow !== undefined ? `flex-grow-${flexGrow}` : '', + flexWrap ? `flex-${flexWrap}` : '', hoverBorderColor ? `hover:${hoverBorderColor}` : '', hoverTextColor ? `hover:${hoverTextColor}` : '', hoverBg ? `hover:${hoverBg}` : '', transition ? 'transition-all' : '', lineClamp ? `line-clamp-${lineClamp}` : '', inset ? `inset-${inset}` : '', + insetY !== undefined && spacingMap[insetY as string | number] ? `inset-y-${spacingMap[insetY as string | number]}` : '', bgOpacity !== undefined ? `bg-opacity-${bgOpacity * 100}` : '', opacity !== undefined ? `opacity-${opacity * 100}` : '', blur ? `blur-${blur}` : '', @@ -343,6 +354,8 @@ export const Box = forwardRef(( group ? 'group' : '', groupHoverBorderColor ? `group-hover:border-${groupHoverBorderColor}` : '', groupHoverTextColor ? `group-hover:text-${groupHoverTextColor}` : '', + groupHoverScale ? 'group-hover:scale-[1.02]' : '', + groupHoverOpacity !== undefined ? `group-hover:opacity-${groupHoverOpacity * 100}` : '', getResponsiveClasses('', display), getFlexDirectionClass(flexDirection), getAlignItemsClass(alignItems), @@ -390,6 +403,7 @@ export const Box = forwardRef(( ...(bottom !== undefined && !spacingMap[bottom as string | number] ? { bottom } : {}), ...(left !== undefined && !spacingMap[left as string | number] ? { left } : {}), ...(right !== undefined && !spacingMap[right as string | number] ? { right } : {}), + ...(insetY !== undefined && !spacingMap[insetY as string | number] ? { top: insetY, bottom: insetY } : {}), ...(hideScrollbar ? { scrollbarWidth: 'none', msOverflowStyle: 'none', '&::-webkit-scrollbar': { display: 'none' } } : {}), ...((props as Record).style as object || {}) }; diff --git a/apps/website/ui/Text.tsx b/apps/website/ui/Text.tsx index c3c9c2e21..9e8db4cc1 100644 --- a/apps/website/ui/Text.tsx +++ b/apps/website/ui/Text.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, ElementType, ComponentPropsWithoutRef } from 'react'; -import { BoxProps } from './Box'; +import { Box, BoxProps } from './Box'; type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96; @@ -194,5 +194,5 @@ export function Text({ ...style }; - return {children}; + return {children}; } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e71efcb9d..cf109919f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -76,6 +76,7 @@ services: - NEXT_TELEMETRY_DISABLED=1 - NODE_ENV=development - API_BASE_URL=http://api:3000 + - DOCKER=true ports: - "3000:3000" volumes: