From fd3b4171aaa333493cb466b370c8270346934d87 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 2 Dec 2025 01:31:31 +0100 Subject: [PATCH] website poc --- apps/website/.env.example | 7 +- apps/website/app/api/signup/route.ts | 73 ++++++--- apps/website/app/page.tsx | 129 +++++++++++++++ .../components/landing/AlternatingSection.tsx | 88 ++++++++++ .../components/landing/EmailCapture.tsx | 149 ++++++++++++----- apps/website/components/landing/Footer.tsx | 15 -- apps/website/components/landing/Hero.tsx | 49 +++++- .../mockups/CareerProgressionMockup.tsx | 104 ++++++++++++ .../mockups/CompanionAutomationMockup.tsx | 155 ++++++++++++++++++ .../components/mockups/RaceHistoryMockup.tsx | 131 +++++++++++++++ .../components/mockups/SimPlatformMockup.tsx | 89 ++++++++++ apps/website/lib/rate-limit.ts | 65 +++++--- apps/website/public/images/ff1600.jpeg | Bin 0 -> 187912 bytes apps/website/public/images/lmp3.jpeg | Bin 0 -> 85601 bytes apps/website/public/images/porsche.jpeg | Bin 0 -> 170381 bytes 15 files changed, 939 insertions(+), 115 deletions(-) create mode 100644 apps/website/components/landing/AlternatingSection.tsx create mode 100644 apps/website/components/mockups/CareerProgressionMockup.tsx create mode 100644 apps/website/components/mockups/CompanionAutomationMockup.tsx create mode 100644 apps/website/components/mockups/RaceHistoryMockup.tsx create mode 100644 apps/website/components/mockups/SimPlatformMockup.tsx create mode 100644 apps/website/public/images/ff1600.jpeg create mode 100644 apps/website/public/images/lmp3.jpeg create mode 100644 apps/website/public/images/porsche.jpeg diff --git a/apps/website/.env.example b/apps/website/.env.example index 5d85948ef..4b00037fc 100644 --- a/apps/website/.env.example +++ b/apps/website/.env.example @@ -12,9 +12,10 @@ NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch # Vercel KV (for email signups and rate limiting) # Get these from: https://vercel.com/dashboard -> Storage -> KV -# Required for /api/signup to work -KV_REST_API_URL=your_kv_rest_api_url_here -KV_REST_API_TOKEN=your_kv_rest_api_token_here +# OPTIONAL in development (uses in-memory fallback) +# REQUIRED in production for persistent data +# KV_REST_API_URL=your_kv_rest_api_url_here +# KV_REST_API_TOKEN=your_kv_rest_api_token_here # Site URL (for metadata and OG tags) NEXT_PUBLIC_SITE_URL=https://gridpilot.com diff --git a/apps/website/app/api/signup/route.ts b/apps/website/app/api/signup/route.ts index b0c0514e6..7fd800eba 100644 --- a/apps/website/app/api/signup/route.ts +++ b/apps/website/app/api/signup/route.ts @@ -1,79 +1,107 @@ import { NextRequest, NextResponse } from 'next/server'; -import { kv } from '@vercel/kv'; import { validateEmail, isDisposableEmail } from '@/lib/email-validation'; import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; -/** - * Email signup storage key - */ const SIGNUP_LIST_KEY = 'signups:emails'; +const isDev = !process.env.KV_REST_API_URL; + +// In-memory fallback for development +const devSignups = new Map(); -/** - * POST /api/signup - * Handle email signup submissions - */ export async function POST(request: NextRequest) { try { - // Parse request body const body = await request.json(); const { email } = body; if (!email || typeof email !== 'string') { return NextResponse.json( - { error: 'Email is required' }, + { error: "That email doesn't look right." }, { status: 400 } ); } - // Validate email format const validation = validateEmail(email); if (!validation.success) { return NextResponse.json( - { error: validation.error }, + { error: "That email doesn't look right." }, { status: 400 } ); } const sanitizedEmail = validation.email!; - // Check for disposable email if (isDisposableEmail(sanitizedEmail)) { return NextResponse.json( - { error: 'Disposable email addresses are not allowed' }, + { error: "That email doesn't look right." }, { status: 400 } ); } - // Rate limiting const clientIp = getClientIp(request); const rateLimitResult = await checkRateLimit(clientIp); if (!rateLimitResult.allowed) { + const retrySeconds = Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000); return NextResponse.json( { - error: 'Too many requests. Please try again later.', + error: 'Too fast. Try again in a minute.', resetAt: rateLimitResult.resetAt, + retryAfter: retrySeconds, }, { status: 429, headers: { - 'Retry-After': Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000).toString(), + 'Retry-After': retrySeconds.toString(), }, } ); } - // Check if email already exists + if (isDev) { + console.warn('[DEV MODE] Using in-memory signup storage - data will not persist'); + + if (devSignups.has(sanitizedEmail)) { + return NextResponse.json( + { error: "Already got you. I'll keep you posted." }, + { status: 409 } + ); + } + + const signupData = { + email: sanitizedEmail, + timestamp: new Date().toISOString(), + ip: clientIp, + }; + + devSignups.set(sanitizedEmail, signupData); + + return NextResponse.json( + { + success: true, + message: 'Thanks. That means a lot.', + }, + { + status: 200, + headers: { + 'X-RateLimit-Remaining': rateLimitResult.remaining.toString(), + 'X-RateLimit-Reset': rateLimitResult.resetAt.toString(), + }, + } + ); + } + + // Production: Use Vercel KV + const { kv } = await import('@vercel/kv'); + const existingSignup = await kv.hget(SIGNUP_LIST_KEY, sanitizedEmail); if (existingSignup) { return NextResponse.json( - { error: 'This email is already registered' }, + { error: "Already got you. I'll keep you posted." }, { status: 409 } ); } - // Store email with timestamp const signupData = { email: sanitizedEmail, timestamp: new Date().toISOString(), @@ -84,11 +112,10 @@ export async function POST(request: NextRequest) { [sanitizedEmail]: JSON.stringify(signupData), }); - // Return success response return NextResponse.json( { success: true, - message: 'Successfully added to waitlist', + message: 'Thanks. That means a lot.', }, { status: 200, @@ -102,7 +129,7 @@ export async function POST(request: NextRequest) { console.error('Signup error:', error); return NextResponse.json( - { error: 'An error occurred. Please try again.' }, + { error: 'Something broke. Try again?' }, { status: 500 } ); } diff --git a/apps/website/app/page.tsx b/apps/website/app/page.tsx index 462a535bc..7fa33d3cb 100644 --- a/apps/website/app/page.tsx +++ b/apps/website/app/page.tsx @@ -1,16 +1,145 @@ import { ModeGuard } from '@/components/shared/ModeGuard'; import Hero from '@/components/landing/Hero'; +import AlternatingSection from '@/components/landing/AlternatingSection'; import FeatureGrid from '@/components/landing/FeatureGrid'; import EmailCapture from '@/components/landing/EmailCapture'; import FAQ from '@/components/landing/FAQ'; import Footer from '@/components/landing/Footer'; +import CareerProgressionMockup from '@/components/mockups/CareerProgressionMockup'; +import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup'; +import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup'; +import SimPlatformMockup from '@/components/mockups/SimPlatformMockup'; export default function HomePage() { return (
+ + {/* Section 1: A Persistent Identity */} + +

