'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 (
{/* Header */}
{props.sport} {props.trending && ( Trending in parlays )}

{props.player}

{props.direction} {props.line} {props.stat.replace(/_/g, ' ')}

{props.onShare && ( )}
{/* Grade letter — the hero */}
{props.grade || '—'}
{/* Projection + confidence */} {(props.projection != null || props.sample_size != null) && (
{props.projection != null && (
Projection
{props.projection.toFixed(1)} {props.stat.replace(/_/g, ' ')}
)} {props.sample_size != null && (
Confidence
{conf.label}
)}
)} {/* Factor analysis — gated for free tier */} props.onUpgradeClick?.('analyst', 'grade_card_factors')} /> {/* Alt lines — gated for free + analyst */} props.onUpgradeClick?.('desk', 'grade_card_alt_lines')} /> {/* Reasoning */} {props.reasoning && (
Model reasoning {showFactors ? (

{props.reasoning}

) : ( props.onUpgradeClick?.('analyst', 'grade_card_reasoning')} /> )}
)} {/* Historical accuracy */} {props.historical_hit_rate != null && (

{props.grade} grades hit at {Math.round(props.historical_hit_rate * 100)}% historically.

)} {/* Sportsbook deep links */}
{SPORTSBOOKS.map((book) => ( {book.label} ))}
{/* Actions */} {props.onAddToParlay && (
)}
); } function SectionLabel({ children }: { children: React.ReactNode }) { return (
{children}
); } 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 (
Factor analysis
{factors && ( )} {killConditions && killConditions.length > 0 && (
KILL CONDITIONS
{killConditions.map((k) => (
{k.code} {k.reason}
))}
)} {!hasContent && gated && (
)}
{gated && (

Full analysis. Kill conditions. Alt lines.

)}
); } 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 (
Alt line ladder
{altLines.map((alt) => { const altTone = gradeTierClass(alt.grade); return (
{alt.line.toFixed(1)} {alt.grade} {alt.hit_rate != null ? `${Math.round(alt.hit_rate * 100)}%` : '—'}
); })}
{gated && (

Alt line ladder + Kelly sizing.

)}
); } function BlurredText({ text, onUpgrade }: { text: string; onUpgrade: () => void }) { return (

{text}

); } /* ───────────────────────────────────────────────────────── 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 (
{grade} {confidence != null && {confidence}%}
); }