website poc
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user