Session 8: Frontend Stripe cutover, soccer pages, sport selector, grade result cards, beta badge

This commit is contained in:
Kev
2026-06-10 15:34:23 -04:00
parent ad5ea8d5a8
commit 4db1c1c539
15 changed files with 1583 additions and 161 deletions
+350
View File
@@ -0,0 +1,350 @@
'use client';
import { useMemo } from 'react';
/**
* Soccer result card — renders an analyze/prop response with
* soccer-specific visual treatment. We can't surface raw feature
* values (the backend response carries only `reasoning.summary` +
* `kill_conditions_triggered` per the engine1 → legacy adapter), so
* we parse the summary for known soccer-signal phrases and surface
* each as a colored chip above the prose.
*
* Free-tier responses already arrive gated (the Session 7h
* `applyTierGating` redacts `reasoning` and `kill_conditions`); we
* just need to detect the `tier_gated` / `locked` markers and show
* an upgrade CTA over the blurred content.
*/
interface KillCondition {
code: string;
reason: string;
locked?: boolean;
}
interface Reasoning {
summary?: string;
steps?: unknown;
locked?: boolean;
}
export interface SoccerGradeResultProps {
player: string;
stat_type: string;
line: number;
direction: 'over' | 'under';
league: string;
grade: string;
confidence?: number;
edge_pct?: number;
reasoning?: Reasoning;
kill_conditions_triggered?: KillCondition[];
tier_gated?: boolean;
upgrade_hint?: string;
onUpgradeClick?: () => void;
onClose?: () => void;
}
type SignalTone = 'positive' | 'caution' | 'warning' | 'neutral';
interface ParsedSignal {
icon: string;
label: string;
detail: string;
tone: SignalTone;
}
const SIGNAL_TONE_STYLE: Record<SignalTone, { color: string; bg: string; border: string }> = {
positive: { color: 'var(--grade-a)', bg: 'rgba(0,200,150,0.08)', border: 'rgba(0,200,150,0.40)' },
caution: { color: 'var(--grade-c, #FFB347)', bg: 'rgba(255,179,71,0.08)', border: 'rgba(255,179,71,0.40)' },
warning: { color: 'var(--grade-d, #ff5a5a)', bg: 'rgba(255,90,90,0.08)', border: 'rgba(255,90,90,0.40)' },
neutral: { color: 'var(--text-secondary)', bg: 'transparent', border: 'var(--border)' },
};
// Pattern-match the concrete sentences `buildSoccerReasoningLines`
// emits in src/services/intelligence/analyzeViaEngine1.js. Order
// matters — earlier patterns win when multiple match the same line.
const SIGNAL_PATTERNS: Array<(line: string) => ParsedSignal | null> = [
(line) => {
const m = line.match(/scores ([\d.]+) goals per 90 minutes/i);
if (m) return { icon: '⚽', label: 'Goals / 90', detail: `${m[1]}`, tone: 'positive' };
return null;
},
(line) => {
const m = line.match(/Expected goals \(xG\): ([\d.]+) per 90 — (.+)/i);
if (m) {
const trend = m[2].toLowerCase();
const tone: SignalTone = trend.includes('regression') ? 'caution'
: trend.includes('breakout') ? 'positive' : 'neutral';
return { icon: '📊', label: 'xG / 90', detail: `${m[1]}${m[2]}`, tone };
}
return null;
},
(line) => {
if (/Designated penalty taker/i.test(line)) {
return { icon: '🎯', label: 'Penalty Taker', detail: '+0.15 goals/90 boost', tone: 'positive' };
}
return null;
},
(line) => {
if (/Direct free-kick specialist/i.test(line)) {
return { icon: '🏹', label: 'Free-Kick Taker', detail: 'shot/goal probability boost', tone: 'positive' };
}
return null;
},
(line) => {
if (/corner taker/i.test(line)) {
return { icon: '⛳', label: 'Corner Taker', detail: 'assist probability boost', tone: 'positive' };
}
return null;
},
(line) => {
const m = line.match(/Match at ([\d,]+)ft altitude\.\s*(.+)/i);
if (m) {
const isAcclimated = /acclimated host/i.test(m[2]);
return {
icon: '🏔️',
label: 'Altitude',
detail: `${m[1]}ft — ${isAcclimated ? 'host acclimated' : 'visitor risk'}`,
tone: isAcclimated ? 'neutral' : 'warning',
};
}
return null;
},
(line) => {
const m = line.match(/(.+?) averages ([\d.]+) cards per match/i);
if (m) {
const cardsPerGame = parseFloat(m[2]);
const tone: SignalTone = cardsPerGame >= 5 ? 'caution' : 'neutral';
return { icon: '🟨', label: `Referee: ${m[1].trim()}`, detail: `${m[2]} cards/match`, tone };
}
return null;
},
(line) => {
const m = line.match(/Averaging only ([\d.]+) minutes per match/i);
if (m) return { icon: '⏱️', label: 'Minutes', detail: `${m[1]}/90 — under-line discount`, tone: 'caution' };
return null;
},
(line) => {
const m = line.match(/(.+?) concedes ([\d.]+) goals per game/i);
if (m) {
const conceded = parseFloat(m[2]);
const tone: SignalTone = conceded <= 0.8 ? 'warning' : conceded >= 1.6 ? 'positive' : 'neutral';
return { icon: '🛡️', label: `Defense: ${m[1].trim()}`, detail: `${m[2]} GA/match`, tone };
}
return null;
},
(line) => {
const m = line.match(/Tournament pedigree: (\d+) career World Cup goals/i);
if (m) return { icon: '🏆', label: 'WC Pedigree', detail: `${m[1]} career goals`, tone: 'positive' };
return null;
},
];
function parseSignals(summary: string | undefined): ParsedSignal[] {
if (!summary) return [];
const out: ParsedSignal[] = [];
// The buildSoccerReasoningLines output is a single `lines.join(' ')`,
// so split on period+space and trim. Some sentences contain periods
// (e.g. "0.67 goals per 90"), so re-match conservatively.
const fragments = summary.split(/(?<=\.)\s+(?=[A-Z⚽📊🎯🏹⛳🏔️🟨⏱️🛡️🏆])/);
for (const frag of fragments) {
for (const fn of SIGNAL_PATTERNS) {
const sig = fn(frag);
if (sig) {
out.push(sig);
break;
}
}
}
return out;
}
function gradeColor(grade: string): string {
const g = (grade || '').trim().toUpperCase().charAt(0);
if (g === 'A') return 'var(--grade-a)';
if (g === 'B') return 'var(--grade-b, #4A9EFF)';
if (g === 'C') return 'var(--grade-c, #FFB347)';
return 'var(--grade-d, #ff5a5a)';
}
export default function SoccerGradeResult(props: SoccerGradeResultProps) {
const {
player, stat_type, line, direction, league, grade, confidence, edge_pct,
reasoning, kill_conditions_triggered, tier_gated, upgrade_hint,
onUpgradeClick, onClose,
} = props;
const signals = useMemo(() => parseSignals(reasoning?.summary), [reasoning?.summary]);
const color = gradeColor(grade);
const locked = !!tier_gated || !!reasoning?.locked;
const kills = Array.isArray(kill_conditions_triggered) ? kill_conditions_triggered : [];
return (
<article
className="surface diagonal-cut"
style={{
padding: 24,
border: `1px solid ${color}`,
background: 'var(--bg-elevated)',
borderRadius: 8,
marginTop: 16,
position: 'relative',
}}
data-testid="soccer-grade-result"
>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
position: 'absolute', top: 8, right: 12,
background: 'transparent', border: 0, cursor: 'pointer',
fontSize: 18, color: 'var(--text-tertiary)',
}}
>
×
</button>
)}
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 16 }}>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}>{player}</div>
<div
className="mono"
style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4, textTransform: 'uppercase', letterSpacing: '0.06em' }}
>
{direction.toUpperCase()} {line.toFixed(1)} {stat_type.replace(/_/g, ' ')} · {league.toUpperCase()}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div className="mono" style={{ fontSize: 44, fontWeight: 800, color, lineHeight: 1, letterSpacing: '-0.04em' }}>
{grade}
</div>
{typeof confidence === 'number' && (
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4 }}>
{confidence.toFixed(0)}% conf
{typeof edge_pct === 'number' && (
<> · {edge_pct >= 0 ? '+' : ''}{edge_pct.toFixed(1)}% edge</>
)}
</div>
)}
</div>
</header>
{signals.length > 0 && !locked && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
{signals.map((sig, idx) => {
const style = SIGNAL_TONE_STYLE[sig.tone];
return (
<div
key={`${sig.label}-${idx}`}
style={{
padding: '8px 12px',
border: `1px solid ${style.border}`,
background: style.bg,
borderRadius: 6,
fontSize: 12,
display: 'flex',
alignItems: 'baseline',
gap: 6,
maxWidth: '100%',
}}
>
<span aria-hidden style={{ fontSize: 14 }}>{sig.icon}</span>
<span
className="mono"
style={{
color: style.color,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
fontSize: 10,
}}
>
{sig.label}
</span>
<span style={{ color: 'var(--text-primary)' }}>{sig.detail}</span>
</div>
);
})}
</div>
)}
{!locked && reasoning?.summary && (
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: 16 }}>
{reasoning.summary}
</p>
)}
{locked && (
<div
style={{
padding: 20,
border: '1px dashed var(--border)',
borderRadius: 6,
background: 'rgba(0,0,0,0.20)',
textAlign: 'center',
marginBottom: 16,
}}
>
<div
className="mono"
style={{
filter: 'blur(4px)',
userSelect: 'none',
color: 'var(--text-tertiary)',
fontSize: 13,
marginBottom: 16,
}}
aria-hidden
>
Goals/90: 0.67 · 📊 xG: 0.52 overperforming · 🏔 altitude 7,349ft · 🟨 ref 4.7 cards/match · 🎯 penalty taker
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
{upgrade_hint || 'Unlock full intelligence — xG regression, altitude, referee, set-piece role.'}
</p>
<button
type="button"
onClick={onUpgradeClick}
className="btn-primary"
style={{ padding: '8px 18px', fontSize: 13 }}
>
Unlock full analysis
</button>
</div>
)}
{kills.length > 0 && (
<section style={{ marginTop: 8 }}>
<h3
className="mono"
style={{ fontSize: 10, color: 'var(--grade-d, #ff5a5a)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}
>
Kill conditions ({kills.length})
</h3>
<ul style={{ display: 'grid', gap: 6 }}>
{kills.map((k, idx) => (
<li
key={`${k.code}-${idx}`}
style={{
padding: '6px 10px',
border: '1px solid rgba(255,90,90,0.30)',
background: 'rgba(255,90,90,0.04)',
borderRadius: 4,
fontSize: 12,
}}
>
<span className="mono" style={{ color: 'var(--grade-d, #ff5a5a)', fontWeight: 700, marginRight: 6 }}>
{k.code}
</span>
<span style={{ color: 'var(--text-secondary)' }}>{k.reason}</span>
</li>
))}
</ul>
</section>
)}
</article>
);
}