Session 42: Player Intelligence System — archetypes, stat strips, player profile, enhanced cards (2011 tests)
Built from the Claude Design "VYNDR Player Intelligence" bundle (10 sections). - Archetypes: src/services/archetypeService.js — 41 archetypes (15 NBA / 5 WNBA-unique / 15 MLB / 6 soccer), classify -> primary+secondary+blend. Frontend visual map web/src/lib/archetypes.js (colors verified == backend). ArchetypeBadge (full/ghost/tint + glyphs) + ArchetypeBlend (DNA bar). - StatStrip (compact/expanded): player name once, horizontal mono stats, inline GradeBadge props, onPlayerClick -> profile. - Stats API: extended src/routes/stats.js with /player/:name, /leaders, /game/:id (rate-limited). Aggregation in playerIntelService.js (sanitizes name param; grades cache; graceful on cold cache). Next proxies added. - Player Profile /player/[name]: all 9 design sections, graceful empty states. - Enhanced GameCard (MLB pitchers + player-grouped StatStrips) + GradeResultCard (archetype strip + stat context + VYNDR intelligence, optional/self-hiding via gradeAdapter.buildIntelFields). Player-name links wired everywhere. - Settings page replaces the S41 redirect (account/subscription/notifications/ display/responsible-play/danger-zone with DELETE-gated delete). LINKS to the real /settings/security MFA page — does not replace it. + BookChip. - Bonus: Stats Explorer /explore (real /api/stats/leaders leaderboard); added Explore + Settings to Nav MORE. Deferred (need data pipelines, Session 43): Team Hub, Offseason Intel, Slate redesign, Stats Explorer sub-panels. Backend 1940 -> 2011 tests (+71), 157 suites. Web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Stat leaders proxy (Session 42). Forwards GET /api/stats/leaders to Express,
|
||||
* preserving ?sport=&stat=&limit=. Powers the Terminal / Stats Explorer.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const qs = req.nextUrl.search;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/stats/leaders${qs}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Stats service is unreachable. Try again in a moment.' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Player intelligence proxy (Session 42). Forwards GET /api/stats/player/:name
|
||||
* to the Express stats route (which sanitizes the name + aggregates archetype /
|
||||
* props / VYNDR intelligence). Thin pass-through; preserves ?sport=.
|
||||
*/
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ name: string }> }) {
|
||||
const { name } = await ctx.params;
|
||||
const qs = req.nextUrl.search;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/stats/player/${encodeURIComponent(name)}${qs}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Player service is unreachable. Try again in a moment.' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
|
||||
/**
|
||||
* Stats Explorer (/explore) — Session 42, design section 07. The data hub:
|
||||
* tonight's league leaderboard (top graded props by confidence) with grade +
|
||||
* archetype context. Consumes the real /api/stats/leaders endpoint. Sub-panels
|
||||
* (hit-rate trends, head-to-head, market-vs-VYNDR) need historical data and are
|
||||
* a Session-43 follow-up — see BUILD-STATE.
|
||||
*/
|
||||
|
||||
interface Leader {
|
||||
player: string;
|
||||
team: string;
|
||||
stat: string;
|
||||
line: number | string;
|
||||
side: string;
|
||||
grade: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
const SPORTS = [
|
||||
{ key: 'nba', label: 'NBA' },
|
||||
{ key: 'mlb', label: 'MLB' },
|
||||
{ key: 'wnba', label: 'WNBA' },
|
||||
];
|
||||
|
||||
export default function ExplorePage() {
|
||||
const [sport, setSport] = useState('nba');
|
||||
const [leaders, setLeaders] = useState<Leader[]>([]);
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState('loading');
|
||||
fetch(`/api/stats/leaders?sport=${sport}&limit=25`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (active) { setLeaders(Array.isArray(d.leaders) ? d.leaders : []); setState('ready'); } })
|
||||
.catch(() => { if (active) setState('error'); });
|
||||
return () => { active = false; };
|
||||
}, [sport]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders;
|
||||
}, [leaders, query]);
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 920, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 14, marginBottom: 22, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 8 }}>STATS · /EXPLORE</div>
|
||||
<h1 style={{ margin: '0 0 8px', fontSize: 30, fontWeight: 800, letterSpacing: '-0.01em' }}>Stats Explorer</h1>
|
||||
<p style={{ margin: 0, maxWidth: 560, fontSize: 14, lineHeight: 1.6, color: 'var(--text-1)' }}>
|
||||
Tonight's league leaderboard — every graded prop, ranked by VYNDR confidence, with the grade and archetype context only VYNDR has.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 8, padding: 4 }}>
|
||||
{SPORTS.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setSport(s.key)}
|
||||
className="mono"
|
||||
style={{ cursor: 'pointer', border: 'none', borderRadius: 5, padding: '7px 14px', fontSize: 11, fontWeight: 600, letterSpacing: '0.04em', color: sport === s.key ? '#06060B' : 'var(--text-1)', background: sport === s.key ? 'var(--g-a)' : 'transparent' }}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FILTER BAR */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11, flexWrap: 'wrap', marginBottom: 14, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 11, padding: '11px 14px' }}>
|
||||
<span className="mono" style={{ fontSize: 14, color: 'var(--text-2)' }}>⌕</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search players"
|
||||
style={{ appearance: 'none', background: 'transparent', border: 'none', outline: 'none', fontFamily: 'var(--sans)', fontSize: 13, color: '#fff', flex: 1, minWidth: 140 }}
|
||||
/>
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>{rows.length} graded</span>
|
||||
</div>
|
||||
|
||||
{/* LEADERBOARD */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 13 }}>
|
||||
<span style={{ width: 6, height: 6, background: 'var(--g-a)', borderRadius: 1 }} />
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--text-1)' }}>LEAGUE LEADERBOARD</span>
|
||||
<SportBadge sport={sport} />
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
|
||||
<div className="mono game-lines-grid" style={{ display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '10px 16px', borderBottom: '1px solid #14141E', fontSize: 9, color: 'var(--text-2)', letterSpacing: '0.08em' }}>
|
||||
<div>#</div><div>PLAYER</div><div style={{ textAlign: 'right' }}>PROP</div><div style={{ textAlign: 'right' }}>CONF</div><div style={{ textAlign: 'center' }}>GRD</div>
|
||||
</div>
|
||||
|
||||
{state === 'loading' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>Loading tonight's slate…</div>}
|
||||
{state === 'error' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--miss)' }}>Could not load the leaderboard. Try again.</div>}
|
||||
{state === 'ready' && rows.length === 0 && (
|
||||
<div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>
|
||||
No graded props for {sport.toUpperCase()} yet — check back when tonight's slate posts.
|
||||
</div>
|
||||
)}
|
||||
{state === 'ready' && rows.map((r, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={playerHref(r.player, sport)}
|
||||
className="game-lines-grid"
|
||||
style={{ textDecoration: 'none', color: 'inherit', display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '13px 16px', borderBottom: '1px solid #14141E' }}
|
||||
>
|
||||
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color: i < 3 ? 'var(--g-a)' : 'var(--text-1)' }}>{i + 1}</div>
|
||||
<div style={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 13, color: '#fff', whiteSpace: 'nowrap' }}>{r.player}</span>
|
||||
{r.team && <span className="mono" style={{ fontSize: 10, color: 'var(--text-1)' }}>{r.team}</span>}
|
||||
</div>
|
||||
<div className="mono" style={{ textAlign: 'right', fontSize: 12, color: '#C8CCD6' }}>{r.stat} {r.side}{r.line}</div>
|
||||
<div className="mono" style={{ textAlign: 'right', fontSize: 13, fontWeight: 600, color: 'var(--text-0)' }}>{r.confidence != null ? `${r.confidence}%` : '—'}</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}><GradeBadge grade={r.grade} size="sm" /></div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams, useRouter } from 'next/navigation';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
||||
|
||||
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
|
||||
interface ActiveProp { stat: string; line: number | string; side: string; grade: string; confidence?: string | null }
|
||||
interface PlayerIntel {
|
||||
player: string;
|
||||
sport: string;
|
||||
team: string;
|
||||
found: boolean;
|
||||
archetype: { primary: { name: string } | null; secondary: { name: string } | null; blend: { archetype: string; weight: number }[] };
|
||||
propDNA: { reliable: string[]; volatile: string[] };
|
||||
education: string;
|
||||
season: { k: string; v: string; lg?: string }[];
|
||||
last10: { d?: string; opp?: string; res?: string; stat?: string }[];
|
||||
splits: { k: string; a: string; b: string }[];
|
||||
gradeHistory: { grade: string; prop: string; hit?: boolean; miss?: boolean }[];
|
||||
activeProps: ActiveProp[];
|
||||
intel: IntelMetric[];
|
||||
injury: { label: string; note: string; cascade: string } | null;
|
||||
}
|
||||
|
||||
const SectionLabel = ({ children, color = '#7A7E8C', dot = '#00D4A0' }: { children: React.ReactNode; color?: string; dot?: string }) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 14 }}>
|
||||
<span style={{ width: 6, height: 6, background: dot, borderRadius: 1 }} />
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color }}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function PlayerProfilePage() {
|
||||
const params = useParams();
|
||||
const search = useSearchParams();
|
||||
const router = useRouter();
|
||||
const rawName = decodeURIComponent(String(params?.name || ''));
|
||||
const sport = (search.get('sport') || 'nba').toLowerCase();
|
||||
|
||||
const [data, setData] = useState<PlayerIntel | null>(null);
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [dnaOpen, setDnaOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState('loading');
|
||||
fetch(`/api/stats/player/${encodeURIComponent(rawName)}?sport=${encodeURIComponent(sport)}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (active) { setData(d); setState('ready'); } })
|
||||
.catch(() => { if (active) setState('error'); });
|
||||
return () => { active = false; };
|
||||
}, [rawName, sport]);
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-2)' }}>Loading player intelligence…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (state === 'error' || !data) {
|
||||
return (
|
||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '40px 16px' }}>
|
||||
<p className="mono" style={{ color: 'var(--miss)' }}>Could not load this player. Try again in a moment.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const p = data;
|
||||
const dna = dnaRows(p.propDNA);
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 920, margin: '0 auto', padding: '20px 16px 120px' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em' }}>PLAYER · /{p.sport.toUpperCase()}</div>
|
||||
<button onClick={() => router.back()} className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 6, padding: '6px 12px', fontSize: 11, color: 'var(--text-1)' }}>← Back</button>
|
||||
</div>
|
||||
|
||||
{/* A. HERO */}
|
||||
<div style={{ position: 'relative', overflow: 'hidden', background: 'linear-gradient(160deg,#15151f,#08080F)', border: '1px solid var(--border)', borderRadius: 16, padding: 26, marginBottom: 14 }}>
|
||||
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', background: 'repeating-linear-gradient(0deg, rgba(0,212,160,0.045) 0px, rgba(0,212,160,0.045) 1px, transparent 1px, transparent 4px)' }} />
|
||||
<div style={{ position: 'relative', display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div className="mono" style={{ flex: 'none', width: 72, height: 72, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'radial-gradient(circle at 30% 25%, #14241F, #0A100E)', border: '1.5px solid #00D4A066', color: 'var(--g-a)', fontWeight: 700, fontSize: 30, boxShadow: '0 0 24px #00D4A022' }}>{initials(p.player)}</div>
|
||||
<div style={{ flex: 1, minWidth: 240 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.015em', lineHeight: 1.05 }}>{p.player}</h1>
|
||||
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<SportBadge sport={p.sport} />
|
||||
{p.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{p.team}</span>}
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-2)' }}>{sportLabel(p.sport)}</span>
|
||||
</div>
|
||||
{p.archetype?.blend?.length > 0 && (
|
||||
<div style={{ marginTop: 15 }}>
|
||||
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 9 }}>ARCHETYPE DNA</div>
|
||||
<ArchetypeBlend blend={p.archetype.blend} size="md" showLegend caption={blendReadout(p.archetype)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{p.season?.length > 0 && (
|
||||
<>
|
||||
<div style={{ position: 'relative', height: 1, background: 'var(--border)', margin: '20px 0 16px' }} />
|
||||
<div className="mono" style={{ position: 'relative', display: 'flex', gap: 28, flexWrap: 'wrap' }}>
|
||||
{p.season.map((s, i) => (
|
||||
<div key={i}><span style={{ fontSize: 20, color: '#fff', fontWeight: 600 }}>{s.v}</span> <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{s.k}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* I. INJURY */}
|
||||
{p.injury && (
|
||||
<div style={{ display: 'flex', gap: 13, alignItems: 'flex-start', background: 'linear-gradient(135deg,#1A1305,#0E0B05)', border: '1px solid #FFB34740', borderRadius: 12, padding: '15px 17px', marginBottom: 14 }}>
|
||||
<span style={{ flex: 'none', fontSize: 16 }}>⚠️</span>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span className="mono" style={{ fontWeight: 700, fontSize: 12, color: 'var(--amber)', letterSpacing: '0.06em' }}>{p.injury.label}</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-0)' }}>{p.injury.note}</span>
|
||||
</div>
|
||||
<div className="mono" style={{ marginTop: 7, fontSize: 12, color: 'var(--text-1)' }}><span style={{ color: 'var(--amber)' }}>CASCADE</span> · {p.injury.cascade}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* B. PROP DNA */}
|
||||
{dna.length > 0 && (
|
||||
<div className="intel-surface" style={{ borderRadius: 14, padding: 20, marginBottom: 14, border: '1px solid rgba(0,212,160,0.22)' }}>
|
||||
<SectionLabel color="var(--g-a)">PROP DNA</SectionLabel>
|
||||
<p style={{ margin: '0 0 16px', fontSize: 12, color: '#7E8A86', maxWidth: 520 }}>Which props this archetype makes reliable vs. volatile — unique to VYNDR.</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 10 }} className="terminal-grid">
|
||||
{dna.map((d, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, background: 'rgba(0,0,0,0.25)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: 9, padding: '11px 13px' }}>
|
||||
<span style={{ flex: 'none', width: 9, height: 9, borderRadius: '50%', background: d.color, boxShadow: `0 0 8px ${d.color}` }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-0)' }}>{d.prop}</span>
|
||||
<span className="mono" style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', color: d.color }}>{d.state}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{p.education && (
|
||||
<>
|
||||
<button onClick={() => setDnaOpen((o) => !o)} className="mono" style={{ marginTop: 14, cursor: 'pointer', background: 'transparent', border: 'none', padding: 0, fontSize: 11, fontWeight: 600, color: 'var(--g-a)', letterSpacing: '0.04em' }}>What does this mean? ⌄</button>
|
||||
{dnaOpen && <p style={{ margin: '12px 0 0', paddingTop: 13, borderTop: '1px solid rgba(0,212,160,0.16)', fontSize: 13, lineHeight: 1.65, color: '#A8B2AE', maxWidth: 640 }}>{p.education}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* C. VYNDR INTELLIGENCE */}
|
||||
{p.intel?.length > 0 && (
|
||||
<div className="intel-surface" style={{ borderRadius: 14, padding: 20, marginBottom: 14, border: '1px solid rgba(0,212,160,0.22)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 16 }}>
|
||||
<span style={{ width: 6, height: 6, background: 'var(--g-a)', borderRadius: 1 }} />
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--g-a)' }}>VYNDR INTELLIGENCE</span>
|
||||
<span style={{ flex: 1, height: 1, background: 'rgba(0,212,160,0.14)' }} />
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>proprietary</span>
|
||||
</div>
|
||||
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
{p.intel.map((m, i) => (
|
||||
<div key={i} style={{ background: '#080D0C', padding: '14px 15px', minHeight: 74, display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
<span className="mono" style={{ fontSize: 9.5, color: 'var(--text-2)', letterSpacing: '0.06em' }}>{m.label}</span>
|
||||
{m.kind === 'grade' ? (
|
||||
<GradeBadge grade={m.value} size="md" />
|
||||
) : m.kind === 'form' ? (
|
||||
<>
|
||||
<div className="mono" style={{ fontSize: 22, fontWeight: 600, color: m.color }}>{m.value}</div>
|
||||
<div style={{ height: 4, borderRadius: 2, background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}><div style={{ height: '100%', width: m.score, background: m.color, boxShadow: `0 0 8px ${m.color}` }} /></div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mono" style={{ fontSize: 18, fontWeight: 600, color: m.color }}>{m.value}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* D. ACTIVE PROPS */}
|
||||
{p.activeProps?.length > 0 && (
|
||||
<>
|
||||
<SectionLabel>ACTIVE PROPS · TONIGHT</SectionLabel>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 9, marginBottom: 8 }}>
|
||||
{p.activeProps.map((pr, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 11, padding: '13px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 150 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: '#fff' }}>{pr.stat}</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{pr.side} {pr.line}</span>
|
||||
</div>
|
||||
<GradeBadge grade={pr.grade} size="md" />
|
||||
{pr.confidence && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>conf <span style={{ color: '#B8BCC8' }}>{pr.confidence}</span></span>}
|
||||
<a href={`/scan?player=${encodeURIComponent(p.player)}&stat=${encodeURIComponent(pr.stat)}`} className="mono" style={{ marginLeft: 'auto', cursor: 'pointer', background: 'transparent', border: '1px solid var(--g-a)', borderRadius: 7, padding: '7px 13px', fontSize: 11, fontWeight: 600, letterSpacing: '0.04em', color: 'var(--g-a)', textDecoration: 'none' }}>GRADE PROP →</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* E/F. SEASON + LAST 10 */}
|
||||
{(p.season?.length > 0 || p.last10?.length > 0) && (
|
||||
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginTop: 24 }}>
|
||||
{p.season?.length > 0 && (
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 18 }}>
|
||||
<SectionLabel>SEASON STATS</SectionLabel>
|
||||
<div className="mono" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{p.season.map((r, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderTop: i ? '1px solid #14141E' : 'none', fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--text-1)' }}>{r.k}</span>
|
||||
<span style={{ color: '#fff', fontWeight: 600 }}>{r.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{p.last10?.length > 0 && (
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 18 }}>
|
||||
<SectionLabel>LAST 10</SectionLabel>
|
||||
<div className="mono" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{p.last10.map((g, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 0', borderTop: i ? '1px solid #14141E' : 'none', fontSize: 11 }}>
|
||||
<span style={{ color: 'var(--text-1)', width: 48 }}>{g.d}</span>
|
||||
<span style={{ color: 'var(--text-1)', width: 58 }}>{g.opp}</span>
|
||||
<span style={{ color: '#C8CCD6', flex: 1, textAlign: 'right' }}>{g.stat}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* G/H. SPLITS + GRADE HISTORY */}
|
||||
{(p.splits?.length > 0 || p.gradeHistory?.length > 0) && (
|
||||
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginTop: 14 }}>
|
||||
{p.splits?.length > 0 && (
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 18 }}>
|
||||
<SectionLabel>SPLITS</SectionLabel>
|
||||
<div className="mono" style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
||||
{p.splits.map((r, i) => (
|
||||
<div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 12, padding: '7px 0', borderTop: i ? '1px solid #14141E' : 'none' }}>
|
||||
<span style={{ color: 'var(--text-1)' }}>{r.k}</span>
|
||||
<span style={{ color: '#fff', fontWeight: 600 }}>{r.a}</span>
|
||||
<span style={{ color: '#C8CCD6' }}>{r.b}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{p.gradeHistory?.length > 0 && (
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 18 }}>
|
||||
<SectionLabel>GRADE HISTORY</SectionLabel>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{p.gradeHistory.map((g, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 0', borderTop: i ? '1px solid #14141E' : 'none' }}>
|
||||
<GradeBadge grade={g.grade} size="sm" />
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{g.prop}</span>
|
||||
{g.hit && <span className="mono" style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 700, color: 'var(--hit)' }}>● HIT</span>}
|
||||
{g.miss && <span className="mono" style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 700, color: 'var(--miss)' }}>● MISS</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state when there is no graded prop yet */}
|
||||
{!p.found && (
|
||||
<div style={{ marginTop: 24, padding: 20, border: '1px dashed var(--border-hi)', borderRadius: 12, textAlign: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-1)', fontSize: 13 }}>No graded props for {p.player} tonight.</p>
|
||||
<p style={{ color: 'var(--text-2)', fontSize: 12, marginTop: 6 }}>The archetype + prop DNA above still apply. Check back when tonight's slate posts.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,212 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
/**
|
||||
* /settings (Session 41 — P0 audit fix).
|
||||
* /settings (Session 42 — Player Intelligence design).
|
||||
*
|
||||
* The audit found /settings 404'd. Account/preferences already live on
|
||||
* /profile (the canonical surface), so forward there rather than build a
|
||||
* second screen that would drift out of sync. Server-side redirect — no
|
||||
* flash of an empty page.
|
||||
* Replaces the Session-41 redirect with the design's full settings surface.
|
||||
* NOTE: /settings/security is a REAL MFA enrollment page — this page LINKS to
|
||||
* it, it does not replace it.
|
||||
*/
|
||||
export default function SettingsPage() {
|
||||
redirect('/profile');
|
||||
|
||||
const Section = ({ label, color = 'var(--text-1)', dot = 'var(--g-a)', children }: { label: string; color?: string; dot?: string; children: React.ReactNode }) => (
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '18px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 16 }}>
|
||||
<span style={{ width: 6, height: 6, background: dot, borderRadius: 1 }} />
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color }}>{label}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Row = ({ children }: { children: React.ReactNode }) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 0', gap: 12, flexWrap: 'wrap' }}>{children}</div>
|
||||
);
|
||||
const Divider = () => <div style={{ height: 1, background: '#14141E', margin: '4px 0' }} />;
|
||||
|
||||
function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button onClick={onClick} aria-pressed={on} style={{ cursor: 'pointer', appearance: 'none', position: 'relative', width: 40, height: 22, borderRadius: 11, border: '1px solid var(--border-hi)', background: on ? 'var(--g-a)' : '#15151f' }}>
|
||||
<span style={{ position: 'absolute', top: 1, left: on ? 19 : 1, width: 18, height: 18, borderRadius: '50%', background: on ? '#06060B' : '#7a7a8e', transition: '.18s' }} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function tierLabel(tier: string) {
|
||||
if (tier === 'desk') return { label: 'DESK', color: '#00ffb8' };
|
||||
if (tier === 'analyst') return { label: 'ANALYST', color: 'var(--g-b)' };
|
||||
return { label: 'FREE', color: 'var(--text-1)' };
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { user, tier } = useAuth();
|
||||
const [emailAlerts, setEmailAlerts] = useState(true);
|
||||
const [pushAlerts, setPushAlerts] = useState(false);
|
||||
const [deleteText, setDeleteText] = useState('');
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [delError, setDelError] = useState('');
|
||||
|
||||
const plan = tierLabel(tier || 'free');
|
||||
const canDelete = deleteText === 'DELETE';
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!canDelete || deleting) return;
|
||||
setDeleting(true);
|
||||
setDelError('');
|
||||
try {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'DELETE',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (res.ok) {
|
||||
router.replace('/?deleted=1');
|
||||
return;
|
||||
}
|
||||
// No fake success — if the backend can't process it, say so.
|
||||
setDelError('Account deletion could not be completed automatically. Email support@vyndr.app and we will remove your account within 48 hours.');
|
||||
} catch {
|
||||
setDelError('Network error. Try again, or email support@vyndr.app.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 640, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 10 }}>SETTINGS</div>
|
||||
<h1 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.01em' }}>Settings</h1>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{/* ACCOUNT */}
|
||||
<Section label="ACCOUNT">
|
||||
<Row>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-1)' }}>Email</span>
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--text-0)' }}>{user?.email || '—'}</span>
|
||||
</Row>
|
||||
<Divider />
|
||||
<Row>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-1)' }}>Plan</span>
|
||||
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', fontWeight: 700, fontSize: 11, letterSpacing: '0.06em', color: plan.color, background: 'color-mix(in srgb, currentColor 12%, transparent)', border: '1px solid color-mix(in srgb, currentColor 40%, transparent)', padding: '3px 10px', borderRadius: 5 }}>{plan.label}</span>
|
||||
</Row>
|
||||
<Divider />
|
||||
<Row>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Security & two-factor</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Authenticator-app MFA</div>
|
||||
</div>
|
||||
<a href="/settings/security" className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 8, padding: '8px 14px', fontSize: 11, fontWeight: 600, color: 'var(--text-1)', textDecoration: 'none' }}>MANAGE →</a>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* SUBSCRIPTION */}
|
||||
<Section label="SUBSCRIPTION">
|
||||
<Row>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#fff' }}>VYNDR {plan.label === 'FREE' ? 'Free' : plan.label === 'DESK' ? 'Desk' : 'Analyst'}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>{plan.label === 'FREE' ? '5 scans / month' : 'Active subscription'}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 9 }}>
|
||||
{plan.label !== 'FREE' && (
|
||||
<button onClick={() => router.push('/profile')} className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 8, padding: '9px 15px', fontSize: 11, fontWeight: 600, color: 'var(--text-1)' }}>MANAGE</button>
|
||||
)}
|
||||
<a href="/pricing" className="mono" style={{ cursor: 'pointer', background: 'var(--g-a)', border: '1px solid var(--g-a)', borderRadius: 8, padding: '9px 15px', fontSize: 11, fontWeight: 700, color: '#06060B', textDecoration: 'none' }}>{plan.label === 'FREE' ? 'UPGRADE' : 'CHANGE PLAN'}</a>
|
||||
</div>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* NOTIFICATIONS */}
|
||||
<Section label="NOTIFICATIONS">
|
||||
<Row>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Email alerts</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Grade locks and line moves</div>
|
||||
</div>
|
||||
<Toggle on={emailAlerts} onClick={() => setEmailAlerts((v) => !v)} />
|
||||
</Row>
|
||||
<Divider />
|
||||
<Row>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Push alerts</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Injury news for tracked players</div>
|
||||
</div>
|
||||
<Toggle on={pushAlerts} onClick={() => setPushAlerts((v) => !v)} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* DISPLAY */}
|
||||
<Section label="DISPLAY PREFERENCES">
|
||||
<Row>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Display, odds format & accessibility</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Region, odds format, text size, motion, contrast, colorblind-safe</div>
|
||||
</div>
|
||||
<button onClick={() => window.__prefs?.()} className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 8, padding: '8px 14px', fontSize: 11, fontWeight: 600, color: 'var(--text-1)' }}>OPEN</button>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* RESPONSIBLE PLAY */}
|
||||
<Section label="RESPONSIBLE PLAY" color="var(--text-1)" dot="var(--amber)">
|
||||
<Row>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-0)' }}>Set a weekly time limit</span>
|
||||
<a href="/responsible-gambling" className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 8, padding: '8px 14px', fontSize: 11, fontWeight: 600, color: 'var(--text-1)', textDecoration: 'none' }}>CONFIGURE</a>
|
||||
</Row>
|
||||
<Divider />
|
||||
<Row>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-0)' }}>Self-exclusion</span>
|
||||
<a href="/responsible-gambling" className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid #FFB34740', borderRadius: 8, padding: '8px 14px', fontSize: 11, fontWeight: 600, color: 'var(--amber)', textDecoration: 'none' }}>TAKE A BREAK</a>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* DANGER ZONE */}
|
||||
<div style={{ background: '#0E0808', border: '1px solid #3A1A1A', borderRadius: 12, padding: '18px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 14 }}>
|
||||
<span style={{ width: 6, height: 6, background: 'var(--miss)', borderRadius: 1 }} />
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: '#FF7A6B' }}>DANGER ZONE</span>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 13, lineHeight: 1.55, color: '#9A8585' }}>
|
||||
Deleting your account is permanent. All grade history and tracked players will be lost. Type{' '}
|
||||
<strong className="mono" style={{ color: '#FF7A6B' }}>DELETE</strong> to confirm.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
value={deleteText}
|
||||
onChange={(e) => setDeleteText(e.target.value)}
|
||||
placeholder="Type DELETE"
|
||||
aria-label="Type DELETE to confirm account deletion"
|
||||
className="mono"
|
||||
style={{ flex: 1, minWidth: 160, appearance: 'none', background: '#0A0606', border: '1px solid #3A1A1A', borderRadius: 8, padding: '10px 13px', fontSize: 13, color: '#fff', letterSpacing: '0.08em', outline: 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={!canDelete || deleting}
|
||||
className="mono"
|
||||
style={{
|
||||
cursor: canDelete && !deleting ? 'pointer' : 'not-allowed',
|
||||
appearance: 'none',
|
||||
background: canDelete ? 'var(--miss)' : '#1A0E0E',
|
||||
border: `1px solid ${canDelete ? 'var(--miss)' : '#3A1A1A'}`,
|
||||
borderRadius: 8,
|
||||
padding: '10px 18px',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: canDelete ? '#0A0606' : '#6A4A4A',
|
||||
letterSpacing: '0.04em',
|
||||
transition: '.15s',
|
||||
}}
|
||||
>
|
||||
{deleting ? 'WORKING…' : 'DELETE ACCOUNT'}
|
||||
</button>
|
||||
</div>
|
||||
{delError && <p className="mono" style={{ marginTop: 12, fontSize: 12, color: '#FFB0A4', lineHeight: 1.5 }}>{delError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user