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

4.0 KiB
Raw Permalink Blame History

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).
  • 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.jsGET /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.