7d6dbc6cf3
MISSION 1 — real sportsbook wordmarks (kills lowercase "betmgm"):
- books.js: add the 6 missing ALLOWED_BOOKS keys (fanatics/bet365/
hardrockbet/betrivers/pointsbet/pinnacle) with real brand names +
colors — no live book falls to neutral gray. Add `slug` fields +
bookSlug()/hasBookSvg() + BUNDLED_BOOK_SVGS.
- Bundle 8 self-authored styled-text wordmark SVGs under
web/public/books/{slug}.svg (draftkings/fanduel/betmgm/caesars/
bet365/pinnacle/hardrockbet/betrivers). NOT copied trademarked logo
glyphs — the book's NAME in brand weight+color; official press-kit
art can drop into the same paths with zero code change.
- BookWordmark: render the local SVG when bundled, else the brand-color
styled-text fallback (never a broken image; never a lowercase key).
- Import BookWordmark into the ledger row (page.tsx:368) + the identical
public-profile row, replacing bare {row.book} text. vyndr/GameCard
line-grid book cell now proper-cases via bookInfo().name (keeps the
preferred-book green highlight).
MISSION 2 — team-logo coverage gaps:
- teamMeta.js: add ESPN-schedule ball-sport abbr aliases the feed emits
that fell to monograms — SA→SAS, NY→NYK, WSH→WAS, BRK→BKN (NBA),
CONN→CON (WNBA). Real-abbr-first lookup means MLB WSH (Nationals) +
WNBA NY (Liberty) still resolve directly; NY in MLB stays null.
Tests: new tests/unit/bookWordmark.test.js (all 10 ALLOWED_BOOKS resolve
to a real brand+non-gray color; 8 bundled SVGs exist; BookWordmark
SVG-first + no-lowercase-leak; ledger/profile import + use BookWordmark).
entityLayer.test.js extended for the new aliases. Full suite green
(239 suites / 2891 tests); next build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import { GradePill } from '@/components/GradeCard';
|
||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||
import BookWordmark from '@/components/vyndr/BookWordmark';
|
||
|
||
/**
|
||
* PublicProfile (A1 Session 10) — the public ledger record for one handle.
|
||
*
|
||
* DATA SEMANTICS: every row here is a REAL settled ledger entry — outcome,
|
||
* actual value, closing-line value. Nothing curated: publishing shows the
|
||
* ENTIRE settled record, misses included. The header NEVER renders a
|
||
* percentage under min_sample (20) settles — it shows RECORD BUILDING.
|
||
*
|
||
* Unknown and unpublished handles arrive as the same 404 — the page renders
|
||
* one indistinguishable not-found state for both.
|
||
*/
|
||
|
||
interface ProfileRow {
|
||
id: string;
|
||
player_name: string;
|
||
sport: string;
|
||
stat: string;
|
||
line: number;
|
||
side: 'over' | 'under';
|
||
locked_odds?: string | null;
|
||
book?: string | null;
|
||
grade: string;
|
||
model_value?: number | null;
|
||
game_date: string;
|
||
closing_line?: number | null;
|
||
clv?: number | null;
|
||
clv_result?: 'beat' | 'faded' | 'flat' | null;
|
||
outcome?: 'hit' | 'miss' | 'push' | null;
|
||
actual_value?: number | null;
|
||
revised_from_grade?: string | null;
|
||
}
|
||
|
||
interface ProfileAggregate {
|
||
settled: number;
|
||
hits: number;
|
||
misses: number;
|
||
pushes: number;
|
||
hit_pct: number | null;
|
||
clv_sample: number;
|
||
clv_beat: number;
|
||
beat_close_pct: number | null;
|
||
pending: number;
|
||
}
|
||
|
||
interface ProfilePayload {
|
||
handle: string;
|
||
aggregate: ProfileAggregate | null;
|
||
entries: ProfileRow[];
|
||
min_sample?: number;
|
||
}
|
||
|
||
const SPORT_COLOR: Record<string, string> = {
|
||
nba: '#E94B3C',
|
||
mlb: '#1E90FF',
|
||
wnba: '#FFB347',
|
||
soccer: '#7BC96F',
|
||
};
|
||
|
||
export default function PublicProfile({ handle }: { handle: string }) {
|
||
const [data, setData] = useState<ProfilePayload | null>(null);
|
||
const [state, setState] = useState<'loading' | 'ready' | 'notfound'>('loading');
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
setState('loading');
|
||
fetch(`/api/profiles/${encodeURIComponent(handle)}`)
|
||
.then(async (r) => {
|
||
if (!active) return;
|
||
if (!r.ok) { setState('notfound'); return; }
|
||
const d = await r.json();
|
||
if (!active) return;
|
||
setData(d);
|
||
setState('ready');
|
||
})
|
||
.catch(() => { if (active) setState('notfound'); });
|
||
return () => { active = false; };
|
||
}, [handle]);
|
||
|
||
if (state === 'loading') {
|
||
return (
|
||
<section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading record…</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
if (state === 'notfound' || !data) {
|
||
// Same state for unknown AND unpublished — the API never tells us which.
|
||
return (
|
||
<section style={{ maxWidth: 640, margin: '0 auto', padding: '64px 16px 120px', textAlign: 'center' }}>
|
||
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--amber, #FFB347)', marginBottom: 10 }}>
|
||
NO RECORD HERE
|
||
</p>
|
||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>This profile does not exist or is not published.</h1>
|
||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||
VYNDR ledgers are private by default. A record only appears here when its owner publishes it.
|
||
</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const agg = data.aggregate;
|
||
const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20;
|
||
|
||
return (
|
||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
|
||
<header style={{ marginBottom: 20 }}>
|
||
<p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--g-a, #00D4A0)', marginBottom: 8 }}>
|
||
PUBLIC LEDGER
|
||
</p>
|
||
<h1 className="mono" style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-0.02em', marginBottom: 6 }}>
|
||
@{data.handle}
|
||
</h1>
|
||
<p style={{ color: 'var(--text-secondary)', fontSize: 15 }}>
|
||
Every settled read. Wins and misses. Nothing curated.
|
||
</p>
|
||
</header>
|
||
|
||
{/* Record header — never a percentage under min_sample settles. */}
|
||
<RecordHeader agg={agg} minSample={minSample} />
|
||
|
||
{data.entries.length === 0 ? (
|
||
<div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}>
|
||
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
||
NO SETTLED READS YET
|
||
</p>
|
||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||
Reads land here as they settle against real results.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
|
||
{data.entries.map((row, i) => (
|
||
<ProfileCard key={row.id} row={row} index={i} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The record HERO (DS4 · billboard — DESIGN-SPEC Part 3 + Part 6 #4). This is
|
||
* the shareable surface Kev pitches partners with. One bold figure among muted
|
||
* context: the HIT RATE as a large mono tabular number, beat-close as the
|
||
* secondary bold figure, the raw record demoted. CLV-VERIFIED badge only when
|
||
* closing-line value is actually tracked (honest — never a decorative badge).
|
||
*/
|
||
function RecordHeader({ agg, minSample }: { agg: ProfileAggregate | null; minSample: number }) {
|
||
const ready = Boolean(agg && agg.settled >= minSample && agg.hit_pct != null);
|
||
const clvVerified = Boolean(ready && agg && agg.beat_close_pct != null);
|
||
return (
|
||
<div
|
||
className="surface diagonal-cut"
|
||
style={{ padding: 24, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }}
|
||
>
|
||
{ready && agg ? (
|
||
<>
|
||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
|
||
<span className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)' }}>
|
||
RECORD · LAST 30D
|
||
</span>
|
||
{clvVerified && (
|
||
<span className="mono" style={{ fontSize: 10.5, fontWeight: 800, letterSpacing: '0.08em', color: 'var(--g-a, #00D4A0)', border: '1px solid var(--g-a, #00D4A0)', borderRadius: 999, padding: '3px 10px', background: 'color-mix(in srgb, var(--g-a, #00D4A0) 12%, transparent)' }}>
|
||
✓ CLV-VERIFIED
|
||
</span>
|
||
)}
|
||
</div>
|
||
{/* HERO figures — hit rate is the largest thing on the page. */}
|
||
<div style={{ display: 'flex', gap: 40, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||
<div>
|
||
<div className="mono" style={{ fontSize: 56, fontWeight: 800, lineHeight: 0.95, letterSpacing: '-0.03em', color: 'var(--g-a, #00D4A0)', fontVariantNumeric: 'tabular-nums' }}>
|
||
{agg.hit_pct}%
|
||
</div>
|
||
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-tertiary)', marginTop: 4 }}>
|
||
HIT RATE · {agg.hits}-{agg.misses}
|
||
</div>
|
||
</div>
|
||
{agg.beat_close_pct != null && (
|
||
<div>
|
||
<div className="mono" style={{ fontSize: 40, fontWeight: 800, lineHeight: 0.95, letterSpacing: '-0.02em', color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
||
{agg.beat_close_pct}%
|
||
</div>
|
||
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-tertiary)', marginTop: 4 }}>
|
||
BEAT CLOSE
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div style={{ marginLeft: 'auto' }}>
|
||
<div className="mono" style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-tertiary)', fontVariantNumeric: 'tabular-nums' }}>
|
||
{agg.pending} pending
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div>
|
||
<p className="mono" style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>
|
||
RECORD BUILDING
|
||
</p>
|
||
<p style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
|
||
Percentages render at {minSample} settled reads.
|
||
{agg && (
|
||
<span className="mono" style={{ color: 'var(--text-primary)' }}>
|
||
{' '}{agg.settled} settled · {agg.pending} pending
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OutcomeChip({ row }: { row: ProfileRow }) {
|
||
if (!row.outcome) {
|
||
return <span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>PENDING</span>;
|
||
}
|
||
const color = row.outcome === 'hit' ? 'var(--g-a, #00D4A0)'
|
||
: row.outcome === 'miss' ? 'var(--miss, #FF6B6B)' : 'var(--text-secondary)';
|
||
const mark = row.outcome === 'hit' ? '✓ HIT' : row.outcome === 'miss' ? '✕ MISS' : '– PUSH';
|
||
return (
|
||
<span className="mono" style={{ fontSize: 12, fontWeight: 700, color }}>
|
||
{mark}{row.actual_value != null ? ` (${row.actual_value})` : ''}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ClvChip({ row }: { row: ProfileRow }) {
|
||
if (!row.clv_result || row.clv == null) return null;
|
||
const color = row.clv_result === 'beat' ? 'var(--g-a, #00D4A0)'
|
||
: row.clv_result === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)';
|
||
return (
|
||
<span className="mono" title={`Closing line value: locked ${row.line}, closed ${row.closing_line}`}
|
||
style={{ fontSize: 10.5, fontWeight: 700, color, letterSpacing: '0.04em' }}>
|
||
CLV {row.clv > 0 ? '+' : ''}{row.clv} · {row.clv_result.toUpperCase()}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
|
||
const sportColor = SPORT_COLOR[row.sport] || 'var(--text-secondary)';
|
||
return (
|
||
<article className={`surface diagonal-cut animate-fade-up stagger-${(index % 6) + 1}`} style={{ padding: 16 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
|
||
<span className="mono" style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 999, background: `${sportColor}1F`, color: sportColor }}>
|
||
{row.sport.toUpperCase()}
|
||
</span>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||
{row.revised_from_grade && (
|
||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', textDecoration: 'line-through' }}>
|
||
{row.revised_from_grade}
|
||
</span>
|
||
)}
|
||
<GradePill grade={row.grade} />
|
||
</span>
|
||
</div>
|
||
{/* Real entity identity (DS0) — headshot / team-colored monogram. */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
||
<PlayerAvatar name={row.player_name} sport={row.sport} size={34} />
|
||
<div style={{ minWidth: 0 }}>
|
||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.player_name}</h3>
|
||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', margin: 0 }}>
|
||
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||
{row.book ? <BookWordmark book={row.book} size={11} /> : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
|
||
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
|
||
</p>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
|
||
<OutcomeChip row={row} />
|
||
<ClvChip row={row} />
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|