0996320bd1
Zero out-of-pocket; everything config-flip-ready but DISABLED/organic. - BOOK IT deep links: web/src/lib/bookLinks.js + affiliateConfig.js (all books enabled:false, Impact/Partnerize param shapes documented, empty params skipped). Wired into StatStrip BookItTeaser (real anchor now) + scan hand-off links. Every book anchor renders rel="sponsored noopener noreferrer" (BOOK_LINK_REL). - Best-price marker: slateAdapter.detectBestBook (only when >=2 books post the SAME line and prices differ — absent beats wrong) + subtle signal-green dot in StatStrip. Slate.groupByGame threads the grouped per-book rows (books[]) onto PropRowProp instead of discarding them. - Partner refs: ?ref=CODE -> vyndr_ref cookie (90d, first-touch, PartnerRefCapture in layout) -> signup metadata partner_ref -> internal GET /api/partners/report/:code (requireInternalAuth; honest zeros + note until the TODO migration in docs/PARTNERS.md adds user_profiles.partner_ref — NOT run). Stripe promo-code convention: partner code == promotion code, verbatim. - Tests: +41 (2398 -> 2439, 209 suites); bookItTeaser + vyndrCoreScreens invariants updated to the new (stronger) rel contract. Web build exit 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
391 lines
12 KiB
TypeScript
391 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import Link from 'next/link';
|
|
|
|
/**
|
|
* PropRow — single prop line in the Slate (Session 13).
|
|
*
|
|
* Three visual states:
|
|
* 1. Ungraded — player | stat | line | book | [Read]
|
|
* 2. Grading — player | stat | line | book | […] (busy)
|
|
* 3. Graded — player | stat | line | book | grade | ▸ (expandable)
|
|
*
|
|
* Pure presentational. The parent owns the grading API call (one
|
|
* shared call site = consistent rate-limit + error handling). PropRow
|
|
* just emits onRead() and reads the supplied state.
|
|
*/
|
|
|
|
export type PropDirection = 'over' | 'under';
|
|
export type Tier = 'free' | 'africa' | 'analyst' | 'desk';
|
|
|
|
export interface PropRowProp {
|
|
player: string;
|
|
stat_type: string;
|
|
line: number;
|
|
direction: PropDirection;
|
|
book?: string;
|
|
// A1 S3 — the FULL per-book rows for this player+stat (the grouped
|
|
// odds shape from Express). Feeds best-price detection downstream;
|
|
// optional so legacy flat callers stay valid.
|
|
books?: Array<{ book?: string; line?: number; over_odds?: number | null; under_odds?: number | null }>;
|
|
// Stable key used by the parent to look up grade results.
|
|
key?: string;
|
|
}
|
|
|
|
export interface KillCondition {
|
|
code: string;
|
|
reason: string;
|
|
locked?: boolean;
|
|
}
|
|
|
|
export interface PropRowResult {
|
|
grade: string; // 'A', 'B', etc.
|
|
confidence?: number;
|
|
edge_pct?: number;
|
|
reasoning?: { summary?: string; steps?: unknown; locked?: boolean };
|
|
kill_conditions_triggered?: KillCondition[];
|
|
tier_gated?: boolean;
|
|
upgrade_hint?: string;
|
|
}
|
|
|
|
export interface PropRowProps {
|
|
prop: PropRowProp;
|
|
result?: PropRowResult | null;
|
|
loading?: boolean;
|
|
error?: string | null;
|
|
tier?: Tier;
|
|
onRead: (prop: PropRowProp) => void;
|
|
onUpgrade?: () => void;
|
|
}
|
|
|
|
const STAT_LABELS: Record<string, string> = {
|
|
goals: 'Goals',
|
|
assists: 'Assists',
|
|
shots_on_target: 'SoT',
|
|
shots: 'Shots',
|
|
tackles: 'Tackles',
|
|
cards: 'Cards',
|
|
corners: 'Corners',
|
|
saves: 'Saves',
|
|
passes: 'Passes',
|
|
clean_sheet: 'Clean Sheet',
|
|
points: 'Pts',
|
|
rebounds: 'Reb',
|
|
threes: '3PT',
|
|
blocks: 'Blk',
|
|
steals: 'Stl',
|
|
pra: 'P+R+A',
|
|
turnovers: 'TO',
|
|
strikeouts: 'K',
|
|
hits: 'H',
|
|
home_runs: 'HR',
|
|
rbi: 'RBI',
|
|
runs: 'R',
|
|
total_bases: 'TB',
|
|
earned_runs: 'ER',
|
|
innings_pitched: 'IP',
|
|
};
|
|
|
|
const BOOK_COLORS: Record<string, string> = {
|
|
draftkings: '#53D337',
|
|
fanduel: '#1493FF',
|
|
betmgm: '#BB9959',
|
|
caesars: '#C8A35F',
|
|
pointsbet: '#E2231A',
|
|
};
|
|
|
|
const BOOK_LABELS: Record<string, string> = {
|
|
draftkings: 'DK',
|
|
fanduel: 'FD',
|
|
betmgm: 'MGM',
|
|
caesars: 'CSR',
|
|
pointsbet: 'PB',
|
|
};
|
|
|
|
function gradeColor(grade?: string): string {
|
|
const g = (grade || '').trim().toUpperCase().charAt(0);
|
|
if (g === 'A') return 'var(--grade-a, #00D4A0)';
|
|
if (g === 'B') return 'var(--grade-b, #4ECDC4)';
|
|
if (g === 'C') return 'var(--grade-c, #FFD93D)';
|
|
return 'var(--grade-d, #FF6B6B)';
|
|
}
|
|
|
|
export default function PropRow(props: PropRowProps) {
|
|
const { prop, result, loading, error, tier = 'free', onRead, onUpgrade } = props;
|
|
const [expanded, setExpanded] = useState(false);
|
|
const isGraded = !!result;
|
|
const isLocked = !!(result?.tier_gated || result?.reasoning?.locked);
|
|
|
|
return (
|
|
<li
|
|
style={{
|
|
borderTop: '1px solid var(--border, #1A1A24)',
|
|
padding: '12px 14px',
|
|
display: 'grid',
|
|
gridTemplateColumns: 'minmax(0, 1fr) auto',
|
|
gap: 12,
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<div style={{ minWidth: 0, display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
|
<span
|
|
style={{
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
color: 'var(--text-0, #F0F0F5)',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
maxWidth: 240,
|
|
}}
|
|
>
|
|
{prop.player}
|
|
</span>
|
|
<span
|
|
className="mono"
|
|
style={{
|
|
fontSize: 10,
|
|
color: 'var(--text-secondary, #8A8A9A)',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.08em',
|
|
}}
|
|
>
|
|
{STAT_LABELS[prop.stat_type] || prop.stat_type}
|
|
</span>
|
|
<span
|
|
className="mono"
|
|
style={{
|
|
fontSize: 13,
|
|
color: 'var(--text-0, #F0F0F5)',
|
|
fontWeight: 600,
|
|
fontVariantNumeric: 'tabular-nums',
|
|
}}
|
|
>
|
|
{prop.direction === 'under' ? 'u' : 'o'}{prop.line.toFixed(1)}
|
|
</span>
|
|
{prop.book && (
|
|
<span
|
|
className="mono"
|
|
aria-label={`Book: ${prop.book}`}
|
|
style={{
|
|
fontSize: 10,
|
|
color: 'var(--text-tertiary, #6B6B7B)',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
}}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
style={{
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: '50%',
|
|
background: BOOK_COLORS[prop.book] || 'var(--text-tertiary, #6B6B7B)',
|
|
}}
|
|
/>
|
|
{BOOK_LABELS[prop.book] || prop.book.slice(0, 3).toUpperCase()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
{!isGraded && !loading && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onRead(prop)}
|
|
className="btn-ghost"
|
|
style={{
|
|
padding: '4px 12px',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
border: '1px solid var(--grade-a, #00D4A0)',
|
|
color: 'var(--grade-a, #00D4A0)',
|
|
background: 'transparent',
|
|
borderRadius: 4,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
Read
|
|
</button>
|
|
)}
|
|
{loading && (
|
|
<span
|
|
className="mono"
|
|
aria-label="Grading"
|
|
style={{
|
|
fontSize: 11,
|
|
color: 'var(--text-tertiary, #6B6B7B)',
|
|
padding: '4px 12px',
|
|
}}
|
|
>
|
|
…
|
|
</span>
|
|
)}
|
|
{isGraded && result && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
aria-expanded={expanded}
|
|
aria-label={`Grade ${result.grade} — ${expanded ? 'collapse' : 'expand'} reasoning`}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
padding: '2px 10px',
|
|
border: `1px solid ${gradeColor(result.grade)}`,
|
|
borderRadius: 4,
|
|
background: 'transparent',
|
|
cursor: 'pointer',
|
|
color: gradeColor(result.grade),
|
|
fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
<span className="mono" style={{ fontSize: 14, fontWeight: 800, letterSpacing: '-0.02em' }}>
|
|
{result.grade}
|
|
</span>
|
|
{typeof result.confidence === 'number' && (
|
|
<span className="mono" style={{ fontSize: 10, opacity: 0.7 }}>
|
|
{result.confidence.toFixed(0)}
|
|
</span>
|
|
)}
|
|
<span aria-hidden style={{ fontSize: 10 }}>{expanded ? '▾' : '▸'}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
style={{
|
|
gridColumn: '1 / -1',
|
|
fontSize: 12,
|
|
color: 'var(--grade-d, #FF6B6B)',
|
|
paddingTop: 6,
|
|
}}
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{expanded && result && (
|
|
<div
|
|
style={{
|
|
gridColumn: '1 / -1',
|
|
padding: '12px 0 4px',
|
|
borderTop: '1px dashed var(--border, #1A1A24)',
|
|
marginTop: 8,
|
|
}}
|
|
>
|
|
{isLocked ? (
|
|
<div
|
|
style={{
|
|
padding: 14,
|
|
border: '1px dashed var(--border, #1A1A24)',
|
|
borderRadius: 6,
|
|
background: 'rgba(0,0,0,0.20)',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
<div
|
|
className="mono"
|
|
aria-hidden
|
|
style={{
|
|
filter: 'blur(4px)',
|
|
userSelect: 'none',
|
|
color: 'var(--text-tertiary, #6B6B7B)',
|
|
fontSize: 12,
|
|
marginBottom: 12,
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
Recent form: 28.4 over last 5 · Opp defense: top-5 vs PG ·
|
|
Pace: +3.1 possessions · Usage: 31% · Trap composite: 0.18
|
|
</div>
|
|
<p style={{ fontSize: 12, color: 'var(--text-secondary, #8A8A9A)', marginBottom: 10 }}>
|
|
{result.upgrade_hint || 'Unlock the reasoning — factor analysis, kill conditions, and trap score.'}
|
|
</p>
|
|
{tier === 'free' ? (
|
|
<button
|
|
type="button"
|
|
onClick={onUpgrade}
|
|
className="btn-primary"
|
|
style={{
|
|
padding: '6px 16px',
|
|
fontSize: 12,
|
|
fontWeight: 700,
|
|
background: 'var(--grade-a, #00D4A0)',
|
|
color: 'var(--bg-0, #0A0A0F)',
|
|
border: 0,
|
|
borderRadius: 4,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
Unlock full analysis
|
|
</button>
|
|
) : (
|
|
<Link href="/pricing" style={{ color: 'var(--grade-a, #00D4A0)', fontSize: 12 }}>
|
|
Upgrade plan →
|
|
</Link>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{result.reasoning?.summary && (
|
|
<p style={{ fontSize: 13, color: 'var(--text-secondary, #8A8A9A)', lineHeight: 1.6, marginBottom: 8 }}>
|
|
{result.reasoning.summary}
|
|
</p>
|
|
)}
|
|
{Array.isArray(result.kill_conditions_triggered) && result.kill_conditions_triggered.length > 0 && (
|
|
<div>
|
|
<h4
|
|
className="mono"
|
|
style={{
|
|
fontSize: 10,
|
|
color: 'var(--grade-d, #FF6B6B)',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.08em',
|
|
marginBottom: 6,
|
|
}}
|
|
>
|
|
Kill conditions ({result.kill_conditions_triggered.length})
|
|
</h4>
|
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 4 }}>
|
|
{result.kill_conditions_triggered.map((k, i) => (
|
|
<li
|
|
key={`${k.code}-${i}`}
|
|
style={{
|
|
fontSize: 12,
|
|
color: 'var(--text-secondary, #8A8A9A)',
|
|
padding: '4px 8px',
|
|
border: '1px solid rgba(255,107,107,0.25)',
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
<span
|
|
className="mono"
|
|
style={{ color: 'var(--grade-d, #FF6B6B)', fontWeight: 700, marginRight: 6 }}
|
|
>
|
|
{k.code}
|
|
</span>
|
|
{k.reason}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
// Stable cache key for the parent's gradedProps map. Exported so the
|
|
// Slate and tests build the same string.
|
|
export function propRowKey(prop: PropRowProp): string {
|
|
return `${prop.player}|${prop.stat_type}|${prop.line}|${prop.direction}|${prop.book || ''}`;
|
|
}
|