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"]
|
||||
}
|
||||
Reference in New Issue
Block a user