wip
This commit is contained in:
111
apps/website/app/api/signup/route.ts
Normal file
111
apps/website/app/api/signup/route.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user