Session 13: The Slate, Africa geo-restriction, OAuth providers, PropRow + GameCard (1311 tests)
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
'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;
|
||||
// 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 || ''}`;
|
||||
}
|
||||
Reference in New Issue
Block a user