S3 (a1): affiliate + partner plumbing

Zero out-of-pocket; everything config-flip-ready but DISABLED/organic.

- BOOK IT deep links: web/src/lib/bookLinks.js + affiliateConfig.js
  (all books enabled:false, Impact/Partnerize param shapes documented,
  empty params skipped). Wired into StatStrip BookItTeaser (real anchor
  now) + scan hand-off links. Every book anchor renders
  rel="sponsored noopener noreferrer" (BOOK_LINK_REL).
- Best-price marker: slateAdapter.detectBestBook (only when >=2 books
  post the SAME line and prices differ — absent beats wrong) + subtle
  signal-green dot in StatStrip. Slate.groupByGame threads the grouped
  per-book rows (books[]) onto PropRowProp instead of discarding them.
- Partner refs: ?ref=CODE -> vyndr_ref cookie (90d, first-touch,
  PartnerRefCapture in layout) -> signup metadata partner_ref ->
  internal GET /api/partners/report/:code (requireInternalAuth; honest
  zeros + note until the TODO migration in docs/PARTNERS.md adds
  user_profiles.partner_ref — NOT run). Stripe promo-code convention:
  partner code == promotion code, verbatim.
- Tests: +41 (2398 -> 2439, 209 suites); bookItTeaser + vyndrCoreScreens
  invariants updated to the new (stronger) rel contract. Web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 14:28:18 -04:00
