Homepage initial styling
This commit is contained in:
parent
1309ab2ece
commit
8fadc27c75
3 changed files with 467 additions and 14 deletions
34
app/app.css
34
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;
|
||||
|
|
|
|||
425
app/components/marketing/LandingPage.tsx
Normal file
425
app/components/marketing/LandingPage.tsx
Normal file
|
|
@ -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<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 = 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<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, 130);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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: "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(
|
||||
<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="hidden shrink-0 md:flex md:items-center md:justify-center md:px-1">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20">
|
||||
<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>
|
||||
|
||||
{/* Center content */}
|
||||
<div className="relative z-10 w-full max-w-[680px] text-center">
|
||||
<h1
|
||||
className="mb-0 leading-[0.95] tracking-[-0.04em]"
|
||||
style={{ fontSize: "clamp(52px, 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: "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>
|
||||
|
||||
<div className="mt-8 flex items-center justify-center gap-2.5">
|
||||
<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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,20 +183,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||
}, [searchParams, setSearchParams]);
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="container mx-auto py-16 px-4 text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Welcome to Brackt</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8">
|
||||
Please sign in to view your leagues
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
return <LandingPage />;
|
||||
}
|
||||
|
||||
const leagueRows: LeagueRowProps[] = leagues.map((league) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue