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:
@@ -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 };
|
||||
Reference in New Issue
Block a user