Files
vyndr/web/src/components/GradeCard.tsx
T
builtbykev d242b11b4b S1 (a1): feature-promise audit — no claim survives unverified
PROMISE-AUDIT.md: every /pricing claim → verified/built/reworded.
BUILT (was vapor): alt line ladder + edge ranking (same-features regrade
at shifted lines, Desk-gated at the API), quarter-Kelly (engine quantile
P(win) x real captured odds — either missing → no sizing), free-tier
kill-condition locked previews. FIXED (was false): analyst 15/day cap vs
the Founder 'Unlimited reads' promise → analyst unlimited; every '40+
factors' claim (real count: 22 named features) reworded truthfully in 7
files. VERIFIED: cascade alerts (real, wired), phi correlation,
leg history, cross-book comparison, WC soccer, real-time feed.
Locked by tests/unit/promiseAudit.test.js. Jest now ignores
.claude/worktrees (parallel agents' suites no longer leak into runs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:24:41 -04:00

535 lines
19 KiB
TypeScript

'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 every factor the model weighs for the prop.",
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]);
return (
<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>
);
}