Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -1,21 +1,534 @@
|
||||
const gradeColors: Record<string, string> = {
|
||||
A: 'bg-[var(--grade-a)]/10 border-[var(--grade-a)] text-[var(--grade-a)]',
|
||||
B: 'bg-[var(--grade-b)]/10 border-[var(--grade-b)] text-[var(--grade-b)]',
|
||||
C: 'bg-[var(--grade-c)]/10 border-[var(--grade-c)] text-[var(--grade-c)]',
|
||||
D: 'bg-[var(--grade-d)]/10 border-[var(--grade-d)] text-[var(--grade-d)]',
|
||||
};
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import ExplainTooltip from '@/components/ExplainTooltip';
|
||||
import ExplainModeToggle from '@/components/ExplainModeToggle';
|
||||
import { markReadComplete } from '@/lib/reads';
|
||||
|
||||
// Short, plain-English explanations rendered when Explain Like I'm New is on.
|
||||
// Each key maps to one piece of data we surface on this card.
|
||||
const EXPLANATIONS = {
|
||||
grade: "Our overall confidence. A-minus means we estimate about a 76% chance this prop hits, based on 40+ factors.",
|
||||
projection: "What our model predicts the player will actually do tonight for this stat.",
|
||||
line: "The number the sportsbook set. The player needs to go over or under it.",
|
||||
overUnder: 'Over = the player needs MORE than the line. Under = LESS.',
|
||||
confidence: "How much data we have on this player and stat. More games = more reliable.",
|
||||
killConditions: "Red flags we detected that could cause this prop to miss regardless of the stats.",
|
||||
factors: "The signals our engine weighs — recent form, matchup, rest, usage, etc.",
|
||||
} as const;
|
||||
|
||||
export type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
export type Tier = 'free' | 'analyst' | 'desk';
|
||||
|
||||
export interface KillCondition {
|
||||
code: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface AltLine {
|
||||
line: number;
|
||||
grade: string;
|
||||
hit_rate?: number;
|
||||
edge_pct?: number;
|
||||
}
|
||||
|
||||
export interface FactorAnalysis {
|
||||
matchup?: string;
|
||||
trend?: string;
|
||||
usage?: string;
|
||||
minutes?: string;
|
||||
pace?: string;
|
||||
rest?: string;
|
||||
weather?: string;
|
||||
abs?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export interface GradeCardProps {
|
||||
sport: Sport;
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
projection?: number;
|
||||
confidence?: number;
|
||||
sample_size?: number;
|
||||
factors?: FactorAnalysis;
|
||||
alt_lines?: AltLine[];
|
||||
kill_conditions?: KillCondition[];
|
||||
reasoning?: string;
|
||||
historical_hit_rate?: number;
|
||||
tier: Tier;
|
||||
onUpgradeClick?: (target: 'analyst' | 'desk', from: string) => void;
|
||||
onAddToParlay?: () => void;
|
||||
onShare?: () => void;
|
||||
trending?: boolean;
|
||||
}
|
||||
|
||||
const SPORTSBOOKS = [
|
||||
{ id: 'draftkings', label: 'DK', color: '#53D337', host: 'sportsbook.draftkings.com' },
|
||||
{ id: 'fanduel', label: 'FD', color: '#1493FF', host: 'sportsbook.fanduel.com' },
|
||||
{ id: 'betmgm', label: 'MGM', color: '#BB9959', host: 'sports.betmgm.com' },
|
||||
{ id: 'caesars', label: 'Caesars', color: '#C8A35F', host: 'sportsbook.caesars.com' },
|
||||
{ id: 'pointsbet', label: 'PB', color: '#E2231A', host: 'pointsbet.com' },
|
||||
];
|
||||
|
||||
function gradeTierClass(grade: string): { color: string; bg: string; border: string } {
|
||||
const g = (grade || '').trim().toUpperCase().charAt(0);
|
||||
if (g === 'A') return { color: 'var(--grade-a)', bg: 'rgba(0,200,150,0.10)', border: 'rgba(0,200,150,0.40)' };
|
||||
if (g === 'B') return { color: 'var(--grade-b)', bg: 'rgba(74,158,255,0.10)', border: 'rgba(74,158,255,0.40)' };
|
||||
if (g === 'C') return { color: 'var(--grade-c)', bg: 'rgba(255,179,71,0.10)', border: 'rgba(255,179,71,0.40)' };
|
||||
return { color: 'var(--grade-d)', bg: 'rgba(255,107,107,0.10)', border: 'rgba(255,107,107,0.40)' };
|
||||
}
|
||||
|
||||
function confidenceLabel(sample?: number): { label: string; tone: 'high' | 'moderate' | 'limited' } {
|
||||
const n = sample ?? 0;
|
||||
if (n >= 30) return { label: `High confidence (${n} games)`, tone: 'high' };
|
||||
if (n >= 12) return { label: `Moderate confidence (${n} games)`, tone: 'moderate' };
|
||||
return { label: `Limited data (${Math.max(0, n)} games)`, tone: 'limited' };
|
||||
}
|
||||
|
||||
function deepLink(host: string, player: string): string {
|
||||
const slug = encodeURIComponent(player);
|
||||
return `https://${host}/?search=${slug}`;
|
||||
}
|
||||
|
||||
export default function GradeCard(props: GradeCardProps) {
|
||||
const tone = gradeTierClass(props.grade);
|
||||
const conf = confidenceLabel(props.sample_size);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
// Animate the grade letter on first paint
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setRevealed(true), 50);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [props.grade]);
|
||||
|
||||
// Mark this card as ONE read for the InstallPrompt / PushPrompt gates.
|
||||
// GradeCardProps doesn't carry a server-side id, so build a stable
|
||||
// composite key from the canonical identifying fields. Per-session
|
||||
// dedupe — viewing the same prop twice in one session counts once.
|
||||
useEffect(() => {
|
||||
if (!revealed || typeof window === 'undefined') return;
|
||||
const readKey = `vyndr_read_${props.sport}_${props.player}_${props.stat}_${props.line}_${props.direction}`;
|
||||
if (!window.sessionStorage.getItem(readKey)) {
|
||||
window.sessionStorage.setItem(readKey, '1');
|
||||
markReadComplete();
|
||||
}
|
||||
}, [revealed, props.sport, props.player, props.stat, props.line, props.direction]);
|
||||
|
||||
const showFactors = props.tier !== 'free';
|
||||
const showAltLines = props.tier === 'desk';
|
||||
const sportBadge = useMemo(() => {
|
||||
const s = props.sport;
|
||||
if (s === 'NBA') return { color: '#E94B3C' };
|
||||
if (s === 'MLB') return { color: '#1E90FF' };
|
||||
return { color: '#FFB347' };
|
||||
}, [props.sport]);
|
||||
|
||||
export default function GradeCard({ grade, confidence, label }: { grade: string; confidence?: number; label?: string }) {
|
||||
const colors = gradeColors[grade] || gradeColors.D;
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-3 px-4 py-2 rounded-xl border ${colors}`}>
|
||||
<span className="font-mono font-bold text-3xl">{grade}</span>
|
||||
{confidence != null && (
|
||||
<div className="text-sm">
|
||||
<div className="font-mono font-medium">{confidence}%</div>
|
||||
{label && <div className="text-xs opacity-70">{label}</div>}
|
||||
<article
|
||||
className="surface diagonal-cut animate-fade-up"
|
||||
style={{ padding: 24, maxWidth: 560, width: '100%' }}
|
||||
aria-label={`Grade card for ${props.player}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: `${sportBadge.color}1F`,
|
||||
color: sportBadge.color,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{props.sport}
|
||||
</span>
|
||||
{props.trending && (
|
||||
<span
|
||||
className="mono"
|
||||
title="Trending in parlays tonight"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
background: 'rgba(255,179,71,0.15)',
|
||||
color: 'var(--grade-c)',
|
||||
}}
|
||||
>
|
||||
Trending in parlays
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 2 }}>{props.player}</h3>
|
||||
<ExplainTooltip explanation={EXPLANATIONS.overUnder}>
|
||||
<p className="mono" style={{ fontSize: 13, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{props.direction} {props.line} {props.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
</ExplainTooltip>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ExplainModeToggle variant="compact" />
|
||||
{props.onShare && (
|
||||
<button
|
||||
onClick={props.onShare}
|
||||
aria-label="Share grade"
|
||||
className="btn-ghost"
|
||||
style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Grade letter — the hero */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', margin: '24px 0' }}>
|
||||
<ExplainTooltip explanation={EXPLANATIONS.grade}>
|
||||
<div
|
||||
className={revealed ? 'animate-grade mono' : 'mono'}
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontWeight: 800,
|
||||
lineHeight: 1,
|
||||
color: tone.color,
|
||||
textShadow: `0 0 40px ${tone.color}33`,
|
||||
padding: '16px 32px',
|
||||
borderRadius: 16,
|
||||
background: `radial-gradient(circle at center, ${tone.bg} 0%, transparent 70%)`,
|
||||
letterSpacing: '-0.04em',
|
||||
}}
|
||||
>
|
||||
{props.grade || '—'}
|
||||
</div>
|
||||
</ExplainTooltip>
|
||||
</div>
|
||||
|
||||
{/* Projection + confidence */}
|
||||
{(props.projection != null || props.sample_size != null) && (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: props.projection != null && props.sample_size != null ? '1fr 1fr' : '1fr',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{props.projection != null && (
|
||||
<ExplainTooltip explanation={EXPLANATIONS.projection}>
|
||||
<div className="surface" style={{ padding: '12px 16px', textAlign: 'center', borderRadius: 12 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 4 }}>Projection</div>
|
||||
<div className="mono" style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{props.projection.toFixed(1)} {props.stat.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
</ExplainTooltip>
|
||||
)}
|
||||
{props.sample_size != null && (
|
||||
<ExplainTooltip explanation={EXPLANATIONS.confidence}>
|
||||
<div className="surface" style={{ padding: '12px 16px', textAlign: 'center', borderRadius: 12 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 4 }}>Confidence</div>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: conf.tone === 'limited' ? 'var(--grade-c)' : 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{conf.label}
|
||||
</div>
|
||||
</div>
|
||||
</ExplainTooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Factor analysis — gated for free tier */}
|
||||
<FactorBlock
|
||||
factors={props.factors}
|
||||
killConditions={props.kill_conditions}
|
||||
gated={!showFactors}
|
||||
onUpgrade={() => props.onUpgradeClick?.('analyst', 'grade_card_factors')}
|
||||
/>
|
||||
|
||||
{/* Alt lines — gated for free + analyst */}
|
||||
<AltLineBlock
|
||||
altLines={props.alt_lines}
|
||||
gated={!showAltLines}
|
||||
currentTier={props.tier}
|
||||
onUpgrade={() => props.onUpgradeClick?.('desk', 'grade_card_alt_lines')}
|
||||
/>
|
||||
|
||||
{/* Reasoning */}
|
||||
{props.reasoning && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<SectionLabel>Model reasoning</SectionLabel>
|
||||
{showFactors ? (
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{props.reasoning}</p>
|
||||
) : (
|
||||
<BlurredText text={props.reasoning} onUpgrade={() => props.onUpgradeClick?.('analyst', 'grade_card_reasoning')} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historical accuracy */}
|
||||
{props.historical_hit_rate != null && (
|
||||
<p style={{ marginTop: 12, fontSize: 12, color: 'var(--text-tertiary)' }} className="mono">
|
||||
{props.grade} grades hit at {Math.round(props.historical_hit_rate * 100)}% historically.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Sportsbook deep links */}
|
||||
<div style={{ marginTop: 20, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{SPORTSBOOKS.map((book) => (
|
||||
<a
|
||||
key={book.id}
|
||||
href={deepLink(book.host, props.player)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mono"
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${book.color}66`,
|
||||
color: book.color,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{book.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{props.onAddToParlay && (
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
|
||||
<button onClick={props.onAddToParlay} className="btn-primary" style={{ flex: 1 }}>
|
||||
Add to Parlay
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-tertiary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorBlock({
|
||||
factors,
|
||||
killConditions,
|
||||
gated,
|
||||
onUpgrade,
|
||||
}: {
|
||||
factors?: FactorAnalysis;
|
||||
killConditions?: KillCondition[];
|
||||
gated: boolean;
|
||||
onUpgrade: () => void;
|
||||
}) {
|
||||
const hasContent = (factors && Object.values(factors).some(Boolean)) || (killConditions && killConditions.length > 0);
|
||||
if (!hasContent && !gated) return null;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', marginTop: 16 }}>
|
||||
<SectionLabel>Factor analysis</SectionLabel>
|
||||
<div className={gated ? 'tier-locked' : ''} aria-hidden={gated}>
|
||||
{factors && (
|
||||
<ul style={{ display: 'grid', gap: 6, marginBottom: killConditions?.length ? 12 : 0 }}>
|
||||
{Object.entries(factors)
|
||||
.filter(([, v]) => Boolean(v))
|
||||
.map(([k, v]) => (
|
||||
<li
|
||||
key={k}
|
||||
style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, gap: 12 }}
|
||||
>
|
||||
<span style={{ color: 'var(--text-tertiary)', textTransform: 'capitalize' }}>{k}</span>
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'right' }}>{v}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{killConditions && killConditions.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(255,71,87,0.30)',
|
||||
background: 'rgba(255,71,87,0.08)',
|
||||
}}
|
||||
>
|
||||
<div className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--danger)', marginBottom: 6 }}>
|
||||
KILL CONDITIONS
|
||||
</div>
|
||||
{killConditions.map((k) => (
|
||||
<div key={k.code} style={{ display: 'flex', gap: 8, fontSize: 13, marginTop: 4 }}>
|
||||
<span className="mono" style={{ color: 'var(--danger)', fontWeight: 700, fontSize: 11 }}>
|
||||
{k.code}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{k.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!hasContent && gated && (
|
||||
<div style={{ height: 120, background: 'var(--bg-elevated)', borderRadius: 12 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{gated && (
|
||||
<div className="tier-locked-overlay">
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
Full analysis. Kill conditions. Alt lines.
|
||||
</p>
|
||||
<button onClick={onUpgrade} className="btn-primary">
|
||||
Unlock — \$14.99/mo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AltLineBlock({
|
||||
altLines,
|
||||
gated,
|
||||
currentTier,
|
||||
onUpgrade,
|
||||
}: {
|
||||
altLines?: AltLine[];
|
||||
gated: boolean;
|
||||
currentTier: Tier;
|
||||
onUpgrade: () => void;
|
||||
}) {
|
||||
if (!altLines || altLines.length === 0) {
|
||||
if (currentTier === 'free') return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', marginTop: 16 }}>
|
||||
<SectionLabel>Alt line ladder</SectionLabel>
|
||||
<div className={gated ? 'tier-locked' : ''} aria-hidden={gated}>
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
{altLines.map((alt) => {
|
||||
const altTone = gradeTierClass(alt.grade);
|
||||
return (
|
||||
<div
|
||||
key={alt.line}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--bg-elevated)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<span className="mono" style={{ fontSize: 14, color: 'var(--text-primary)' }}>
|
||||
{alt.line.toFixed(1)}
|
||||
</span>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
color: altTone.color,
|
||||
background: altTone.bg,
|
||||
}}
|
||||
>
|
||||
{alt.grade}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{alt.hit_rate != null ? `${Math.round(alt.hit_rate * 100)}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gated && (
|
||||
<div className="tier-locked-overlay">
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
Alt line ladder + Kelly sizing.
|
||||
</p>
|
||||
<button onClick={onUpgrade} className="btn-primary">
|
||||
Go Desk — $44.99/mo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlurredText({ text, onUpgrade }: { text: string; onUpgrade: () => void }) {
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<p className="tier-locked" style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||
{text}
|
||||
</p>
|
||||
<button
|
||||
onClick={onUpgrade}
|
||||
className="btn-primary"
|
||||
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
Unlock — \$14.99/mo
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
Lightweight grade pill — back-compat for callers that only
|
||||
want the colored letter (used by ledger/scan summaries)
|
||||
───────────────────────────────────────────────────────── */
|
||||
export function GradePill({ grade, confidence }: { grade: string; confidence?: number }) {
|
||||
const tone = gradeTierClass(grade);
|
||||
return (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${tone.border}`,
|
||||
background: tone.bg,
|
||||
color: tone.color,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 22, lineHeight: 1 }}>{grade}</span>
|
||||
{confidence != null && <span style={{ fontSize: 12, opacity: 0.85 }}>{confidence}%</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user