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
+72
View File
@@ -0,0 +1,72 @@
# A1 Session 3 — Affiliate + Partnership Plumbing
## Operating constraints
- ZERO out-of-pocket. No affiliate program is approved yet — everything ships
config-flip-ready but DISABLED / organic by default.
- Data semantics: never fabricate market values. A "best price" claim renders
only when genuinely multiple books post the same line — absent beats wrong.
- No exclamation points in any copy (VOICE v1.1).
## 1. BOOK IT deep-link builder
- `web/src/lib/bookLinks.js` (CommonJS, unit-testable)
- `SUPPORTED_BOOKS`: draftkings, fanduel, betmgm, caesars, betrivers
(hosts sourced from the existing SPORTSBOOKS list in scan/page.tsx +
lib/books.js brand map).
- `buildBookLink({ book, player, sport, state? }, config?) → { url, tracking } | null`
- Unknown book → null (absent beats wrong).
- Organic base: `https://{host}/?search={player}` (existing deep-link shape).
- Affiliate layer: reads `web/src/lib/affiliateConfig.js` — per book
`{ enabled: false, params: {} }` with Impact/Partnerize-style param
shapes documented in comments (irclickid / sharedid / afid / btag).
- `tracking: true` only when the book is enabled AND at least one
non-empty param was appended. Disabled → clean organic link.
- `BOOK_LINK_REL = 'sponsored noopener noreferrer'` — every book anchor
renders this rel.
- Wiring:
- `StatStrip.tsx` BookItTeaser → real `<a>` deep link (best-price book when
known, else the prop's book, else DraftKings). Organic until config flips.
- `scan/page.tsx` sportsbook hand-off anchors → `buildBookLink` +
`BOOK_LINK_REL` (replaces the local SPORTSBOOKS/deepLink pair).
## 2. Best-line highlight (cheap version)
- Finding (documented in the session report): the grouped odds proxy already
ships per-book rows to the browser as `lines[]` per player+stat;
`Slate.groupByGame`/`pickLine` collapse to ONE line and discard the rest.
- Ship: thread `books` (the raw `lines[]`) onto each PropRowProp;
`slateAdapter.detectBestBook(rows, side, refLine)` returns
`{ book, odds }` ONLY when ≥2 books post the SAME line for the side and
prices differ; `buildPlayerStripsFromProps` attaches `bestBook` + `book`
to each strip prop; StatStrip renders a subtle signal-green dot
(one meaning: best available price).
## 3. Partner ref system
- (a) `web/src/lib/partnerRef.js` (CommonJS) — parse `?ref=CODE`, sanitize
(AZ 09 - _, ≤32, uppercased), first-party cookie `vyndr_ref` (90d,
SameSite=Lax, Path=/), first-touch (never overwrites an existing cookie).
`components/vyndr/PartnerRefCapture.tsx` mounted in the layout
(GlobalHosts pattern).
- (b) `src/routes/partners.js``GET /api/partners/report/:code` behind
`requireInternalAuth``{ signups, conversions, mrr_attributed }` read
from `user_profiles` filtered by `partner_ref`. The column does NOT exist
yet: the endpoint degrades to zeros + a note, and the TODO migration is
documented in docs/PARTNERS.md (NOT run).
- (c) Signup: `AuthContext.signUp` forwards the `vyndr_ref` cookie into
Supabase signup metadata as `partner_ref`.
- (d) `docs/PARTNERS.md` — Stripe promo-code mapping convention + the
TODO migration.
## Acceptance criteria
- All book links organic by default; flipping `enabled` + params in
affiliateConfig produces a tracked URL with the params appended.
- Best-price dot never renders on single-book or unequal-line data.
- `?ref=CODE` first visit sets `vyndr_ref` for 90 days; signup metadata
carries `partner_ref`; report endpoint 401s without the internal key.
- Full `npx jest` green; `cd web && npx next build` exit 0.
## Test plan
- tests/unit/bookLinks.test.js — organic default, enabled param shapes,
aliases, betrivers state, unknown book, rel constant + source assertions.
- tests/unit/slateAdapterBestBook.test.js — detectBestBook honesty rules +
strip attachment.
- tests/unit/partnerRef.test.js — sanitize/parse/cookie/first-touch.
- tests/unit/partnersRoute.test.js — report math + internal-auth mount.