Session 35: Design system Phase D — core screens: Grade Result, Slate card, Scan, Terminal, Landing (1839 tests)

VYNDR 2.0 conversion, Phase D (the screens users touch). Frontend-only; zero
backend changes.

- GradeResultCard + ProcessingGrade (the core product moment): intel-surface
  grade hero, signal breakdown, kill conditions, best-book strip, alt ladder;
  sections self-hide when empty.
- lib/gradeAdapter.js maps engine output -> §7 contract and tier-gates content
  (free teaser / analyst kill-conditions / desk alt ladder) so the new card
  doesn't give paid content away.
- Scan result wired to ProcessingGrade->GradeResultCard, preserving scan limits,
  parlay add, reads tracking, and noopener sportsbook deep-links.
- GameCard (Bloomberg best/worst line cells) built + tested.
- Terminal page replaces its stub with a real league-intelligence screen.
- Landing gets the founder-seat ClaimMeter.

Honest scope: live dashboard/Slate swap onto GameCard, scan input -> TerminalInput,
full landing rebuild, and the blurred-paywall polish (Phase G) are deferred to
keep working flows stable.

22 new tests. Backend 1818 -> 1839, 143 suites, zero regressions. Web build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-16 00:20:45 -04:00
parent 907c7b17c1
commit 1d83682cdb
13 changed files with 1205 additions and 33 deletions
+75 -22
View File
@@ -2,7 +2,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import GradeCard from '@/components/GradeCard';
import ProcessingGrade from '@/components/vyndr/ProcessingGrade';
import type { GradeResultData } from '@/components/vyndr/GradeResultCard';
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
import { markReadComplete } from '@/lib/reads';
import { useAuth } from '@/contexts/AuthContext';
import { useParlay } from '@/contexts/ParlayContext';
import {
@@ -83,6 +86,16 @@ const SPORT_ACCENT: Record<Sport, string> = {
WNBA: '#FFB347',
};
// Sportsbook deep-links — preserved from the legacy GradeCard so the new
// design keeps the book hand-off. target=_blank + noopener,noreferrer.
const SPORTSBOOKS = [
{ id: 'draftkings', label: 'DraftKings', host: 'sportsbook.draftkings.com' },
{ id: 'fanduel', label: 'FanDuel', host: 'sportsbook.fanduel.com' },
{ id: 'betmgm', label: 'BetMGM', host: 'sports.betmgm.com' },
{ id: 'caesars', label: 'Caesars', host: 'sportsbook.caesars.com' },
];
const deepLink = (host: string, player: string) => `https://${host}/?search=${encodeURIComponent(player)}`;
export default function ScanPage() {
const router = useRouter();
const { user, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth();
@@ -249,6 +262,17 @@ export default function ScanPage() {
}
};
// Count a completed read once per prop per session (drives the Install/Push
// prompt gates) — preserved from the legacy GradeCard's reveal effect.
useEffect(() => {
if (!result || typeof window === 'undefined') return;
const readKey = `vyndr_read_${sport}_${selectedPlayer}_${stat}_${line}_${direction}`;
if (!window.sessionStorage.getItem(readKey)) {
window.sessionStorage.setItem(readKey, '1');
markReadComplete();
}
}, [result, sport, selectedPlayer, stat, line, direction]);
const reset = () => {
setResult(null);
setError('');
@@ -645,29 +669,27 @@ export default function ScanPage() {
</div>
)}
{/* Grade card output */}
{/* Grade result — VYNDR 2.0 ProcessingGrade → GradeResultCard (Session 35).
Engine output is mapped to the §7 contract and tier-gated by the adapter. */}
{result && (
<div style={{ marginTop: 32, display: 'grid', gap: 16 }}>
<GradeCard
sport={sport}
player={selectedPlayer}
stat={stat}
line={Number(line)}
direction={direction}
grade={result.grade}
projection={result.projection}
confidence={result.confidence}
sample_size={result.sample_size}
factors={result.factors}
alt_lines={result.alt_lines}
kill_conditions={result.kill_conditions}
reasoning={result.reasoning}
historical_hit_rate={result.historical_hit_rate}
tier={tier}
onUpgradeClick={(target, from) => {
trackUpgradeClicked({ current_tier: tier, target_tier: target, trigger_location: from });
router.push(`/api/checkout?tier=${target}`);
}}
<ProcessingGrade
key={`${selectedPlayer}-${stat}-${line}-${direction}`}
data={mapScanToGradeResult({
player: selectedPlayer,
sport,
stat,
line: Number(line),
direction,
grade: result.grade,
projection: result.projection,
confidence: result.confidence,
sample_size: result.sample_size,
factors: result.factors,
alt_lines: result.alt_lines,
kill_conditions: result.kill_conditions,
tier,
}) as GradeResultData}
onAddToParlay={() => {
addLeg({
sport,
@@ -680,8 +702,39 @@ export default function ScanPage() {
});
open();
}}
onReadAnother={reset}
/>
{/* Sportsbook hand-off (preserved feature) */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
{SPORTSBOOKS.map((b) => (
<a
key={b.id}
href={deepLink(b.host, selectedPlayer)}
target="_blank"
rel="noopener noreferrer"
className="mono"
style={{ padding: '7px 13px', fontSize: 11, fontWeight: 700, borderRadius: 6, border: '1px solid var(--border-hi)', color: 'var(--text-1)', textDecoration: 'none' }}
>
{b.label}
</a>
))}
</div>
{/* Free-tier nudge — full paywall treatment returns in Phase G */}
{tier === 'free' && (
<button
onClick={() => {
trackUpgradeClicked({ current_tier: tier, target_tier: 'analyst', trigger_location: 'grade_card_teaser' });
router.push('/api/checkout?tier=analyst');
}}
className="mono"
style={{ padding: '12px 16px', borderRadius: 8, border: '1px solid rgba(255,179,71,.4)', background: 'rgba(255,179,71,.06)', color: 'var(--amber)', fontWeight: 700, fontSize: 13, cursor: 'pointer' }}
>
Unlock every signal + kill conditions $14.99/mo
</button>
)}
<div style={{ display: 'flex', gap: 12 }}>
<button onClick={reset} className="btn-ghost" style={{ flex: 1 }}>
Read another prop