Session 13: The Slate, Africa geo-restriction, OAuth providers, PropRow + GameCard (1311 tests)

This commit is contained in:
Kev
2026-06-11 03:48:07 -04:00
parent d957dee17b
commit 10159209fa
18 changed files with 1452 additions and 64 deletions
+386
View File
@@ -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 || ''}`;
}