From 70d5f5689e3dc8485b8c2fc5bdcaa3265c851d8e Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sat, 6 Dec 2025 00:17:24 +0100 Subject: [PATCH] wip --- .gitignore | 4 +- apps/website/app/api/signup/route.ts | 111 ++++++++++++++ apps/website/app/drivers/[id]/page.tsx | 4 +- apps/website/app/layout.tsx | 24 +++- apps/website/app/leagues/[id]/page.tsx | 14 +- .../app/leagues/[id]/standings/page.tsx | 10 +- apps/website/app/leagues/page.tsx | 4 +- apps/website/app/profile/page.tsx | 4 +- .../components/drivers/CreateDriverForm.tsx | 2 +- apps/website/components/landing/Footer.tsx | 21 ++- .../components/leagues/LeagueAdmin.tsx | 2 +- .../components/leagues/LeagueDropSection.tsx | 2 +- .../components/leagues/LeagueSchedule.tsx | 15 +- .../leagues/LeagueScoringSection.tsx | 2 +- .../leagues/LeagueTimingsSection.tsx | 2 +- apps/website/env.d.ts | 3 +- apps/website/next.config.mjs | 8 ++ apps/website/package.json | 15 +- apps/website/public/favicon.svg | 14 ++ .../public/images/avatars/avatar-1.svg | 11 -- .../public/images/avatars/avatar-2.svg | 11 -- .../public/images/avatars/avatar-3.svg | 11 -- .../public/images/avatars/avatar-4.svg | 11 -- .../public/images/avatars/avatar-5.svg | 11 -- .../public/images/avatars/avatar-6.svg | 11 -- .../images/avatars/female-default-avatar.jpeg | Bin 0 -> 69812 bytes .../images/avatars/male-default-avatar.jpg | Bin 0 -> 255606 bytes .../avatars/neutral-default-avatar.jpeg | Bin 0 -> 67382 bytes .../public/images/logos/icon-square-dark.svg | 1 + .../images/logos/wordmark-rectangle-dark.svg | 47 ++++++ apps/website/public/images/tracks/daytona.jpg | Bin 0 -> 573792 bytes apps/website/public/images/tracks/spa.jpg | Bin 0 -> 573253 bytes package-lock.json | 134 ++++++++++------- package.json | 73 +++++----- .../media/DemoImageServiceAdapter.ts | 12 +- .../use-cases/GetLeagueScoringConfigQuery.ts | 2 +- .../domain/services/EventScoringService.ts | 2 +- packages/racing/index.ts | 7 +- packages/racing/package.json | 7 +- .../logo/svg/Rectangle Wordmark Dark.svg | 2 +- resources/logo/svg/Rectangle Wordmark.svg | 2 +- resources/logo/svg/Square Icon Dark.svg | 2 +- resources/logo/svg/Square Icon.svg | 2 +- resources/logo/svg/Square Logo Dark.svg | 2 +- resources/logo/svg/Square Logo.svg | 2 +- resources/logo/svg/Square Wordmark Dark.svg | 2 +- resources/logo/svg/Square Wordmark.svg | 2 +- resources/logo/svg/Wordmark Dark.svg | 47 ++++++ resources/logo/svg/Wordmark Light.svg | 49 +++++++ scripts/merge-website-env.js | 72 ++++++++++ .../DemoImageServiceAdapter.test.ts | 40 ++++++ tests/unit/website/getAppMode.test.ts | 63 ++++++++ tests/unit/website/signupRoute.test.ts | 135 ++++++++++++++++++ vitest.config.ts | 2 + 54 files changed, 826 insertions(+), 210 deletions(-) create mode 100644 apps/website/app/api/signup/route.ts create mode 100644 apps/website/public/favicon.svg delete mode 100644 apps/website/public/images/avatars/avatar-1.svg delete mode 100644 apps/website/public/images/avatars/avatar-2.svg delete mode 100644 apps/website/public/images/avatars/avatar-3.svg delete mode 100644 apps/website/public/images/avatars/avatar-4.svg delete mode 100644 apps/website/public/images/avatars/avatar-5.svg delete mode 100644 apps/website/public/images/avatars/avatar-6.svg create mode 100644 apps/website/public/images/avatars/female-default-avatar.jpeg create mode 100644 apps/website/public/images/avatars/male-default-avatar.jpg create mode 100644 apps/website/public/images/avatars/neutral-default-avatar.jpeg create mode 100644 apps/website/public/images/logos/icon-square-dark.svg create mode 100644 apps/website/public/images/logos/wordmark-rectangle-dark.svg create mode 100644 apps/website/public/images/tracks/daytona.jpg create mode 100644 apps/website/public/images/tracks/spa.jpg create mode 100644 resources/logo/svg/Wordmark Dark.svg create mode 100644 resources/logo/svg/Wordmark Light.svg create mode 100644 scripts/merge-website-env.js create mode 100644 tests/unit/infrastructure/DemoImageServiceAdapter.test.ts create mode 100644 tests/unit/website/getAppMode.test.ts create mode 100644 tests/unit/website/signupRoute.test.ts diff --git a/.gitignore b/.gitignore index 2deeef40b..d21b34eb8 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,6 @@ logs/ # Temporary files tmp/ -temp/ \ No newline at end of file +temp/ +.vercel +.env*.local diff --git a/apps/website/app/api/signup/route.ts b/apps/website/app/api/signup/route.ts new file mode 100644 index 000000000..e6063240d --- /dev/null +++ b/apps/website/app/api/signup/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from 'next/server'; + +import { validateEmail, isDisposableEmail } from '@gridpilot/identity/domain/value-objects/EmailAddress'; +import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; + +const SIGNUP_DEV_STORE = new Map(); +const SIGNUP_KV_HASH_KEY = 'signups:emails'; + +const isDev = !process.env.KV_REST_API_URL; + +function jsonError(status: number, message: string, extra: Record = {}) { + return NextResponse.json( + { + error: message, + ...extra, + }, + { status }, + ); +} + +export async function POST(request: Request) { + let body: unknown; + + try { + body = await request.json(); + } catch { + return jsonError(400, 'Invalid request body'); + } + + const email = (body as any)?.email; + + if (typeof email !== 'string' || !email.trim()) { + return jsonError(400, 'Invalid email address'); + } + + const validation = validateEmail(email); + + if (!validation.success) { + return jsonError(400, validation.error || 'Invalid email address'); + } + + const normalizedEmail = validation.email; + + if (isDisposableEmail(normalizedEmail)) { + return jsonError(400, 'Disposable email addresses are not allowed'); + } + + const ip = getClientIp(request); + + try { + const rateResult = await checkRateLimit(ip); + + if (!rateResult.allowed) { + const retryAfterSeconds = Math.max(0, Math.round((rateResult.resetAt - Date.now()) / 1000)); + + return jsonError(429, 'Too many signups, please try again later.', { + retryAfter: retryAfterSeconds, + }); + } + } catch { + return jsonError(503, 'Temporarily unable to accept signups.'); + } + + try { + if (isDev) { + const existing = SIGNUP_DEV_STORE.get(normalizedEmail); + + if (existing) { + return jsonError(409, 'You are already on the list.'); + } + + SIGNUP_DEV_STORE.set(normalizedEmail, { + email: normalizedEmail, + createdAt: Date.now(), + ip, + }); + } else { + const { kv } = await import('@vercel/kv'); + + const existing = await kv.hget<{ email: string; createdAt: number; ip: string }>( + SIGNUP_KV_HASH_KEY, + normalizedEmail, + ); + + if (existing) { + return jsonError(409, 'You are already on the list.'); + } + + await kv.hset(SIGNUP_KV_HASH_KEY, { + [normalizedEmail]: { + email: normalizedEmail, + createdAt: Date.now(), + ip, + }, + }); + } + } catch (error) { + console.error('Signup storage error:', error); + return jsonError(503, 'Temporarily unable to accept signups.'); + } + + return NextResponse.json( + { + ok: true, + message: 'You are on the grid! We will be in touch soon.', + }, + { + status: 201, + }, + ); +} \ No newline at end of file diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx index f3dae6479..4438fc671 100644 --- a/apps/website/app/drivers/[id]/page.tsx +++ b/apps/website/app/drivers/[id]/page.tsx @@ -8,8 +8,8 @@ import DriverProfile from '@/components/drivers/DriverProfile'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; -import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; -import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; +import { EntityMappers } from '@gridpilot/racing'; +import type { DriverDTO } from '@gridpilot/racing'; export default function DriverDetailPage({ searchParams, diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index e2198a5b5..5cf147ca2 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -1,5 +1,7 @@ import React from 'react'; import type { Metadata } from 'next'; +import Image from 'next/image'; +import Link from 'next/link'; import './globals.css'; import { getAppMode } from '@/lib/mode'; import { getAuthService } from '@/lib/auth'; @@ -35,6 +37,9 @@ export const metadata: Metadata = { title: 'GridPilot - iRacing League Racing Platform', description: 'Structure over chaos. The professional platform for iRacing league racing.', }, + icons: { + icon: '/favicon.svg', + }, }; export default async function RootLayout({ @@ -75,9 +80,22 @@ export default async function RootLayout({
-
-

