From 8fadc27c75e269f4b7be67718cc20d279fc3677f Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Wed, 22 Apr 2026 09:33:17 -0700 Subject: [PATCH] Homepage initial styling --- app/app.css | 34 ++ app/components/marketing/LandingPage.tsx | 425 +++++++++++++++++++++++ app/routes/home.tsx | 22 +- 3 files changed, 467 insertions(+), 14 deletions(-) create mode 100644 app/components/marketing/LandingPage.tsx diff --git a/app/app.css b/app/app.css index 8d5f85f..9f9efc8 100644 --- a/app/app.css +++ b/app/app.css @@ -55,6 +55,7 @@ html { } :root { + --navbar-height: 64px; --radius: 0.625rem; --background: #14171e; --foreground: #ffffff; @@ -125,6 +126,39 @@ html { animation: rpf-slide-in 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; } +@keyframes spin-in { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} +.animate-spin-in { + animation: spin-in 70ms ease both; +} + +@keyframes sport-land { + 0% { opacity: 0; transform: scale(0.9); } + 60% { opacity: 1; transform: scale(1.03); } + 100% { opacity: 1; transform: scale(1.0); } +} +.animate-sport-land { + animation: sport-land 450ms cubic-bezier(0.34, 1.56, 0.64, 1) both; +} + +@keyframes page-fade-in { + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } +} +.animate-page-fade-in { + animation: page-fade-in 0.5s ease both; +} + +@keyframes ticker-scroll { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} +.animate-ticker { + animation: ticker-scroll 28s linear infinite; +} + /* NProgress theme override */ #nprogress .bar { background: var(--electric) !important; diff --git a/app/components/marketing/LandingPage.tsx b/app/components/marketing/LandingPage.tsx new file mode 100644 index 0000000..f2f49c9 --- /dev/null +++ b/app/components/marketing/LandingPage.tsx @@ -0,0 +1,425 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router"; +import { Users, Shuffle, Trophy } 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(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 = 36; + +function getInterval(tick: number): number { + const pct = tick / TOTAL_TICKS; + return Math.round(130 - (130 - 38) * (pct * pct)); +} + +function SlotMachineHeadline() { + const [text, setText] = useState(""); + const [landed, setLanded] = useState(false); + const [animKey, setAnimKey] = useState(0); + const tickRef = useRef(0); + const timerRef = useRef | null>(null); + const sportsSeq = useRef([]); + + 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, 130); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + if (landed) { + return ( + + Every Sport. + + ); + } + + return ( + + {text} + + ); +} + +// ─── 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 ( + + + {(winner || champ) && ( + + )} + {champ && ( + + CHAMP + + )} + + ); +} + +function hLine(x1: number, y1: number, x2: number, color: string) { + return ; +} + +function connector(ax: number, ay: number, bx: number, by: number, cx: number, color: string) { + const midY = (ay + by) / 2; + return ( + + ); +} + +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 ( + + {r1Pairs.map(([a, b], i) => ( + + + + {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)} + + ))} + + {r2Slots.map((s, ri) => ( + + + {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)} + + ))} + + + + ); +} + +// ─── 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 ( +
+
+
+
+ {TICKER_ITEMS.map(({ sport, key }) => ( + + {sport} + + ))} +
+
+ ); +} + +// ─── Static data ────────────────────────────────────────────────────────────── + +const HOW_IT_WORKS_STEPS = [ + { + icon: Users, + title: "Create a League", + body: "Name it, pick your sports, invite your friends with a link. Takes 30 seconds.", + }, + { + icon: Shuffle, + title: "Draft Your Teams", + body: "Snake draft — everyone picks teams from any sport on the calendar. NHL, NBA, World Cup, whatever is running.", + }, + { + icon: Trophy, + title: "Watch & Win", + body: "Your teams earn points as they win real games. The bracket updates live. Last one standing takes it.", + }, +]; + +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" }, +]; + +// ─── 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( + + + +

{step.body}

+
+
+ ); + if (i < HOW_IT_WORKS_STEPS.length - 1) { + // Triangles reference #brackt-primary-gradient defined in (mounted in root.tsx) + howItWorksItems.push( +
+ + + +
+ ); + } + }); + + return ( +
+ {/* ── Hero ── */} +
+ {/* Bracket decorations — lg+ only */} +
+ + +
+ + {/* Center content */} +
+

+ Fantasy + +

+ +

+ Draft teams across every sport — NHL, NBA, NFL, EPL, World Cup, and more. + Compete with your friends in a league all season long. +

+ +
+ + +
+ + +
+
+ + {/* ── How It Works ── */} +
+
+ {howItWorksItems} +
+
+ + {/* ── Features Grid ── */} +
+
+
+ {FEATURES.map(({ title, body }) => ( +
+

{title}

+

{body}

+
+ ))} +
+
+
+ + {/* ── CTA Banner ── */} +
+
+
+

+ Your group chat is waiting for this. +

+

+ Free to play. Five minutes to set up. The real question is — who drafts the Colorado Avalanche? +

+
+ +
+
+ + {/* ── Footer ── */} +
+ © 2026 Brackt. All rights reserved. + +
+
+ ); +} diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 202b7f7..84045c6 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -5,6 +5,7 @@ import { getAuth } from "@clerk/react-router/server"; import { addDays, subDays } from "date-fns"; import type { Route } from "./+types/home"; +import { LandingPage } from "~/components/marketing/LandingPage"; import { findLeaguesWithActiveSeasonsByUserId } from "~/models/league"; import { findSeasonById, findCurrentSeasonWithSports } from "~/models/season"; import { findTeamByOwnerAndSeason } from "~/models/team"; @@ -160,6 +161,12 @@ export async function loader(args: Route.LoaderArgs) { }; } +const VALID_STATUSES = ["draft", "active", "pre_draft", "completed"] as const; +type ValidStatus = (typeof VALID_STATUSES)[number]; +function isValidStatus(s: string): s is ValidStatus { + return VALID_STATUSES.includes(s as ValidStatus); +} + export default function Home({ loaderData }: Route.ComponentProps) { const { leagues, isLoggedIn, upcomingCalendarEvents } = loaderData; const [searchParams, setSearchParams] = useSearchParams(); @@ -176,22 +183,9 @@ export default function Home({ loaderData }: Route.ComponentProps) { }, [searchParams, setSearchParams]); if (!isLoggedIn) { - return ( -
-

Welcome to Brackt

-

- Please sign in to view your leagues -

-
- ); + return ; } -const VALID_STATUSES = ["draft", "active", "pre_draft", "completed"] as const; -type ValidStatus = (typeof VALID_STATUSES)[number]; -function isValidStatus(s: string): s is ValidStatus { - return VALID_STATUSES.includes(s as ValidStatus); -} - const leagueRows: LeagueRowProps[] = leagues.map((league) => { const rawStatus = league.currentSeason?.status ?? "pre_draft"; return {