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