This commit is contained in:
2025-12-06 00:17:24 +01:00
parent 78c85a429c
commit 70d5f5689e
54 changed files with 826 additions and 210 deletions

View File

@@ -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<string, { email: string; createdAt: number; ip: string }>();
const SIGNUP_KV_HASH_KEY = 'signups:emails';
const isDev = !process.env.KV_REST_API_URL;
function jsonError(status: number, message: string, extra: Record<string, unknown> = {}) {
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,
},
);
}

View File

@@ -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,

View File

@@ -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({
<body className="antialiased overflow-x-hidden">
<header className="fixed top-0 left-0 right-0 z-50 bg-deep-graphite/80 backdrop-blur-sm border-b border-white/5">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-baseline space-x-3">
<h1 className="text-2xl font-semibold text-white">GridPilot</h1>
<p className="text-sm text-gray-400 font-light">Making league racing less chaotic</p>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Link href="/" className="inline-flex items-center">
<Image
src="/images/logos/wordmark-rectangle-dark.svg"
alt="GridPilot"
width={160}
height={30}
className="h-6 w-auto md:h-8"
priority
/>
</Link>
<p className="hidden sm:block text-sm text-gray-400 font-light">
Making league racing less chaotic
</p>
</div>
</div>
</div>
</header>

View File

@@ -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,

View File

@@ -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<LeagueDriverSeasonStatsDTO[]>([]);

View File

@@ -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() {
</p>
<Button
variant="primary"
onClick={() => setShowCreateForm(true)}
onClick={() => router.push('/leagues/create')}
>
Create Your First League
</Button>

View File

@@ -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';