Session 16: Live hero prop, sport-specific markets fix, soccer weather, Sentry CSP (1429 tests)

This commit is contained in:
Kev
2026-06-11 18:15:25 -04:00
parent 167996d99a
commit 73b65a0248
11 changed files with 1010 additions and 101 deletions
+169
View File
@@ -0,0 +1,169 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
// Cache the response for 15 minutes (server-side) so cold visitors
// don't trigger a fresh grade on every page load. The cache header
// is what most CDNs / Coolify reverse proxies honor; Next.js itself
// already opts into dynamic rendering via `dynamic = 'force-dynamic'`.
export const revalidate = 900;
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
const HERO_FETCH_TIMEOUT_MS = 6000;
interface OddsResponseProp {
player: string;
stat_type: string;
line: number;
direction?: 'over' | 'under';
book?: string;
home_team?: string;
away_team?: string;
}
interface OddsResponse {
sport?: string;
source?: string;
props?: OddsResponseProp[];
error?: string;
}
interface GradeResponse {
grade?: string;
confidence?: number;
edge_pct?: number;
projection?: number;
reasoning?: { summary?: string; steps?: unknown };
kill_conditions_triggered?: Array<{ code: string; reason: string }>;
}
/**
* Live hero prop endpoint (Session 16).
*
* Picks one fresh, real prop from the day's odds and grades it. The
* landing page hero renders this in place of the static Jokic mockup
* — cold visitors see proof of live intelligence on first paint
* instead of a hypothetical example.
*
* Sport cascade: NBA → WNBA → MLB. Whichever sport produces a non-
* empty `props` list first wins. When every sport is empty (off-
* hours, holiday slate, upstream odds quota burned), responds with
* `{ isStatic: true }` and the client falls back to the existing
* static card. Never throws — odds outages must not blank the
* landing page.
*
* Two-stage flow:
* 1. GET ${BACKEND}/api/odds/{sport} → pick random prop
* 2. POST ${BACKEND}/api/analyze/prop → grade it
*
* Both calls share a 6s timeout (AbortController). The overall
* route is wrapped in try/catch and always 200s (with `isStatic:true`
* on failure) so the client renders gracefully.
*/
async function fetchWithTimeout(url: string, init?: RequestInit, ms = HERO_FETCH_TIMEOUT_MS): Promise<Response | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
async function pickPropFromSport(sport: string): Promise<{ prop: OddsResponseProp; sport: string } | null> {
const res = await fetchWithTimeout(`${BACKEND_URL}/api/odds/${sport}`, {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!res || !res.ok) return null;
const body = (await res.json().catch(() => null)) as OddsResponse | null;
if (!body || !Array.isArray(body.props) || body.props.length === 0) return null;
// Bias toward A-list player names — props with longer player names
// tend to be top-of-rotation stars (better hero material). Cheap
// heuristic, not a hard filter; we still random-pick among the top
// half of the sorted list so multiple page loads vary.
const sorted = body.props
.filter((p) => p.player && p.stat_type && Number.isFinite(p.line))
.sort((a, b) => (b.player.length - a.player.length));
if (sorted.length === 0) return null;
const topHalf = sorted.slice(0, Math.max(3, Math.ceil(sorted.length / 2)));
const pick = topHalf[Math.floor(Math.random() * topHalf.length)];
return { prop: pick, sport };
}
async function gradeProp(sport: string, prop: OddsResponseProp): Promise<GradeResponse | null> {
const res = await fetchWithTimeout(`${BACKEND_URL}/api/analyze/prop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
sport,
player: prop.player,
stat_type: prop.stat_type,
line: prop.line,
direction: prop.direction || 'over',
book: prop.book || 'draftkings',
}),
cache: 'no-store',
});
if (!res || !res.ok) return null;
return (await res.json().catch(() => null)) as GradeResponse | null;
}
export async function GET() {
// The order matters: NBA props lead because mid-summer the cascade
// would otherwise constantly land on the same sport. After NBA
// off-season concludes, swap to a season-aware ordering (winter:
// NBA, summer: MLB + WNBA, fall: NFL — when supported).
const sportsToTry = ['nba', 'wnba', 'mlb'];
try {
for (const sport of sportsToTry) {
const picked = await pickPropFromSport(sport);
if (!picked) continue;
const grade = await gradeProp(picked.sport, picked.prop);
if (!grade) continue;
return NextResponse.json(
{
isStatic: false,
sport: picked.sport,
prop: picked.prop,
grade,
},
{ headers: { 'Cache-Control': 'public, s-maxage=900, stale-while-revalidate=60' } },
);
}
} catch {
// Falls through to static fallback below.
}
// Static fallback — keeps the hero alive when every sport is empty.
return NextResponse.json(
{
isStatic: true,
sport: 'nba',
prop: {
player: 'Nikola Jokic',
stat_type: 'points',
line: 26.5,
direction: 'over',
book: 'draftkings',
home_team: 'DEN',
away_team: 'LAL',
},
grade: {
grade: 'A-',
confidence: 73,
edge_pct: 6.2,
projection: 29.4,
reasoning: {
summary: 'L5 form is 28.6 over 5 games, +2.1 above the line. Lakers are bottom-five vs Cs.',
},
kill_conditions_triggered: [],
},
},
{ headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60' } },
);
}
+14 -94
View File
@@ -1,6 +1,10 @@
'use client';
import { GradePill } from './GradeCard';
// Session 16 — the floating demo card on the right side of the hero
// is now driven by /api/hero-prop. Live prop + grade renders with a
// glitch/blur overlay on the reasoning. Falls back to the static
// Jokic layout when no live odds are available.
import LiveHeroProp from './LiveHeroProp';
export default function Hero() {
return (
@@ -75,7 +79,7 @@ export default function Hero() {
</p>
</div>
<FloatingDemoCard />
<LiveHeroProp />
</div>
<p
style={{
@@ -173,95 +177,11 @@ function SportBadgeStrip() {
);
}
function FloatingDemoCard() {
return (
<div
className="animate-fade-up stagger-3"
style={{
position: 'relative',
transform: 'rotate(-1deg)',
padding: 24,
background: 'var(--bg-elevated)',
border: '1px solid var(--border-focus)',
borderRadius: 20,
boxShadow: '0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px var(--accent-glow)',
maxWidth: 380,
marginInline: 'auto',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<span
className="mono"
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 999,
background: 'rgba(233,75,60,0.15)',
color: '#E94B3C',
fontWeight: 700,
}}
>
NBA
</span>
<h3 style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>Nikola Jokic</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
Over 26.5 points
</p>
</div>
<GradePill grade="A-" confidence={73} />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<Stat label="Projection" value="29.4 pts" />
<Stat label="Edge" value="+6.2%" tone="positive" />
</div>
<ul style={{ display: 'grid', gap: 6, fontSize: 12, color: 'var(--text-secondary)' }}>
<li style={row}>
<span>Matchup</span>
<span style={{ color: 'var(--text-primary)' }}>LAL · 26th vs C</span>
</li>
<li style={row}>
<span>L10 form</span>
<span style={{ color: 'var(--text-primary)' }}>27.4 / 7 of 10</span>
</li>
<li style={row}>
<span>Usage shift</span>
<span style={{ color: 'var(--grade-a)' }}>+3.2% w/o Murray</span>
</li>
</ul>
</div>
);
}
const row: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
paddingBlock: 4,
borderBottom: '1px solid var(--border)',
};
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'positive' }) {
return (
<div
style={{
flex: 1,
padding: '8px 12px',
background: 'var(--bg-surface)',
borderRadius: 10,
textAlign: 'center',
}}
>
<div style={{ fontSize: 10, color: 'var(--text-tertiary)' }}>{label}</div>
<div
className="mono"
style={{
fontSize: 14,
fontWeight: 700,
color: tone === 'positive' ? 'var(--grade-a)' : 'var(--text-primary)',
}}
>
{value}
</div>
</div>
);
}
// Session 16 — FloatingDemoCard / Stat / row removed. The hero card
// is now a live, graded prop fetched on mount; see LiveHeroProp.tsx.
// The static Jokic layout lives ONCE inside that component as the
// cold-start fallback when /api/hero-prop returns isStatic:true.
//
// GradePill (re-exported by GradeCard) is still imported at the top
// of this file because the section header uses it elsewhere; if a
// future cleanup confirms no other usage, that import can drop too.
+333
View File
@@ -0,0 +1,333 @@
'use client';
import { useEffect, useState } from 'react';
import { GradePill } from './GradeCard';
/**
* Live hero prop card (Session 16).
*
* Replaces the static Jokic mockup. Fetches /api/hero-prop on mount,
* renders the resulting graded prop, applies a glitch/blur overlay
* on the reasoning section so the grade letter + projection + edge
* are crisp (the hook) but the supporting analysis stays behind a
* paywall (the convert).
*
* Two states:
* - Loading or `isStatic === true` from the API → render the
* existing static layout (kept identical for visual stability
* across the cold-start path).
* - Live prop returned → render real data with the glitch overlay.
*
* Glitch overlay: backdrop-filter blur(4px) + a scan-line gradient
* pseudo-element. CSS keyframes in globals.css ensure mobile gets a
* slower, less-CPU-hungry version (the gradient is static there).
*/
type HeroPropApi = {
isStatic?: boolean;
sport?: string;
prop?: {
player: string;
stat_type: string;
line: number;
direction?: 'over' | 'under';
book?: string;
home_team?: string;
away_team?: string;
};
grade?: {
grade?: string;
confidence?: number;
edge_pct?: number;
projection?: number;
reasoning?: { summary?: string };
kill_conditions_triggered?: Array<{ code: string; reason: string }>;
};
};
const SPORT_LABEL: Record<string, string> = {
nba: 'NBA',
wnba: 'WNBA',
mlb: 'MLB',
soccer_wc: 'World Cup',
};
const SPORT_COLOR: Record<string, string> = {
nba: '#E94B3C',
wnba: '#FFB347',
mlb: '#1E90FF',
soccer_wc: '#00D4A0',
};
const row: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
paddingBlock: 4,
borderBottom: '1px solid var(--border)',
};
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'positive' }) {
return (
<div
style={{
flex: 1,
padding: '8px 12px',
background: 'var(--bg-surface)',
borderRadius: 10,
textAlign: 'center',
}}
>
<div style={{ fontSize: 10, color: 'var(--text-tertiary)' }}>{label}</div>
<div
className="mono"
style={{
fontSize: 14,
fontWeight: 700,
color: tone === 'positive' ? 'var(--grade-a)' : 'var(--text-primary)',
}}
>
{value}
</div>
</div>
);
}
export default function LiveHeroProp() {
const [data, setData] = useState<HeroPropApi | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let alive = true;
fetch('/api/hero-prop', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((json) => {
if (!alive) return;
setData(json);
setLoaded(true);
})
.catch(() => {
if (!alive) return;
setLoaded(true);
});
return () => { alive = false; };
}, []);
// While loading OR if the API returned the static fallback, render
// the deterministic Jokic layout. Visual continuity matters here —
// cold visitors should see SOMETHING on first paint, then the
// live data slots in once /api/hero-prop returns.
const isLive = loaded && data && !data.isStatic && data.prop && data.grade;
// Pull display fields with safe fallbacks.
const prop = data?.prop;
const grade = data?.grade;
const sport = data?.sport || 'nba';
const matchupLabel = prop?.home_team && prop?.away_team
? `${prop.away_team} @ ${prop.home_team}`
: '—';
const statTypeLabel = (prop?.stat_type || 'points').replace(/_/g, ' ');
const lineDisplay = prop ? `${(prop.direction || 'over').charAt(0).toUpperCase() + (prop.direction || 'over').slice(1)} ${prop.line} ${statTypeLabel}` : '';
// Static-fallback view (the original Jokic card, byte-for-byte
// visually). We render this until the live API returns, then swap.
if (!isLive) {
return (
<div
style={{
position: 'relative',
transform: 'rotate(-1deg)',
padding: 24,
background: 'var(--bg-elevated)',
border: '1px solid var(--border-focus)',
borderRadius: 20,
boxShadow: '0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px var(--accent-glow)',
maxWidth: 380,
marginInline: 'auto',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<span
className="mono"
style={{
fontSize: 11, padding: '2px 8px', borderRadius: 999,
background: 'rgba(233,75,60,0.15)', color: '#E94B3C', fontWeight: 700,
}}
>
NBA
</span>
<h3 style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>Nikola Jokic</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
Over 26.5 points
</p>
</div>
<GradePill grade="A-" confidence={73} />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<Stat label="Projection" value="29.4 pts" />
<Stat label="Edge" value="+6.2%" tone="positive" />
</div>
<ul style={{ display: 'grid', gap: 6, fontSize: 12, color: 'var(--text-secondary)' }}>
<li style={row}><span>Matchup</span><span style={{ color: 'var(--text-primary)' }}>LAL · 26th vs C</span></li>
<li style={row}><span>L10 form</span><span style={{ color: 'var(--text-primary)' }}>27.4 / 7 of 10</span></li>
<li style={row}><span>Usage shift</span><span style={{ color: 'var(--grade-a)' }}>+3.2% w/o Murray</span></li>
</ul>
</div>
);
}
// Live render.
const gradeText = grade?.grade || 'C';
const confidence = typeof grade?.confidence === 'number' ? Math.round(grade.confidence) : 50;
const projection = typeof grade?.projection === 'number' ? grade.projection.toFixed(1) : '—';
const edge = typeof grade?.edge_pct === 'number' ? grade.edge_pct : 0;
const edgeDisplay = `${edge >= 0 ? '+' : ''}${edge.toFixed(1)}%`;
const reasoning = grade?.reasoning?.summary || '';
const sportLabel = SPORT_LABEL[sport] || sport.toUpperCase();
const sportColor = SPORT_COLOR[sport] || 'var(--grade-a)';
return (
<div
style={{
position: 'relative',
transform: 'rotate(-1deg)',
padding: 24,
background: 'var(--bg-elevated)',
border: '1px solid var(--border-focus)',
borderRadius: 20,
boxShadow: '0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px var(--accent-glow)',
maxWidth: 380,
marginInline: 'auto',
}}
>
{/* LIVE badge — pulsing dot communicates "this was graded just now". */}
<div
className="mono"
aria-label="Live"
style={{
position: 'absolute', top: 12, right: 12,
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 9, color: 'var(--grade-a)', fontWeight: 800, letterSpacing: '0.1em',
}}
>
<span
aria-hidden
style={{
width: 6, height: 6, borderRadius: '50%',
background: 'var(--grade-a)',
boxShadow: '0 0 8px var(--grade-a)',
}}
/>
LIVE
</div>
{/* Header — visible, the hook. */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, marginTop: 8 }}>
<div>
<span
className="mono"
style={{
fontSize: 11, padding: '2px 8px', borderRadius: 999,
background: `${sportColor}26`, color: sportColor, fontWeight: 700,
}}
>
{sportLabel}
</span>
<h3 style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>{prop!.player}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{lineDisplay}
</p>
</div>
<GradePill grade={gradeText} confidence={confidence} />
</div>
{/* Projection + edge — visible, the proof. */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<Stat label="Projection" value={`${projection} ${statTypeLabel}`} />
<Stat label="Edge" value={edgeDisplay} tone={edge > 0 ? 'positive' : undefined} />
</div>
{/* Reasoning — BLURRED, the paywall. */}
<div
className="hero-grade-reasoning"
aria-label="Full analysis available after sign up"
style={{
position: 'relative',
padding: 12,
background: 'var(--bg-surface)',
borderRadius: 10,
marginBottom: 12,
overflow: 'hidden',
}}
>
<div
style={{
filter: 'blur(4px)',
userSelect: 'none',
pointerEvents: 'none',
fontSize: 12,
color: 'var(--text-secondary)',
lineHeight: 1.4,
}}
aria-hidden
>
{reasoning || 'Recent form: 28.4 over last 5. Opp defense: top-5 vs PG. Pace: +3.1. Trap composite 0.18. Usage 31%. Kill conditions: 0.'}
</div>
{/* Scan-line overlay — pure CSS gradient pseudo-element via
inline style + position absolute. Subtle on desktop,
disabled on mobile by the @media query below. */}
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
backgroundImage: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,212,160,0.06) 2px, rgba(0,212,160,0.06) 4px)',
pointerEvents: 'none',
mixBlendMode: 'overlay',
}}
/>
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(180deg, rgba(18,18,26,0) 30%, rgba(18,18,26,0.85) 100%)',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
bottom: 6,
left: 0,
right: 0,
textAlign: 'center',
fontSize: 10,
color: 'var(--text-tertiary)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
}}
className="mono"
>
Classified · Sign up to unlock
</div>
</div>
{/* CTA — drives the conversion the blur sets up. */}
<a
href="/signup"
className="btn-primary"
style={{
display: 'block',
textAlign: 'center',
padding: '10px 16px',
fontSize: 13,
fontWeight: 700,
textDecoration: 'none',
}}
>
Sign up to read the full analysis
</a>
</div>
);
}