+ Your races, your seasons, your progress — finally in one place. +

+
    +
  • + + Lifetime stats and season history across all your leagues +
  • +
  • + + Track your performance, consistency, and team contributions +
  • +
  • + + Your own rating that reflects real league competition +
  • +
+

+ iRacing gives you physics. GridPilot gives you a career. +

+ + } + mockup={} + layout="text-left" + /> + + + {/* Section 2: Results That Actually Stay */} + +

+ Every race you run stays with you. +

+
    +
  • + + Your stats, your team, your story — all connected +
  • +
  • + + One race result updates your profile, team points, rating, and season history +
  • +
  • + + No more fragmented data across spreadsheets and forums +
  • +
+

+ Your racing career, finally in one place. +

+ + } + mockup={} + layout="text-right" + /> + + {/* Section 3: Automatic Session Creation */} + +

+ Setting up league races used to mean clicking through iRacing's wizard 20 times. +

+
    +
  • + + Our companion app syncs with your league schedule +
  • +
  • + + When it's race time, it creates the iRacing session automatically +
  • +
  • + + No clicking through wizards. No manual setup +
  • +
  • + + Runs on your machine, totally transparent, completely safe +
  • +
+

+ Automation instead of repetition. +

+ + } + mockup={} + layout="text-left" + /> + + {/* Section 4: Game-Agnostic Platform */} + +

+ Right now, we're focused on making iRacing league racing better. +

+

+ But sims come and go. Your leagues, your teams, your rating — those stay. +

+

+ GridPilot is built to outlast any single platform. +

+

+ When the next sim arrives, your competitive identity moves with you. +

+ + } + mockup={} + layout="text-right" + /> +