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