Session 23: All-day intelligence layer — schedule, game lines, streaks, hot lists, stat filtering, ParlayAPI dead (1567 tests)

This commit is contained in:
Kev
2026-06-12 11:16:58 -04:00
parent 6ab49d4c37
commit 0538205fab
32 changed files with 2276 additions and 2 deletions
+9
View File
@@ -10,6 +10,11 @@ import Hero from '@/components/Hero';
// every sport returns zero (off-hours / upstream outages).
import TonightsSlate from '@/components/TonightsSlate';
import LivePropsStrip from '@/components/LivePropsStrip';
// Session 23 — all-day intelligence teasers. Free/cheap content that
// keeps the landing page alive even when odds-api props are empty.
// Both self-hide when there's nothing to show.
import StreaksPanel from '@/components/StreaksPanel';
import HotListPanel from '@/components/HotListPanel';
import Features from '@/components/Features';
import HowItWorks from '@/components/HowItWorks';
import Pricing from '@/components/Pricing';
@@ -41,6 +46,10 @@ export default function Home() {
<Hero />
<TonightsSlate />
<LivePropsStrip />
<div style={{ maxWidth: 960, margin: '0 auto', padding: '0 16px' }}>
<StreaksPanel sport="nba" tier="free" limit={3} />
<HotListPanel sport="mlb" tier="free" limit={3} />
</div>
<Features />
<HowItWorks />
<Pricing />
+123
View File
@@ -0,0 +1,123 @@
'use client';
import { useEffect, useState } from 'react';
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
/**
* HotListPanel (Session 23).
*
* Rolling recent-window leaders — ranked by who's TRENDING, not who has
* the biggest raw number. A 20-PPG player erupting for 28/31/25 is hot;
* a 30-PPG star who dropped 28 is not. Free users see the top 3.
*
* Self-hides when empty so the landing page never renders a dead box.
*/
interface HotPlayer {
rank: number;
name: string;
playerId: string | number | null;
team: string | null;
stat: string;
recentAvg: number;
statLine: string;
trendDescription: string;
}
export interface HotListPanelProps {
sport: string;
tier?: Tier;
stat?: string;
limit?: number;
}
export default function HotListPanel({ sport, tier = 'free', stat = 'all', limit }: HotListPanelProps) {
const [players, setPlayers] = useState<HotPlayer[] | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch(`/api/hotlist/${sport}?stat=${encodeURIComponent(stat)}`);
if (!res.ok) { if (!cancelled) setPlayers([]); return; }
const data = await res.json();
if (!cancelled) setPlayers(Array.isArray(data?.players) ? data.players : []);
} catch {
if (!cancelled) setPlayers([]);
}
}
load();
return () => { cancelled = true; };
}, [sport, stat]);
if (!players || players.length === 0) return null;
const tierCount = getVisibleCount(tier, players.length);
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
const visible = players.slice(0, cap);
const hidden = limit ? players.length - visible.length : getHiddenCount(tier, players.length);
return (
<section className="hot-list-panel" style={{ margin: '16px 0' }}>
<h3 style={panelHeading}>📈 HOT RIGHT NOW</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{visible.map((p) => (
<div key={`${p.name}-${p.stat}`} style={rowStyle}>
<span style={rankStyle}>#{p.rank}</span>
<img
src={getHeadshotUrl({ sport, playerId: p.playerId })}
alt={p.name}
width={36}
height={36}
style={avatarStyle}
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>{p.name}{p.team ? ` · ${p.team}` : ''}</div>
<div style={statLineStyle}>{p.statLine}</div>
</div>
<span style={trendStyle}>{p.trendDescription}</span>
</div>
))}
</div>
{hidden > 0 && (
<a href="/pricing" style={upsellStyle}>
{hidden} more upgrade to see the full board
</a>
)}
</section>
);
}
const panelHeading: React.CSSProperties = {
fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase',
color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px',
};
const rowStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 10px', borderRadius: 10,
background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)',
};
const rankStyle: React.CSSProperties = {
flex: '0 0 auto', fontSize: 13, fontWeight: 800, width: 28,
color: 'var(--text-tertiary, #6A6A78)',
};
const avatarStyle: React.CSSProperties = {
borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto',
};
const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
};
const statLineStyle: React.CSSProperties = {
fontSize: 12, color: 'var(--text-secondary, #9A9AA8)',
};
const trendStyle: React.CSSProperties = {
flex: '0 0 auto', fontSize: 11, fontWeight: 700, padding: '3px 8px',
borderRadius: 6, background: 'rgba(46,160,67,0.15)', color: '#3FB950',
};
const upsellStyle: React.CSSProperties = {
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
color: 'var(--accent, #E94B3C)', textDecoration: 'none',
};
+23
View File
@@ -5,6 +5,12 @@ import { useRouter } from 'next/navigation';
import GameCard, { SlateSport } from '@/components/GameCard';
import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
import { useAuth } from '@/contexts/AuthContext';
// Session 23 — all-day intelligence layer. The stat filter is the
// navigation system; streaks + hot lists layer ON TOP of the odds the
// Slate already shows, never replacing them.
import StatFilterPills from '@/components/StatFilterPills';
import StreaksPanel from '@/components/StreaksPanel';
import HotListPanel from '@/components/HotListPanel';
/**
* The Slate (Session 13).
@@ -181,6 +187,10 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
const router = useRouter();
const { session } = useAuth();
const [tab, setTab] = useState<SlateTab>(initialTab);
// Session 23 — active stat category for the intelligence panels. 'all'
// shows everything; selecting one narrows streaks + hot list. Schedule
// and game lines stay visible regardless (handled inside GameCard).
const [activeStat, setActiveStat] = useState<string>('all');
const [games, setGames] = useState<SlateGame[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
@@ -416,6 +426,13 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
);
})}
</div>
{/* Session 23 — stat filter pills, below the sport tabs and above
all content. Narrows the streaks + hot list panels. */}
<StatFilterPills
sport={tab === 'all' ? 'nba' : tab}
activeStat={activeStat}
onChange={setActiveStat}
/>
</div>
{/* Body */}
@@ -525,6 +542,12 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
))}
</div>
{/* Session 23 — intelligence layer. These coexist WITH the odds
above; they never replace games. Both self-hide when empty, so
an off-hours slate with no warm logs simply shows the games. */}
<StreaksPanel sport={tab === 'all' ? 'nba' : tab} tier={tier} stat={activeStat} />
<HotListPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} stat={activeStat} />
{unsupportedSports.length > 0 && !loading && (
<p
className="mono"
+71
View File
@@ -0,0 +1,71 @@
'use client';
import { getStatFilters, formatStatLabel } from '@/config/statFilters';
/**
* StatFilterPills (Session 23).
*
* The navigation system for the all-day intelligence layer. Sits below
* the sport tabs and above all content. Selecting a stat narrows the
* streaks panel, hot list, and props to that category — while schedule
* and game lines stay visible regardless (they're game-level, not
* stat-specific). "All" (default) shows everything.
*
* Controlled component: the parent owns `activeStat` and re-fetches the
* stat-scoped panels when it changes.
*/
export interface StatFilterPillsProps {
sport: string;
activeStat: string;
onChange: (stat: string) => void;
}
export default function StatFilterPills({ sport, activeStat, onChange }: StatFilterPillsProps) {
const filters = getStatFilters(sport);
if (filters.length <= 1) return null; // nothing to filter by
return (
<div
className="stat-filters"
role="tablist"
aria-label="Filter by stat category"
style={{
display: 'flex',
gap: 8,
overflowX: 'auto',
padding: '8px 0',
WebkitOverflowScrolling: 'touch',
}}
>
{filters.map((stat) => {
const active = activeStat === stat;
return (
<button
key={stat}
role="tab"
aria-selected={active}
className={active ? 'active' : ''}
onClick={() => onChange(stat)}
style={{
flex: '0 0 auto',
padding: '6px 14px',
borderRadius: 999,
border: `1px solid ${active ? 'var(--accent, #E94B3C)' : 'var(--border, #2A2A36)'}`,
background: active ? 'var(--accent, #E94B3C)' : 'transparent',
color: active ? '#0A0A0F' : 'var(--text-secondary, #9A9AA8)',
fontSize: 13,
fontWeight: active ? 700 : 500,
letterSpacing: '0.02em',
cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'all 0.15s ease',
}}
>
{formatStatLabel(stat)}
</button>
);
})}
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
'use client';
import { useEffect, useState } from 'react';
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
/**
* StreaksPanel (Session 23).
*
* Surfaces computed player streaks for a sport, narrowed by the active
* stat filter. Everything through VYNDR's lens — "4-game 28+ scoring
* streak", not "31.2 PPG". Free users see the top 3 with an upgrade
* nudge; paid users see the full list.
*
* Self-hides when there are no streaks so the landing page never shows an
* empty box — the other layers (schedule, game lines, props) carry the
* slate when no logs are warm yet.
*/
interface Streak {
player: string;
playerId: string | number | null;
team: string | null;
type: string;
category: string;
currentStreak: number;
description: string;
}
export interface StreaksPanelProps {
sport: string;
tier?: Tier;
stat?: string;
/** Optional hard cap (teaser usage on the landing page). */
limit?: number;
}
export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit }: StreaksPanelProps) {
const [streaks, setStreaks] = useState<Streak[] | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch(`/api/streaks/${sport}?stat=${encodeURIComponent(stat)}`);
if (!res.ok) { if (!cancelled) setStreaks([]); return; }
const data = await res.json();
if (!cancelled) setStreaks(Array.isArray(data?.streaks) ? data.streaks : []);
} catch {
if (!cancelled) setStreaks([]);
}
}
load();
return () => { cancelled = true; };
}, [sport, stat]);
if (!streaks || streaks.length === 0) return null;
const tierCount = getVisibleCount(tier, streaks.length);
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
const visible = streaks.slice(0, cap);
const hidden = limit ? streaks.length - visible.length : getHiddenCount(tier, streaks.length);
return (
<section className="streaks-panel" style={{ margin: '16px 0' }}>
<h3 style={panelHeading}>🔥 STREAKS</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{visible.map((s) => (
<div key={`${s.player}-${s.type}`} style={rowStyle}>
<img
src={getHeadshotUrl({ sport, playerId: s.playerId })}
alt={s.player}
width={36}
height={36}
style={avatarStyle}
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>{s.player}{s.team ? ` · ${s.team}` : ''}</div>
<div style={streakDesc}>{s.description}</div>
</div>
<span style={badgeStyle}>{s.currentStreak} G</span>
</div>
))}
</div>
{hidden > 0 && (
<a href="/pricing" style={upsellStyle}>
{hidden} more streak{hidden === 1 ? '' : 's'} upgrade to see all
</a>
)}
</section>
);
}
const panelHeading: React.CSSProperties = {
fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase',
color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px',
};
const rowStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 10px', borderRadius: 10,
background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)',
};
const avatarStyle: React.CSSProperties = {
borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto',
};
const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
};
const streakDesc: React.CSSProperties = {
fontSize: 12, color: 'var(--text-secondary, #9A9AA8)',
};
const badgeStyle: React.CSSProperties = {
flex: '0 0 auto', fontSize: 12, fontWeight: 800, padding: '3px 8px',
borderRadius: 6, background: 'rgba(233,75,60,0.15)', color: 'var(--accent, #E94B3C)',
};
const upsellStyle: React.CSSProperties = {
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
color: 'var(--accent, #E94B3C)', textDecoration: 'none',
};
+39
View File
@@ -0,0 +1,39 @@
/**
* Stat-filter categories per sport — frontend mirror (Session 23).
* Keep aligned with `src/config/statFilters.js` in the Node backend.
*
* The stat filter is VYNDR's navigation system: users browse by what
* they care about (a 3-point streak, hot hitters, run lines), not by
* sport alone. Drives StatFilterPills and the `?stat=` param on the
* /api/streaks and /api/hotlist endpoints.
*/
export type StatFilterSport = 'nba' | 'wnba' | 'mlb' | 'soccer' | 'nfl';
export const STAT_FILTERS: Record<StatFilterSport, string[]> = {
nba: ['all', 'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra'],
wnba: ['all', 'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals'],
mlb: ['all', 'hits', 'home_runs', 'stolen_bases', 'rbis', 'strikeouts', 'total_bases', 'on_base'],
soccer: ['all', 'goals', 'assists', 'shots', 'tackles', 'saves'],
nfl: ['all', 'passing_yards', 'rushing_yards', 'receiving_yards', 'touchdowns', 'interceptions'],
};
const LABELS: Record<string, string> = {
all: 'All',
points: 'Points', rebounds: 'Rebounds', assists: 'Assists', threes: '3-Pointers',
blocks: 'Blocks', steals: 'Steals', pra: 'PRA',
hits: 'Hits', home_runs: 'Home Runs', stolen_bases: 'Stolen Bases', rbis: 'RBIs',
strikeouts: 'Strikeouts', total_bases: 'Total Bases', on_base: 'On-Base',
goals: 'Goals', shots: 'Shots', tackles: 'Tackles', saves: 'Saves',
passing_yards: 'Pass Yds', rushing_yards: 'Rush Yds', receiving_yards: 'Rec Yds',
touchdowns: 'TDs', interceptions: 'INTs',
};
export function getStatFilters(sport: string): string[] {
return STAT_FILTERS[sport as StatFilterSport] || ['all'];
}
export function formatStatLabel(stat: string): string {
if (LABELS[stat]) return LABELS[stat];
return stat.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}