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
+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 };