GridPilot

-

Making league racing less chaotic

+
+
+ + GridPilot + +

+ Making league racing less chaotic +

+
diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 38ebe64c7..03199fee4 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -12,12 +12,14 @@ import StandingsTable from '@/components/leagues/StandingsTable'; import LeagueScoringTab from '@/components/leagues/LeagueScoringTab'; import DriverIdentity from '@/components/drivers/DriverIdentity'; import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; -import { League } from '@gridpilot/racing/domain/entities/League'; -import { Driver } from '@gridpilot/racing/domain/entities/Driver'; -import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; -import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO'; -import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO'; -import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; +import { + League, + Driver, + EntityMappers, + type DriverDTO, + type LeagueDriverSeasonStatsDTO, + type LeagueScoringConfigDTO, +} from '@gridpilot/racing'; import { getLeagueRepository, getRaceRepository, diff --git a/apps/website/app/leagues/[id]/standings/page.tsx b/apps/website/app/leagues/[id]/standings/page.tsx index 3ab36ada8..8e4b2b30e 100644 --- a/apps/website/app/leagues/[id]/standings/page.tsx +++ b/apps/website/app/leagues/[id]/standings/page.tsx @@ -3,12 +3,14 @@ import { useState, useEffect } from 'react'; import Card from '@/components/ui/Card'; import StandingsTable from '@/components/leagues/StandingsTable'; -import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; -import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO'; -import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; +import { + EntityMappers, + type DriverDTO, + type LeagueDriverSeasonStatsDTO, +} from '@gridpilot/racing'; import { getGetLeagueDriverSeasonStatsQuery, getDriverRepository } from '@/lib/di-container'; -export default function LeagueStandingsPage({ params }: { params: { id: string } }) { +export default function LeagueStandingsPage({ params }: any) { const leagueId = params.id; const [standings, setStandings] = useState([]); diff --git a/apps/website/app/leagues/page.tsx b/apps/website/app/leagues/page.tsx index 848b2f29a..bd0038173 100644 --- a/apps/website/app/leagues/page.tsx +++ b/apps/website/app/leagues/page.tsx @@ -40,7 +40,7 @@ export default function LeaguesPage() { .filter((league) => { const matchesSearch = league.name.toLowerCase().includes(searchQuery.toLowerCase()) || - league.description.toLowerCase().includes(searchQuery.toLowerCase()); + (league.description ?? '').toLowerCase().includes(searchQuery.toLowerCase()); return matchesSearch; }) .sort((a, b) => { @@ -137,7 +137,7 @@ export default function LeaguesPage() {

diff --git a/apps/website/app/profile/page.tsx b/apps/website/app/profile/page.tsx index 9ef5a86f3..ef859d18b 100644 --- a/apps/website/app/profile/page.tsx +++ b/apps/website/app/profile/page.tsx @@ -2,9 +2,7 @@ import { useState, useEffect } from 'react'; import { getDriverRepository } from '@/lib/di-container'; -import { Driver } from '@gridpilot/racing/domain/entities/Driver'; -import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; -import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; +import { Driver, EntityMappers, type DriverDTO } from '@gridpilot/racing'; import CreateDriverForm from '@/components/drivers/CreateDriverForm'; import Card from '@/components/ui/Card'; import DriverProfile from '@/components/drivers/DriverProfile'; diff --git a/apps/website/components/drivers/CreateDriverForm.tsx b/apps/website/components/drivers/CreateDriverForm.tsx index 1f7adea75..7f9eeb4a9 100644 --- a/apps/website/components/drivers/CreateDriverForm.tsx +++ b/apps/website/components/drivers/CreateDriverForm.tsx @@ -4,7 +4,7 @@ import { useState, FormEvent } from 'react'; import { useRouter } from 'next/navigation'; import Input from '../ui/Input'; import Button from '../ui/Button'; -import { Driver } from '@gridpilot/racing/domain/entities/Driver'; +import { Driver } from '@gridpilot/racing'; import { getDriverRepository } from '../../lib/di-container'; interface FormErrors { diff --git a/apps/website/components/landing/Footer.tsx b/apps/website/components/landing/Footer.tsx index 084d8e90e..da7cce1b8 100644 --- a/apps/website/components/landing/Footer.tsx +++ b/apps/website/components/landing/Footer.tsx @@ -1,8 +1,12 @@ 'use client'; +import Image from 'next/image'; import { useRef } from 'react'; import { useScrollProgress } from '@/hooks/useScrollProgress'; +const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || 'https://discord.gg/gridpilot'; +const xUrl = process.env.NEXT_PUBLIC_X_URL || '#'; + export default function Footer() { return (