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:
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Hero from '@/components/Hero';
|
||||
import { ClaimMeter } from '@/components/vyndr';
|
||||
// Session 17 — game-count strip mounted between the hero and the
|
||||
// existing LivePropsStrip. Shows "X NBA · Y WNBA · Z MLB games
|
||||
// being graded right now" with a signup CTA. Hides itself when
|
||||
@@ -44,6 +45,10 @@ export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
{/* Founder-seat scarcity meter (§12) */}
|
||||
<div style={{ padding: '0 16px 8px' }}>
|
||||
<ClaimMeter />
|
||||
</div>
|
||||
<TonightsSlate />
|
||||
<LivePropsStrip />
|
||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '0 16px' }}>
|
||||
|
||||
+75
-22
@@ -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
|
||||
|
||||
@@ -1,13 +1,206 @@
|
||||
import RouteStub from '@/components/vyndr/RouteStub';
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
|
||||
export const metadata = { title: 'Terminal' };
|
||||
export const metadata = { title: 'The Terminal' };
|
||||
|
||||
// Sample league-intelligence dataset (§7 shapes). Real wiring to
|
||||
// scheduleService.getGameSummary (injury cascades) + schedule/odds (leaders)
|
||||
// lands in a later session; the screen + contracts are real now.
|
||||
const INJURY_WIRE = [
|
||||
{
|
||||
player: 'Jamal Murray', team: 'DEN', sport: 'nba', status: 'OUT', injury: 'Hamstring', posted: '2h ago',
|
||||
cascade: [
|
||||
{ player: 'Nikola Jokić', stat: 'Assists', delta: '+3.2% usage', grade: 'A+' },
|
||||
{ player: 'Russell Westbrook', stat: 'Points', delta: '+4.1% usage', grade: 'B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
player: 'Jeremy Sochan', team: 'SA', sport: 'nba', status: 'OUT', injury: 'Ankle sprain', posted: '3h ago',
|
||||
cascade: [
|
||||
{ player: 'Victor Wembanyama', stat: 'Points', delta: '+3.2% usage', grade: 'A' },
|
||||
{ player: 'Devin Vassell', stat: '3PT Made', delta: '+2.0% usage', grade: 'B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
player: 'Mookie Betts', team: 'LAD', sport: 'mlb', status: 'GTD', injury: 'Wrist', posted: '40m ago',
|
||||
cascade: [{ player: 'Shohei Ohtani', stat: 'RBIs', delta: '+1.9% lineup', grade: 'C' }],
|
||||
},
|
||||
];
|
||||
|
||||
const IMPACTED_GAMES = [
|
||||
{ sport: 'nba', match: 'LAL @ SA', time: '10:30 PM ET', vvi: 92, graded: 7, note: 'Sochan OUT spikes Wembanyama usage; LAL pace + 26th-vs-C matchup compound it.', drivers: ['INJURY CASCADE', 'PACE', 'MATCHUP', 'BLOWOUT RISK'] },
|
||||
{ sport: 'mlb', match: 'LAD @ ATL', time: '7:20 PM ET', vvi: 78, graded: 5, note: 'Truist Park boosts LH power; Ohtani platoon edge vs RHP, ATL bullpen on fumes.', drivers: ['PARK FACTOR', 'PLATOON', 'BULLPEN FATIGUE'] },
|
||||
{ sport: 'wnba', match: 'NY @ LV', time: '9:00 PM ET', vvi: 71, graded: 4, note: "Wilson 31% usage vs NY's soft interior; Liberty on a back-to-back.", drivers: ['USAGE', 'MATCHUP', 'REST EDGE'] },
|
||||
];
|
||||
|
||||
const FACTOR_PULSE = [
|
||||
{ count: 11, label: 'Injury cascades', hint: 'props recalibrated', color: 'var(--g-b)' },
|
||||
{ count: 8, label: 'Pace mismatches', hint: 'tempo edges', color: 'var(--g-a)' },
|
||||
{ count: 6, label: 'Park / platoon', hint: 'MLB power spots', color: 'var(--g-a)' },
|
||||
{ count: 5, label: 'Blowout risk', hint: 'minutes-capped', color: 'var(--miss)' },
|
||||
];
|
||||
|
||||
const LEADERS: { key: string; rows: { player: string; team: string; val: string; grade: string; gradeable: boolean }[] }[] = [
|
||||
{
|
||||
key: 'NBA · Points',
|
||||
rows: [
|
||||
{ player: 'Nikola Jokić', team: 'DEN', val: '31.2', grade: 'A+', gradeable: true },
|
||||
{ player: 'Victor Wembanyama', team: 'SA', val: '29.4', grade: 'A', gradeable: true },
|
||||
{ player: 'Anthony Edwards', team: 'MIN', val: '28.4', grade: 'A', gradeable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'MLB · Total Bases',
|
||||
rows: [
|
||||
{ player: 'Shohei Ohtani', team: 'LAD', val: '2.25', grade: 'A', gradeable: true },
|
||||
{ player: 'Aaron Judge', team: 'NYY', val: '2.10', grade: 'B', gradeable: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MATCHUP_EXPLOITS = [
|
||||
{ sport: 'nba', team: 'LAL', rank: '26th', stat: 'Points allowed to C', exploit: 'Wembanyama Points O26.5 · A' },
|
||||
{ sport: 'wnba', team: 'NY', rank: '8th', stat: 'Opp paint FG% allowed', exploit: "A'ja Wilson Points O23.5 · A" },
|
||||
{ sport: 'mlb', team: 'ATL', rank: 'T-4th', stat: 'HR/9 to lefties', exploit: 'Ohtani Total Bases O1.5 · A' },
|
||||
];
|
||||
|
||||
const statusColor = (s: string) =>
|
||||
s === 'OUT' ? 'var(--miss)' : s === 'GTD' || s === 'QUESTIONABLE' ? 'var(--amber)' : 'var(--text-1)';
|
||||
|
||||
function vviColor(v: number) {
|
||||
return v >= 85 ? 'var(--g-ap)' : v >= 75 ? 'var(--g-a)' : v >= 65 ? 'var(--g-b)' : 'var(--g-c)';
|
||||
}
|
||||
|
||||
const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 };
|
||||
|
||||
export default function TerminalPage() {
|
||||
return (
|
||||
<RouteStub
|
||||
title="The Terminal"
|
||||
arriving="SESSION 35"
|
||||
blurb="League intelligence: injury cascades, game-impact scores, gradeable leaders, factor pulse, matchup exploits."
|
||||
/>
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '28px 16px 96px' }}>
|
||||
{/* HEADER */}
|
||||
<header style={{ marginBottom: 22 }}>
|
||||
<SectionHead accent="var(--g-a)">▚ LEAGUE INTELLIGENCE</SectionHead>
|
||||
<h1 className="mono" style={{ fontSize: 30, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 6px' }}>THE TERMINAL</h1>
|
||||
<p className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>
|
||||
The context the books hide — injury cascades, volatility, and the spots tonight's slate underprices.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* VVI — IMPACTED GAMES (intel surface) */}
|
||||
<SectionHead style={{ marginBottom: 12 }}>VYNDR VOLATILITY INDEX · MOST-IMPACTED GAMES</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12, marginBottom: 28 }}>
|
||||
{IMPACTED_GAMES.map((g) => (
|
||||
<div key={g.match} className="intel-surface scanlines" style={{ borderRadius: 10, padding: 16, position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'relative', zIndex: 2 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SportBadge sport={g.sport} size="sm" />
|
||||
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: '#e8fff4' }}>{g.match}</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="mono" style={{ fontSize: 26, fontWeight: 800, lineHeight: 1, color: vviColor(g.vvi), textShadow: '0 0 14px currentColor' }}>{g.vvi}</div>
|
||||
<div className="label" style={{ fontSize: 8.5, color: 'rgba(232,255,244,.5)' }}>VVI</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: 'rgba(232,255,244,.75)', margin: '10px 0', lineHeight: 1.55 }}>{g.note}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{g.drivers.map((d) => (
|
||||
<span key={d} className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', padding: '2px 6px', borderRadius: 3, color: '#bdf5e2', border: '1px solid rgba(0,255,184,.3)', background: 'rgba(0,0,0,.25)' }}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mono" style={{ marginTop: 10, fontSize: 11, color: 'var(--g-a)' }}>{g.graded} graded props · {g.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 20 }}>
|
||||
{/* INJURY CASCADES */}
|
||||
<div>
|
||||
<SectionHead style={{ marginBottom: 12 }}>INJURY WIRE · CASCADE ANALYSIS</SectionHead>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{INJURY_WIRE.map((w) => (
|
||||
<div key={w.player} style={card}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SportBadge sport={w.sport} size="sm" />
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>{w.player}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{w.team}</span>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: statusColor(w.status) }}>{w.status}</span>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-2)', marginBottom: 10 }}>{w.injury} · {w.posted}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{w.cascade.map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--g-a)' }}>↳</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{c.player}</span>
|
||||
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)' }}> · {c.stat} <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>▲ {c.delta}</span></span>
|
||||
</span>
|
||||
<GradeBadge grade={c.grade} size="sm" glow />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{/* FACTOR PULSE */}
|
||||
<div>
|
||||
<SectionHead style={{ marginBottom: 12 }}>FACTOR PULSE · FIRING NOW</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
{FACTOR_PULSE.map((f) => (
|
||||
<div key={f.label} style={card}>
|
||||
<div className="mono" style={{ fontSize: 28, fontWeight: 800, color: f.color, lineHeight: 1 }}>{f.count}</div>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, marginTop: 6 }}>{f.label}</div>
|
||||
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-2)', marginTop: 2 }}>{f.hint}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GRADEABLE LEADERS */}
|
||||
<div>
|
||||
<SectionHead style={{ marginBottom: 12 }}>GRADEABLE LEADERS</SectionHead>
|
||||
{LEADERS.map((grp) => (
|
||||
<div key={grp.key} style={{ ...card, marginBottom: 10 }}>
|
||||
<div className="label" style={{ marginBottom: 9 }}>{grp.key}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{grp.rows.map((r) => (
|
||||
<div key={r.player} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600 }}>{r.player} <span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{r.team}</span></span>
|
||||
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)' }}>{r.val}</span>
|
||||
<GradeBadge grade={r.grade} size="sm" glow={r.gradeable} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MATCHUP EXPLOITS */}
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<SectionHead style={{ marginBottom: 12 }}>MATCHUP EXPLOITS · UNDERPRICED SPOTS</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
{MATCHUP_EXPLOITS.map((m, i) => (
|
||||
<div key={i} style={card}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<SportBadge sport={m.sport} size="sm" />
|
||||
<span className="mono" style={{ fontSize: 13, fontWeight: 700 }}>{m.team}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--miss)', fontWeight: 700 }}>{m.rank}</span>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', marginBottom: 8 }}>{m.stat}</div>
|
||||
<div className="mono" style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--g-a)' }}>↳ {m.exploit}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user