2026-04-22 09:33:17 -07:00
import { useEffect , useRef , useState } from "react" ;
import { Link } from "react-router" ;
2026-04-22 14:44:09 -07:00
import { Users , Shuffle , Trophy , ChevronDown } from "lucide-react" ;
2026-04-22 09:33:17 -07:00
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 ;
}
2026-04-22 14:44:09 -07:00
const TOTAL_TICKS = 12 ;
2026-04-22 09:33:17 -07:00
2026-04-22 14:44:09 -07:00
// 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).
2026-04-22 09:33:17 -07:00
function getInterval ( tick : number ) : number {
2026-04-22 14:44:09 -07:00
const pct = Math . min ( ( tick / TOTAL_TICKS ) * 1.5 , 1 ) ;
return Math . round ( 800 - ( 800 - 340 ) * ( pct * pct ) ) ;
2026-04-22 09:33:17 -07:00
}
function SlotMachineHeadline() {
2026-04-22 14:44:09 -07:00
const [ text , setText ] = useState ( "\u00A0" ) ;
2026-04-22 09:33:17 -07:00
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 ) ) ;
}
2026-04-22 14:44:09 -07:00
timerRef . current = setTimeout ( tick , 800 ) ;
2026-04-22 09:33:17 -07:00
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 ──────────────────────────────────────────────────────
const LIME = "#adf661" ;
const LIME_DIM = "rgba(173,246,97,0.07)" ;
const LIME_DIM_BORDER = "rgba(173,246,97,0.25)" ;
const LIME_CHAMP = "rgba(173,246,97,0.12)" ;
const LIME_CHAMP_BORDER = "rgba(173,246,97,0.8)" ;
const SLOT_BG = "rgba(32,33,36,0.95)" ;
const SLOT_BORDER = "rgba(255,255,255,0.10)" ;
const LINE_COLOR_R1 = "rgba(255,255,255,0.15)" ;
const LINE_COLOR_R2 = "rgba(255,255,255,0.28)" ;
// SVG coordinate-space layout constants
const SW = 80 ;
const SH = 20 ;
const GAP = 10 ;
const PAIR_H = SH * 2 + GAP ;
const R1_X = 10 ;
const R2_X = 120 ;
const R3_X = 210 ;
const BRACKET_W = 300 ;
const BRACKET_H = 360 ;
const R1_TOP = 50 ;
const PAIR_GAP = 20 ;
type SlotDef = {
x : number ; y : number ; w : number ; h : number ;
winner? : boolean ; champ? : boolean ;
} ;
function BracketSlot ( { x , y , w , h , winner , champ } : SlotDef ) {
const bg = champ ? LIME_CHAMP : winner ? LIME_DIM : SLOT_BG ;
const border = champ ? LIME_CHAMP_BORDER : winner ? LIME_DIM_BORDER : SLOT_BORDER ;
const bw = champ ? 1.5 : 1 ;
return (
< g >
< rect x = { x } y = { y } width = { w } height = { h } rx = { 2 } fill = { bg } stroke = { border } strokeWidth = { bw } / >
{ ( winner || champ ) && (
< rect x = { x } y = { y } width = { 3 } height = { h } rx = { 1 } fill = { LIME } / >
) }
{ champ && (
< text
x = { x + w / 2 } y = { y + h + 10 }
textAnchor = "middle"
fontSize = { 7 } fontWeight = { 700 } fill = { LIME }
style = { { fontFamily : "Barlow, sans-serif" , letterSpacing : "0.08em" } }
>
CHAMP
< / text >
) }
< / g >
) ;
}
function hLine ( x1 : number , y1 : number , x2 : number , color : string ) {
return < line x1 = { x1 } y1 = { y1 } x2 = { x2 } y2 = { y1 } stroke = { color } strokeWidth = { 1 } / > ;
}
function connector ( ax : number , ay : number , bx : number , by : number , cx : number , color : string ) {
const midY = ( ay + by ) / 2 ;
return (
< path d = { ` M ${ ax } ${ ay } H ${ cx } V ${ midY } H ${ bx } ` } fill = "none" stroke = { color } strokeWidth = { 1 } / >
) ;
}
function BracketDecor ( { flip = false } : { flip? : boolean } ) {
const r1Pairs : [ SlotDef , SlotDef ] [ ] = Array . from ( { length : 4 } , ( _ , i ) = > {
const pairY = R1_TOP + i * ( PAIR_H + PAIR_GAP ) ;
const winner = i % 3 !== 1 ;
return [
{ x : R1_X , y : pairY , w : SW , h : SH , winner : winner && i % 2 === 0 } ,
{ x : R1_X , y : pairY + SH + GAP , w : SW , h : SH , winner : winner && i % 2 !== 0 } ,
] ;
} ) ;
const r2Slots : SlotDef [ ] = [ 0 , 1 ] . map ( ( i ) = > {
const top1Y = R1_TOP + ( i * 2 ) * ( PAIR_H + PAIR_GAP ) ;
const bot2Y = R1_TOP + ( i * 2 + 1 ) * ( PAIR_H + PAIR_GAP ) + SH + GAP ;
const midY = ( top1Y + bot2Y ) / 2 - SH / 2 ;
return { x : R2_X , y : midY , w : SW , h : SH , winner : i === 0 } ;
} ) ;
const r3Y = ( r2Slots [ 0 ] . y + r2Slots [ 1 ] . y + SH ) / 2 - SH / 2 ;
const r3Slot : SlotDef = { x : R3_X , y : r3Y , w : SW , h : SH + 4 , champ : true } ;
return (
< svg
width = { BRACKET_W } height = { BRACKET_H }
style = { {
position : "absolute" ,
top : "50%" ,
transform : ` translateY(-52%) ${ flip ? " scaleX(-1)" : "" } ` ,
[ flip ? "right" : "left" ] : 0 ,
opacity : 0.75 ,
pointerEvents : "none" ,
} }
>
{ r1Pairs . map ( ( [ a , b ] , i ) = > (
< g key = { ` r1- ${ a . y } ` } >
< BracketSlot { ...a } / >
< BracketSlot { ...b } / >
{ connector (
R1_X + SW , a . y + SH / 2 ,
r2Slots [ Math . floor ( i / 2 ) ] . x , r2Slots [ Math . floor ( i / 2 ) ] . y + SH / 2 ,
R1_X + SW + 14 ,
LINE_COLOR_R1
) }
{ hLine ( R1_X + SW + 14 , ( a . y + SH / 2 + b . y + SH / 2 ) / 2 , R2_X , LINE_COLOR_R1 ) }
< / g >
) ) }
{ r2Slots . map ( ( s , ri ) = > (
< g key = { ` r2- ${ s . y } ` } >
< BracketSlot { ...s } / >
{ connector (
R2_X + SW , r2Slots [ 0 ] . y + SH / 2 ,
r3Slot . x , r3Slot . y + ( r3Slot . h ? ? SH ) / 2 ,
R2_X + SW + 14 ,
LINE_COLOR_R2
) }
{ ri === 1 && hLine ( R2_X + SW + 14 , ( r2Slots [ 0 ] . y + SH / 2 + r2Slots [ 1 ] . y + SH / 2 ) / 2 , R3_X , LINE_COLOR_R2 ) }
< / g >
) ) }
< BracketSlot { ...r3Slot } / >
< / svg >
) ;
}
2026-04-22 14:44:09 -07:00
// ─── 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 >
) ;
}
2026-04-22 09:33:17 -07:00
// ─── 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" ,
2026-04-22 14:44:09 -07:00
body : "Start a league, pick your sports (we can help), and invite your friends. It takes 30 seconds to set the stage." ,
2026-04-22 09:33:17 -07:00
} ,
{
icon : Shuffle ,
title : "Draft Your Teams" ,
2026-04-22 14:44:09 -07:00
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." ,
2026-04-22 09:33:17 -07:00
} ,
{
icon : Trophy ,
title : "Watch & Win" ,
2026-04-22 14:44:09 -07:00
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." ,
2026-04-22 09:33:17 -07:00
} ,
] ;
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" } ,
2026-04-22 15:27:28 -07:00
{ label : "Privacy Policy" , to : "/privacy-policy" } ,
2026-04-22 09:33:17 -07:00
] ;
// ─── 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 (
2026-04-22 14:44:09 -07:00
< 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" >
2026-04-22 09:33:17 -07:00
< 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))" } }
>
{ /* Bracket decorations — lg+ only */ }
< div className = "hidden lg:block" >
< BracketDecor / >
< BracketDecor flip / >
< / div >
2026-04-22 14:44:09 -07:00
< ScrollHint / >
2026-04-22 09:33:17 -07:00
{ /* Center content */ }
< div className = "relative z-10 w-full max-w-[680px] text-center" >
< h1
className = "mb-0 leading-[0.95] tracking-[-0.04em]"
2026-04-22 14:44:09 -07:00
style = { { fontSize : "clamp(38px, 8vw, 88px)" , fontWeight : 900 } }
2026-04-22 09:33:17 -07:00
>
< 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 : "460px" } }
>
Draft teams across every sport — NHL , NBA , NFL , EPL , World Cup , and more .
Compete with your friends in a league all season long .
< / p >
2026-04-22 14:44:09 -07:00
< div className = "mt-8 flex flex-col items-center justify-center gap-2.5 sm:flex-row" >
2026-04-22 09:33:17 -07:00
< 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 >
) ;
}