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