brackt/app/components/marketing/LandingPage.tsx
2026-04-22 22:17:57 -07:00

467 lines
18 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { Users, Shuffle, Trophy, ChevronDown } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { SectionCardHeader } from "~/components/ui/SectionCardHeader";
// ─── Slot Machine ────────────────────────────────────────────────────────────
const SPORTS = [
"Football", "Baseball", "Hockey", "Basketball", "Soccer", "Tennis", "Golf",
"eSports", "Formula 1", "Aussie Rules", "Lacrosse", "Darts", "College Football", "Snooker", "March Madness",
];
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
const TOTAL_TICKS = 12;
// WCAG 2.3.1: no more than 3 flashes/second → minimum interval 334 ms.
// Curve runs 800 ms → 340 ms over 12 ticks (ramps to full speed at tick 8).
function getInterval(tick: number): number {
const pct = Math.min((tick / TOTAL_TICKS) * 1.5, 1);
return Math.round(800 - (800 - 340) * (pct * pct));
}
function SlotMachineHeadline() {
const [text, setText] = useState(SPORTS[0]);
const [landed, setLanded] = useState(false);
const [animKey, setAnimKey] = useState(0);
const tickRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sportsSeq = useRef<string[]>([]);
useEffect(() => {
sportsSeq.current = shuffle(SPORTS);
let idx = 0;
function tick() {
tickRef.current++;
const sport = sportsSeq.current[idx % sportsSeq.current.length];
idx++;
setText(sport);
setAnimKey((k) => k + 1);
if (tickRef.current >= TOTAL_TICKS) {
setLanded(true);
return;
}
timerRef.current = setTimeout(tick, getInterval(tickRef.current));
}
timerRef.current = setTimeout(tick, 800);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
if (landed) {
return (
<span
className="animate-sport-land block bg-clip-text text-transparent pb-[0.12em]"
style={{ backgroundImage: "linear-gradient(to bottom, #adf661, #2ce1c1)" }}
>
Every Sport.
</span>
);
}
return (
<span key={animKey} className="animate-spin-in block text-white">
{text}
</span>
);
}
// ─── Bracket Decoration ──────────────────────────────────────────────────────
function BracketDecor({ flip = false, mobileTall = false }: { flip?: boolean; mobileTall?: boolean }) {
const base = flip ? "r" : "l";
const id = mobileTall ? `${base}-m` : base;
const W = 399;
const H = mobileTall ? 2000 : 560;
const pad = 36;
const spacing = (H - 2 * pad) / 7;
const r1Ys = Array.from({ length: 8 }, (_, i) => pad + i * spacing);
const r2Ys = [0, 1, 2, 3].map((i) => (r1Ys[i * 2] + r1Ys[i * 2 + 1]) / 2);
const r3Ys = [0, 1].map((i) => (r2Ys[i * 2] + r2Ys[i * 2 + 1]) / 2);
const champY = (r3Ys[0] + r3Ys[1]) / 2;
const x0 = 4;
const x1 = 77;
const x2 = 161;
const x3 = 252;
const gradId = `grad-${id}`;
const glowId = `glow-${id}`;
const finalsGlowId = `finals-glow-${id}`;
const finalsHaloId = `finals-halo-${id}`;
const finalsFadeMaskGradId = `finals-fade-mask-grad-${id}`;
const finalsFadeMaskId = `finals-fade-mask-${id}`;
const finalsLineGlowId = `finals-line-glow-${id}`;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
style={{
position: "absolute",
top: "50%",
transform: `translateY(-52%)${flip ? " scaleX(-1)" : ""}`,
[flip ? "right" : "left"]: 0,
pointerEvents: "none",
height: mobileTall ? "auto" : "75vh",
width: mobileTall ? "32vw" : "auto",
}}
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2={H} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#adf661" />
<stop offset="100%" stopColor="#2ce1c1" />
</linearGradient>
<filter id={glowId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id={finalsGlowId} filterUnits="userSpaceOnUse"
x={x2 - 15} y={r3Ys[0] - 15} width={x3 - x2 + 30} height={r3Ys[1] - r3Ys[0] + 30}>
<feGaussianBlur stdDeviation="7" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id={finalsHaloId} filterUnits="userSpaceOnUse"
x={x2 - 40} y={r3Ys[0] - 40} width={x3 - x2 + 80} height={r3Ys[1] - r3Ys[0] + 80}>
<feGaussianBlur stdDeviation="16" />
</filter>
<linearGradient id={finalsFadeMaskGradId} x1={x3} y1="0" x2={W} y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="white" />
<stop offset="50%" stopColor="white" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</linearGradient>
<mask id={finalsFadeMaskId} maskUnits="userSpaceOnUse" x={x3} y={champY - 20} width={W - x3} height={40}>
<rect x="0" y="0" width={W} height={H} fill={`url(#${finalsFadeMaskGradId})`} />
</mask>
<filter id={finalsLineGlowId} filterUnits="userSpaceOnUse" x={x3 - 10} y={champY - 15} width={W - x3 + 20} height={30}>
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g filter={`url(#${finalsHaloId})`} stroke={`url(#${gradId})`} strokeWidth={2} fill="none" opacity={0.75}>
{r3Ys.map((midY) => (
<line key={midY} x1={x2} y1={midY} x2={x3} y2={midY} />
))}
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
</g>
<g filter={`url(#${finalsGlowId})`} stroke={`url(#${gradId})`} strokeWidth={1.5} fill="none">
{r3Ys.map((midY) => (
<line key={midY} x1={x2} y1={midY} x2={x3} y2={midY} />
))}
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
</g>
<g filter={`url(#${glowId})`} stroke={`url(#${gradId})`} strokeWidth={1.5} fill="none">
{r1Ys.map((y) => (
<g key={y}>
<circle cx={x0} cy={y} r={3} fill={`url(#${gradId})`} stroke="none" />
<line x1={x0} y1={y} x2={x1} y2={y} />
</g>
))}
{r2Ys.map((midY, i) => (
<g key={midY}>
<line x1={x1} y1={r1Ys[i * 2]} x2={x1} y2={r1Ys[i * 2 + 1]} />
<circle cx={x1} cy={midY} r={3} fill={`url(#${gradId})`} stroke="none" />
<line x1={x1} y1={midY} x2={x2} y2={midY} />
</g>
))}
{r3Ys.map((midY, i) => (
<g key={midY}>
<line x1={x2} y1={r2Ys[i * 2]} x2={x2} y2={r2Ys[i * 2 + 1]} />
<circle cx={x2} cy={midY} r={3} fill={`url(#${gradId})`} stroke="none" />
<line x1={x2} y1={midY} x2={x3} y2={midY} />
</g>
))}
<line x1={x3} y1={r3Ys[0]} x2={x3} y2={r3Ys[1]} />
<circle cx={x3} cy={champY} r={3} fill={`url(#${gradId})`} stroke="none" />
</g>
<line
x1={x3} y1={champY} x2={W} y2={champY}
stroke={`url(#${gradId})`}
strokeWidth={1.5}
fill="none"
filter={`url(#${finalsLineGlowId})`}
mask={`url(#${finalsFadeMaskId})`}
/>
</svg>
);
}
// ─── Scroll Hint ─────────────────────────────────────────────────────────────
function ScrollHint() {
const [visible, setVisible] = useState(true);
useEffect(() => {
const onScroll = () => { if (window.scrollY > 50) setVisible(false); };
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<div
className="pointer-events-none absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce transition-opacity duration-500"
style={{ opacity: visible ? 0.35 : 0 }}
>
<ChevronDown className="h-6 w-6 text-white" />
</div>
);
}
// ─── Sport Ticker ─────────────────────────────────────────────────────────────
const TICKER_ITEMS = [
...SPORTS.map((s) => ({ sport: s.toUpperCase(), key: `${s}-a` })),
...SPORTS.map((s) => ({ sport: s.toUpperCase(), key: `${s}-b` })),
];
function SportTicker() {
return (
<div className="relative overflow-hidden pb-px" style={{ marginTop: "52px" }}>
<div
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-[60px]"
style={{ background: "linear-gradient(to right, #14171e, transparent)" }}
/>
<div
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-[60px]"
style={{ background: "linear-gradient(to left, #14171e, transparent)" }}
/>
<div className="flex animate-ticker" style={{ width: "max-content" }}>
{TICKER_ITEMS.map(({ sport, key }) => (
<span
key={key}
className="mx-1.5 shrink-0 rounded-[3px] px-3 py-1 text-[11px] font-bold uppercase tracking-[0.05em] border bg-card text-muted-foreground"
>
{sport}
</span>
))}
</div>
</div>
);
}
// ─── Static data ──────────────────────────────────────────────────────────────
const HOW_IT_WORKS_STEPS = [
{
icon: Users,
title: "Create a League",
body: "Start a league, pick your sports (we can help), and invite your friends. It takes 30 seconds to set the stage.",
},
{
icon: Shuffle,
title: "Draft Your Teams",
body: "Pick teams from all the sports in order to build a superteam to win you bragging rights! Draft your way, slow or fast, with standard timers or high-pressure chess clocks.",
},
{
icon: Trophy,
title: "Watch & Win",
body: "Your teams earn points for how well they perform in the playoffs. Follow your rooting interests along the way, and gloat over your leaguemates when they fall.",
},
];
const FEATURES = [
{
title: "Every sport, one league",
body: "NHL running at the same time as the NBA Finals? Draft from both. Brackt syncs across every major sports calendar.",
},
{
title: "Live bracket view",
body: "Watch your bracket update as games end. Heartbreak and glory, all on one screen.",
},
{
title: "Snake draft",
body: "Fair picks for everyone. No one ends up with dregs. The draft is the fun part — we let you enjoy it.",
},
{
title: "Invite in one click",
body: "Share a link. Friends join, pick a name, they're in. No downloads, no friction.",
},
];
const FOOTER_LINKS = [
{ label: "Home", to: "/" },
{ label: "Rules", to: "/rules" },
{ label: "How to Play", to: "/how-to-play" },
{ label: "Support", to: "/support" },
{ label: "Privacy Policy", to: "/privacy-policy" },
];
// ─── Main Landing Page ────────────────────────────────────────────────────────
export function LandingPage() {
// Interleave step cards with gradient triangle separators
const howItWorksItems: React.ReactNode[] = [];
HOW_IT_WORKS_STEPS.forEach((step, i) => {
howItWorksItems.push(
<Card key={step.title} className="flex-1 gap-2">
<SectionCardHeader icon={step.icon} title={step.title} />
<CardContent className="px-3 sm:px-6">
<p className="text-sm text-muted-foreground">{step.body}</p>
</CardContent>
</Card>
);
if (i < HOW_IT_WORKS_STEPS.length - 1) {
// Triangles reference #brackt-primary-gradient defined in <BracktGradients /> (mounted in root.tsx)
howItWorksItems.push(
<div key={`arrow-${step.title}`} className="flex shrink-0 items-center justify-center py-1 md:px-1 md:py-0">
<svg width="20" height="20" viewBox="0 0 20 20" className="rotate-90 md:rotate-0">
<polygon
points="3,2 18,10 3,18"
fill="url(#brackt-primary-gradient)"
stroke="url(#brackt-primary-gradient)"
strokeWidth={3}
strokeLinejoin="round"
/>
</svg>
</div>
);
}
});
return (
<div className="animate-page-fade-in">
{/* ── Hero ── */}
<section
className="relative flex flex-col items-center justify-center overflow-hidden px-8"
style={{ minHeight: "calc(100vh - var(--navbar-height))" }}
>
{/* Dot-grid texture */}
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.07) 1px, transparent 1px)",
backgroundSize: "32px 32px",
maskImage: "radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 100%)",
WebkitMaskImage: "radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 100%)",
}}
/>
{/* Bracket decorations */}
<div className="lg:hidden">
<BracketDecor mobileTall />
<BracketDecor flip mobileTall />
</div>
<div className="hidden lg:block">
<BracketDecor />
<BracketDecor flip />
</div>
<ScrollHint />
{/* Center content */}
<div className="relative z-10 w-[80%] lg:w-full max-w-[680px] text-center px-6 py-4 lg:px-0 lg:py-0">
<h1
className="mb-0 leading-[0.95] tracking-[-0.04em]"
style={{ fontSize: "clamp(38px, 8vw, 88px)", fontWeight: 900 }}
>
<span className="block text-white">Fantasy</span>
<SlotMachineHeadline />
</h1>
<p
className="mx-auto mt-8 text-muted-foreground"
style={{ fontSize: "16px", fontWeight: 400, lineHeight: 1.65, maxWidth: "414px" }}
>
Draft teams across every league and sport, like the NFL, NHL, EPL, F1, and more.
Compete against your friends in a season-long league.
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-2.5 sm:flex-row">
<Button asChild size="lg" className="text-sm font-extrabold">
<Link to="/leagues/new">Create a League</Link>
</Button>
<Button asChild variant="outline" size="lg" className="text-sm font-semibold">
<Link to="/how-to-play">How it works</Link>
</Button>
</div>
<SportTicker />
</div>
</section>
{/* ── How It Works ── */}
<section className="mx-auto w-full max-w-[1100px] px-10 py-18">
<div className="flex flex-col gap-4 md:flex-row md:items-stretch">
{howItWorksItems}
</div>
</section>
{/* ── Features Grid ── */}
<section className="mx-auto w-full max-w-[1100px] px-10 pb-18">
<div className="overflow-hidden rounded-[6px] border">
<div className="grid grid-cols-1 md:grid-cols-2" style={{ gap: "1px", background: "rgb(255 255 255 / 10%)" }}>
{FEATURES.map(({ title, body }) => (
<div key={title} className="bg-card p-7 transition-colors hover:bg-[#22252e]">
<h3 className="mb-2 text-base font-extrabold tracking-[-0.01em]">{title}</h3>
<p className="text-sm leading-[1.65] text-muted-foreground">{body}</p>
</div>
))}
</div>
</div>
</section>
{/* ── CTA Banner ── */}
<section className="mx-auto w-full max-w-[1100px] px-10 pb-20">
<div className="bg-card border flex flex-col gap-8 rounded-[6px] p-12 md:flex-row md:items-center md:justify-between">
<div>
<h2
className="mb-3 font-black tracking-[-0.025em]"
style={{ fontSize: "clamp(22px, 3vw, 34px)" }}
>
Your group chat is waiting for this.
</h2>
<p className="text-sm leading-[1.65] text-muted-foreground" style={{ maxWidth: "460px" }}>
Free to play. Five minutes to set up. The real question is who drafts the Colorado Avalanche?
</p>
</div>
<Button asChild size="lg" className="shrink-0 text-sm font-extrabold">
<Link to="/leagues/new">Create a League It&apos;s Free</Link>
</Button>
</div>
</section>
{/* ── Footer ── */}
<footer
className="flex flex-col items-start gap-3 border-t px-10 py-5 text-xs sm:flex-row sm:items-center sm:justify-between"
style={{ color: "rgb(255 255 255 / 28%)" }}
>
<span>© 2026 Brackt. All rights reserved.</span>
<nav className="flex gap-5">
{FOOTER_LINKS.map(({ label, to }) => (
<Link
key={label}
to={to}
className="transition-colors hover:text-muted-foreground"
style={{ color: "inherit" }}
>
{label}
</Link>
))}
</nav>
</footer>
</div>
);
}