Files
vyndr/web/src/app/u/[handle]/PublicProfile.tsx
T
builtbykev 7d6dbc6cf3 Wave 2B: sportsbook wordmarks + team-logo coverage
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>
2026-07-13 11:44:15 -04:00

286 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}