Session C (night2): the aggregator mounted — Explore hub, lens panels, one filter
- StreaksPanel renders THE LENS (interpreted read + tonight difficulty) + snapshot grade letters; streaks are FREE for every tier per the product definition (the picture is free, the read is paid) — only hot lists keep the free top-3 gate. - One stat selection now filters ALL layers: card props narrow together with streaks + hot lists (Slate activeStat → slateGameToCardData). - /explore = server SEO shell (daily-indexable: 'MLB Hit Streaks, Hot Hitters & Stat Leaders') + ExploreHub client: leaders + full streaks + hot lists, real-tier gated, defaults to the in-season sport. - Landing teaser fixed: it pointed at NBA (off-season → self-hid forever); now MLB top-3 through the lens — the anonymous-visitor hook actually shows. - Player dossier: ACTIVE STREAKS block (free, lens reads) via new ?player= filter on /api/streaks/:sport. - Schedule layer already the Slate base (mergeSlate) — verified, no change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+11
-122
@@ -1,128 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
import ExploreHub from '@/components/ExploreHub';
|
||||
|
||||
/**
|
||||
* Stats Explorer (/explore) — Session 42, design section 07. The data hub:
|
||||
* tonight's league leaderboard (top graded props by confidence) with grade +
|
||||
* archetype context. Consumes the real /api/stats/leaders endpoint. Sub-panels
|
||||
* (hit-rate trends, head-to-head, market-vs-VYNDR) need historical data and are
|
||||
* a Session-43 follow-up — see BUILD-STATE.
|
||||
* /explore — the aggregator hub (Session 60, night2/C). Server shell for SEO:
|
||||
* daily-regenerating indexable surface (streaks, hot lists, leaders). The
|
||||
* interactive hub is the client component.
|
||||
*/
|
||||
|
||||
interface Leader {
|
||||
player: string;
|
||||
team: string;
|
||||
stat: string;
|
||||
line: number | string;
|
||||
side: string;
|
||||
grade: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
const SPORTS = [
|
||||
{ key: 'nba', label: 'NBA' },
|
||||
{ key: 'mlb', label: 'MLB' },
|
||||
{ key: 'wnba', label: 'WNBA' },
|
||||
];
|
||||
export const metadata = {
|
||||
title: 'MLB Hit Streaks, Hot Hitters & Stat Leaders — VYNDR Explore',
|
||||
description:
|
||||
'Live player streaks (hit streaks, on-base streaks, HR streaks), 7-day hot hitters and hot sluggers, and tonight\'s graded stat leaders — every number with matchup context. MLB + WNBA, updated daily.',
|
||||
keywords: ['MLB hit streaks', 'hottest hitters this week', 'WNBA player streaks', 'player prop stats', 'hot hitters MLB'],
|
||||
};
|
||||
|
||||
export default function ExplorePage() {
|
||||
const [sport, setSport] = useState('nba');
|
||||
const [leaders, setLeaders] = useState<Leader[]>([]);
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState('loading');
|
||||
fetch(`/api/stats/leaders?sport=${sport}&limit=25`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (active) { setLeaders(Array.isArray(d.leaders) ? d.leaders : []); setState('ready'); } })
|
||||
.catch(() => { if (active) setState('error'); });
|
||||
return () => { active = false; };
|
||||
}, [sport]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders;
|
||||
}, [leaders, query]);
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 920, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 14, marginBottom: 22, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 8 }}>STATS · /EXPLORE</div>
|
||||
<h1 style={{ margin: '0 0 8px', fontSize: 30, fontWeight: 800, letterSpacing: '-0.01em' }}>Stats Explorer</h1>
|
||||
<p style={{ margin: 0, maxWidth: 560, fontSize: 14, lineHeight: 1.6, color: 'var(--text-1)' }}>
|
||||
Tonight's league leaderboard — every graded prop, ranked by VYNDR confidence, with the grade and archetype context only VYNDR has.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 8, padding: 4 }}>
|
||||
{SPORTS.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setSport(s.key)}
|
||||
className="mono"
|
||||
style={{ cursor: 'pointer', border: 'none', borderRadius: 5, padding: '7px 14px', fontSize: 11, fontWeight: 600, letterSpacing: '0.04em', color: sport === s.key ? '#06060B' : 'var(--text-1)', background: sport === s.key ? 'var(--g-a)' : 'transparent' }}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FILTER BAR */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11, flexWrap: 'wrap', marginBottom: 14, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 11, padding: '11px 14px' }}>
|
||||
<span className="mono" style={{ fontSize: 14, color: 'var(--text-2)' }}>⌕</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search players"
|
||||
style={{ appearance: 'none', background: 'transparent', border: 'none', outline: 'none', fontFamily: 'var(--sans)', fontSize: 13, color: '#fff', flex: 1, minWidth: 140 }}
|
||||
/>
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>{rows.length} graded</span>
|
||||
</div>
|
||||
|
||||
{/* LEADERBOARD */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 13 }}>
|
||||
<span style={{ width: 6, height: 6, background: 'var(--g-a)', borderRadius: 1 }} />
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--text-1)' }}>LEAGUE LEADERBOARD</span>
|
||||
<SportBadge sport={sport} />
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<div className="mono game-lines-grid" style={{ display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '10px 16px', borderBottom: '1px solid #14141E', fontSize: 9, color: 'var(--text-2)', letterSpacing: '0.08em' }}>
|
||||
<div>#</div><div>PLAYER</div><div style={{ textAlign: 'right' }}>PROP</div><div style={{ textAlign: 'right' }}>CONF</div><div style={{ textAlign: 'center' }}>GRD</div>
|
||||
</div>
|
||||
|
||||
{state === 'loading' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>Loading tonight's slate…</div>}
|
||||
{state === 'error' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--miss)' }}>Could not load the leaderboard. Try again.</div>}
|
||||
{state === 'ready' && rows.length === 0 && (
|
||||
<div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>
|
||||
No graded props for {sport.toUpperCase()} yet — check back when tonight's slate posts.
|
||||
</div>
|
||||
)}
|
||||
{state === 'ready' && rows.map((r, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={playerHref(r.player, sport)}
|
||||
className="game-lines-grid"
|
||||
style={{ textDecoration: 'none', color: 'inherit', display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '13px 16px', borderBottom: '1px solid #14141E' }}
|
||||
>
|
||||
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color: i < 3 ? 'var(--g-a)' : 'var(--text-1)' }}>{i + 1}</div>
|
||||
<div style={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 13, color: '#fff', whiteSpace: 'nowrap' }}>{r.player}</span>
|
||||
{r.team && <span className="mono" style={{ fontSize: 10, color: 'var(--text-1)' }}>{r.team}</span>}
|
||||
</div>
|
||||
<div className="mono" style={{ textAlign: 'right', fontSize: 12, color: '#C8CCD6' }}>{r.stat} {r.side}{r.line}</div>
|
||||
<div className="mono" style={{ textAlign: 'right', fontSize: 13, fontWeight: 600, color: 'var(--text-0)' }}>{r.confidence != null ? `${r.confidence}%` : '—'}</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}><GradeBadge grade={r.grade} size="sm" /></div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
return <ExploreHub />;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,10 @@ export default function Home() {
|
||||
<TonightsSlate />
|
||||
<LivePropsStrip />
|
||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '0 16px' }}>
|
||||
<StreaksPanel sport="nba" tier="free" limit={3} />
|
||||
{/* Session 60 (night2/C) — the free hook: top-3 REAL streaks through
|
||||
the lens. MLB is the in-season board (was nba — off-season = the
|
||||
panel self-hid and the teaser never showed). */}
|
||||
<StreaksPanel sport="mlb" tier="free" limit={3} />
|
||||
<HotListPanel sport="mlb" tier="free" limit={3} />
|
||||
</div>
|
||||
<Features />
|
||||
|
||||
@@ -7,6 +7,7 @@ import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||
import PlayerStreaks from '@/components/vyndr/PlayerStreaks';
|
||||
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
||||
|
||||
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
|
||||
@@ -134,6 +135,10 @@ export default function PlayerProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session 60 (night2/C) — active streaks join the dossier (FREE layer,
|
||||
through the lens). Self-hides when the player has none. */}
|
||||
<PlayerStreaks player={p.player} sport={p.sport} />
|
||||
|
||||
{/* B. PROP DNA */}
|
||||
{dna.length > 0 && (
|
||||
<div className="intel-surface" style={{ borderRadius: 14, padding: 20, marginBottom: 14, border: '1px solid rgba(0,212,160,0.22)' }}>
|
||||
|
||||
Reference in New Issue
Block a user