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>
@@ -0,0 +1,114 @@
|
|||||||
|
// Wave 2B (data train) — sportsbook wordmarks. Every book the odds feed emits
|
||||||
|
// resolves to a real brand (name + non-gray color); the ~8 major books render a
|
||||||
|
// bundled local wordmark SVG; the ledger row renders BookWordmark, never bare
|
||||||
|
// lowercase `row.book`. The SVGs are self-authored styled text wordmarks (not
|
||||||
|
// copied trademarked logos), swappable for official press-kit art later.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { bookInfo, bookSlug, hasBookSvg, BUNDLED_BOOK_SVGS } = require('../../web/src/lib/books');
|
||||||
|
|
||||||
|
// The exact keys oddsNormalizer.ALLOWED_BOOKS emits into the pipeline.
|
||||||
|
const ALLOWED_BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle'];
|
||||||
|
const DEFAULT_FG = '#B8BCC8';
|
||||||
|
|
||||||
|
const REPO = path.resolve(__dirname, '../..');
|
||||||
|
const read = (p) => fs.readFileSync(path.join(REPO, p), 'utf8');
|
||||||
|
|
||||||
|
describe('bookInfo — every ALLOWED_BOOK is a real brand (no neutral-gray fall)', () => {
|
||||||
|
test.each(ALLOWED_BOOKS)('%s resolves to a real brand name + color', (key) => {
|
||||||
|
const b = bookInfo(key);
|
||||||
|
// name must be a real brand, not the raw uppercased key echoed back
|
||||||
|
expect(b.name).toBeTruthy();
|
||||||
|
expect(b.name.toUpperCase()).not.toBe(key.toUpperCase());
|
||||||
|
// color must not be the neutral-gray default
|
||||||
|
expect(b.fg).toBeTruthy();
|
||||||
|
expect(b.fg.toLowerCase()).not.toBe(DEFAULT_FG.toLowerCase());
|
||||||
|
// never a bare lowercase feed key in the display name
|
||||||
|
expect(b.name).not.toBe(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 6 previously-missing keys now resolve (were gray)', () => {
|
||||||
|
expect(bookInfo('fanatics').name).toBe('Fanatics');
|
||||||
|
expect(bookInfo('bet365').name).toBe('bet365');
|
||||||
|
expect(bookInfo('hardrockbet').name).toBe('Hard Rock');
|
||||||
|
expect(bookInfo('betrivers').name).toBe('BetRivers');
|
||||||
|
expect(bookInfo('pointsbet').name).toBe('PointsBet');
|
||||||
|
expect(bookInfo('pinnacle').name).toBe('Pinnacle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case-insensitive: DraftKings / DK / draftkings all resolve the same', () => {
|
||||||
|
expect(bookInfo('DraftKings').name).toBe('DraftKings');
|
||||||
|
expect(bookInfo('DK').name).toBe('DraftKings');
|
||||||
|
expect(bookInfo('draftkings').name).toBe('DraftKings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bundled book wordmark SVGs', () => {
|
||||||
|
const EXPECTED = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers'];
|
||||||
|
|
||||||
|
test('BUNDLED_BOOK_SVGS is exactly the 8 major books', () => {
|
||||||
|
expect([...BUNDLED_BOOK_SVGS].sort()).toEqual([...EXPECTED].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(EXPECTED)('web/public/books/%s.svg exists and is a real <svg>', (slug) => {
|
||||||
|
const svg = read(`web/public/books/${slug}.svg`);
|
||||||
|
expect(svg).toMatch(/<svg[\s\S]*<\/svg>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bookSlug resolves feed keys + codes to the bundled slug', () => {
|
||||||
|
expect(bookSlug('draftkings')).toBe('draftkings');
|
||||||
|
expect(bookSlug('DK')).toBe('draftkings');
|
||||||
|
expect(bookSlug('hardrockbet')).toBe('hardrockbet');
|
||||||
|
expect(hasBookSvg('betmgm')).toBe(true);
|
||||||
|
expect(hasBookSvg('MGM')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a book with no bundled SVG (fanatics) still has NO svg but a real name', () => {
|
||||||
|
expect(hasBookSvg('fanatics')).toBe(false);
|
||||||
|
expect(bookInfo('fanatics').name).toBe('Fanatics');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown book → no svg, no crash', () => {
|
||||||
|
expect(hasBookSvg('zzzbook')).toBe(false);
|
||||||
|
expect(bookSlug('zzzbook')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BookWordmark source — SVG-first, styled-text fallback, no lowercase leak', () => {
|
||||||
|
const src = read('web/src/components/vyndr/BookWordmark.tsx');
|
||||||
|
|
||||||
|
test('references the local /books/{slug}.svg path', () => {
|
||||||
|
expect(src).toContain('/books/');
|
||||||
|
expect(src).toMatch(/\$\{slug\}\.svg/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves via the books registry (hasBookSvg + bookInfo + bookSlug)', () => {
|
||||||
|
expect(src).toContain('hasBookSvg');
|
||||||
|
expect(src).toContain('bookInfo');
|
||||||
|
expect(src).toContain('bookSlug');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the brand NAME (never the raw lowercase key)', () => {
|
||||||
|
// the fallback renders b.name (properly-cased brand), not the raw `book` prop
|
||||||
|
expect(src).toContain('b.name');
|
||||||
|
expect(src).not.toMatch(/>\s*\{book\}\s*</); // never render the raw prop verbatim
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ledger + public-profile rows render BookWordmark, not bare row.book', () => {
|
||||||
|
test('ledger page imports + uses BookWordmark', () => {
|
||||||
|
const src = read('web/src/app/ledger/page.tsx');
|
||||||
|
expect(src).toMatch(/import\s*\{[^}]*BookWordmark[^}]*\}\s*from\s*'@\/components\/vyndr'/);
|
||||||
|
expect(src).toContain('<BookWordmark book={row.book}');
|
||||||
|
// the old bare-text render is gone
|
||||||
|
expect(src).not.toContain("{row.book || '—'}");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('public profile page imports + uses BookWordmark', () => {
|
||||||
|
const src = read('web/src/app/u/[handle]/PublicProfile.tsx');
|
||||||
|
expect(src).toContain('BookWordmark');
|
||||||
|
expect(src).toContain('<BookWordmark book={row.book}');
|
||||||
|
expect(src).not.toContain("{row.book || '—'}");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,25 @@ describe('resolveTeam — abbr / full name / nickname / alias', () => {
|
|||||||
expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ');
|
expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ');
|
||||||
expect(resolveTeam('CHW', 'mlb').abbr).toBe('CWS');
|
expect(resolveTeam('CHW', 'mlb').abbr).toBe('CWS');
|
||||||
});
|
});
|
||||||
|
test('ESPN-schedule ball-sport abbr aliases (Wave 2B)', () => {
|
||||||
|
// NBA abbrs the ESPN schedule emits that used to fall to a monogram
|
||||||
|
expect(resolveTeam('SA', 'nba').abbr).toBe('SAS'); // Spurs
|
||||||
|
expect(resolveTeam('NY', 'nba').abbr).toBe('NYK'); // Knicks
|
||||||
|
expect(resolveTeam('WSH', 'nba').abbr).toBe('WAS'); // Wizards
|
||||||
|
expect(resolveTeam('BRK', 'nba').abbr).toBe('BKN'); // Nets
|
||||||
|
// WNBA
|
||||||
|
expect(resolveTeam('CONN', 'wnba').abbr).toBe('CON'); // Sun
|
||||||
|
expect(resolveTeam('WSH', 'wnba').abbr).toBe('WAS'); // Mystics
|
||||||
|
});
|
||||||
|
test('global alias never shadows a real same-abbr team in another sport', () => {
|
||||||
|
// WSH is a real MLB abbr (Nationals) — must resolve directly, NOT via the
|
||||||
|
// NBA/WNBA WSH→WAS alias.
|
||||||
|
expect(resolveTeam('WSH', 'mlb').name).toBe('Washington Nationals');
|
||||||
|
// NY is the WNBA Liberty's real abbr — direct hit, not the NBA NY→NYK alias.
|
||||||
|
expect(resolveTeam('NY', 'wnba').name).toBe('New York Liberty');
|
||||||
|
// NY is genuinely ambiguous in MLB (NYY/NYM) → stays null, never guessed.
|
||||||
|
expect(resolveTeam('NY', 'mlb')).toBeNull();
|
||||||
|
});
|
||||||
test('unknown team → null (never a fake)', () => {
|
test('unknown team → null (never a fake)', () => {
|
||||||
expect(resolveTeam('ZZZ', 'mlb')).toBeNull();
|
expect(resolveTeam('ZZZ', 'mlb')).toBeNull();
|
||||||
expect(resolveTeam('', 'mlb')).toBeNull();
|
expect(resolveTeam('', 'mlb')).toBeNull();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 24" width="72" height="24" role="img" aria-label="bet365"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="800" letter-spacing="-0.01em" fill="#3EA76B">bet365</text></svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 86 24" width="86" height="24" role="img" aria-label="BetMGM"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="800" letter-spacing="0.00em" fill="#C8A24B">BetMGM</text></svg>
|
||||||
|
After Width: | Height: | Size: 282 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 112 24" width="112" height="24" role="img" aria-label="BetRivers"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="800" letter-spacing="-0.01em" fill="#3E8FD6">BetRivers</text></svg>
|
||||||
|
After Width: | Height: | Size: 291 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 24" width="88" height="24" role="img" aria-label="Caesars"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="700" letter-spacing="0.02em" fill="#1A7F5A">Caesars</text></svg>
|
||||||
|
After Width: | Height: | Size: 284 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 124 24" width="124" height="24" role="img" aria-label="DraftKings"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="800" letter-spacing="-0.01em" fill="#53D337">DraftKings</text></svg>
|
||||||
|
After Width: | Height: | Size: 293 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 24" width="90" height="24" role="img" aria-label="FanDuel"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="800" letter-spacing="-0.01em" fill="#1493FF">FanDuel</text></svg>
|
||||||
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 106 24" width="106" height="24" role="img" aria-label="Hard Rock"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="800" letter-spacing="0.00em" fill="#D4A24B">Hard Rock</text></svg>
|
||||||
|
After Width: | Height: | Size: 290 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 24" width="98" height="24" role="img" aria-label="Pinnacle"><text x="1" y="18" font-family="Inter, 'Helvetica Neue', Arial, sans-serif" font-size="17" font-weight="700" letter-spacing="0.01em" fill="#C8434F">Pinnacle</text></svg>
|
||||||
|
After Width: | Height: | Size: 286 B |
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { GradePill } from '@/components/GradeCard';
|
import { GradePill } from '@/components/GradeCard';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Skeleton, EmptyState, ArchetypeBadge } from '@/components/vyndr';
|
import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark } from '@/components/vyndr';
|
||||||
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
|
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -365,7 +365,7 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
|
|||||||
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
||||||
</p>
|
</p>
|
||||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||||
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
|
{row.book ? <BookWordmark book={row.book} size={11} /> : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
|
||||||
{/* model_value is MODEL output — always labeled, never blended with market numbers. */}
|
{/* model_value is MODEL output — always labeled, never blended with market numbers. */}
|
||||||
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
|
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { GradePill } from '@/components/GradeCard';
|
import { GradePill } from '@/components/GradeCard';
|
||||||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||||
|
import BookWordmark from '@/components/vyndr/BookWordmark';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PublicProfile (A1 Session 10) — the public ledger record for one handle.
|
* PublicProfile (A1 Session 10) — the public ledger record for one handle.
|
||||||
@@ -272,7 +273,7 @@ function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||||
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
|
{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>}
|
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { bookInfo } from '@/lib/books';
|
import { bookInfo, bookSlug, hasBookSvg } from '@/lib/books';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BookWordmark (DS0) — a sportsbook renders as its brand: the real name in
|
* BookWordmark (DS0 · Wave 2B) — a sportsbook renders as its brand. When a
|
||||||
* the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare
|
* bundled local wordmark SVG exists (`web/public/books/{slug}.svg`, for the ~8
|
||||||
* lowercase "draftkings" string (DESIGN-SPEC Part 2). For inline contexts
|
* major books) it renders that; otherwise it falls back to the real book NAME
|
||||||
* where the BookChip tile is too heavy. `best` gives it the signal ring.
|
* in the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare
|
||||||
|
* lowercase "draftkings" string (DESIGN-SPEC Part 2).
|
||||||
|
*
|
||||||
|
* The bundled SVGs are self-authored styled text wordmarks — not copied
|
||||||
|
* trademarked logo glyphs — so the founder can drop official press-kit art into
|
||||||
|
* the same paths later with zero code change. The files are local + always
|
||||||
|
* present, so no broken-image state is possible. `best` gives the signal ring.
|
||||||
*/
|
*/
|
||||||
export default function BookWordmark({
|
export default function BookWordmark({
|
||||||
book,
|
book,
|
||||||
@@ -16,6 +22,8 @@ export default function BookWordmark({
|
|||||||
size?: number;
|
size?: number;
|
||||||
}) {
|
}) {
|
||||||
const b = bookInfo(book);
|
const b = bookInfo(book);
|
||||||
|
const slug = bookSlug(book);
|
||||||
|
const svg = hasBookSvg(book) && slug ? `/books/${slug}.svg` : null;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="mono"
|
className="mono"
|
||||||
@@ -29,7 +37,12 @@ export default function BookWordmark({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{best && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--g-a)', boxShadow: '0 0 6px var(--g-a)' }} />}
|
{best && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--g-a)', boxShadow: '0 0 6px var(--g-a)' }} />}
|
||||||
{b.name}
|
{svg ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={svg} alt={b.name} height={Math.round(size * 1.35)} style={{ display: 'block', width: 'auto', height: Math.round(size * 1.35) }} />
|
||||||
|
) : (
|
||||||
|
b.name
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@
|
|||||||
import TeamLogo from '@/components/vyndr/TeamLogo';
|
import TeamLogo from '@/components/vyndr/TeamLogo';
|
||||||
import { accentColor } from '@/lib/teamMeta';
|
import { accentColor } from '@/lib/teamMeta';
|
||||||
import { playerHref } from '@/lib/playerHref';
|
import { playerHref } from '@/lib/playerHref';
|
||||||
import { isPreferredBook } from '@/lib/books';
|
import { isPreferredBook, bookInfo } from '@/lib/books';
|
||||||
import { pendingSummary, topReadForCard } from '@/lib/slateAdapter';
|
import { pendingSummary, topReadForCard } from '@/lib/slateAdapter';
|
||||||
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
||||||
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
||||||
@@ -301,7 +301,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
|||||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div>
|
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div>
|
||||||
{g.lines.map((ln, i) => (
|
{g.lines.map((ln, i) => (
|
||||||
<span key={i} style={{ display: 'contents' }}>
|
<span key={i} style={{ display: 'contents' }}>
|
||||||
<div className="mono" style={{ fontSize: 12, fontWeight: 700, color: isPreferredBook(ln.book, preferredBooks) ? 'var(--g-a)' : 'var(--text-1)', paddingLeft: 2, textShadow: isPreferredBook(ln.book, preferredBooks) ? '0 0 8px rgba(0,212,160,.5)' : 'none' }} title={isPreferredBook(ln.book, preferredBooks) ? 'Your book' : undefined}>{ln.book}</div>
|
<div className="mono" style={{ fontSize: 12, fontWeight: 700, color: isPreferredBook(ln.book, preferredBooks) ? 'var(--g-a)' : 'var(--text-1)', paddingLeft: 2, textShadow: isPreferredBook(ln.book, preferredBooks) ? '0 0 8px rgba(0,212,160,.5)' : 'none' }} title={isPreferredBook(ln.book, preferredBooks) ? 'Your book' : undefined}>{bookInfo(ln.book).name}</div>
|
||||||
<LineCell value={ln.awayML} best={ln.bestAway} worst={ln.worstAway} />
|
<LineCell value={ln.awayML} best={ln.bestAway} worst={ln.worstAway} />
|
||||||
<LineCell value={ln.homeML} best={ln.bestHome} worst={ln.worstHome} />
|
<LineCell value={ln.homeML} best={ln.bestHome} worst={ln.worstHome} />
|
||||||
<LineCell value={ln.ou} best={ln.bestOU} />
|
<LineCell value={ln.ou} best={ln.bestOU} />
|
||||||
|
|||||||
@@ -1,29 +1,69 @@
|
|||||||
/* Sportsbook brand map (Session 42) — ported from the design's BookChip.dc.html
|
/* Sportsbook brand map (Session 42) — ported from the design's BookChip.dc.html
|
||||||
BOOKS table. CommonJS so it's testable + importable from the .tsx chip. */
|
BOOKS table. CommonJS so it's testable + importable from the .tsx chip.
|
||||||
|
|
||||||
|
Wave 2B (data train) — the map now covers EVERY key the odds feed emits
|
||||||
|
(`oddsNormalizer.ALLOWED_BOOKS`): draftkings, fanduel, betmgm, caesars,
|
||||||
|
fanatics, bet365, hardrockbet, pointsbet, betrivers, pinnacle. No live book
|
||||||
|
falls to the neutral-gray default. Entries carry a `slug` = the canonical
|
||||||
|
lowercase book key; `bookSlug()` resolves any input (id/code/name) to it, and
|
||||||
|
BUNDLED_BOOK_SVGS names the 8 books that have a local wordmark SVG under
|
||||||
|
`web/public/books/{slug}.svg` (self-authored styled wordmarks, swappable for
|
||||||
|
official press-kit art without a code change). */
|
||||||
|
|
||||||
const BOOKS = {
|
const BOOKS = {
|
||||||
DK: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
DK: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
||||||
DRAFTKINGS: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
DRAFTKINGS: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
||||||
FD: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
FD: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
||||||
FANDUEL: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
FANDUEL: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
||||||
MGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
MGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||||
BETMGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
BETMGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||||
CZR: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
CZR: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
||||||
CAESARS: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
CAESARS: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
||||||
ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
||||||
'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
||||||
BR: { name: 'BetRivers', mono: 'BR', bg: '#1A0E22', fg: '#B07CFF', bd: '#B07CFF55' },
|
// BetRivers is a blue book (its logo is blue "BetRivers"), not purple.
|
||||||
|
BR: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
|
||||||
|
BETRIVERS: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
|
||||||
PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
||||||
PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
||||||
FAN: { name: 'Fanatics', mono: 'FAN', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
|
FAN: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
|
||||||
B365: { name: 'bet365', mono: '365', bg: '#0A1A12', fg: '#2E8B57', bd: '#2E8B5766' },
|
FANATICS: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
|
||||||
HR: { name: 'Hard Rock', mono: 'HR', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
|
B365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' },
|
||||||
|
BET365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' },
|
||||||
|
HR: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
|
||||||
|
HARDROCKBET: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
|
||||||
|
POINTSBET: { name: 'PointsBet', mono: 'PTS', slug: 'pointsbet', bg: '#1F0808', fg: '#E4344A', bd: '#E4344A55' },
|
||||||
|
PINNACLE: { name: 'Pinnacle', mono: 'PIN', slug: 'pinnacle', bg: '#180C0E', fg: '#C8434F', bd: '#C8434F55' },
|
||||||
UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' },
|
UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The 8 books with a bundled local wordmark SVG (web/public/books/{slug}.svg).
|
||||||
|
// Self-authored styled text wordmarks — NOT copied trademarked logo glyphs.
|
||||||
|
const BUNDLED_BOOK_SVGS = new Set([
|
||||||
|
'draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DEFAULT_FG = '#B8BCC8';
|
||||||
|
|
||||||
function bookInfo(book) {
|
function bookInfo(book) {
|
||||||
const key = String(book == null ? '' : book).toUpperCase();
|
const key = String(book == null ? '' : book).toUpperCase();
|
||||||
return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: '#B8BCC8', bd: '#23232F' };
|
return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: DEFAULT_FG, bd: '#23232F' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical lowercase book slug for any input (id/code/name), or null when the
|
||||||
|
* book isn't in the registry. Used to look up the bundled wordmark SVG. */
|
||||||
|
function bookSlug(book) {
|
||||||
|
const info = bookInfo(book);
|
||||||
|
if (info.slug) return info.slug;
|
||||||
|
// Unknown-but-nameable: derive a slug from the resolved name (still not the
|
||||||
|
// raw lowercase feed key). Only bundled slugs matter for the SVG lookup.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does `book` have a bundled local wordmark SVG? */
|
||||||
|
function hasBookSvg(book) {
|
||||||
|
const slug = bookSlug(book);
|
||||||
|
return slug != null && BUNDLED_BOOK_SVGS.has(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Canonical comparison key for a book (Session 49) — resolves "DK"/"draftkings"
|
/** Canonical comparison key for a book (Session 49) — resolves "DK"/"draftkings"
|
||||||
@@ -39,4 +79,4 @@ function isPreferredBook(book, preferred) {
|
|||||||
return preferred.some((p) => bookKey(p) === k);
|
return preferred.some((p) => bookKey(p) === k);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { BOOKS, bookInfo, bookKey, isPreferredBook };
|
module.exports = { BOOKS, BUNDLED_BOOK_SVGS, bookInfo, bookSlug, hasBookSvg, bookKey, isPreferredBook };
|
||||||
|
|||||||
@@ -195,7 +195,16 @@ function resolveTeam(key, sport) {
|
|||||||
const up = String(key).toUpperCase().trim();
|
const up = String(key).toUpperCase().trim();
|
||||||
if (table[up]) return { abbr: up, sport: sp, ...table[up] };
|
if (table[up]) return { abbr: up, sport: sp, ...table[up] };
|
||||||
// statsapi/ESPN alias fallbacks for the two MLB mismatches
|
// statsapi/ESPN alias fallbacks for the two MLB mismatches
|
||||||
const ALIAS = { ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD', GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX' };
|
// Global alias table. resolveTeam checks the real abbr FIRST, so an entry
|
||||||
|
// only fires for a sport whose table lacks that key — e.g. WSH resolves
|
||||||
|
// directly to the MLB Nationals, but NBA/WNBA (no WSH key) fall through to
|
||||||
|
// WAS (Wizards/Mystics). ESPN-schedule abbrs the ball sports actually emit
|
||||||
|
// (SA/NY/WSH/BRK for NBA, CONN for WNBA) are covered here.
|
||||||
|
const ALIAS = {
|
||||||
|
ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD',
|
||||||
|
GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX',
|
||||||
|
SA: 'SAS', NY: 'NYK', WSH: 'WAS', BRK: 'BKN', CONN: 'CON',
|
||||||
|
};
|
||||||
if (ALIAS[up] && table[ALIAS[up]]) return { abbr: ALIAS[up], sport: sp, ...table[ALIAS[up]] };
|
if (ALIAS[up] && table[ALIAS[up]]) return { abbr: ALIAS[up], sport: sp, ...table[ALIAS[up]] };
|
||||||
if (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) {
|
if (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) {
|
||||||
const a = SOCCER_ALIAS[normKey(key)];
|
const a = SOCCER_ALIAS[normKey(key)];
|
||||||
|
|||||||