website poc

This commit is contained in:
2025-12-02 00:19:49 +01:00
parent 7330ccd82d
commit 747a77cb39
42 changed files with 8772 additions and 241 deletions

24
apps/website/.env.example Normal file
View File

@@ -0,0 +1,24 @@
# GridPilot Website Environment Variables
# Application Mode
# Controls whether the site is in pre-launch or post-launch mode
# Valid values: "pre-launch" | "post-launch"
# Default: "pre-launch" (if not set)
GRIDPILOT_MODE=pre-launch
# For client-side mode detection (must match GRIDPILOT_MODE)
# Note: NEXT_PUBLIC_ prefix exposes this to the browser
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
# Site URL (for metadata and OG tags)
NEXT_PUBLIC_SITE_URL=https://gridpilot.com
# Example for post-launch mode:
# GRIDPILOT_MODE=post-launch
# NEXT_PUBLIC_GRIDPILOT_MODE=post-launch

View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

34
apps/website/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

321
apps/website/README.md Normal file
View File

@@ -0,0 +1,321 @@
# GridPilot Landing Page
Pre-launch landing page for GridPilot with email signup functionality.
## Features
- **Mode Switching**: Toggle between pre-launch (landing page only) and post-launch (full platform) modes
- **Email Capture**: Collect email signups with validation and rate limiting
- **Production Ready**: Configured for Vercel deployment with KV storage
## Local Development Setup
### Prerequisites
- Node.js 20+
- npm or pnpm
### Installation
1. Clone the repository and navigate to the website directory:
```bash
cd apps/website
```
2. Install dependencies:
```bash
npm install
```
3. Copy environment variables:
```bash
cp .env.example .env.local
```
4. Configure environment variables in `.env.local`:
```bash
# Application Mode (pre-launch or post-launch)
GRIDPILOT_MODE=pre-launch
NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
# Vercel KV (required for email signups)
KV_REST_API_URL=your_kv_rest_api_url_here
KV_REST_API_TOKEN=your_kv_rest_api_token_here
# Site URL
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```
5. Start the development server:
```bash
npm run dev
```
Visit `http://localhost:3000` to see the landing page.
## Environment Variables
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `GRIDPILOT_MODE` | Application mode | `pre-launch` or `post-launch` |
| `NEXT_PUBLIC_GRIDPILOT_MODE` | Client-side mode detection | Must match `GRIDPILOT_MODE` |
| `KV_REST_API_URL` | Vercel KV REST API endpoint | From Vercel Dashboard |
| `KV_REST_API_TOKEN` | Vercel KV authentication token | From Vercel Dashboard |
| `NEXT_PUBLIC_SITE_URL` | Public site URL | `https://gridpilot.com` |
### Getting Vercel KV Credentials
1. Go to [Vercel Dashboard](https://vercel.com/dashboard)
2. Navigate to **Storage****Create Database****KV**
3. Create a new KV database (free tier available)
4. Copy the `KV_REST_API_URL` and `KV_REST_API_TOKEN` from the database settings
5. Add them to your environment variables
## Mode Switching
The application supports two modes:
### Pre-Launch Mode (Default)
- Shows landing page with email capture
- Only `/` and `/api/signup` routes are accessible
- All other routes return 404
To activate:
```bash
GRIDPILOT_MODE=pre-launch
NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
```
### Post-Launch Mode
- Full platform access
- All routes are accessible
- Landing page is still available at `/`
To activate:
```bash
GRIDPILOT_MODE=post-launch
NEXT_PUBLIC_GRIDPILOT_MODE=post-launch
```
## Email Signup API
### Endpoint
`POST /api/signup`
### Request Body
```json
{
"email": "user@example.com"
}
```
### Response
**Success (200):**
```json
{
"success": true,
"message": "Successfully added to waitlist"
}
```
**Error (400/409/429/500):**
```json
{
"error": "Error message here"
}
```
### Features
- **Validation**: Email format validation using Zod
- **Rate Limiting**: Max 5 submissions per IP per hour
- **Duplicate Prevention**: Prevents duplicate email submissions
- **Disposable Email Detection**: Blocks common disposable email services
- **Storage**: Emails stored in Vercel KV with timestamp and IP
### Accessing Submitted Emails
Submitted emails are stored in Vercel KV under the key `signups:emails`.
**Option 1: Vercel KV Dashboard**
1. Go to [Vercel Dashboard](https://vercel.com/dashboard)
2. Navigate to **Storage** → Your KV Database
3. Browse the `signups:emails` hash
**Option 2: CLI (with Vercel KV SDK)**
```typescript
import { kv } from '@vercel/kv';
// Get all signups
const signups = await kv.hgetall('signups:emails');
console.log(signups);
```
## Deployment
### Vercel Deployment (Recommended)
1. **Connect Repository**
- Go to [Vercel Dashboard](https://vercel.com/new)
- Import your Git repository
- Select the `apps/website` directory as the root
2. **Configure Build Settings**
- Framework Preset: Next.js
- Build Command: `npm run build`
- Output Directory: `.next`
- Install Command: `npm install`
3. **Set Environment Variables**
In Vercel Dashboard → Project Settings → Environment Variables:
**Production:**
```
GRIDPILOT_MODE=pre-launch
NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
KV_REST_API_URL=<your_vercel_kv_url>
KV_REST_API_TOKEN=<your_vercel_kv_token>
NEXT_PUBLIC_SITE_URL=https://gridpilot.com
```
**Preview (optional):**
Same as production, or use different values for testing
4. **Deploy**
- Click "Deploy"
- Vercel will automatically deploy on every push to main branch
### Switching to Post-Launch Mode
When ready to launch the full platform:
1. Go to Vercel Dashboard → Project Settings → Environment Variables
2. Update `GRIDPILOT_MODE` and `NEXT_PUBLIC_GRIDPILOT_MODE` to `post-launch`
3. Redeploy the application (automatic if you save changes)
### Custom Domain Setup
1. Go to Vercel Dashboard → Project Settings → Domains
2. Add your domain (e.g., `gridpilot.com`)
3. Follow DNS configuration instructions
4. Update `NEXT_PUBLIC_SITE_URL` environment variable to match your domain
## Build & Test
### Build for Production
```bash
npm run build
```
This creates an optimized production build in `.next/` directory.
### Start Production Server
```bash
npm run start
```
### Lint
```bash
npm run lint
```
## Testing Email Signup
### Test Locally
1. Start the development server
2. Navigate to `http://localhost:3000`
3. Scroll to "Want Early Access?" section
4. Enter an email and click "Join Waitlist"
### Test Scenarios
- ✅ Valid email submission
- ✅ Invalid email format rejection
- ✅ Duplicate email prevention
- ✅ Rate limiting (5 submissions per hour)
- ✅ Loading states
- ✅ Error messages
- ✅ Success confirmation
### Test Rate Limiting
Submit the same email 5 times within an hour. The 6th submission should return a 429 error.
## Troubleshooting
### Email Signup Not Working
**Issue**: API returns 500 error
**Solution**: Check that `KV_REST_API_URL` and `KV_REST_API_TOKEN` are correctly set in environment variables
### Mode Switching Not Working
**Issue**: Routes still show 404 in post-launch mode
**Solution**:
1. Ensure `GRIDPILOT_MODE` and `NEXT_PUBLIC_GRIDPILOT_MODE` are both set to `post-launch`
2. Restart the development server or redeploy to Vercel
3. Clear browser cache
### Rate Limiting Issues
**Issue**: Rate limiting not working in development
**Solution**: Vercel KV may need to be properly configured. Check KV dashboard for errors.
## Project Structure
```
apps/website/
├── app/
│ ├── api/
│ │ └── signup/
│ │ └── route.ts # Email signup API endpoint
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx # Landing page
├── components/
│ ├── landing/
│ │ ├── EmailCapture.tsx # Email signup form
│ │ ├── FAQ.tsx
│ │ ├── FeatureGrid.tsx
│ │ ├── Hero.tsx
│ │ └── RatingExplainer.tsx
│ ├── mockups/ # UI mockups
│ ├── shared/
│ │ └── ModeGuard.tsx # Client-side mode guard
│ └── ui/ # Reusable UI components
├── lib/
│ ├── email-validation.ts # Email validation utilities
│ ├── mode.ts # Mode detection utilities
│ └── rate-limit.ts # Rate limiting utilities
├── middleware.ts # Next.js middleware for route protection
├── .env.example # Environment variables template
└── README.md # This file
```
## Tech Stack
- **Framework**: Next.js 15 (App Router)
- **Language**: TypeScript
- **Styling**: Tailwind CSS
- **Validation**: Zod
- **Storage**: Vercel KV (Redis)
- **Deployment**: Vercel
## Support
For issues or questions, contact the development team or open an issue in the repository.

View File

@@ -0,0 +1,120 @@
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';
/**
* 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' },
{ status: 400 }
);
}
// Validate email format
const validation = validateEmail(email);
if (!validation.success) {
return NextResponse.json(
{ error: validation.error },
{ status: 400 }
);
}
const sanitizedEmail = validation.email!;
// Check for disposable email
if (isDisposableEmail(sanitizedEmail)) {
return NextResponse.json(
{ error: 'Disposable email addresses are not allowed' },
{ status: 400 }
);
}
// Rate limiting
const clientIp = getClientIp(request);
const rateLimitResult = await checkRateLimit(clientIp);
if (!rateLimitResult.allowed) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
resetAt: rateLimitResult.resetAt,
},
{
status: 429,
headers: {
'Retry-After': Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000).toString(),
},
}
);
}
// Check if email already exists
const existingSignup = await kv.hget(SIGNUP_LIST_KEY, sanitizedEmail);
if (existingSignup) {
return NextResponse.json(
{ error: 'This email is already registered' },
{ status: 409 }
);
}
// Store email with timestamp
const signupData = {
email: sanitizedEmail,
timestamp: new Date().toISOString(),
ip: clientIp,
};
await kv.hset(SIGNUP_LIST_KEY, {
[sanitizedEmail]: JSON.stringify(signupData),
});
// Return success response
return NextResponse.json(
{
success: true,
message: 'Successfully added to waitlist',
},
{
status: 200,
headers: {
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.resetAt.toString(),
},
}
);
} catch (error) {
console.error('Signup error:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}
/**
* GET /api/signup
* Return 405 Method Not Allowed
*/
export async function GET() {
return NextResponse.json(
{ error: 'Method not allowed' },
{ status: 405 }
);
}

View File

@@ -0,0 +1,27 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-deep-graphite: #0E0F11;
--color-iron-gray: #181B1F;
--color-charcoal-outline: #22262A;
--color-primary-blue: #198CFF;
--color-performance-green: #6FE37A;
--color-warning-amber: #FFC556;
--color-neon-aqua: #43C9E6;
}
body {
@apply bg-deep-graphite text-white antialiased;
}
}
@layer utilities {
.animate-spring {
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}
}

View File

@@ -0,0 +1,41 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'GridPilot - iRacing League Racing Platform',
description: 'The dedicated home for serious iRacing leagues. Automatic results, standings, team racing, and professional race control.',
openGraph: {
title: 'GridPilot - iRacing League Racing Platform',
description: 'Structure over chaos. The professional platform for iRacing league racing.',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'GridPilot - iRacing League Racing Platform',
description: 'Structure over chaos. The professional platform for iRacing league racing.',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="scroll-smooth">
<body className="antialiased">
<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>
</div>
</header>
<div className="pt-16">
{children}
</div>
</body>
</html>
);
}

