Session 23: All-day intelligence layer — schedule, game lines, streaks, hot lists, stat filtering, ParlayAPI dead (1567 tests)
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user