Files
vyndr/specs/a1-s3-affiliate-partner-plumbing.md
builtbykev 0996320bd1 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>
2026-07-11 14:28:18 -04:00

73 lines
4.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.