20
apps/website/app/page.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { ModeGuard } from '@/components/shared/ModeGuard';
import Hero from '@/components/landing/Hero';
import FeatureGrid from '@/components/landing/FeatureGrid';
import EmailCapture from '@/components/landing/EmailCapture';
import FAQ from '@/components/landing/FAQ';
import Footer from '@/components/landing/Footer';
export default function HomePage() {
return (
<ModeGuard mode="pre-launch">
<main className="min-h-screen">
<Hero />
<FeatureGrid />
<EmailCapture />
<FAQ />
<Footer />
</main>
</ModeGuard>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import { useState, FormEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
export default function EmailCapture() {
const [email, setEmail] = useState('');
const [isValid, setIsValid] = useState(true);
const [submitted, setSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!email) {
setIsValid(false);
setErrorMessage('Email is required');
return;
}
setIsSubmitting(true);
setErrorMessage('');
try {
const response = await fetch('/api/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (!response.ok) {
setIsValid(false);
setErrorMessage(data.error || 'Failed to submit email');
return;
}
setSubmitted(true);
setEmail('');
} catch (error) {
setIsValid(false);
setErrorMessage('Network error. Please try again.');
console.error('Signup error:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<section id="early-access" className="relative py-24 bg-gradient-to-b from-deep-graphite to-iron-gray">
<div className="max-w-2xl mx-auto px-6">
<AnimatePresence mode="wait">
{submitted ? (
<motion.div
key="success"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
className="relative rounded-xl bg-gradient-to-br from-iron-gray via-deep-graphite to-iron-gray p-12 text-center border border-charcoal-outline shadow-[0_0_80px_rgba(111,227,122,0.15)]"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-performance-green/20 mb-6"
>
<svg className="w-8 h-8 text-performance-green" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</motion.div>
<h2 className="text-3xl font-semibold text-white mb-4">You&apos;re on the list!</h2>
<p className="text-base text-gray-400 font-light">
Check your email for confirmation. We&apos;ll notify you when GridPilot launches.
</p>
</motion.div>
) : (
<motion.div
key="form"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="relative rounded-xl bg-gradient-to-br from-iron-gray via-deep-graphite to-iron-gray p-8 md:p-16 border border-charcoal-outline shadow-[0_0_80px_rgba(25,140,255,0.15)]"
>
<div className="text-center mb-10">
<h2 className="text-4xl md:text-5xl font-semibold text-white mb-3">
Join the Waitlist
</h2>
<div className="w-32 h-1 bg-gradient-to-r from-primary-blue to-neon-aqua mx-auto mb-6 rounded-full" />
<p className="text-lg text-gray-400 font-light max-w-lg mx-auto">
Be among the first to experience GridPilot when we launch early access.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setIsValid(true);
setErrorMessage('');
}}
placeholder="your@email.com"
disabled={isSubmitting}
className={`w-full px-6 py-4 rounded-lg bg-iron-gray text-white placeholder-gray-500 border transition-all duration-150 ${
!isValid
? 'border-red-500 focus:ring-2 focus:ring-red-500'
: 'border-charcoal-outline focus:border-neon-aqua focus:ring-2 focus:ring-neon-aqua/50'
} hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed`}
aria-label="Email address"
/>
{!isValid && errorMessage && (
<p className="mt-2 text-sm text-red-400">{errorMessage}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="px-8 py-4 rounded-full bg-gradient-to-r from-primary-blue to-neon-aqua text-white font-semibold transition-all duration-150 hover:shadow-glow-strong hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 whitespace-nowrap"
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Joining...
</span>
) : (
'Get Early Access'
)}
</button>
</div>
</form>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="mt-8 flex flex-col sm:flex-row gap-4 justify-center text-sm text-gray-400 font-light"
>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-performance-green" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span>No spam, ever</span>
</div>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-performance-green" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span>Early feature access</span>
</div>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-performance-green" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span>Priority support</span>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</section>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
const faqs = [
{
question: "What is GridPilot?",
answer: "A platform to make league racing less chaotic. I kept running into the same problems running leagues myself (scattered spreadsheets, manual standings, DM chaos for protests), so I'm building something to fix that."
},
{
question: "How much does it cost?",
answer: "No idea yet. I'm focusing on making something useful first. Money isn't even on the table until the platform actually proves itself. When we do introduce any pricing, all running leagues will be able to finish their seasons before being charged. No subscriptions, no surprise fees."
},
{
question: "When does it launch?",
answer: "When it's ready. I'm aiming for ASAP, but definitely not before late March 2025. I'd rather ship something solid than rush it."
},
{
question: "Will this replace iRacing / my existing tools?",
answer: "No. I don't want to replace anything. GridPilot sits on top of iRacing - it just handles the league management part (standings, schedules, protests, team scoring) so you don't need 5 different spreadsheets and Discord channels. Racing still happens in iRacing."
},
{
question: "What about my existing league data?",
answer: "I'm thinking about migration tools to import your current standings, rosters, and results. Can't promise anything yet, but it's on my mind."
},
{
question: "Who's building this?",
answer: "Me (and hopefully some help along the way). I'm a racer who got tired of the chaos. This started as scratching my own itch."
}
];
function FAQItem({ faq, index }: { faq: typeof faqs[0]; index: number }) {
const [isOpen, setIsOpen] = useState(false);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className="group"
>
<div className="rounded-lg bg-iron-gray border border-charcoal-outline transition-all duration-150 hover:-translate-y-1 hover:shadow-lg hover:border-primary-blue/50">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full p-6 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-blue rounded-lg"
>
<div className="flex items-center justify-between gap-4">
<h3 className="text-lg font-semibold text-white group-hover:text-primary-blue transition-colors duration-150">
{faq.question}
</h3>
<motion.svg
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.15, ease: 'easeInOut' }}
className="w-5 h-5 text-neon-aqua flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</motion.svg>
</div>
</button>
<motion.div
initial={false}
animate={{
height: isOpen ? 'auto' : 0,
opacity: isOpen ? 1 : 0
}}
transition={{
height: { duration: 0.3, ease: [0.34, 1.56, 0.64, 1] },
opacity: { duration: 0.2, ease: 'easeInOut' }
}}
className="overflow-hidden"
>
<div className="px-6 pb-6">
<p className="text-base text-gray-300 font-light">
{faq.answer}
</p>
</div>
</motion.div>
</div>
</motion.div>
);
}
export default function FAQ() {
return (
<section className="py-24 bg-deep-graphite">
<div className="max-w-3xl mx-auto px-6">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-semibold text-white mb-3">
Frequently Asked Questions
</h2>
<div className="w-32 h-1 bg-gradient-to-r from-primary-blue to-neon-aqua mx-auto rounded-full" />
</div>
<div className="space-y-4">
{faqs.map((faq, index) => (
<FAQItem key={faq.question} faq={faq} index={index} />
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,79 @@
import Section from '@/components/ui/Section';
import Container from '@/components/ui/Container';
import Heading from '@/components/ui/Heading';
import MockupStack from '@/components/ui/MockupStack';
import LeagueHomeMockup from '@/components/mockups/LeagueHomeMockup';
import StandingsTableMockup from '@/components/mockups/StandingsTableMockup';
import TeamCompetitionMockup from '@/components/mockups/TeamCompetitionMockup';
import ProtestWorkflowMockup from '@/components/mockups/ProtestWorkflowMockup';
import LeagueDiscoveryMockup from '@/components/mockups/LeagueDiscoveryMockup';
import DriverProfileMockup from '@/components/mockups/DriverProfileMockup';
const features = [
{
title: "A Real Home for Your League",
description: "Stop juggling Discord, spreadsheets, and iRacing admin panels. GridPilot brings everything into one dedicated platform built specifically for league racing.",
MockupComponent: LeagueHomeMockup
},
{
title: "Automatic Results & Standings",
description: "Race happens. Results appear. Standings update. No manual data entry, no spreadsheet formulas, no waiting for someone to publish.",
MockupComponent: StandingsTableMockup
},
{
title: "Real Team Racing",
description: "Constructors' championships that actually matter. Driver lineups. Team strategies. Multi-class racing done right.",
MockupComponent: TeamCompetitionMockup
},
{
title: "Clean Protests & Penalties",
description: "Structured incident reporting with video clip references. Steward review workflows. Transparent penalty application. Professional race control.",
MockupComponent: ProtestWorkflowMockup
},
{
title: "Find Your Perfect League",
description: "Search and discover leagues by game, region, and skill level. Browse featured competitions, check driver counts, and join communities that match your racing style.",
MockupComponent: LeagueDiscoveryMockup
},
{
title: "Your Racing Identity",
description: "Cross-league driver profiles with career stats, achievements, and racing history. Build your reputation across multiple championships and showcase your progression.",
MockupComponent: DriverProfileMockup
}
];
export default function FeatureGrid() {
return (
<Section variant="default">
<Container>
<Container size="sm" center>
<Heading level={2} className="text-white">
Built for League Racing
</Heading>
<p className="mt-4 text-lg text-gray-400">
Everything you need to run a professional iRacing league, nothing you don&apos;t
</p>
</Container>
<div className="mx-auto mt-16 grid max-w-2xl grid-cols-1 gap-16 sm:mt-20 lg:max-w-none lg:grid-cols-2 xl:grid-cols-3">
{features.map((feature, index) => (
<div key={feature.title} className="flex flex-col gap-6">
<div className="aspect-video w-full">
<MockupStack index={index}>
<feature.MockupComponent />
</MockupStack>
</div>
<div>
<Heading level={3} className="text-white">
{feature.title}
</Heading>
<p className="mt-2 text-base leading-7 text-gray-400">
{feature.description}
</p>
</div>
</div>
))}
</div>
</Container>
</Section>
);
}

View File

@@ -0,0 +1,74 @@
export default function Footer() {
return (
<footer className="relative bg-deep-graphite">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
<div className="max-w-6xl mx-auto px-6 py-16">
<div className="grid grid-cols-1 md:grid-cols-3 gap-12 mb-12">
<div>
<h3 className="text-xl font-semibold text-white mb-2">GridPilot</h3>
<div className="w-16 h-0.5 bg-gradient-to-r from-primary-blue to-neon-aqua mb-4 rounded-full" />
<p className="text-sm text-gray-400 font-light">
Making league racing less chaotic
</p>
</div>
<div>
<h4 className="text-sm font-semibold text-white mb-4">Product</h4>
<ul className="space-y-3">
<li>
<a href="#features" className="text-sm text-gray-400 hover:text-primary-blue transition-colors duration-150 font-light">
Features
</a>
</li>
<li>
<span className="text-sm text-gray-400 font-light cursor-not-allowed opacity-50">
Pricing <span className="text-xs">(coming soon)</span>
</span>
</li>
<li>
<span className="text-sm text-gray-400 font-light cursor-not-allowed opacity-50">
Roadmap <span className="text-xs">(coming soon)</span>
</span>
</li>
<li>
<span className="text-sm text-gray-400 font-light cursor-not-allowed opacity-50">
Docs <span className="text-xs">(coming soon)</span>
</span>
</li>
</ul>
</div>
<div>
<h4 className="text-sm font-semibold text-white mb-4">Legal</h4>
<ul className="space-y-3">
<li>
<span className="text-sm text-gray-400 font-light cursor-not-allowed opacity-50">
Privacy <span className="text-xs">(coming soon)</span>
</span>
</li>
<li>
<span className="text-sm text-gray-400 font-light cursor-not-allowed opacity-50">
Terms <span className="text-xs">(coming soon)</span>
</span>
</li>
<li>
<span className="text-sm text-gray-400 font-light cursor-not-allowed opacity-50">
Status <span className="text-xs">(coming soon)</span>
</span>
</li>
</ul>
</div>
</div>
<div className="pt-8 border-t border-charcoal-outline">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-sm text-gray-500 font-light">
© 2025 GridPilot. All rights reserved.
</p>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,34 @@
import Button from '@/components/ui/Button';
import Container from '@/components/ui/Container';
import Heading from '@/components/ui/Heading';
export default function Hero() {
return (
<section className="relative overflow-hidden bg-deep-graphite px-6 py-24 sm:py-32 lg:px-8">
{/* Subtle radial gradient overlay */}
<div className="absolute inset-0 bg-gradient-radial from-primary-blue/10 via-transparent to-transparent opacity-40" />
{/* Optional motorsport grid pattern */}
<div className="absolute inset-0 opacity-[0.015]" style={{
backgroundImage: `linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)`,
backgroundSize: '40px 40px'
}} />
<Container size="sm" center>
<Heading level={1} className="text-white leading-tight tracking-tight">
Where iRacing League Racing Finally Comes Together
</Heading>
<p className="mt-8 text-lg leading-relaxed text-slate-400 font-light">
No more Discord chaos. No more spreadsheets. No more manual standings.
GridPilot is the dedicated home for serious iRacing leagues that want structure over chaos.
</p>
<div className="mt-12 flex items-center justify-center">
<Button as="a" href="#early-access" className="shadow-glow hover:shadow-glow-strong transition-shadow duration-300">
Get Early Access
</Button>
</div>
</Container>
</section>
);
}

View File

@@ -0,0 +1,220 @@
'use client';
import { motion, useReducedMotion, useMotionValue, useSpring, useTransform } from 'framer-motion';
import { useEffect } from 'react';
export default function DriverProfileMockup() {
const shouldReduceMotion = useReducedMotion();
const stats = [
{ label: 'Wins', value: 24 },
{ label: 'Podiums', value: 48 },
{ label: 'Championships', value: 3 },
{ label: 'Races', value: 156 },
{ label: 'Finish Rate', value: 94, suffix: '%' }
];
const formData = [85, 72, 68, 91, 88, 95, 88, 79, 82, 91];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: shouldReduceMotion ? 0 : 0.1 }
}
};
const itemVariants = {
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 10 },
visible: {
opacity: 1,
y: 0,
transition: { type: 'spring' as const, stiffness: 200, damping: 20 }
}
};
return (
<div className="relative w-full h-full bg-gradient-to-br from-deep-graphite via-iron-gray to-deep-graphite rounded-lg p-6 md:p-8 overflow-hidden">
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6"
>
<div className="flex items-center justify-between mb-4">
<div>
<div className="h-6 w-48 bg-white/10 rounded mb-2"></div>
<div className="h-4 w-24 bg-white/5 rounded"></div>
</div>
<div className="text-4xl font-bold text-charcoal-outline">#33</div>
</div>
<div className="flex items-center gap-4 mb-2">
<div className="text-xs text-gray-400">GridPilot Rating:</div>
<AnimatedRating shouldReduceMotion={shouldReduceMotion ?? false} value={2150} />
<div className="text-xs text-gray-400 ml-4">iRating:</div>
<AnimatedRating shouldReduceMotion={shouldReduceMotion ?? false} value={3200} />
</div>
<div className="relative h-3 bg-charcoal-outline rounded-full overflow-hidden">
<motion.div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full"
initial={{ width: '0%' }}
animate={{ width: '86%' }}
transition={{ delay: shouldReduceMotion ? 0 : 0.4, duration: 0.8, ease: 'easeOut' }}
/>
</div>
<div className="flex justify-end mt-1">
<span className="text-xs text-gray-400">86%</span>
</div>
</motion.div>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="mb-6"
>
<div className="h-4 w-32 bg-white/10 rounded mb-3"></div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{stats.map((stat, index) => (
<motion.div
key={stat.label}
variants={itemVariants}
className="bg-iron-gray/50 border border-charcoal-outline rounded-lg p-3 text-center"
>
<AnimatedCounter
value={stat.value}
shouldReduceMotion={shouldReduceMotion ?? false}
delay={index * 0.1}
suffix={stat.suffix}
/>
<div className="text-xs text-gray-400 mt-1">{stat.label}</div>
</motion.div>
))}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: shouldReduceMotion ? 0 : 0.6 }}
className="mb-6"
>
<div className="h-4 w-28 bg-white/10 rounded mb-3"></div>
<div className="h-20 bg-iron-gray/30 border border-charcoal-outline rounded-lg p-3 flex items-end gap-1">
{formData.map((value, i) => (
<motion.div
key={i}
className="flex-1 bg-gradient-to-t from-performance-green to-primary-blue rounded-sm"
initial={{ height: 0 }}
animate={{ height: `${value}%` }}
transition={{
delay: shouldReduceMotion ? 0 : 0.8 + i * 0.05,
duration: 0.4,
ease: 'easeOut'
}}
/>
))}
</div>
<div className="flex justify-between mt-1 text-xs text-gray-500">
<span>Last 10 races</span>
<span>Recent</span>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: shouldReduceMotion ? 0 : 0.8 }}
>
<div className="h-4 w-20 bg-white/10 rounded mb-3"></div>
<div className="space-y-2">
{[
{ team: 'Red Bull Racing', status: 'Current', color: 'primary-blue' },
{ team: 'Mercedes AMG', status: '2023', color: 'charcoal-outline' }
].map((team, i) => (
<motion.div
key={team.team}
initial={{ opacity: 0, x: shouldReduceMotion ? 0 : -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: shouldReduceMotion ? 0 : 0.9 + i * 0.1 }}
className="flex items-center justify-between bg-iron-gray/30 border border-charcoal-outline rounded-lg p-2 text-sm"
>
<div className="h-3 w-32 bg-white/10 rounded"></div>
<span className={`text-xs px-2 py-0.5 rounded ${
team.status === 'Current'
? 'bg-primary-blue/20 text-primary-blue'
: 'bg-charcoal-outline text-gray-400'
}`}>
{team.status}
</span>
</motion.div>
))}
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: shouldReduceMotion ? 0 : 1.2 }}
className="mt-4 text-center text-xs text-gray-400"
>
Active in 3 leagues
</motion.div>
</motion.div>
</div>
);
}
function AnimatedRating({ shouldReduceMotion, value }: { shouldReduceMotion: boolean; value: number }) {
const count = useMotionValue(0);
const rounded = useTransform(count, (v) => Math.round(v));
const spring = useSpring(count, { stiffness: 50, damping: 25 });
useEffect(() => {
if (shouldReduceMotion) {
count.set(value);
} else {
const timeout = setTimeout(() => count.set(value), 200);
return () => clearTimeout(timeout);
}
}, [shouldReduceMotion, count, value]);
return (
<motion.span className="text-lg font-bold text-primary-blue font-mono">
{shouldReduceMotion ? value : <motion.span>{rounded}</motion.span>}
</motion.span>
);
}
function AnimatedCounter({
value,
shouldReduceMotion,
delay,
suffix = ''
}: {
value: number;
shouldReduceMotion: boolean;
delay: number;
suffix?: string;
}) {
const count = useMotionValue(0);
const rounded = useTransform(count, (v) => Math.round(v));
useEffect(() => {
if (shouldReduceMotion) {
count.set(value);
} else {
const timeout = setTimeout(() => count.set(value), delay * 1000 + 400);
return () => clearTimeout(timeout);
}
}, [shouldReduceMotion, count, value, delay]);
return (
<div className="text-xl font-bold text-white font-mono">
{shouldReduceMotion ? value : <motion.span>{rounded}</motion.span>}{suffix}
</div>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { useState } from 'react';
export default function LeagueDiscoveryMockup() {
const shouldReduceMotion = useReducedMotion();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const leagues = [
{
name: 'Championship Series',
carClass: 'GT3',
region: 'EU',
skill: '2.5k iRating',
drivers: 48,
schedule: 'Wed 20:00 CET',
rating: 4.8,
icon: '🏁'
},
{
name: 'Touring Car Challenge',
carClass: 'TCR',
region: 'NA',
skill: 'Open skill',
drivers: 32,
schedule: 'Sat 18:00 EST',
rating: 4.6,
icon: '🏎️'
}
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: shouldReduceMotion ? 0 : 0.15 }
}
};
const cardVariants = {
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 20 },
visible: {
opacity: 1,
y: 0,
transition: { type: 'spring' as const, stiffness: 200, damping: 20 }
}
};
return (
<div className="relative w-full h-full bg-gradient-to-br from-deep-graphite via-iron-gray to-deep-graphite rounded-lg p-6 md:p-8 overflow-hidden">
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6"
>
<div className="h-6 w-52 bg-white/10 rounded mb-4"></div>
<div className="flex gap-2 flex-wrap">
{['Game', 'Region', 'Skill'].map((filter, i) => (
<motion.div
key={filter}
initial={{ opacity: 0, scale: shouldReduceMotion ? 1 : 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: shouldReduceMotion ? 0 : i * 0.1 }}
className="h-8 px-4 bg-charcoal-outline border border-primary-blue/30 rounded-full flex items-center"
>
<div className="h-2 w-12 bg-white/10 rounded"></div>
</motion.div>
))}
</div>
</motion.div>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-4"
>
{leagues.map((league, index) => (
<motion.div
key={league.name}
variants={cardVariants}
onHoverStart={() => !shouldReduceMotion && setHoveredIndex(index)}
onHoverEnd={() => setHoveredIndex(null)}
whileHover={shouldReduceMotion ? {} : {
scale: 1.02,
y: -4,
transition: { duration: 0.2 }
}}
className="bg-iron-gray/80 border border-charcoal-outline rounded-lg p-4 backdrop-blur-sm"
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="text-2xl">{league.icon}</div>
<div>
<div className="h-4 w-40 bg-white/10 rounded mb-2"></div>
<div className="flex gap-2 text-xs">
<span className="px-2 py-0.5 bg-primary-blue/20 text-primary-blue rounded">
{league.carClass}
</span>
<span className="px-2 py-0.5 bg-neon-aqua/20 text-neon-aqua rounded">
{league.region}
</span>
<span className="px-2 py-0.5 bg-charcoal-outline text-gray-400 rounded">
{league.skill}
</span>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-400 mb-3">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<span>{league.drivers} drivers</span>
</div>
<div className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{league.schedule}</span>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
{[...Array(5)].map((_, i) => (
<motion.svg
key={i}
className={`w-4 h-4 ${i < Math.floor(league.rating) ? 'text-warning-amber' : 'text-charcoal-outline'}`}
fill="currentColor"
viewBox="0 0 20 20"
animate={hoveredIndex === index && !shouldReduceMotion ? {
scale: [1, 1.2, 1],
transition: { delay: i * 0.05, duration: 0.3 }
} : {}}
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</motion.svg>
))}
<span className="text-xs text-gray-400 ml-1">{league.rating}</span>
</div>
<div className="flex gap-2">
<motion.button
whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
className="px-3 py-1 bg-primary-blue text-white text-xs rounded hover:bg-primary-blue/80 transition-colors"
>
Join
</motion.button>
<motion.button
whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
className="px-3 py-1 bg-charcoal-outline text-gray-300 text-xs rounded hover:bg-charcoal-outline/80 transition-colors"
>
View
</motion.button>
</div>
</div>
</motion.div>
))}
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
export default function LeagueHomeMockup() {
const shouldReduceMotion = useReducedMotion();
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: shouldReduceMotion ? 0 : 0.1 }
}
};
const itemVariants = {
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 20 },
visible: { opacity: 1, y: 0 }
};
return (
<div className="relative w-full h-full bg-gradient-to-br from-deep-graphite via-iron-gray to-deep-graphite rounded-lg p-8 overflow-hidden">
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-6"
>
<motion.div variants={itemVariants}>
<div className="text-xl font-bold text-white mb-2">Super GT Championship</div>
<div className="text-sm text-gray-400">Season 3 Round 8/12</div>
</motion.div>
<motion.div variants={itemVariants}>
<div className="text-base font-semibold text-white mb-4">Upcoming Races</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<motion.div
key={i}
className="relative flex items-center gap-4 bg-iron-gray rounded-lg p-4 border border-charcoal-outline shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)]"
whileHover={shouldReduceMotion ? {} : {
y: -2,
boxShadow: '0 4px 24px rgba(0,0,0,0.4), 0 0 20px rgba(25,140,255,0.3)',
transition: { duration: 0.15 }
}}
transition={{ type: 'spring', stiffness: 200, damping: 20 }}
>
<div className="h-10 w-10 bg-charcoal-outline rounded border border-primary-blue/20"></div>
<div className="flex-1">
<div className="h-3 w-32 bg-white/10 rounded mb-2"></div>
<div className="h-2.5 w-24 bg-white/5 rounded font-mono"></div>
</div>
{i === 1 && (
<motion.div
className="absolute right-4"
animate={shouldReduceMotion ? {} : {
scale: [1, 1.2, 1],
boxShadow: [
'0 0 20px rgba(25,140,255,0.3)',
'0 0 32px rgba(67,201,230,0.4)',
'0 0 20px rgba(25,140,255,0.3)'
]
}}
transition={{ duration: 2, repeat: Infinity }}
>
<div className="w-3 h-3 bg-primary-blue rounded-full shadow-glow"></div>
</motion.div>
)}
</motion.div>
))}
</div>
</motion.div>
<motion.div variants={itemVariants}>
<div className="text-base font-semibold text-white mb-4">Recent Results</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline shadow-[0_4px_24px_rgba(0,0,0,0.4)]">
<div className="flex items-center gap-3 mb-3 pb-3 border-b border-charcoal-outline">
<div className="h-2.5 w-8 bg-white/10 rounded font-mono"></div>
<div className="h-2.5 flex-1 bg-white/10 rounded"></div>
<div className="h-2.5 w-12 bg-white/10 rounded font-mono"></div>
</div>
{[1, 2].map((i) => (
<div key={i} className="flex items-center gap-3 py-2">
<div className="h-2.5 w-8 bg-white/5 rounded font-mono"></div>
<div className="h-2.5 flex-1 bg-white/5 rounded"></div>
<div className="h-2.5 w-12 bg-performance-green/20 rounded text-center font-mono text-performance-green"></div>
</div>
))}
</div>
</motion.div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { useState } from 'react';
export default function ProtestWorkflowMockup() {
const shouldReduceMotion = useReducedMotion();
const [activeStep, setActiveStep] = useState<number>(1);
const steps = [
{
name: 'Submit',
status: 'pending',
color: 'charcoal-outline',
icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'
},
{
name: 'Review',
status: 'active',
color: 'warning-amber',
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4'
},
{
name: 'Resolve',
status: 'resolved',
color: 'performance-green',
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'
},
];
const stepVariants = {
hidden: { opacity: 0, scale: shouldReduceMotion ? 1 : 0.8 },
visible: (i: number) => ({
opacity: 1,
scale: 1,
transition: {
delay: shouldReduceMotion ? 0 : i * 0.2,
type: 'spring' as const,
stiffness: 200,
damping: 20
}
})
};
const arrowVariants = {
hidden: { opacity: 0, pathLength: 0 },
visible: (i: number) => ({
opacity: 1,
pathLength: 1,
transition: {
delay: shouldReduceMotion ? 0 : i * 0.2 + 0.1,
duration: 0.5,
ease: 'easeOut' as const
}
})
};
const getStatusColor = (status: string) => {
switch (status) {
case 'pending': return 'bg-charcoal-outline border-charcoal-outline text-gray-500';
case 'active': return 'bg-warning-amber/20 border-warning-amber text-warning-amber';
case 'resolved': return 'bg-performance-green/20 border-performance-green text-performance-green';
default: return 'bg-charcoal-outline border-charcoal-outline text-gray-500';
}
};
return (
<div className="relative w-full h-full bg-gradient-to-br from-deep-graphite via-iron-gray to-deep-graphite rounded-lg overflow-hidden p-6 flex flex-col justify-center">
<div className="flex flex-col md:flex-row items-center justify-center gap-4 mb-4">
{steps.map((step, i) => (
<div key={step.name} className="flex items-center flex-shrink-0">
<motion.div
custom={i}
variants={stepVariants}
initial="hidden"
animate="visible"
className="flex flex-col items-center"
onHoverStart={() => !shouldReduceMotion && setActiveStep(i)}
>
<motion.div
className={`relative w-12 h-12 md:w-14 md:h-14 rounded-lg flex items-center justify-center mb-2 border-2 ${getStatusColor(step.status)}`}
whileHover={shouldReduceMotion ? {} : {
scale: 1.1,
boxShadow: step.status === 'active'
? '0 0 32px rgba(255,197,86,0.4)'
: step.status === 'resolved'
? '0 0 32px rgba(111,227,122,0.4)'
: '0 0 20px rgba(34,38,42,0.4)',
transition: { duration: 0.2 }
}}
transition={{ type: 'spring', stiffness: 300, damping: 15 }}
>
<svg className="w-6 h-6 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={step.icon} />
</svg>
{step.status === 'active' && (
<motion.div
className="absolute inset-0 rounded-lg"
animate={shouldReduceMotion ? {} : {
boxShadow: [
'0 0 20px rgba(255,197,86,0.3)',
'0 0 32px rgba(255,197,86,0.5)',
'0 0 20px rgba(255,197,86,0.3)'
]
}}
transition={{ duration: 2, repeat: Infinity }}
/>
)}
</motion.div>
<div className="text-xs text-white/70 text-center mb-1">{step.name}</div>
<motion.div
className={`h-1.5 w-10 md:w-12 rounded ${
step.status === 'pending' ? 'bg-charcoal-outline' :
step.status === 'active' ? 'bg-warning-amber/30' :
'bg-performance-green/30'
}`}
animate={shouldReduceMotion ? {} : step.status === 'active' ? {
opacity: [0.5, 1, 0.5]
} : {}}
transition={{ duration: 2, repeat: Infinity }}
/>
</motion.div>
{i < steps.length - 1 && (
<div className="hidden md:block relative ml-1.5">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none">
<path d="M5 12h14m-7-7l7 7-7 7" stroke="#43C9E6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
)}
</div>
))}
</div>
<motion.div
initial={{ opacity: 0, scaleX: 0 }}
animate={{ opacity: 1, scaleX: 1 }}
transition={{ delay: shouldReduceMotion ? 0 : 0.8, duration: 0.6 }}
className="relative h-1 bg-charcoal-outline rounded-full overflow-hidden"
>
<motion.div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-neon-aqua to-primary-blue rounded-full"
initial={{ width: '0%' }}
animate={{ width: `${((activeStep + 1) / steps.length) * 100}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
'use client';
import { motion, useReducedMotion, useMotionValue, useSpring, useTransform } from 'framer-motion';
import { useEffect, useState } from 'react';
export default function RatingFactorsMockup() {
const shouldReduceMotion = useReducedMotion();
const [isHovered, setIsHovered] = useState<number | null>(null);
const factors = [
{ name: 'Position', value: 85, color: 'text-primary-blue', bgColor: 'bg-primary-blue' },
{ name: 'Field Strength', value: 72, color: 'text-neon-aqua', bgColor: 'bg-neon-aqua' },
{ name: 'Consistency', value: 68, color: 'text-performance-green', bgColor: 'bg-performance-green' },
{ name: 'Clean Driving', value: 91, color: 'text-warning-amber', bgColor: 'bg-warning-amber' },
{ name: 'Reliability', value: 88, color: 'text-primary-blue', bgColor: 'bg-primary-blue' },
{ name: 'Team Points', value: 79, color: 'text-performance-green', bgColor: 'bg-performance-green' },
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: shouldReduceMotion ? 0 : 0.1 }
}
};
const itemVariants = {
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 20 },
visible: {
opacity: 1,
y: 0,
transition: { type: 'spring' as const, stiffness: 200, damping: 20 }
}
};
return (
<div className="relative w-full h-full bg-gradient-to-br from-deep-graphite via-iron-gray to-deep-graphite rounded-lg p-8 overflow-hidden">
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-6"
>
<div className="h-7 w-56 bg-white/10 rounded mx-auto mb-3"></div>
<div className="h-4 w-40 bg-white/5 rounded mx-auto"></div>
</motion.div>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="grid grid-cols-3 gap-4 mb-6 max-w-4xl mx-auto"
>
{factors.map((factor, index) => (
<motion.div
key={factor.name}
variants={itemVariants}
className="flex flex-col items-center"
onHoverStart={() => !shouldReduceMotion && setIsHovered(index)}
onHoverEnd={() => setIsHovered(null)}
>
<RatingFactor
value={factor.value}
color={factor.color}
bgColor={factor.bgColor}
name={factor.name}
shouldReduceMotion={shouldReduceMotion ?? false}
isHovered={isHovered === index}
/>
</motion.div>
))}
</motion.div>
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: shouldReduceMotion ? 0 : 0.6 }}
className="flex items-center justify-center gap-3 bg-iron-gray/50 rounded-lg p-5 border border-charcoal-outline shadow-[0_4px_24px_rgba(0,0,0,0.4)] backdrop-blur-sm max-w-md mx-auto"
>
<div className="text-center">
<div className="h-3 w-28 bg-white/10 rounded mb-2 mx-auto"></div>
<div className="h-12 w-24 bg-charcoal-outline rounded flex items-center justify-center border border-primary-blue/30">
<AnimatedRating shouldReduceMotion={shouldReduceMotion ?? false} />
</div>
</div>
</motion.div>
</div>
);
}
function RatingFactor({
value,
color,
bgColor,
name,
shouldReduceMotion,
isHovered
}: {
value: number;
color: string;
bgColor: string;
name: string;
shouldReduceMotion: boolean;
isHovered: boolean;
}) {
const progress = useMotionValue(0);
const smoothProgress = useSpring(progress, { stiffness: 60, damping: 25 });
const width = useTransform(smoothProgress, (v) => `${v}%`);
useEffect(() => {
if (shouldReduceMotion) {
progress.set(value);
} else {
const timeout = setTimeout(() => progress.set(value), 200);
return () => clearTimeout(timeout);
}
}, [value, shouldReduceMotion, progress]);
return (
<motion.div
className="w-full"
whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-light text-gray-400 tracking-wide">{name}</div>
<motion.span
className={`text-sm font-semibold font-mono ${color}`}
animate={isHovered && !shouldReduceMotion ? { scale: 1.1 } : { scale: 1 }}
>
{value}
</motion.span>
</div>
<div className="relative h-2 bg-charcoal-outline rounded-full overflow-hidden">
<motion.div
className={`absolute inset-y-0 left-0 ${bgColor} rounded-full`}
style={{ width }}
animate={isHovered && !shouldReduceMotion ? {
boxShadow: `0 0 12px currentColor`
} : {}}
/>
</div>
</motion.div>
);
}
function AnimatedRating({ shouldReduceMotion }: { shouldReduceMotion: boolean }) {
const count = useMotionValue(0);
const rounded = useTransform(count, (v) => Math.round(v));
const spring = useSpring(count, { stiffness: 50, damping: 25 });
useEffect(() => {
if (shouldReduceMotion) {
count.set(1342);
} else {
const timeout = setTimeout(() => count.set(1342), 800);
return () => clearTimeout(timeout);
}
}, [shouldReduceMotion, count]);
return (
<motion.span className="text-3xl font-bold text-primary-blue font-mono">
{shouldReduceMotion ? 1342 : <motion.span>{rounded}</motion.span>}
</motion.span>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { motion, useReducedMotion, useMotionValue, useSpring } from 'framer-motion';
import { useEffect, useState } from 'react';
export default function StandingsTableMockup() {
const shouldReduceMotion = useReducedMotion();
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
const getRowAnimation = (i: number) => ({
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 10 },
visible: {
opacity: 1,
y: 0,
transition: {
delay: shouldReduceMotion ? 0 : i * 0.05,
type: 'spring' as const,
stiffness: 300,
damping: 24
}
}
});
return (
<div className="relative w-full h-full bg-gradient-to-br from-deep-graphite via-iron-gray to-deep-graphite rounded-lg p-6 overflow-hidden">
<div className="mb-4">
<div className="flex items-center gap-4 pb-3 border-b border-charcoal-outline">
<div className="text-xs font-mono text-gray-400">#</div>
<div className="text-xs flex-1 font-semibold text-white">Driver</div>
<div className="text-xs font-mono text-gray-400">Wins</div>
<div className="text-xs font-mono text-gray-400">Points</div>
</div>
</div>
<div className="space-y-1">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<motion.div
key={i}
variants={getRowAnimation(i)}
initial="hidden"
animate="visible"
className={`relative flex items-center gap-4 py-3 px-3 rounded-lg border transition-all duration-150 ${
i <= 3
? 'bg-gradient-to-r from-performance-green/10 to-iron-gray border-performance-green/20'
: 'bg-iron-gray border-charcoal-outline'
}`}
onHoverStart={() => !shouldReduceMotion && setHoveredRow(i)}
onHoverEnd={() => setHoveredRow(null)}
whileHover={shouldReduceMotion ? {} : {
scale: 1.01,
boxShadow: '0 0 20px rgba(25,140,255,0.3)',
transition: { duration: 0.15 }
}}
>
<motion.div
className={`h-7 w-7 rounded-full flex items-center justify-center font-semibold text-xs ${
i <= 3
? 'bg-primary-blue text-white shadow-glow'
: 'bg-charcoal-outline text-gray-400'
}`}
animate={
shouldReduceMotion ? {} : i <= 3 && hoveredRow === i
? { scale: 1.15, boxShadow: '0 0 28px rgba(25,140,255,0.5)' }
: {}
}
>
{i}
</motion.div>
<div className="flex-1">
<div className="h-3 w-full max-w-[140px] bg-white/10 rounded"></div>
</div>
<div className="h-3 w-16 bg-white/5 rounded font-mono"></div>
<div className="relative">
<AnimatedPoints
points={300 - i * 20}
position={i}
shouldReduceMotion={shouldReduceMotion ?? false}
/>
</div>
</motion.div>
))}
</div>
</div>
);
}
function AnimatedPoints({
points,
position,
shouldReduceMotion
}: {
points: number;
position: number;
shouldReduceMotion: boolean;
}) {
const motionValue = useMotionValue(0);
const spring = useSpring(motionValue, { stiffness: 50, damping: 20 });
useEffect(() => {
if (shouldReduceMotion) {
motionValue.set(points);
} else {
setTimeout(() => motionValue.set(points), 100 + position * 50);
}
}, [points, position, shouldReduceMotion, motionValue]);
const percentage = (points / 300) * 100;
return (
<div className="relative w-24 h-7 bg-charcoal-outline rounded border border-primary-blue/20 overflow-hidden">
<motion.div
className={`absolute inset-y-0 left-0 ${
position <= 3
? 'bg-gradient-to-r from-performance-green/40 to-performance-green/20'
: 'bg-gradient-to-r from-iron-gray to-charcoal-outline'
}`}
initial={{ width: '0%' }}
animate={{ width: `${percentage}%` }}
transition={{ duration: shouldReduceMotion ? 0 : 0.8, ease: 'easeOut', delay: 0.1 + position * 0.05 }}
/>
<div className="relative h-full flex items-center justify-center">
<motion.span className="text-xs font-mono font-semibold text-white">
{shouldReduceMotion ? points : <motion.span>{spring}</motion.span>}
</motion.span>
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { useState } from 'react';
export default function TeamCompetitionMockup() {
const shouldReduceMotion = useReducedMotion();
const [hoveredDriver, setHoveredDriver] = useState<number | null>(null);
const [hoveredTeam, setHoveredTeam] = useState<number | null>(null);
const teamColors = ['#198CFF', '#6FE37A', '#FFC556', '#43C9E6', '#9333EA'];
const leftColumnVariants = {
hidden: { opacity: 0, x: shouldReduceMotion ? 0 : -20 },
visible: {
opacity: 1,
x: 0,
transition: {
type: 'spring' as const,
stiffness: 100,
damping: 20
}
}
};
const rightColumnVariants = {
hidden: { opacity: 0, x: shouldReduceMotion ? 0 : 20 },
visible: {
opacity: 1,
x: 0,
transition: {
type: 'spring' as const,
stiffness: 100,
damping: 20
}
}
};
const rowVariants = {
hidden: { opacity: 0, scale: shouldReduceMotion ? 1 : 0.95 },
visible: (i: number) => ({
opacity: 1,
scale: 1,
transition: {
delay: shouldReduceMotion ? 0 : 0.3 + i * 0.05,
type: 'spring' as const,
stiffness: 300,
damping: 25
}
})
};
return (
<div className="relative w-full h-full bg-gradient-to-br from-deep-graphite via-iron-gray to-deep-graphite rounded-lg p-6 overflow-hidden">
<div className="grid grid-cols-2 gap-6 h-full">
<motion.div
variants={leftColumnVariants}
initial="hidden"
animate="visible"
className="relative"
>
<div className="h-5 w-24 bg-white/10 rounded mb-4 text-xs flex items-center justify-center text-white font-semibold">
Drivers
</div>
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<motion.div
key={i}
custom={i}
variants={rowVariants}
initial="hidden"
animate="visible"
className="relative flex items-center gap-3 bg-iron-gray rounded-lg p-2.5 border border-charcoal-outline overflow-hidden"
onHoverStart={() => !shouldReduceMotion && setHoveredDriver(i)}
onHoverEnd={() => setHoveredDriver(null)}
whileHover={shouldReduceMotion ? {} : {
scale: 1.02,
boxShadow: `0 0 20px ${teamColors[i-1]}40`,
transition: { duration: 0.15 }
}}
>
<div
className="absolute left-0 top-0 bottom-0 w-0.5"
style={{ backgroundColor: teamColors[i-1] }}
/>
<div
className="h-5 w-5 rounded-full flex items-center justify-center font-semibold text-[10px] border-2"
style={{
borderColor: teamColors[i-1],
backgroundColor: `${teamColors[i-1]}20`
}}
>
<span className="text-white">{i}</span>
</div>
<div className="flex-1 min-w-0">
<div className="h-2.5 w-full bg-white/10 rounded"></div>
</div>
<div className="h-3 w-12 bg-charcoal-outline rounded font-mono text-[10px] flex items-center justify-center text-white/70"></div>
{hoveredDriver === i && (
<motion.div
className="absolute inset-0 pointer-events-none"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
background: `linear-gradient(90deg, ${teamColors[i-1]}10 0%, transparent 100%)`
}}
/>
)}
</motion.div>
))}
</div>
</motion.div>
<div className="absolute left-1/2 top-8 bottom-8 w-px bg-gradient-to-b from-transparent via-charcoal-outline to-transparent backdrop-blur-sm" />
<motion.div
variants={rightColumnVariants}
initial="hidden"
animate="visible"
className="relative"
>
<div className="h-5 w-32 bg-white/10 rounded mb-4 text-xs flex items-center justify-center text-white font-semibold">
Constructors
</div>
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<motion.div
key={i}
custom={i}
variants={rowVariants}
initial="hidden"
animate="visible"
className="relative flex items-center gap-3 bg-iron-gray rounded-lg p-2.5 border border-charcoal-outline overflow-hidden"
onHoverStart={() => !shouldReduceMotion && setHoveredTeam(i)}
onHoverEnd={() => setHoveredTeam(null)}
whileHover={shouldReduceMotion ? {} : {
scale: 1.02,
boxShadow: `0 0 20px ${teamColors[i-1]}40`,
transition: { duration: 0.15 }
}}
>
<div
className="absolute left-0 top-0 bottom-0 w-0.5"
style={{ backgroundColor: teamColors[i-1] }}
/>
<div
className="h-5 w-5 rounded flex items-center justify-center font-semibold text-[10px] border-2"
style={{
borderColor: teamColors[i-1],
backgroundColor: `${teamColors[i-1]}20`
}}
>
<span className="text-white">{i}</span>
</div>
<div className="flex-1 min-w-0">
<div className="h-2.5 w-full bg-white/10 rounded mb-1.5"></div>
<div className="relative h-1.5 bg-charcoal-outline rounded-full overflow-hidden">
<motion.div
className="absolute inset-y-0 left-0 rounded-full"
style={{ backgroundColor: teamColors[i-1] }}
initial={{ width: '0%' }}
animate={{ width: `${100 - (i-1) * 15}%` }}
transition={{ duration: shouldReduceMotion ? 0 : 0.8, delay: 0.4 + i * 0.05 }}
/>
</div>
</div>
{i === 3 && (
<div className="h-4 px-1.5 bg-warning-amber/20 rounded text-[9px] flex items-center justify-center text-warning-amber font-semibold border border-warning-amber/30">
=
</div>
)}
{hoveredTeam === i && (
<motion.div
className="absolute inset-0 pointer-events-none"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
background: `linear-gradient(90deg, ${teamColors[i-1]}10 0%, transparent 100%)`
}}
/>
)}
</motion.div>
))}
</div>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { ReactNode } from 'react';
/**
* ModeGuard - Conditional rendering component based on application mode
*
* Usage:
* <ModeGuard mode="pre-launch">
* <PreLaunchContent />
* </ModeGuard>
*
* <ModeGuard mode="post-launch">
* <FullPlatformContent />
* </ModeGuard>
*/
export type GuardMode = 'pre-launch' | 'post-launch';
interface ModeGuardProps {
mode: GuardMode;
children: ReactNode;
fallback?: ReactNode;
}
/**
* Client-side mode guard component
* Note: For initial page load, rely on middleware for route protection
* This component is for conditional UI rendering within accessible pages
*/
export function ModeGuard({ mode, children, fallback = null }: ModeGuardProps) {
const currentMode = getClientMode();
if (currentMode === mode) {
return <>{children}</>;
}
return <>{fallback}</>;
}
/**
* Get mode on client side from injected environment variable
* Falls back to 'pre-launch' if not available
*/
function getClientMode(): GuardMode {
if (typeof window === 'undefined') {
return 'pre-launch';
}
const mode = process.env.NEXT_PUBLIC_GRIDPILOT_MODE;
if (mode === 'post-launch') {
return 'post-launch';
}
return 'pre-launch';
}
/**
* Hook to get current mode in client components
*/
export function useAppMode(): GuardMode {
return getClientMode();
}
/**
* Hook to check if in pre-launch mode
*/
export function useIsPreLaunch(): boolean {
return getClientMode() === 'pre-launch';
}
/**
* Hook to check if in post-launch mode
*/
export function useIsPostLaunch(): boolean {
return getClientMode() === 'post-launch';
}

View File

@@ -0,0 +1,53 @@
import { ButtonHTMLAttributes, AnchorHTMLAttributes, ReactNode } from 'react';
type ButtonAsButton = ButtonHTMLAttributes<HTMLButtonElement> & {
as?: 'button';
href?: never;
};
type ButtonAsLink = AnchorHTMLAttributes<HTMLAnchorElement> & {
as: 'a';
href: string;
};
type ButtonProps = (ButtonAsButton | ButtonAsLink) & {
variant?: 'primary' | 'secondary';
children: ReactNode;
};
export default function Button({
variant = 'primary',
children,
className = '',
as = 'button',
...props
}: ButtonProps) {
const baseStyles = 'rounded-full px-6 py-3 text-sm font-semibold transition-all duration-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 hover:scale-[1.03] active:scale-[0.98]';
const variantStyles = {
primary: 'bg-primary-blue text-white hover:shadow-glow active:ring-2 active:ring-primary-blue focus-visible:outline-primary-blue',
secondary: 'bg-iron-gray text-white border border-charcoal-outline hover:shadow-glow-strong hover:border-primary-blue focus-visible:outline-primary-blue'
};
const classes = `${baseStyles} ${variantStyles[variant]} ${className}`;
if (as === 'a') {
return (
<a
className={classes}
{...(props as AnchorHTMLAttributes<HTMLAnchorElement>)}
>
{children}
</a>
);
}
return (
<button
className={classes}
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,14 @@
import { ReactNode } from 'react';
interface CardProps {
children: ReactNode;
className?: string;
}
export default function Card({ children, className = '' }: CardProps) {
return (
<div className={`rounded-lg bg-iron-gray p-6 shadow-card border border-charcoal-outline hover:shadow-glow transition-shadow duration-200 ${className}`}>
{children}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { ReactNode } from 'react';
interface ContainerProps {
size?: 'sm' | 'md' | 'lg' | 'xl';
center?: boolean;
children: ReactNode;
className?: string;
}
export default function Container({
size = 'lg',
center = false,
children,
className = ''
}: ContainerProps) {
const sizeStyles = {
sm: 'max-w-2xl',
md: 'max-w-4xl',
lg: 'max-w-7xl',
xl: 'max-w-[1400px]'
};
const centerStyles = center ? 'text-center' : '';
return (
<div className={`mx-auto ${sizeStyles[size]} ${centerStyles} ${className}`}>
{children}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { ReactNode } from 'react';
interface HeadingProps {
level: 1 | 2 | 3;
children: ReactNode;
className?: string;
}
export default function Heading({ level, children, className = '' }: HeadingProps) {
const baseStyles = 'font-bold tracking-tight';
const levelStyles = {
1: 'text-4xl sm:text-6xl',
2: 'text-3xl sm:text-4xl',
3: 'text-xl sm:text-2xl'
};
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
return (
<Tag className={`${baseStyles} ${levelStyles[level]} ${className}`}>
{children}
</Tag>
);
}

View File

@@ -0,0 +1,31 @@
import { InputHTMLAttributes, ReactNode } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
error?: boolean;
errorMessage?: string;
}
export default function Input({
error = false,
errorMessage,
className = '',
...props
}: InputProps) {
const baseStyles = 'block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6';
const errorStyles = error ? 'ring-warning-amber' : 'ring-charcoal-outline';
return (
<div className="w-full">
<input
className={`${baseStyles} ${errorStyles} ${className}`}
aria-invalid={error}
{...props}
/>
{error && errorMessage && (
<p className="mt-2 text-sm text-warning-amber">
{errorMessage}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { ReactNode } from 'react';
interface MockupStackProps {
children: ReactNode;
index?: number;
}
export default function MockupStack({ children, index = 0 }: MockupStackProps) {
const shouldReduceMotion = useReducedMotion();
const seed = index * 1337;
const rotation1 = ((seed * 17) % 80 - 40) / 20;
const rotation2 = ((seed * 23) % 80 - 40) / 20;
return (
<div className="relative w-full h-full" style={{ perspective: '1200px' }}>
<motion.div
className="absolute rounded-lg bg-iron-gray/80 border border-charcoal-outline"
style={{
rotate: rotation1,
zIndex: 1,
top: '-8px',
left: '-8px',
right: '-8px',
bottom: '-8px',
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
}}
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 0.5, scale: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
/>
<motion.div
className="absolute rounded-lg bg-iron-gray/90 border border-charcoal-outline"
style={{
rotate: rotation2,
zIndex: 2,
top: '-4px',
left: '-4px',
right: '-4px',
bottom: '-4px',
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
}}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 0.7, scale: 1 }}
transition={{ duration: 0.3, delay: 0.15 }}
/>
<motion.div
className="relative z-10 w-full h-full rounded-lg overflow-hidden"
style={{
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
}}
whileHover={
shouldReduceMotion
? {}
: {
scale: 1.02,
rotateY: 3,
rotateX: -2,
y: -12,
transition: {
type: 'spring',
stiffness: 200,
damping: 20,
},
}
}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<motion.div
className="absolute inset-0 pointer-events-none rounded-lg"
whileHover={
shouldReduceMotion
? {}
: {
boxShadow: '0 0 40px rgba(25, 140, 255, 0.4)',
transition: { duration: 0.2 },
}
}
/>
{children}
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { ReactNode } from 'react';
interface SectionProps {
variant?: 'default' | 'dark' | 'light';
children: ReactNode;
className?: string;
id?: string;
}
export default function Section({
variant = 'default',
children,
className = '',
id
}: SectionProps) {
const variantStyles = {
default: 'bg-deep-graphite',
dark: 'bg-iron-gray',
light: 'bg-charcoal-outline'
};
return (
<section
id={id}
className={`${variantStyles[variant]} px-6 py-32 sm:py-40 lg:px-8 ${className}`}
>
{children}
</section>
);
}

View File

@@ -0,0 +1,53 @@
import { z } from 'zod';
/**
* Email validation schema using Zod
*/
export const emailSchema = z.string()
.email('Invalid email format')
.min(3, 'Email too short')
.max(254, 'Email too long')
.toLowerCase()
.trim();
/**
* Validates an email address
* @param email - The email address to validate
* @returns Validation result with sanitized email or error
*/
export function validateEmail(email: string): {
success: boolean;
email?: string;
error?: string;
} {
const result = emailSchema.safeParse(email);
if (result.success) {
return {
success: true,
email: result.data,
};
}
return {
success: false,
error: result.error.errors[0]?.message || 'Invalid email',
};
}
/**
* Check if email appears to be from a disposable email service
* Basic check - can be extended with comprehensive list
*/
const DISPOSABLE_DOMAINS = new Set([
'tempmail.com',
'throwaway.email',
'guerrillamail.com',
'mailinator.com',
'10minutemail.com',
]);
export function isDisposableEmail(email: string): boolean {
const domain = email.split('@')[1]?.toLowerCase();
return domain ? DISPOSABLE_DOMAINS.has(domain) : false;
}

75
apps/website/lib/mode.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* Mode detection system for GridPilot website
*
* Controls whether the site shows pre-launch content or full platform
* Based on GRIDPILOT_MODE environment variable
*/
export type AppMode = 'pre-launch' | 'post-launch';
const VALID_MODES: readonly AppMode[] = ['pre-launch', 'post-launch'] as const;
/**
* Get the current application mode from environment variable
* Defaults to 'pre-launch' if not set or invalid
*
* @throws {Error} If mode is set but invalid (development only)
* @returns {AppMode} The current application mode
*/
export function getAppMode(): AppMode {
const mode = process.env.GRIDPILOT_MODE;
if (!mode) {
return 'pre-launch';
}
if (!isValidMode(mode)) {
const validModes = VALID_MODES.join(', ');
const error = `Invalid GRIDPILOT_MODE: "${mode}". Must be one of: ${validModes}`;
if (process.env.NODE_ENV === 'development') {
throw new Error(error);
}
console.error(error);
return 'pre-launch';
}
return mode;
}
/**
* Type guard to check if a string is a valid AppMode
*/
function isValidMode(mode: string): mode is AppMode {
return VALID_MODES.includes(mode as AppMode);
}
/**
* Check if currently in pre-launch mode
*/
export function isPreLaunch(): boolean {
return getAppMode() === 'pre-launch';
}
/**
* Check if currently in post-launch mode
*/
export function isPostLaunch(): boolean {
return getAppMode() === 'post-launch';
}
/**
* Get list of public routes that are always accessible
*/
export function getPublicRoutes(): readonly string[] {
return ['/', '/api/signup'] as const;
}
/**
* Check if a route is public (accessible in all modes)
*/
export function isPublicRoute(pathname: string): boolean {
const publicRoutes = getPublicRoutes();
return publicRoutes.includes(pathname);
}

View File

@@ -0,0 +1,89 @@
import { kv } from '@vercel/kv';
/**
* Rate limit configuration
*/
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour in milliseconds
const MAX_REQUESTS_PER_WINDOW = 5;
/**
* Rate limit key prefix
*/
const RATE_LIMIT_PREFIX = 'ratelimit:signup:';
/**
* Check if an IP address has exceeded rate limits
* @param identifier - IP address or unique identifier
* @returns Object with allowed status and retry information
*/
export async function checkRateLimit(identifier: string): Promise<{
allowed: boolean;
remaining: number;
resetAt: number;
}> {
const key = `${RATE_LIMIT_PREFIX}${identifier}`;
const now = Date.now();
try {
// Get current count
const count = await kv.get<number>(key) || 0;
if (count >= MAX_REQUESTS_PER_WINDOW) {
// Get TTL to determine reset time
const ttl = await kv.ttl(key);
const resetAt = now + (ttl * 1000);
return {
allowed: false,
remaining: 0,
resetAt,
};
}
// Increment counter
const newCount = count + 1;
if (count === 0) {
// First request - set with expiry
await kv.set(key, newCount, {
px: RATE_LIMIT_WINDOW,
});
} else {
// Subsequent request - increment without changing TTL
await kv.incr(key);
}
// Calculate reset time
const ttl = await kv.ttl(key);
const resetAt = now + (ttl * 1000);
return {
allowed: true,
remaining: MAX_REQUESTS_PER_WINDOW - newCount,
resetAt,
};
} catch (error) {
// If rate limiting fails, allow the request
console.error('Rate limit check failed:', error);
return {
allowed: true,
remaining: MAX_REQUESTS_PER_WINDOW,
resetAt: now + RATE_LIMIT_WINDOW,
};
}
}
/**
* Get client IP from request headers
*/
export function getClientIp(request: Request): string {
// Try various headers that might contain the IP
const headers = request.headers;
return (
headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
headers.get('x-real-ip') ||
headers.get('cf-connecting-ip') || // Cloudflare
'unknown'
);
}

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getAppMode, isPublicRoute } from './lib/mode';
/**
* Next.js middleware for route protection based on application mode
*
* In pre-launch mode:
* - Only allows access to public routes (/, /api/signup)
* - Returns 404 for all other routes
*
* In post-launch mode:
* - All routes are accessible
*/
export function middleware(request: NextRequest) {
const mode = getAppMode();
const { pathname } = request.nextUrl;
// In post-launch mode, allow all routes
if (mode === 'post-launch') {
return NextResponse.next();
}
// In pre-launch mode, check if route is public
if (isPublicRoute(pathname)) {
return NextResponse.next();
}
// Protected route in pre-launch mode - return 404
return new NextResponse(null, {
status: 404,
statusText: 'Not Found',
});
}
/**
* Configure which routes the middleware should run on
* Excludes Next.js internal routes and static assets
*/
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder files
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};

View File

@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
typescript: {
ignoreBuildErrors: false,
},
eslint: {
ignoreDuringBuilds: false,
},
};
export default nextConfig;

32
apps/website/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@gridpilot/website",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next"
},
"dependencies": {
"@vercel/kv": "^3.0.0",
"framer-motion": "^12.23.25",
"next": "^15.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.22",
"eslint": "^8.57.0",
"eslint-config-next": "^15.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,32 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
'deep-graphite': '#0E0F11',
'iron-gray': '#181B1F',
'charcoal-outline': '#22262A',
'primary-blue': '#198CFF',
'performance-green': '#6FE37A',
'warning-amber': '#FFC556',
'neon-aqua': '#43C9E6',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
boxShadow: {
'glow': '0 0 20px rgba(25, 140, 255, 0.3)',
'glow-strong': '0 0 28px rgba(25, 140, 255, 0.5)',
'card': '0 8px 24px rgba(0, 0, 0, 0.12)',
},
transitionTimingFunction: {
'spring': 'cubic-bezier(0.34, 1.56, 0.64, 1)',
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

229
docs/THEME.md Normal file
View File

@@ -0,0 +1,229 @@
# GridPilot Theme — “Smooth Performance Dark”
*A modern, ultra-polished, buttery-smooth interface that feels engineered, premium, and joyful — without losing the seriousness of sim racing.*
---
# 1. Design Philosophy
GridPilot should feel like:
- **a precision instrument**, not a toy
- **a premium dashboard**, not a corporate SaaS page
- **smooth and responsive**, not flashy
- **crafted**, not overdesigned
- **racing-inspired**, not gamer-edgy
It combines:
- the readability & seriousness of motorsport tools
- with the soft, fluid, polished feel of a high-end app
Think:
**"iRacing x Apple UI x Motorsport telemetry aesthetics"**.
---
# 2. Visual Style
### Core Aesthetic:
- dark, matte background
- soft gradients (subtle, not neon)
- elegant glows only where needed
- crisp typography
- generous spacing
- smooth UI hierarchy transitions
- layer depth through blur + shadow stacking (but tasteful)
### Color Palette:
- **Deep Graphite:** `#0E0F11` (main background)
- **Iron Gray:** `#181B1F` (cards & panels)
- **Charcoal Outline:** `#22262A` (borders)
- **Primary Blue:** `#198CFF` (accents, active states)
- **Performance Green:** `#6FE37A` (success)
- **Warning Amber:** `#FFC556` (markers)
- **Subtle Neon Aqua:** `#43C9E6` (interactive glow effects)
Colors are **precise**, not noisy.
---
# 3. Animation Philosophy — “Buttery Smooth, Never Distracting”
Animations in GridPilot should:
- feel like a **fast steering rack**: sharp + controlled
- feel **premium**, not “flashy”
- be **motivated**, not ornamental
- communicate **state change** clearly
**Animation Style:**
- low-spring, high-damping motion
- small distances, high velocity
- micro-easing, Apple-like rebound
- intelligent inertia
- zero stutter
**Target vibe:**
> “Everything feels alive and responsive, like the UI wants to race with you.”
---
# 4. Where Animations Should Shine
### ✔ Hover Interactions
Buttons + cards get:
- subtle upscale (`1.0 → 1.03`)
- color bloom
- ambient glow (low opacity, soft spread)
### ✔ Page Transitions
- fade + slide (3050px)
- layered parallax for content panels
- 150250ms total
Feels warm, inviting, non-static.
### ✔ Filters & Tabs
- sliding underline indicator
- smooth kinetic scrolling
- minimal ripple or highlight
### ✔ Dialogs & Panels
- spring pop (`scale 0.96 → 1`)
- soft drop shadow expansion
- background blur fade-in
### ✔ Table Row Expand / Collapse
- height transition: 150ms
- opacity fade-in: 120ms
- chevron rotation: 180ms
Feels like unfolding technical data — perfect for racing nerds.
### ✔ Notifications
- slide-in from top right
- friction-based deceleration
- micro-bounce at rest state
---
# 5. What NOT to animate
To avoid becoming “too modern = startup SaaS = untrustworthy”:
**No:**
- giant hero animations
- unnecessary motion in typography
- floating shapes / illustration wobble
- confetti / particle effects
- autoplay video backgrounds
- mobile-app style “over cute” transitions
GridPilot must feel:
**professional → premium → but still understated.**
---
# 6. Component Design Rules
### Cards
- slightly rounded (68px)
- soft shadow (blur 2028px)
- subtle ambient noise texture (optional)
- gentle hover glow
### Buttons
- pill shape (but not too round)
- glossy gradient *only when hovered*
- laser-sharp outline on active state
- fast press down animation (`75ms`)
### Tables
- high-density, readable
- animated sort indicators
- fade-in rows on update
- highlight row on hover
### Modals
- glassy blurred background
- smooth opening
- soft drop-shadow bloom
- quick responsive closing
---
# 7. Typography
A modern, premium sans-serif:
- **Inter**
- **Roboto Flex**
- or **Plus Jakarta Sans**
Font weight:
- light + regular for body
- semibold for headings
- numeric fields medium or monospaced (for racing aesthetics)
Typography motion:
- heading fade-in
- numeric counters animate upward subtly (60120ms)
---
# 8. UX Tone
GridPilot should **feel**:
- confident
- calm
- minimal
- smart
- “built by people who actually race”
- respectful of the users time
- not corporate
- not recruiter-slick
- not childish gamer UI
But also:
**pleasant, smooth, and delightful to interact with.**
---
# 9. Comparative Inspirations
### From iRacing UI:
- dark palette
- density
- data-first layout
- serious tone
### From VRS:
- technical clarity
- motorsport professionalism
### From Apple UI:
- smooth transitions
- subtle bounce
- soft shadows
- tasteful blur
### From SimHub / Racelab:
- functional panels
- high readability
- non-intrusive visuals
GridPilot combines all these influences without looking like any of them directly.
---
# 10. Goal Summary
**GridPilot = the “luxury cockpit dashboard” of league racing platforms.**
- dark, technical look
- premium smoothness
- fast, precise interactions
- functional layouts
- no corporate noise
- no SaaS gimmicks
- no over-the-top neon gamer aesthetic
Just clean, fast, beautiful racing software
that feels as nice to use as a fresh lap in a good rhythm.

197
docs/VOICE.md Normal file
View File

@@ -0,0 +1,197 @@
# GridPilot — Voice & Tone Guide
*A calm, clear, confident voice built for sim racers.*
---
## 1. Core Personality
GridPilots voice is:
### **Calm**
Never loud, never chaotic, never dramatic.
### **Clear**
Short sentences. Direct meaning. Zero fluff.
### **Competent**
We sound like people who know racing and know what matters.
### **Friendly, not goofy**
Approachable and human — without memes, slang, or cringe.
### **Non-corporate**
We avoid startup jargon and “enterprise” tone completely.
---
## 2. How GridPilot Sounds
### **Direct**
We make the point quickly. No fillers.
**Example:**
“Standings updated.”
### **Minimal**
We remove unnecessary words.
Writing should feel clean — like the UI.
### **Honest**
We never overpromise or exaggerate.
GridPilot says what it does, nothing more.
### **Human**
Natural phrasing, like talking to another racer.
---
## 3. What We Avoid
### ❌ Corporate language
“scalable solution,” “empower,” “revolutionize,” “synergy”
### ❌ Startup hype
“game-changing,” “disruptive,” “next-gen”
### ❌ Gamer slang
“lets gooo,” “pog,” “EZ clap,” emojis everywhere
### ❌ Sales pressure
“Sign up now!!! Limited time only!!!”
### ❌ Over-explaining
No long paragraphs to say something simple.
---
## 4. Tone by Context
### **Landing Page**
- calm
- confident
- benefit-focused
- no hype
- welcoming
Example:
“League racing should feel organized. GridPilot brings everything into one place.”
---
### **In-App UI**
- crisp
- tool-like
- neutral
- small sentences
Examples:
“Race added.”
“Results imported.”
“Penalty applied.”
---
### **Notifications**
- non-intrusive
- soft
- clear
Examples:
“Your next race is tomorrow.”
“Standings updated.”
---
### **Errors**
- helpful
- steady
- no blame
Examples:
“Something went wrong. Try again.”
“Connection lost. Reconnecting…”
---
### **Emails**
- friendly
- simple
- short
Example:
“Your season starts next week. Heres the schedule.”
---
## 5. Writing Style Rules
### **Short sentences**
Easy to read. Easy to scan.
### **Simple verbs**
Join, manage, view, race, update.
### **Active voice**
“GridPilot updates your standings.”
Not:
“Your standings are being updated…”
### **No marketing padding**
We never pretend to be bigger than we are.
### **Every sentence should have purpose**
No filler words. No decorative language.
---
## 6. Phrases We Like
- “Everything in one place.”
- “Your racing identity.”
- “Clean standings.”
- “No spreadsheets.”
- “Race. We handle the rest.”
- “Built for league racing.”
- “Clear. Simple. Consistent.”
These reinforce clarity and confidence.
---
## 7. Phrases We Never Use
- “premium experience”
- “unlock your potential”
- “cutting-edge AI”
- “transform the sim racing landscape”
- “were disrupting the industry”
- “epic,” “insane,” “crazy good,” etc.
Anything salesy, dramatic, childish, or corporate is banned.
---
## 8. Emotional Goals
GridPilot should make people feel:
### **In control**
Information is clear and predictable.
### **Supported**
Admin work is easier. Driver info is organized.
### **Respected**
We never talk down to users.
### **Focused**
The tone keeps attention on racing, not us.
### **Confident**
The platform feels stable and trustworthy.
---
## 9. One-line Voice Summary
**GridPilot speaks like a calm, competent racer who explains things clearly — never loud, never corporate, never cringe.**

View File

@@ -1,217 +1,186 @@
# GridPilot — Where iRacing League Racing Finally Comes Together # GridPilot — Where League Racing Finally Means Something
*A modern platform that makes league racing clearer, cleaner, and actually meaningful — for drivers, admins, and teams.* *A platform that gives iRacing drivers, teams, and leagues a real competitive identity — not just another set of tools.*
--- ---
## League racing deserves better. ## League racing is incredible.
Whats missing is everything around it.
If youve ever joined or run a league, you already know: If you've been in any league, you know the feeling:
- Discord chaos - results scattered across Discord
- scattered spreadsheets - standings hidden in spreadsheets
- unclear standings
- missing results
- manual points
- confusing sign-ups
- random DMs for protests
- zero identity
- no long-term stats - no long-term stats
- and nothing that ties the whole experience together - no consistent identity
- no team history
- no career progression
- no structure that carries into the next season
- and nothing that ties all your racing together
The racing is incredible. The races are great.
**Everything around it shouldnt feel like work.** **The ecosystem isnt.**
GridPilot fixes the part iRacing leaves behind. GridPilot fixes the part thats always been missing:
**a real home for your league racing identity.**
--- ---
# What GridPilot gives you # What GridPilot actually brings
## 📅 A real home for your league Not just tools.
Every league gets: Not just QoL.
GridPilot gives league racing something it never had before:
- full schedule ## ⭐ A persistent identity
- standings Your races, your seasons, your progress — finally in one place.
- results
- roster
- rules
- branding
- clean public league page
- Discord / Twitch / YouTube links
No complexity. - lifetime stats
No setup nightmares. - season history
Just a “this looks legit” league hub.
---
## 🏁 Automatic results & standings
You race.
We handle everything:
- positions
- incidents
- drop weeks
- driver points
- team points
- season stats
- updated standings within seconds
Admins never touch spreadsheets again.
Drivers always know where they stand.
---
## 👥 Real team racing — constructors-style
Not just tags next to names.
A real team championship system:
- multiple drivers score points
- lineups can change every week
- contribution stats per driver
- season-long team battles
- proper constructors standings
Feels like real motorsport, not Discord roleplay.
---
## ⭐ GridPilot Rating — league racing finally has meaning
iRating is great for matchmaking.
But league racing is different:
- same rivals every week
- real racecraft
- real consistency
- real reliability
- real incident patterns
- real teamwork
So GridPilot gives drivers a rating system built **specifically** for league competition.
### GridPilot Rating reflects:
- finishing positions (normalized)
- field strength
- consistency
- clean driving
- incidents vs outcomes
- reliability (attendance)
- team contribution - team contribution
This isnt “whos fastest in officials.”
This is **whos valuable in league racing**.
It gives league racing the one thing it never had:
**long-term meaning**.
---
## 👤 Driver profiles that actually feel like a racing career
Every driver gets:
- race history
- season standings
- personal rating
- incident trends - incident trends
- pace consistency - performance consistency
- team membership - multi-league overview
- multi-league stats - your own rating
- long-term progression
iRacing gives you physics. iRacing gives you physics.
**GridPilot gives you identity.** **GridPilot gives you a career.**
--- ---
## ⚖ Clean protests & quick penalties ## ⭐ A rating built for league racing
No more chaotic DMs or lost evidence. iRating doesnt reflect league performance.
GridPilot Rating does.
Drivers submit: It measures:
- timestamps - finishing results
- involved drivers - field strength
- description - clean driving
- optional replay clip - consistency
- reliability (attendance)
- team impact
Admins get: This isnt about matchmaking.
- a clean review panel This is about **who you are as a league racer**.
- quick penalty tools (warnings, time penalties, points deduction, DQ)
- standings update instantly
It keeps things fair without drama.
--- ---
## 🚀 Race management without headaches ## Real team competition
Admins get: Not cosmetic tags.
Not “one driver per car.”
- season builder GridPilot supports real constructors-style team racing:
- scoring presets - multiple drivers score points
- structured sign-ups - every contribution counts
- roster tools - team standings across seasons
- penalty tools - team identity & history
- league branding - rivalries that build over time
- automatic results
- one-click “create sessions” helper
Everything around racing becomes **organized** instead of exhausting. For the first time, **teams actually matter**.
--- ---
# Why people will sign up ## ⭐ A unified place for leagues
Leagues get a clean presence that builds prestige over time:
### Drivers want: - schedule
- structure - results
- identity - standings
- progression - rules
- clean standings - roster
- team pride - branding
No more living across five tools and ten channels.
---
## ⭐ Results that actually stay
Every race you run becomes part of:
- your profile
- your team's profile
- your rating
- your season
- your history
League racing is no longer “one season and forgotten.”
It becomes a story.
---
# Why drivers will sign up
Drivers finally get:
- long-term identity
- a rating that reflects real competition - a rating that reflects real competition
- real team progression
- stats that mean something
- rivalries that persist
- a racing home
### Admins want: Its the first time league racing feels connected instead of fragmented.
- less admin work
- automatic standings
- headache-free seasons
- reliable tools
- fewer mistakes
- professional league pages
### Teams want:
- real constructors scoring
- recruiting tools
- contribution stats
- long-term reputation
GridPilot gives all three what they've been missing.
--- ---
# Why were building this # Why teams will sign up
Because league racing is the most exciting part of sim racing — Teams finally get:
and the most poorly supported. - constructors championships
- contribution breakdowns
- performance history
- recruiting visibility
- reputation across leagues and seasons
Were keeping it simple: Teams become part of the world — not just a name next to a driver.
build the tools that actually help,
ship fast, ---
improve constantly,
and let the community guide the rest. # Why admins will use it
Admin tooling exists, yes —
but **only to serve the identity and competition layer**.
GridPilot handles:
- results
- standings
- points
- penalties
- registrations
So admins can focus on running the league —
not running spreadsheets.
---
# Why we're building this
Because league racing deserves a place where everything connects:
- your races
- your story
- your stats
- your team
- your rivals
- your seasons
All in one place, finally.
No hype. No hype.
No nonsense. No buzzwords.
Just better league racing. Just clarity and identity.
--- ---
# Want early access? # Want early access?
GridPilot is opening early access to **everyone** who wants cleaner, more meaningful league racing. GridPilot is opening early access to **everyone** who wants their league racing to feel meaningful, structured, and connected.
Drop your email if you want: Drop your email if you want:
- early feature access - early access
- updates as things roll out - progress updates
- a direct influence on what we build next - a say in what comes next
- zero spam, zero BS - zero spam
[ Email field ] [ Sign Up ] [ Email field ] [ Sign Up ]
Lets make league racing better — together. Lets make league racing meaningful — together.

5379
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,12 @@
"companion:dev": "npm run dev --workspace=@gridpilot/companion", "companion:dev": "npm run dev --workspace=@gridpilot/companion",
"companion:build": "npm run build --workspace=@gridpilot/companion", "companion:build": "npm run build --workspace=@gridpilot/companion",
"companion:start": "npm run start --workspace=@gridpilot/companion", "companion:start": "npm run start --workspace=@gridpilot/companion",
"website:dev": "npm run dev --workspace=@gridpilot/website",
"website:build": "npm run build --workspace=@gridpilot/website",
"website:start": "npm run start --workspace=@gridpilot/website",
"website:lint": "npm run lint --workspace=@gridpilot/website",
"website:type-check": "npm run type-check --workspace=@gridpilot/website",
"website:clean": "npm run clean --workspace=@gridpilot/website",
"chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug", "chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug",
"docker:e2e:up": "docker-compose -f docker/docker-compose.e2e.yml up -d", "docker:e2e:up": "docker-compose -f docker/docker-compose.e2e.yml up -d",
"docker:e2e:down": "docker-compose -f docker/docker-compose.e2e.yml down", "docker:e2e:down": "docker-compose -f docker/docker-compose.e2e.yml down",