parent e4d2e79f95
commit 0996320bd1
23 changed files with 1151 additions and 36 deletions
+37
View File
@@ -0,0 +1,37 @@
/* ============================================================
Affiliate configuration (A1 Session 3).
NO AFFILIATE PROGRAM IS APPROVED YET. Every book ships
`enabled: false` — bookLinks.js builds clean ORGANIC links until
an operator flips a book on and fills its params. This file is the
single flip point; no component hardcodes tracking params.
Param shapes by network (fill when approved — placeholders only):
Impact Radius (DraftKings, FanDuel run on Impact):
{ irclickid: '<click-id template or static campaign id>',
sharedid: 'vyndr', // sub-affiliate / source tag
wpsrc: '<campaign source>' }
Partnerize / btag-style (BetMGM, Caesars, BetRivers):
{ btag: '<partner btag>', // BetMGM/Caesars partner tag
afid: '<affiliate id>', // network affiliate id
siteid:'<site/property id>' }
Rules:
- `tracking: true` is only reported when a book is enabled AND at
least one non-empty param actually lands on the URL.
- Never invent values here — empty string params are skipped by the
builder, so a half-filled config still degrades to organic-plus-
whatever-is-real, never a fabricated id.
============================================================ */
const AFFILIATE_CONFIG = {
draftkings: { enabled: false, params: {} }, // Impact — irclickid / sharedid / wpsrc
fanduel: { enabled: false, params: {} }, // Impact — irclickid / sharedid / wpsrc
betmgm: { enabled: false, params: {} }, // Partnerize — btag / afid / siteid
caesars: { enabled: false, params: {} }, // Partnerize — btag / afid / siteid
betrivers: { enabled: false, params: {} }, // btag / afid / siteid
};
module.exports = { AFFILIATE_CONFIG };
+95
View File
@@ -0,0 +1,95 @@
/* ============================================================
BOOK IT deep-link builder (A1 Session 3).
Builds a per-book player-prop hand-off URL:
buildBookLink({ book, player, sport, state? }) → { url, tracking } | null
- Base hosts mirror the scan page's legacy SPORTSBOOKS list; the
organic URL shape (`/?search=<player>`) is the shape the product
has always shipped.
- The affiliate layer reads web/src/lib/affiliateConfig.js. Every
book is `enabled: false` today (no program approved), so links are
clean + organic. Flipping a book's config produces a tracked URL —
no component changes needed.
- Every rendered book anchor must carry BOOK_LINK_REL
("sponsored noopener noreferrer") — sponsored is required for
affiliate compliance and harmless on organic links.
- Unknown book → null. Absent beats wrong.
CommonJS so the plain-JS Jest suite requires it AND .tsx imports it
(allowJs) — same pattern as slateAdapter/books/playerName.
============================================================ */
const { bookKey } = require('./books');
const { AFFILIATE_CONFIG } = require('./affiliateConfig');
/** rel attribute every sportsbook anchor must render. */
const BOOK_LINK_REL = 'sponsored noopener noreferrer';
/** The books we build deep links for (order = display order). */
const SUPPORTED_BOOKS = [
{ id: 'draftkings', label: 'DraftKings', host: 'sportsbook.draftkings.com' },
{ id: 'fanduel', label: 'FanDuel', host: 'sportsbook.fanduel.com' },
{ id: 'betmgm', label: 'BetMGM', host: 'sports.betmgm.com' },
{ id: 'caesars', label: 'Caesars', host: 'sportsbook.caesars.com' },
// BetRivers routes by state subdomain (mi.betrivers.com); www works
// as the state-agnostic landing.
{ id: 'betrivers', label: 'BetRivers', host: 'www.betrivers.com' },
];
const HOSTS = SUPPORTED_BOOKS.reduce((m, b) => {
m[b.id] = b.host;
return m;
}, {});
/** Canonicalize any book spelling ("DK", "DraftKings", "draftkings") →
* a SUPPORTED_BOOKS id, or null when it isn't a book we link to. */
function resolveBookId(book) {
const k = bookKey(book);
return HOSTS[k] ? k : null;
}
/** Two-letter US state code or null — anything else is dropped, never guessed. */
function cleanState(state) {
const s = String(state == null ? '' : state).trim().toLowerCase();
return /^[a-z]{2}$/.test(s) ? s : null;
}
/**
* Build the deep link for one book + player.
*
* @param {{ book: string, player: string, sport?: string, state?: string }} input
* @param {object} [config] — injectable for tests; defaults to AFFILIATE_CONFIG.
* @returns {{ url: string, tracking: boolean } | null}
*/
function buildBookLink(input, config = AFFILIATE_CONFIG) {
const { book, player, state } = input || {};
const id = resolveBookId(book);
if (!id || !player) return null;
let host = HOSTS[id];
if (id === 'betrivers') {
const st = cleanState(state);
if (st) host = `${st}.betrivers.com`;
}
const url = new URL(`https://${host}/`);
url.searchParams.set('search', String(player));
// Affiliate layer — only when the operator flipped this book on AND
// real (non-empty) params exist. Otherwise the link stays organic.
let tracking = false;
const cfg = config && config[id];
if (cfg && cfg.enabled === true && cfg.params && typeof cfg.params === 'object') {
for (const [key, value] of Object.entries(cfg.params)) {
const v = String(value == null ? '' : value).trim();
if (!key || !v) continue; // never emit an empty/fabricated id
url.searchParams.set(key, v);
tracking = true;
}
}
return { url: url.toString(), tracking };
}
module.exports = { buildBookLink, resolveBookId, SUPPORTED_BOOKS, BOOK_LINK_REL };
+83
View File
@@ -0,0 +1,83 @@
/* ============================================================
Partner ref capture (A1 Session 3).
A partner shares vyndr.app/?ref=CODE. On first visit we set a
FIRST-PARTY cookie `vyndr_ref` (90 days). Signup forwards the code
into Supabase user metadata as `partner_ref`, which is what the
internal /api/partners/report/:code attribution reads.
Attribution model = FIRST TOUCH: an existing vyndr_ref cookie is
never overwritten by a later ?ref= visit. Codes are sanitized
(A-Z 0-9 - _, max 32) and stored UPPERCASE — the same convention
as Stripe promo codes (see docs/PARTNERS.md).
CommonJS so the plain-JS Jest suite exercises it directly.
============================================================ */
const REF_COOKIE = 'vyndr_ref';
const REF_TTL_DAYS = 90;
/** Sanitize a raw code → canonical UPPERCASE code, or null. */
function sanitizeRefCode(raw) {
const s = String(raw == null ? '' : raw).trim().toUpperCase();
if (!s || s.length > 32) return null;
return /^[A-Z0-9_-]+$/.test(s) ? s : null;
}
/** Pull a sanitized ?ref= code out of a location.search string. */
function parseRefFromSearch(search) {
try {
const params = new URLSearchParams(search || '');
return sanitizeRefCode(params.get('ref'));
} catch {
return null;
}
}
/** Read the vyndr_ref value out of a document.cookie string. */
function readRefCookie(cookieStr) {
const parts = String(cookieStr == null ? '' : cookieStr).split(';');
for (const part of parts) {
const [name, ...rest] = part.split('=');
if (name && name.trim() === REF_COOKIE) {
return sanitizeRefCode(decodeURIComponent(rest.join('=').trim()));
}
}
return null;
}
/** Build the Set-Cookie string for a code (first-party, 90d, lax). */
function buildRefCookie(code, { days = REF_TTL_DAYS, secure = false } = {}) {
const clean = sanitizeRefCode(code);
if (!clean) return null;
const maxAge = Math.round(days * 24 * 60 * 60);
return `${REF_COOKIE}=${encodeURIComponent(clean)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${secure ? '; Secure' : ''}`;
}
/**
* Capture ?ref=CODE from the current visit into the cookie.
* First-touch: no-op when the cookie already exists. `win` is
* injectable for tests (defaults to the browser window).
* Returns the code that was captured, or null.
*/
function captureRef(win) {
const w = win || (typeof window !== 'undefined' ? window : null);
if (!w || !w.document || !w.location) return null;
const code = parseRefFromSearch(w.location.search);
if (!code) return null;
if (readRefCookie(w.document.cookie)) return null; // first touch wins
const cookie = buildRefCookie(code, { secure: w.location.protocol === 'https:' });
if (!cookie) return null;
w.document.cookie = cookie;
return code;
}
module.exports = {
REF_COOKIE,
REF_TTL_DAYS,
sanitizeRefCode,
parseRefFromSearch,
readRefCookie,
buildRefCookie,
captureRef,
};
+51
View File
@@ -51,6 +51,49 @@ function detectBestLines(books) {
}));
}
/**
* Best available price across a prop's book rows (A1 Session 3).
*
* `rows` is the grouped odds shape the Express /api/odds proxy already
* ships the browser: [{ book, line, over_odds, under_odds }] — one row
* per book for the same player+stat.
*
* Data-semantics rule: a "best price" claim is only honest when ≥2 books
* post the SAME line for the side and their prices differ — comparing
* odds across different lines is meaningless, and a lone price isn't
* "best". Anything else → null. Absent beats wrong.
*
* @param {Array<{book?:string,line?:number,over_odds?:number|null,under_odds?:number|null}>} rows
* @param {string} [side] — 'over' | 'under' (default over)
* @param {number|null} [refLine] — the line the card displays; when given,
* only books at that exact line compete.
* @returns {{ book: string, odds: number } | null}
*/
function detectBestBook(rows, side = 'over', refLine = null) {
const key = String(side || 'over').toLowerCase().startsWith('u') ? 'under_odds' : 'over_odds';
const valid = (Array.isArray(rows) ? rows : []).filter(
(r) => r && r.book && Number.isFinite(r.line) && r[key] != null && parseAmericanOdds(r[key]) != null,
);
if (valid.length < 2) return null;
// Compare at ONE line: the displayed line when given, else the modal line.
let line = Number.isFinite(refLine) ? refLine : null;
if (line == null) {
const counts = new Map();
for (const r of valid) counts.set(r.line, (counts.get(r.line) || 0) + 1);
let bestCount = 0;
for (const [ln, c] of counts) if (c > bestCount) { bestCount = c; line = ln; }
}
const atLine = valid.filter((r) => r.line === line);
if (atLine.length < 2) return null;
const decimals = atLine.map((r) => parseAmericanOdds(r[key]));
const max = Math.max(...decimals);
if (max === Math.min(...decimals)) return null; // identical prices → no "best"
const winner = atLine[decimals.indexOf(max)];
return { book: winner.book, odds: winner[key] };
}
function formatGameTime(iso) {
if (!iso) return '';
try {
@@ -309,6 +352,11 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
line: rec.line,
side,
grade: rec.grade,
// A1 S3 — the prop's own book + the best available price across the
// game's book rows for the graded side (null unless ≥2 books at the
// same current line disagree — see detectBestBook).
book: p.book || null,
bestBook: detectBestBook(p.books, side === 'U' ? 'under' : 'over', p.line),
gradedAt: rec.gradedAt
? { ...rec.gradedAt, ago: gradedAgo(rec.gradedAt.timestamp, now) }
: null,
@@ -324,6 +372,8 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
} else {
byPlayer[pk].props.push({
stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true,
book: p.book || null,
bestBook: detectBestBook(p.books, p.direction || 'over', p.line),
});
}
}
@@ -374,6 +424,7 @@ function pitchersForGameTeams(awayTeam, homeTeam, pitcherMap) {
module.exports = {
parseAmericanOdds,
detectBestLines,
detectBestBook,
mapGameLines,
mapScheduleToGameCards,
formatGameTime,