website poc

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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