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:
@@ -0,0 +1,119 @@
|
||||
# VYNDR Partner + Affiliate Plumbing (A1 Session 3)
|
||||
|
||||
Everything here ships DISABLED / organic by default. No affiliate program is
|
||||
approved yet; nothing spends money and no link carries tracking params until an
|
||||
operator flips config. Data semantics apply to attribution too — the report
|
||||
endpoint returns zeros before it returns invented numbers.
|
||||
|
||||
## 1. Partner ref links
|
||||
|
||||
A partner shares:
|
||||
|
||||
```
|
||||
https://vyndr.app/?ref=CODE
|
||||
```
|
||||
|
||||
- `CODE` — A-Z 0-9 dash underscore, max 32 chars, case-insensitive
|
||||
(canonicalized to UPPERCASE everywhere).
|
||||
- On first visit `PartnerRefCapture` (mounted in the layout) sets the
|
||||
first-party cookie `vyndr_ref` for 90 days (SameSite=Lax, Path=/).
|
||||
- Attribution is FIRST TOUCH: an existing cookie is never overwritten.
|
||||
- On email/password signup, `AuthContext.signUp` forwards the cookie value
|
||||
into Supabase signup metadata as `partner_ref`
|
||||
(`auth.users.raw_user_meta_data.partner_ref`).
|
||||
- Known limitation: OAuth signups (Google, etc.) do not carry signup
|
||||
metadata — those signups are not attributed yet.
|
||||
|
||||
## 2. Partner report endpoint (internal)
|
||||
|
||||
```
|
||||
GET /api/partners/report/:code
|
||||
Header: x-internal-key: $VYNDR_INTERNAL_KEY
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "ok": true, "code": "CODE", "signups": 0, "conversions": 0, "mrr_attributed": 0 }
|
||||
```
|
||||
|
||||
- `signups` — user_profiles rows with `partner_ref = CODE`
|
||||
- `conversions` — those currently `subscription_status = 'active'` on a paid tier
|
||||
- `mrr_attributed` — sum of the real monthly price per active profile
|
||||
(analyst 19.99 / 14.99 founder, desk 49.99 / 34.99 founder)
|
||||
|
||||
### TODO migration (documented, NOT run)
|
||||
|
||||
`user_profiles` has no `partner_ref` column today, and signup metadata lives in
|
||||
`auth.users.raw_user_meta_data`, which PostgREST cannot query. Until the
|
||||
migration below is applied, the endpoint returns zeros with a `note`. Once
|
||||
applied, real numbers flow with no code change.
|
||||
|
||||
```sql
|
||||
-- TODO migration: 021_partner_ref.sql (do not run until reviewed)
|
||||
alter table public.user_profiles
|
||||
add column if not exists partner_ref text;
|
||||
|
||||
create index if not exists idx_user_profiles_partner_ref
|
||||
on public.user_profiles(partner_ref)
|
||||
where partner_ref is not null;
|
||||
|
||||
-- Copy the signup metadata field on profile creation
|
||||
-- (extend the existing handle_new_user trigger):
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger as $$
|
||||
begin
|
||||
insert into public.user_profiles (id, email, partner_ref)
|
||||
values (
|
||||
new.id,
|
||||
new.email,
|
||||
upper(nullif(new.raw_user_meta_data->>'partner_ref', ''))
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
|
||||
-- One-time backfill for signups that predate the column:
|
||||
update public.user_profiles p
|
||||
set partner_ref = upper(u.raw_user_meta_data->>'partner_ref')
|
||||
from auth.users u
|
||||
where u.id = p.id
|
||||
and p.partner_ref is null
|
||||
and nullif(u.raw_user_meta_data->>'partner_ref', '') is not null;
|
||||
```
|
||||
|
||||
Note: migration 004 (`referral_codes` / `referral_conversions`) is the separate
|
||||
USER-referral system (codes owned by auth users). Partner codes are external
|
||||
strings and deliberately do not depend on those tables.
|
||||
|
||||
## 3. Stripe promo-code mapping convention
|
||||
|
||||
One code, three systems, zero lookup tables:
|
||||
|
||||
- The partner's ref code IS their Stripe promotion code, verbatim.
|
||||
Partner "HOOPSPOD" → `?ref=HOOPSPOD` link → Stripe promotion code
|
||||
`HOOPSPOD` on checkout.
|
||||
- Codes are created in Stripe as promotion codes on a shared partner coupon;
|
||||
set promotion code metadata `partner: <CODE>` so Stripe reporting can be
|
||||
cross-checked against `/api/partners/report/:code`.
|
||||
- Cookie-attributed signups and promo-code redemptions are reconciled by the
|
||||
shared code string — a signup that used the promo but arrived without the
|
||||
cookie still attributes through Stripe's side.
|
||||
- Founder codes (VYNDR, BETONBLK, `FOUNDER_CODE_EXPIRY` in stripeService) are
|
||||
NOT partner codes — do not issue a partner code that collides with a founder
|
||||
code.
|
||||
|
||||
## 4. Sportsbook affiliate config (BOOK IT deep links)
|
||||
|
||||
- `web/src/lib/bookLinks.js` builds every sportsbook deep link (StatStrip
|
||||
BOOK IT + scan hand-off). Organic shape: `https://{host}/?search={player}`.
|
||||
- `web/src/lib/affiliateConfig.js` is the single flip point: per book
|
||||
`{ enabled: false, params: {} }`. Param shapes (Impact: irclickid /
|
||||
sharedid / wpsrc; Partnerize-style: btag / afid / siteid) are documented in
|
||||
that file. Empty param values are skipped — a half-filled config degrades to
|
||||
organic, never a fabricated id.
|
||||
- Every book anchor renders `rel="sponsored noopener noreferrer"`
|
||||
(`BOOK_LINK_REL`) — required for affiliate compliance, harmless organic.
|
||||
- BetRivers accepts an optional two-letter `state` for its state subdomain
|
||||
(`mi.betrivers.com`); anything else falls back to `www`.
|
||||
Reference in New Issue
Block a user