489d849f1e
Phone audit found LEDGER READ CARDS rendering B badges with BLUE borders + C with AMBER right now in prod — GradePill (components/GradeCard.tsx) hardcoded the OLD palette (rgba(74,158,255) blue-B, rgba(255,179,71) amber-C) for bg/border while the text used the migrated token. Migrated bg/border to color-mix on the grade token, so B renders neutral-white and C grey (matching the board). - globals.css .grade-*-bg → token-derived color-mix (was raw blue/amber rgba). - DELETED glow from .grade-glow-b/c/d (glow is A-tier ONLY, by law) — B/C/D keep their token color, no text-shadow. - Purged the last dead grade-blue #4A9EFF fallbacks (SoccerGradeResult, the intelligence INFO dot). - REGRESSION LOCK: vyndrParityQA fails if #4a9eff / rgba(74,158,255) reappears anywhere in web/src, if GradePill hardcodes blue/amber rgba, or if grade-glow B/C/D grow a text-shadow again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
540 lines
19 KiB
TypeScript
540 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);
|
|
// P0-1 — TOKEN-DERIVED (was hardcoded old palette: blue-B / amber-C).
|
|
// bg/border follow the grade token via color-mix,
|
|
// so B renders neutral-white and C grey, matching the board-row badges.
|
|
const tok = g === 'A' ? 'var(--grade-a)' : g === 'B' ? 'var(--grade-b)' : g === 'C' ? 'var(--grade-c)' : 'var(--grade-d)';
|
|
return {
|
|
color: tok,
|
|
bg: `color-mix(in srgb, ${tok} 12%, transparent)`,
|
|
border: `color-mix(in srgb, ${tok} 42%, transparent)`,
|
|
};
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|