{props.player}
{props.direction} {props.line} {props.stat.replace(/_/g, ' ')}
{props.reasoning}
) : ({props.grade} grades hit at {Math.round(props.historical_hit_rate * 100)}% historically.
)} {/* Sportsbook deep links */}'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 (
{props.direction} {props.line} {props.stat.replace(/_/g, ' ')}
{props.reasoning}
{props.grade} grades hit at {Math.round(props.historical_hit_rate * 100)}% historically.
{props.player}
Full analysis. Kill conditions. Alt lines.
Alt line ladder + Kelly sizing.
{text}