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:
@@ -9,6 +9,7 @@ import Footer from '@/components/Footer';
|
||||
import AuthGate from '@/components/AuthGate';
|
||||
import HashRedirect from '@/components/vyndr/HashRedirect';
|
||||
import GlobalHosts from '@/components/vyndr/GlobalHosts';
|
||||
import PartnerRefCapture from '@/components/vyndr/PartnerRefCapture';
|
||||
// Session 50 — the Parlay Lab supersedes the legacy ParlayTray.
|
||||
import ParlayPanel from '@/components/vyndr/ParlayPanel';
|
||||
import BottomTabBar from '@/components/BottomTabBar';
|
||||
@@ -156,6 +157,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<SentryInit />
|
||||
{/* Session 38 — prefs apply + paywall/checkout/prefs globals (§9/§10/§12) */}
|
||||
<GlobalHosts />
|
||||
{/* A1 S3 — first-visit ?ref=CODE → vyndr_ref cookie (90d, first-touch) */}
|
||||
<PartnerRefCapture />
|
||||
</ParlayProvider>
|
||||
</ExplainModeProvider>
|
||||
</AuthProvider>
|
||||
|
||||
+19
-21
@@ -17,6 +17,7 @@ import {
|
||||
trackUpgradeClicked,
|
||||
} from '@/lib/analytics';
|
||||
import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot';
|
||||
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
@@ -103,15 +104,8 @@ const SPORT_ACCENT: Record<Sport, string> = {
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
// Sportsbook deep-links — preserved from the legacy GradeCard so the new
|
||||
// design keeps the book hand-off. target=_blank + noopener,noreferrer.
|
||||
const SPORTSBOOKS = [
|
||||
{ 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' },
|
||||
];
|
||||
const deepLink = (host: string, player: string) => `https://${host}/?search=${encodeURIComponent(player)}`;
|
||||
// Sportsbook deep-links — A1 S3: built by lib/bookLinks (organic until the
|
||||
// affiliate config flips a book on). rel is BOOK_LINK_REL on every anchor.
|
||||
|
||||
export default function ScanPage() {
|
||||
const router = useRouter();
|
||||
@@ -775,18 +769,22 @@ export default function ScanPage() {
|
||||
|
||||
{/* Sportsbook hand-off (preserved feature) */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
|
||||
{SPORTSBOOKS.map((b) => (
|
||||
<a
|
||||
key={b.id}
|
||||
href={deepLink(b.host, selectedPlayer)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mono"
|
||||
style={{ padding: '7px 13px', fontSize: 11, fontWeight: 700, borderRadius: 6, border: '1px solid var(--border-hi)', color: 'var(--text-1)', textDecoration: 'none' }}
|
||||
>
|
||||
{b.label} ↗
|
||||
</a>
|
||||
))}
|
||||
{SUPPORTED_BOOKS.map((b) => {
|
||||
const link = buildBookLink({ book: b.id, player: selectedPlayer, sport });
|
||||
if (!link) return null;
|
||||
return (
|
||||
<a
|
||||
key={b.id}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel={BOOK_LINK_REL}
|
||||
className="mono"
|
||||
style={{ padding: '7px 13px', fontSize: 11, fontWeight: 700, borderRadius: 6, border: '1px solid var(--border-hi)', color: 'var(--text-1)', textDecoration: 'none' }}
|
||||
>
|
||||
{b.label} ↗
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Free-tier nudge — full paywall treatment returns in Phase G */}
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface PropRowProp {
|
||||
line: number;
|
||||
direction: PropDirection;
|
||||
book?: string;
|
||||
// A1 S3 — the FULL per-book rows for this player+stat (the grouped
|
||||
// odds shape from Express). Feeds best-price detection downstream;
|
||||
// optional so legacy flat callers stay valid.
|
||||
books?: Array<{ book?: string; line?: number; over_odds?: number | null; under_odds?: number | null }>;
|
||||
// Stable key used by the parent to look up grade results.
|
||||
key?: string;
|
||||
}
|
||||
|
||||
@@ -345,6 +345,9 @@ function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
|
||||
line: lineInfo.line,
|
||||
direction: lineInfo.direction,
|
||||
book: lineInfo.book,
|
||||
// A1 S3 — keep the full per-book rows so the strip layer can mark
|
||||
// the best available price (pickLine alone discards the comparison).
|
||||
books: Array.isArray(r.lines) ? r.lines : undefined,
|
||||
});
|
||||
}
|
||||
// Sort each game's props by player + stat for stable rendering.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { captureRef } from '@/lib/partnerRef';
|
||||
|
||||
/**
|
||||
* PartnerRefCapture (A1 S3) — mounted once in the layout (GlobalHosts
|
||||
* pattern). On first visit with ?ref=CODE it sets the first-party
|
||||
* `vyndr_ref` cookie (90 days, first-touch). Renders nothing.
|
||||
*/
|
||||
export default function PartnerRefCapture() {
|
||||
useEffect(() => {
|
||||
captureRef(window);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
||||
// A1 S3 — BOOK IT is a real per-book deep link now (organic until the
|
||||
// affiliate config flips a book on). rel MUST stay BOOK_LINK_REL.
|
||||
import { buildBookLink, BOOK_LINK_REL } from '@/lib/bookLinks';
|
||||
import { bookInfo } from '@/lib/books';
|
||||
|
||||
export interface StatCell {
|
||||
label: string;
|
||||
@@ -20,6 +24,10 @@ export interface StripProp {
|
||||
// Session 60 (Phase 2.5) — intraday movement + public revision.
|
||||
movement?: { kind: 'steam' | 'value' | 'against' | 'revised' | string; delta: number; currentLine: number } | null;
|
||||
revisedFrom?: string | null;
|
||||
// A1 S3 — the prop's source book + the best available price across books
|
||||
// (only set when ≥2 books post the same line and prices differ).
|
||||
book?: string | null;
|
||||
bestBook?: { book: string; odds: number } | null;
|
||||
}
|
||||
|
||||
/** Phase 2.5 movement chip: STEAM ▲ (market chasing), VALUE ▲ (better
|
||||
@@ -75,6 +83,7 @@ const Sep = ({ ch = '|' }: { ch?: string }) => (
|
||||
export default function StatStrip({
|
||||
player,
|
||||
team,
|
||||
sport,
|
||||
archetype,
|
||||
stats,
|
||||
last10,
|
||||
@@ -125,17 +134,45 @@ export default function StatStrip({
|
||||
</span>
|
||||
);
|
||||
};
|
||||
// Session 52 — Push-to-Book teaser on graded props (feature not live yet).
|
||||
// A1 S3 — BOOK IT is a real sportsbook deep link (was the Session 52
|
||||
// teaser). Targets the best-priced book when one is known, else the
|
||||
// prop's source book, else DraftKings. Links are ORGANIC until the
|
||||
// operator flips a book in affiliateConfig — the anchor is identical
|
||||
// either way, and rel is always BOOK_LINK_REL (sponsored noopener
|
||||
// noreferrer) for affiliate compliance.
|
||||
const BookItTeaser = ({ p }: { p: StripProp }) => {
|
||||
if (!p.grade) return null;
|
||||
const target = (p.bestBook && p.bestBook.book) || p.book || 'draftkings';
|
||||
const link = buildBookLink({ book: target, player, sport });
|
||||
if (!link) return null;
|
||||
return (
|
||||
<span
|
||||
<a
|
||||
className="mono"
|
||||
title="Push-to-Book coming soon — connect your sportsbook"
|
||||
style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--text-2)', cursor: 'default', padding: '2px 5px', borderRadius: 4, border: '1px solid var(--border)', opacity: 0.6 }}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel={BOOK_LINK_REL}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={`Open ${bookInfo(target).name} — search lands on ${player}`}
|
||||
style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--text-1)', cursor: 'pointer', padding: '2px 5px', borderRadius: 4, border: '1px solid var(--border)', textDecoration: 'none' }}
|
||||
>
|
||||
BOOK IT ⟶
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
// A1 S3 — best available price marker. ONE meaning: this prop's line is
|
||||
// posted at a better price at `bestBook` than at least one other book.
|
||||
// Renders only when the adapter verified ≥2 books at the same line.
|
||||
const BestPriceDot = ({ p }: { p: StripProp }) => {
|
||||
if (!p.bestBook) return null;
|
||||
const odds = p.bestBook.odds;
|
||||
const oddsStr = typeof odds === 'number' && odds > 0 ? `+${odds}` : String(odds);
|
||||
return (
|
||||
<span
|
||||
title={`Best available price · ${bookInfo(p.bestBook.book).name} ${oddsStr}`}
|
||||
aria-label={`Best available price at ${bookInfo(p.bestBook.book).name}, ${oddsStr}`}
|
||||
style={{ width: 6, height: 6, borderRadius: '50%', display: 'inline-block', flexShrink: 0,
|
||||
background: 'var(--g-a)', boxShadow: '0 0 0 2px rgba(0,212,160,.22)' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const last10Str = typeof last10 === 'string'
|
||||
@@ -226,7 +263,7 @@ export default function StatStrip({
|
||||
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{i > 0 && <span style={{ color: '#3A3A48' }}>·</span>}
|
||||
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}>
|
||||
{p.stat} {p.side}{p.line} {p.grade && <GradeBadge grade={p.grade} size="sm" />}
|
||||
{p.stat} {p.side}{p.line} <BestPriceDot p={p} /> {p.grade && <GradeBadge grade={p.grade} size="sm" />}
|
||||
<ParlayBtn p={p} />
|
||||
<BookItTeaser p={p} />
|
||||
</span>
|
||||
@@ -245,6 +282,7 @@ export default function StatStrip({
|
||||
return (
|
||||
<div key={i} className="mono" style={{ fontSize: 11, color: 'var(--text-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ color: 'var(--text-1)' }}>{p.stat} {p.line}</span>
|
||||
<BestPriceDot p={p} />
|
||||
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>{next ? `Grades post ${next}` : 'Awaiting next scan'}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -254,6 +292,7 @@ export default function StatStrip({
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<div className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#B8BCC8' }}>
|
||||
<span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span>
|
||||
<BestPriceDot p={p} />
|
||||
{/* Phase 2.5 — a revised grade is PUBLIC: original struck through. */}
|
||||
{p.revisedFrom && (
|
||||
<span className="mono" title="Grade revised after the line moved against the read — original preserved" style={{ fontSize: 10.5, color: 'var(--text-2)', textDecoration: 'line-through' }}>{p.revisedFrom}</span>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
import { readRefCookie } from '@/lib/partnerRef';
|
||||
|
||||
export type Tier = 'free' | 'analyst' | 'desk';
|
||||
|
||||
@@ -135,10 +136,17 @@ export default function AuthProvider({ children }: { children: React.ReactNode }
|
||||
async (email, password, ageVerified) => {
|
||||
if (!ageVerified) return { error: 'You must confirm you are 21 or older.' };
|
||||
if (!supabase) return { error: 'Auth is not configured. Set Supabase env vars.' };
|
||||
// A1 S3 — partner attribution: forward the first-touch vyndr_ref
|
||||
// cookie (set by PartnerRefCapture on a ?ref=CODE visit) into the
|
||||
// signup metadata. The internal /api/partners report reads it.
|
||||
const partnerRef = readRefCookie(document.cookie);
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: { emailRedirectTo: `${window.location.origin}/auth/callback` },
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||
...(partnerRef ? { data: { partner_ref: partnerRef } } : {}),
|
||||
},
|
||||
});
|
||||
if (error) return { error: error.message };
|
||||
return {};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,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