website poc
This commit is contained in:
24
apps/website/.env.example
Normal file
24
apps/website/.env.example
Normal 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
|
||||||
3
apps/website/.eslintrc.json
Normal file
3
apps/website/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
34
apps/website/.gitignore
vendored
Normal file
34
apps/website/.gitignore
vendored
Normal 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
321
apps/website/README.md
Normal 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.
|
||||||
120
apps/website/app/api/signup/route.ts
Normal file
120
apps/website/app/api/signup/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/website/app/globals.css
Normal file
27
apps/website/app/globals.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/website/app/layout.tsx
Normal file
41
apps/website/app/layout.tsx
Normal 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
20
apps/website/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
apps/website/components/landing/EmailCapture.tsx
Normal file
172
apps/website/components/landing/EmailCapture.tsx
Normal 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're on the list!</h2>
|
||||||
|
<p className="text-base text-gray-400 font-light">
|
||||||
|
Check your email for confirmation. We'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
apps/website/components/landing/FAQ.tsx
Normal file
106
apps/website/components/landing/FAQ.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/website/components/landing/FeatureGrid.tsx
Normal file
79
apps/website/components/landing/FeatureGrid.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/website/components/landing/Footer.tsx
Normal file
74
apps/website/components/landing/Footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/website/components/landing/Hero.tsx
Normal file
34
apps/website/components/landing/Hero.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
apps/website/components/mockups/DriverProfileMockup.tsx
Normal file
220
apps/website/components/mockups/DriverProfileMockup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
apps/website/components/mockups/LeagueDiscoveryMockup.tsx
Normal file
171
apps/website/components/mockups/LeagueDiscoveryMockup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/website/components/mockups/LeagueHomeMockup.tsx
Normal file
94
apps/website/components/mockups/LeagueHomeMockup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
apps/website/components/mockups/ProtestWorkflowMockup.tsx
Normal file
150
apps/website/components/mockups/ProtestWorkflowMockup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
apps/website/components/mockups/RatingFactorsMockup.tsx
Normal file
165
apps/website/components/mockups/RatingFactorsMockup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
apps/website/components/mockups/StandingsTableMockup.tsx
Normal file
128
apps/website/components/mockups/StandingsTableMockup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
apps/website/components/mockups/TeamCompetitionMockup.tsx
Normal file
189
apps/website/components/mockups/TeamCompetitionMockup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
apps/website/components/shared/ModeGuard.tsx
Normal file
78
apps/website/components/shared/ModeGuard.tsx
Normal 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';
|
||||||
|
}
|
||||||
53
apps/website/components/ui/Button.tsx
Normal file
53
apps/website/components/ui/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/website/components/ui/Card.tsx
Normal file
14
apps/website/components/ui/Card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/website/components/ui/Container.tsx
Normal file
30
apps/website/components/ui/Container.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/website/components/ui/Heading.tsx
Normal file
25
apps/website/components/ui/Heading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/website/components/ui/Input.tsx
Normal file
31
apps/website/components/ui/Input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
apps/website/components/ui/MockupStack.tsx
Normal file
91
apps/website/components/ui/MockupStack.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/website/components/ui/Section.tsx
Normal file
30
apps/website/components/ui/Section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/website/lib/email-validation.ts
Normal file
53
apps/website/lib/email-validation.ts
Normal 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
75
apps/website/lib/mode.ts
Normal 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);
|
||||||
|
}
|
||||||
89
apps/website/lib/rate-limit.ts
Normal file
89
apps/website/lib/rate-limit.ts
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/website/middleware.ts
Normal file
51
apps/website/middleware.ts
Normal 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)$).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
12
apps/website/next.config.mjs
Normal file
12
apps/website/next.config.mjs
Normal 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
32
apps/website/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/website/postcss.config.js
Normal file
6
apps/website/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
32
apps/website/tailwind.config.js
Normal file
32
apps/website/tailwind.config.js
Normal 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: [],
|
||||||
|
}
|
||||||
27
apps/website/tsconfig.json
Normal file
27
apps/website/tsconfig.json
Normal 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
229
docs/THEME.md
Normal 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 (30–50px)
|
||||||
|
- layered parallax for content panels
|
||||||
|
- 150–250ms 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 (6–8px)
|
||||||
|
- soft shadow (blur 20–28px)
|
||||||
|
- 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 (60–120ms)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. UX Tone
|
||||||
|
|
||||||
|
GridPilot should **feel**:
|
||||||
|
|
||||||
|
- confident
|
||||||
|
- calm
|
||||||
|
- minimal
|
||||||
|
- smart
|
||||||
|
- “built by people who actually race”
|
||||||
|
- respectful of the user’s 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
197
docs/VOICE.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# GridPilot — Voice & Tone Guide
|
||||||
|
*A calm, clear, confident voice built for sim racers.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Core Personality
|
||||||
|
|
||||||
|
GridPilot’s 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
|
||||||
|
“let’s 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. Here’s 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”
|
||||||
|
- “we’re 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.**
|
||||||
@@ -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.
|
||||||
|
What’s missing is everything around it.
|
||||||
|
|
||||||
If you’ve 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 shouldn’t feel like work.**
|
**The ecosystem isn’t.**
|
||||||
|
|
||||||
GridPilot fixes the part iRacing leaves behind.
|
GridPilot fixes the part that’s 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 isn’t “who’s fastest in officials.”
|
|
||||||
This is **who’s 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 doesn’t 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 isn’t 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:
|
It’s 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 we’re 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
|
||||||
|
|
||||||
We’re 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 ]
|
||||||
|
|
||||||
Let’s make league racing better — together.
|
Let’s make league racing meaningful — together.
|
||||||
5379
package-lock.json
generated
5379
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user