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:
@@ -90,9 +90,12 @@ router.get('/:sport', async (req, res) => {
|
|||||||
}
|
}
|
||||||
const stat = req.query.stat ? String(req.query.stat).toLowerCase() : 'all';
|
const stat = req.query.stat ? String(req.query.stat).toLowerCase() : 'all';
|
||||||
const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 0;
|
const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 0;
|
||||||
|
// Session 60 (night2/C) — a player's active streaks for the dossier page.
|
||||||
|
const playerKey = req.query.player ? nameKey(String(req.query.player).slice(0, 60)) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const roster = await loadRosterLogs(sport);
|
let roster = await loadRosterLogs(sport);
|
||||||
|
if (playerKey) roster = roster.filter((p) => nameKey(p.name) === playerKey);
|
||||||
const streaks = streaksService.computeStreaks(roster, sport, { stat });
|
const streaks = streaksService.computeStreaks(roster, sport, { stat });
|
||||||
// Session 60 — form heat (hot hitters/sluggers/shooters) joins the feed.
|
// Session 60 — form heat (hot hitters/sluggers/shooters) joins the feed.
|
||||||
const heat = streaksService.computeFormHeat(roster, sport, {})
|
const heat = streaksService.computeFormHeat(roster, sport, {})
|
||||||
|
|||||||
@@ -1,26 +1,44 @@
|
|||||||
// Session 42 — Stats Explorer (/explore) page (bonus design section 07).
|
// Session 42 — Stats Explorer; Session 60 (night2/C) — the AGGREGATOR hub.
|
||||||
|
// The page is now a server shell (SEO metadata) around the ExploreHub client
|
||||||
|
// component: leaders + full streaks + hot lists = the "entire picture" page.
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||||
const src = fs.readFileSync(path.join(WEB, 'app', 'explore', 'page.tsx'), 'utf8');
|
const hub = fs.readFileSync(path.join(WEB, 'components', 'ExploreHub.tsx'), 'utf8');
|
||||||
|
const shell = fs.readFileSync(path.join(WEB, 'app', 'explore', 'page.tsx'), 'utf8');
|
||||||
|
|
||||||
describe('Stats Explorer page', () => {
|
describe('Stats Explorer hub (client)', () => {
|
||||||
it('consumes the real /api/stats/leaders endpoint', () => {
|
it('consumes the real /api/stats/leaders endpoint', () => {
|
||||||
expect(src).toContain('/api/stats/leaders');
|
expect(hub).toContain('/api/stats/leaders');
|
||||||
});
|
});
|
||||||
it('has NBA/MLB/WNBA sport tabs + a player search filter', () => {
|
it('has NBA/MLB/WNBA sport tabs + a player search filter', () => {
|
||||||
expect(src).toContain('NBA');
|
expect(hub).toContain('NBA');
|
||||||
expect(src).toContain('MLB');
|
expect(hub).toContain('MLB');
|
||||||
expect(src).toContain('WNBA');
|
expect(hub).toContain('WNBA');
|
||||||
expect(src).toContain('Search players');
|
expect(hub).toContain('Search players');
|
||||||
});
|
});
|
||||||
it('rows link to the player profile', () => {
|
it('rows link to the player profile', () => {
|
||||||
expect(src).toContain('playerHref');
|
expect(hub).toContain('playerHref');
|
||||||
});
|
});
|
||||||
it('handles loading / error / empty states', () => {
|
it('handles loading / error / empty states', () => {
|
||||||
expect(src).toContain("'loading'");
|
expect(hub).toContain("'loading'");
|
||||||
expect(src).toContain("'error'");
|
expect(hub).toContain("'error'");
|
||||||
expect(src).toContain('No graded props');
|
expect(hub).toContain('No graded props');
|
||||||
|
});
|
||||||
|
// Session 60 — the aggregator: full streaks + hot lists on one page,
|
||||||
|
// gated by the REAL tier (streaks free for all; hot lists top-3 free).
|
||||||
|
it('mounts the streaks + hot-list aggregator with the real tier', () => {
|
||||||
|
expect(hub).toContain('<StreaksPanel sport={sport} tier={tier}');
|
||||||
|
expect(hub).toContain('<HotListPanel sport={sport} tier={tier}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Explore page shell (server, SEO)', () => {
|
||||||
|
it('is a server component carrying daily-indexable metadata', () => {
|
||||||
|
expect(shell).not.toContain("'use client'");
|
||||||
|
expect(shell).toContain('export const metadata');
|
||||||
|
expect(shell).toContain('Hit Streaks');
|
||||||
|
expect(shell).toContain('<ExploreHub />');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-122
@@ -1,128 +1,17 @@
|
|||||||
'use client';
|
import ExploreHub from '@/components/ExploreHub';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import SportBadge from '@/components/vyndr/SportBadge';
|
|
||||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
|
||||||
import { playerHref } from '@/lib/playerHref';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stats Explorer (/explore) — Session 42, design section 07. The data hub:
|
* /explore — the aggregator hub (Session 60, night2/C). Server shell for SEO:
|
||||||
* tonight's league leaderboard (top graded props by confidence) with grade +
|
* daily-regenerating indexable surface (streaks, hot lists, leaders). The
|
||||||
* archetype context. Consumes the real /api/stats/leaders endpoint. Sub-panels
|
* interactive hub is the client component.
|
||||||
* (hit-rate trends, head-to-head, market-vs-VYNDR) need historical data and are
|
|
||||||
* a Session-43 follow-up — see BUILD-STATE.
|
|
||||||
*/
|
*/
|
||||||
|
export const metadata = {
|
||||||
interface Leader {
|
title: 'MLB Hit Streaks, Hot Hitters & Stat Leaders — VYNDR Explore',
|
||||||
player: string;
|
description:
|
||||||
team: string;
|
'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.',
|
||||||
stat: string;
|
keywords: ['MLB hit streaks', 'hottest hitters this week', 'WNBA player streaks', 'player prop stats', 'hot hitters MLB'],
|
||||||
line: number | string;
|
};
|
||||||
side: string;
|
|
||||||
grade: string;
|
|
||||||
confidence: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SPORTS = [
|
|
||||||
{ key: 'nba', label: 'NBA' },
|
|
||||||
{ key: 'mlb', label: 'MLB' },
|
|
||||||
{ key: 'wnba', label: 'WNBA' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ExplorePage() {
|
export default function ExplorePage() {
|
||||||
const [sport, setSport] = useState('nba');
|
return <ExploreHub />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ export default function Home() {
|
|||||||
<TonightsSlate />
|
<TonightsSlate />
|
||||||
<LivePropsStrip />
|
<LivePropsStrip />
|
||||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '0 16px' }}>
|
<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} />
|
<HotListPanel sport="mlb" tier="free" limit={3} />
|
||||||
</div>
|
</div>
|
||||||
<Features />
|
<Features />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import GradeBadge from '@/components/vyndr/GradeBadge';
|
|||||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||||
|
import PlayerStreaks from '@/components/vyndr/PlayerStreaks';
|
||||||
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
||||||
|
|
||||||
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
|
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
|
||||||
@@ -134,6 +135,10 @@ export default function PlayerProfilePage() {
|
|||||||
</div>
|
</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 */}
|
{/* B. PROP DNA */}
|
||||||
{dna.length > 0 && (
|
{dna.length > 0 && (
|
||||||
<div className="intel-surface" style={{ borderRadius: 14, padding: 20, marginBottom: 14, border: '1px solid rgba(0,212,160,0.22)' }}>
|
<div className="intel-surface" style={{ borderRadius: 14, padding: 20, marginBottom: 14, border: '1px solid rgba(0,212,160,0.22)' }}>
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExploreHub (Session 42 leaders; Session 60 night2/C — THE AGGREGATOR).
|
||||||
|
* The "entire picture" page: leaders + full streaks + hot lists, one sport
|
||||||
|
* selector, everything free and interpreted through the lens. The page shell
|
||||||
|
* (app/explore/page.tsx) is a server component carrying the SEO metadata.
|
||||||
|
*/
|
||||||
|
import StreaksPanel from '@/components/StreaksPanel';
|
||||||
|
import HotListPanel from '@/components/HotListPanel';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
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 default function ExploreHub() {
|
||||||
|
const { tier } = useAuth();
|
||||||
|
const [sport, setSport] = useState('mlb');
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Session 60 (night2/C) — THE AGGREGATOR. Full streaks + hot lists
|
||||||
|
for the selected sport, every row through the lens. The picture is
|
||||||
|
free; the grade is the paid layer. Panels self-hide when cold. */}
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<StreaksPanel sport={sport} tier={tier} stat="all" />
|
||||||
|
<HotListPanel sport={sport} tier={tier} stat="all" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -176,7 +176,12 @@ interface PitcherResponse { games?: PitcherGame[] }
|
|||||||
type GradeIndex = ReturnType<typeof indexGrades>;
|
type GradeIndex = ReturnType<typeof indexGrades>;
|
||||||
type DeltaIndex = ReturnType<typeof indexDeltas>;
|
type DeltaIndex = ReturnType<typeof indexDeltas>;
|
||||||
type PitcherMap = ReturnType<typeof buildPitcherMap>;
|
type PitcherMap = ReturnType<typeof buildPitcherMap>;
|
||||||
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap): GameCardData {
|
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap, statFilter: string = 'all'): GameCardData {
|
||||||
|
// Session 60 (night2/C) — ONE stat selection filters every layer: props on
|
||||||
|
// the cards narrow together with the streaks + hot-list panels below.
|
||||||
|
const props = statFilter && statFilter !== 'all'
|
||||||
|
? g.props.filter((p) => String(p.stat_type || '').toLowerCase() === statFilter)
|
||||||
|
: g.props;
|
||||||
return {
|
return {
|
||||||
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
|
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
|
||||||
sport: g.sport,
|
sport: g.sport,
|
||||||
@@ -189,7 +194,7 @@ function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: D
|
|||||||
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
|
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
|
||||||
// Session 59 (work-order 1.6) — pass the game's participants so the join
|
// Session 59 (work-order 1.6) — pass the game's participants so the join
|
||||||
// guard can drop bad feed rows (a player whose real team isn't in this game).
|
// guard can drop bad feed rows (a player whose real team isn't in this game).
|
||||||
playerStrips: buildPlayerStripsFromProps(g.props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }),
|
playerStrips: buildPlayerStripsFromProps(props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }),
|
||||||
// Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers).
|
// Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers).
|
||||||
pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined,
|
pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined,
|
||||||
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })),
|
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })),
|
||||||
@@ -854,7 +859,7 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
|||||||
{filteredGames.map((g, i) => (
|
{filteredGames.map((g, i) => (
|
||||||
<VyndrGameCard
|
<VyndrGameCard
|
||||||
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
||||||
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap)}
|
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat)}
|
||||||
preferredBooks={preferredBooks}
|
preferredBooks={preferredBooks}
|
||||||
onOpen={() => router.push('/scan')}
|
onOpen={() => router.push('/scan')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
|
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
|
||||||
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
|
import { type Tier } from '@/lib/tierGate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StreaksPanel (Session 23).
|
* StreaksPanel (Session 23).
|
||||||
@@ -25,6 +25,9 @@ interface Streak {
|
|||||||
category: string;
|
category: string;
|
||||||
currentStreak: number;
|
currentStreak: number;
|
||||||
description: string;
|
description: string;
|
||||||
|
// Session 60 (night2/C) — THE LENS + optional snapshot grade letter.
|
||||||
|
lens?: { builtVs?: string[] | null; matchup?: string | null; difficulty?: string | null; read?: string | null };
|
||||||
|
grade?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreaksPanelProps {
|
export interface StreaksPanelProps {
|
||||||
@@ -56,10 +59,13 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
|
|||||||
|
|
||||||
if (!streaks || streaks.length === 0) return null;
|
if (!streaks || streaks.length === 0) return null;
|
||||||
|
|
||||||
const tierCount = getVisibleCount(tier, streaks.length);
|
// Session 60 (product def) — the PICTURE is free: every streak shows for
|
||||||
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
|
// every tier. Only an explicit `limit` (landing teaser) caps the list.
|
||||||
const visible = streaks.slice(0, cap);
|
// The paid layer is the GRADE, not the data. tierGate still governs the
|
||||||
const hidden = limit ? streaks.length - visible.length : getHiddenCount(tier, streaks.length);
|
// hot LISTS (top-3 free) — not streaks.
|
||||||
|
void tier;
|
||||||
|
const visible = limit && limit > 0 ? streaks.slice(0, limit) : streaks;
|
||||||
|
const hidden = streaks.length - visible.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="streaks-panel" style={{ margin: '16px 0' }}>
|
<section className="streaks-panel" style={{ margin: '16px 0' }}>
|
||||||
@@ -76,16 +82,27 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
|
|||||||
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
|
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={playerName}>{s.player}{s.team ? ` · ${s.team}` : ''}</div>
|
<div style={playerName}>
|
||||||
<div style={streakDesc}>{s.description}</div>
|
{s.player}{s.team ? ` · ${s.team}` : ''}
|
||||||
|
{/* Grade letter when the snapshot graded this player (the paid
|
||||||
|
READ layer; the letter itself is public on the slate). */}
|
||||||
|
{s.grade && <span className="mono" style={{ marginLeft: 8, fontSize: 10.5, fontWeight: 800, color: 'var(--g-a, #00D4A0)', border: '1px solid rgba(0,212,160,.35)', borderRadius: 4, padding: '1px 5px' }}>{s.grade}</span>}
|
||||||
|
</div>
|
||||||
|
{/* THE LENS — the interpreted read, never the raw streak alone. */}
|
||||||
|
<div style={streakDesc}>{s.lens?.read || s.description}</div>
|
||||||
|
{s.lens?.difficulty && (
|
||||||
|
<div className="mono" style={{ fontSize: 10.5, marginTop: 2, color: s.lens.difficulty === 'step up' ? 'var(--amber, #FFB347)' : s.lens.difficulty === 'step down' ? 'var(--g-a, #00D4A0)' : 'var(--text-tertiary, #6A6A78)' }}>
|
||||||
|
{s.lens.difficulty === 'step up' ? '▲ TOUGHER SPOT TONIGHT' : s.lens.difficulty === 'step down' ? '▼ SOFTER SPOT TONIGHT' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span style={badgeStyle}>{s.currentStreak} G</span>
|
<span style={badgeStyle}>{s.currentStreak} G</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{hidden > 0 && (
|
{hidden > 0 && (
|
||||||
<a href="/pricing" style={upsellStyle}>
|
<a href="/explore" style={upsellStyle}>
|
||||||
{hidden} more streak{hidden === 1 ? '' : 's'} — upgrade to see all →
|
{hidden} more streak{hidden === 1 ? '' : 's'} on the board →
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlayerStreaks (Session 60, night2/C) — a player's ACTIVE streaks + form
|
||||||
|
* heat on the dossier page. FREE layer (the picture), rendered through the
|
||||||
|
* lens (the interpreted read), beside the paid intelligence blocks.
|
||||||
|
* Self-hides when the player has no active streaks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface StreakRow {
|
||||||
|
type: string;
|
||||||
|
currentStreak: number;
|
||||||
|
description: string;
|
||||||
|
lens?: { read?: string | null; difficulty?: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerStreaks({ player, sport }: { player: string; sport: string }) {
|
||||||
|
const [rows, setRows] = useState<StreakRow[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
fetch(`/api/streaks/${encodeURIComponent(sport)}?player=${encodeURIComponent(player)}`)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => { if (active && data) setRows(Array.isArray(data.streaks) ? data.streaks : []); })
|
||||||
|
.catch(() => { /* self-hide */ });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [player, sport]);
|
||||||
|
|
||||||
|
if (!rows || rows.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16, marginBottom: 14 }}>
|
||||||
|
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: '#ff8b7a', marginBottom: 10 }}>
|
||||||
|
🔥 ACTIVE STREAKS
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{rows.slice(0, 5).map((s) => (
|
||||||
|
<div key={s.type} style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
||||||
|
<span className="mono" style={{ flexShrink: 0, fontSize: 12, fontWeight: 800, color: '#ffb0a4' }}>{s.currentStreak}G</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12.5, color: 'var(--text-0)' }}>{s.lens?.read || s.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user