Session 16: Live hero prop, sport-specific markets fix, soccer weather, Sentry CSP (1429 tests)
This commit is contained in:
+14
-94
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user