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:
+18
-1
@@ -1,7 +1,24 @@
|
|||||||
# VYNDR — Build State
|
# VYNDR — Build State
|
||||||
|
|
||||||
## Last Updated
|
## Last Updated
|
||||||
2026-07-10
|
2026-07-11
|
||||||
|
|
||||||
|
## Session S3 (a1 board, 2026-07-11) — Affiliate + Partnership Plumbing ✅
|
||||||
|
Zero out-of-pocket; everything config-flip-ready but DISABLED/organic.
|
||||||
|
2398 → **2439 tests** (209 suites), web build exit 0.
|
||||||
|
- **BOOK IT deep links** — `web/src/lib/bookLinks.js` + `affiliateConfig.js`
|
||||||
|
(every book `enabled:false`; Impact/Partnerize param shapes documented).
|
||||||
|
StatStrip BOOK IT is a real anchor now (organic); scan hand-off links moved
|
||||||
|
to the builder. Every book anchor renders `rel="sponsored noopener noreferrer"`.
|
||||||
|
- **Best-price marker** — `slateAdapter.detectBestBook` (≥2 books, SAME line,
|
||||||
|
differing prices, else null) + green dot in StatStrip; `books[]` (the grouped
|
||||||
|
odds rows) now threads Slate → strips instead of being discarded by pickLine.
|
||||||
|
- **Partner refs** — `?ref=CODE` → 90d first-party `vyndr_ref` cookie
|
||||||
|
(first-touch, `PartnerRefCapture` in layout) → signup metadata `partner_ref`
|
||||||
|
→ internal `GET /api/partners/report/:code` (zeros + note until the TODO
|
||||||
|
migration in docs/PARTNERS.md adds `user_profiles.partner_ref`; NOT run).
|
||||||
|
- **docs/PARTNERS.md** — ref link spec, Stripe promo-code mapping convention
|
||||||
|
(partner code == promotion code), TODO migration SQL.
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
SHIP BUILD v59.0 — Overnight session: ledger team/opponent addendum,
|
SHIP BUILD v59.0 — Overnight session: ledger team/opponent addendum,
|
||||||
|
|||||||
@@ -801,6 +801,32 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section).
|
|||||||
amber / VALUE green / red = STALE-or-miss only), mono data surfaces, and
|
amber / VALUE green / red = STALE-or-miss only), mono data surfaces, and
|
||||||
schedule-derived waiting copy. Keep them green.
|
schedule-derived waiting copy. Keep them green.
|
||||||
|
|
||||||
|
## Affiliate + Partner Plumbing (A1 S3 — non-obvious)
|
||||||
|
- **Every sportsbook link goes through `web/src/lib/bookLinks.js`**
|
||||||
|
(`buildBookLink` → `{url, tracking}`; unknown book → null). The affiliate
|
||||||
|
layer is `web/src/lib/affiliateConfig.js` — ALL books `enabled:false` (no
|
||||||
|
program approved); flipping a book + filling params produces tracked URLs
|
||||||
|
with NO component changes. Empty param values are SKIPPED (a half-filled
|
||||||
|
config degrades to organic, never a fabricated id). Anchors MUST render
|
||||||
|
`rel={BOOK_LINK_REL}` ("sponsored noopener noreferrer") — tests assert it.
|
||||||
|
- **Best-price dot honesty rule:** `slateAdapter.detectBestBook(rows, side,
|
||||||
|
refLine)` returns a book ONLY when ≥2 books post the SAME line for the side
|
||||||
|
and prices differ — never compare odds across different lines, never mark a
|
||||||
|
lone price "best". The per-book rows reach the browser as `lines[]` on each
|
||||||
|
grouped prop; `Slate.groupByGame` now threads them as `books` on PropRowProp
|
||||||
|
(pickLine still picks the single displayed line).
|
||||||
|
- **Partner attribution:** `?ref=CODE` → `vyndr_ref` cookie (90d, FIRST-touch,
|
||||||
|
`lib/partnerRef.js` + `PartnerRefCapture` in layout) → `signUp` metadata
|
||||||
|
`partner_ref` → internal `GET /api/partners/report/:code`. The
|
||||||
|
`user_profiles.partner_ref` column does NOT exist yet — the endpoint returns
|
||||||
|
zeros + note until the TODO migration in `docs/PARTNERS.md` runs (metadata
|
||||||
|
lands in auth.users.raw_user_meta_data, which PostgREST can't query). OAuth
|
||||||
|
signups carry no metadata (known gap). Partner code == Stripe promotion code,
|
||||||
|
verbatim (docs/PARTNERS.md §3).
|
||||||
|
- **Worktree build gotcha:** `web/node_modules` isn't shared into git worktrees
|
||||||
|
and Turbopack REJECTS a symlink pointing outside the project root — use
|
||||||
|
`cp -al` (hardlink copy) from the main repo's web/node_modules.
|
||||||
|
|
||||||
## Active Skills
|
## Active Skills
|
||||||
- vyndr-voice (all user-facing output)
|
- vyndr-voice (all user-facing output)
|
||||||
- prop-analysis (grading methodology)
|
- prop-analysis (grading methodology)
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
|
(A–Z 0–9 - _, ≤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.
|
||||||
@@ -187,6 +187,9 @@ app.use('/api/content', contentRoutes);
|
|||||||
// the public surface; the Next.js admin route proxies through with
|
// the public surface; the Next.js admin route proxies through with
|
||||||
// the key kept server-side.
|
// the key kept server-side.
|
||||||
app.use('/api/internal', internalRoutes);
|
app.use('/api/internal', internalRoutes);
|
||||||
|
// A1 S3 — partner attribution report. Internal-key gated (router-level
|
||||||
|
// requireInternalAuth); no Next proxy on purpose — never browser-facing.
|
||||||
|
app.use('/api/partners', require('./routes/partners'));
|
||||||
|
|
||||||
// Session 10 — Sentry's Express error handler catches uncaught
|
// Session 10 — Sentry's Express error handler catches uncaught
|
||||||
// errors from every route mounted above. Must come AFTER routes but
|
// errors from every route mounted above. Must come AFTER routes but
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partner attribution report (A1 Session 3).
|
||||||
|
*
|
||||||
|
* GET /api/partners/report/:code — internal-only (requireInternalAuth,
|
||||||
|
* same key as the snapshot pipeline; never browser-reachable, no Next
|
||||||
|
* proxy on purpose). Returns per-partner attribution:
|
||||||
|
*
|
||||||
|
* { ok, code, signups, conversions, mrr_attributed, note? }
|
||||||
|
*
|
||||||
|
* Data source: public.user_profiles filtered by `partner_ref` — the
|
||||||
|
* code a signup carried in its metadata (set by the web PartnerRefCapture
|
||||||
|
* → AuthContext.signUp flow).
|
||||||
|
*
|
||||||
|
* IMPORTANT — the `partner_ref` column does NOT exist on user_profiles
|
||||||
|
* yet. Signup metadata lands in auth.users.raw_user_meta_data, which
|
||||||
|
* PostgREST can't query. Until the TODO migration in docs/PARTNERS.md
|
||||||
|
* is applied (adds the column + copies it from the signup metadata in
|
||||||
|
* handle_new_user), the select errors and this endpoint HONESTLY
|
||||||
|
* degrades to zeros with a `note` — it never fabricates attribution.
|
||||||
|
* Once the migration runs, real numbers flow with no code change here.
|
||||||
|
*
|
||||||
|
* MRR attribution uses the REAL tier prices (founder_pricing-aware) on
|
||||||
|
* currently-active paid profiles. It is monthly recurring revenue at
|
||||||
|
* today's price book — not lifetime value, not a projection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { requireInternalAuth } = require('../middleware/internalAuth');
|
||||||
|
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(requireInternalAuth({ loopbackOnly: false }));
|
||||||
|
|
||||||
|
// Tier price book (matches stripeService / pricing page).
|
||||||
|
const TIER_MRR = {
|
||||||
|
analyst: { standard: 19.99, founder: 14.99 },
|
||||||
|
desk: { standard: 49.99, founder: 34.99 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Same convention as web/src/lib/partnerRef.js — A-Z 0-9 - _, ≤32, uppercase. */
|
||||||
|
function sanitizePartnerCode(raw) {
|
||||||
|
const s = String(raw == null ? '' : raw).trim().toUpperCase();
|
||||||
|
if (!s || s.length > 32) return null;
|
||||||
|
return /^[A-Z0-9_-]+$/.test(s) ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure report math over user_profiles rows
|
||||||
|
* ({ tier, subscription_status, founder_pricing }).
|
||||||
|
* signups — every profile attributed to the code
|
||||||
|
* conversions — profiles currently on an active paid tier
|
||||||
|
* mrr_attributed — sum of those profiles' monthly price (founder-aware)
|
||||||
|
*/
|
||||||
|
function buildPartnerReport(rows) {
|
||||||
|
const list = Array.isArray(rows) ? rows : [];
|
||||||
|
let conversions = 0;
|
||||||
|
let mrr = 0;
|
||||||
|
for (const r of list) {
|
||||||
|
if (!r) continue;
|
||||||
|
const tier = String(r.tier || 'free').toLowerCase();
|
||||||
|
const active = String(r.subscription_status || '') === 'active';
|
||||||
|
const prices = TIER_MRR[tier];
|
||||||
|
if (!prices || !active) continue;
|
||||||
|
conversions += 1;
|
||||||
|
mrr += r.founder_pricing === true ? prices.founder : prices.standard;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
signups: list.length,
|
||||||
|
conversions,
|
||||||
|
mrr_attributed: Math.round(mrr * 100) / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Injectable for tests (no network / no Supabase env needed).
|
||||||
|
let _getClient = getSupabaseServiceClient;
|
||||||
|
function _setClientForTests(fn) {
|
||||||
|
_getClient = typeof fn === 'function' ? fn : getSupabaseServiceClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/report/:code', async (req, res) => {
|
||||||
|
const code = sanitizePartnerCode(req.params.code);
|
||||||
|
if (!code) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'invalid partner code' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const supabase = _getClient();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('user_profiles')
|
||||||
|
.select('tier, subscription_status, founder_pricing')
|
||||||
|
.eq('partner_ref', code);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// Column not migrated yet (docs/PARTNERS.md TODO) — zeros, never invented.
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
code,
|
||||||
|
signups: 0,
|
||||||
|
conversions: 0,
|
||||||
|
mrr_attributed: 0,
|
||||||
|
note: 'partner_ref is not queryable yet — apply the TODO migration in docs/PARTNERS.md to activate attribution',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.json({ ok: true, code, ...buildPartnerReport(data) });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err && err.message ? err.message : String(err);
|
||||||
|
console.error('[partners/report] failed:', message);
|
||||||
|
return res.status(500).json({ ok: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
module.exports.sanitizePartnerCode = sanitizePartnerCode;
|
||||||
|
module.exports.buildPartnerReport = buildPartnerReport;
|
||||||
|
module.exports._setClientForTests = _setClientForTests;
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
// Session 52 — Push-to-Book "Coming Soon" teaser (source-asserted).
|
// Session 52 shipped BOOK IT as a "coming soon" teaser. A1 Session 3
|
||||||
|
// made it a REAL sportsbook deep link (organic until affiliateConfig
|
||||||
|
// flips a book on). GradeResultCard's PUSH-TO-BOOK (account linking /
|
||||||
|
// bet-slip push) remains a genuine teaser — that feature is unbuilt.
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||||
|
|
||||||
describe('BOOK IT teaser', () => {
|
describe('BOOK IT deep link (was the S52 teaser)', () => {
|
||||||
it('StatStrip renders the "BOOK IT" teaser on graded props', () => {
|
it('StatStrip renders BOOK IT as a real anchor built by bookLinks', () => {
|
||||||
const src = read('components/vyndr/StatStrip.tsx');
|
const src = read('components/vyndr/StatStrip.tsx');
|
||||||
expect(src).toContain('BookItTeaser');
|
expect(src).toContain('BookItTeaser');
|
||||||
expect(src).toContain('BOOK IT ⟶');
|
expect(src).toContain('BOOK IT ⟶');
|
||||||
expect(src).toContain('Push-to-Book coming soon');
|
|
||||||
expect(src).toContain('<BookItTeaser p={p} />');
|
expect(src).toContain('<BookItTeaser p={p} />');
|
||||||
|
expect(src).toContain('buildBookLink({ book: target, player, sport })');
|
||||||
|
expect(src).toContain('rel={BOOK_LINK_REL}');
|
||||||
|
// the dead-span teaser copy is gone
|
||||||
|
expect(src).not.toContain('Push-to-Book coming soon');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GradeResultCard renders "PUSH-TO-BOOK · COMING SOON"', () => {
|
it('GradeResultCard still renders the honest "PUSH-TO-BOOK · COMING SOON" teaser', () => {
|
||||||
const src = read('components/vyndr/GradeResultCard.tsx');
|
const src = read('components/vyndr/GradeResultCard.tsx');
|
||||||
expect(src).toContain('PUSH-TO-BOOK · COMING SOON');
|
expect(src).toContain('PUSH-TO-BOOK · COMING SOON');
|
||||||
expect(src).toContain('Connect DraftKings, FanDuel, BetMGM');
|
expect(src).toContain('Connect DraftKings, FanDuel, BetMGM');
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// A1 Session 3 — BOOK IT deep-link builder. Organic by default; the
|
||||||
|
// affiliate layer only fires when a book is explicitly enabled with
|
||||||
|
// real params. Every anchor renders rel="sponsored noopener noreferrer".
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { buildBookLink, resolveBookId, SUPPORTED_BOOKS, BOOK_LINK_REL } = require('../../web/src/lib/bookLinks');
|
||||||
|
const { AFFILIATE_CONFIG } = require('../../web/src/lib/affiliateConfig');
|
||||||
|
|
||||||
|
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||||
|
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||||
|
|
||||||
|
describe('buildBookLink — organic by default', () => {
|
||||||
|
it('builds a clean organic link for every supported book (default config)', () => {
|
||||||
|
for (const b of SUPPORTED_BOOKS) {
|
||||||
|
const out = buildBookLink({ book: b.id, player: 'Aaron Judge', sport: 'mlb' });
|
||||||
|
expect(out).not.toBeNull();
|
||||||
|
expect(out.tracking).toBe(false);
|
||||||
|
expect(out.url).toContain(`https://${b.host}/`);
|
||||||
|
expect(out.url).toContain('search=Aaron+Judge');
|
||||||
|
// no affiliate params leak into an organic link
|
||||||
|
expect(out.url).not.toMatch(/irclickid|sharedid|btag|afid|siteid/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ships EVERY book disabled in the checked-in config', () => {
|
||||||
|
for (const b of SUPPORTED_BOOKS) {
|
||||||
|
expect(AFFILIATE_CONFIG[b.id]).toBeDefined();
|
||||||
|
expect(AFFILIATE_CONFIG[b.id].enabled).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves book aliases (DK / DraftKings / draftkings)', () => {
|
||||||
|
expect(resolveBookId('DK')).toBe('draftkings');
|
||||||
|
expect(resolveBookId('DraftKings')).toBe('draftkings');
|
||||||
|
expect(resolveBookId('fanduel')).toBe('fanduel');
|
||||||
|
expect(resolveBookId('MGM')).toBe('betmgm');
|
||||||
|
expect(resolveBookId('CZR')).toBe('caesars');
|
||||||
|
expect(resolveBookId('BR')).toBe('betrivers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown books and missing player (absent beats wrong)', () => {
|
||||||
|
expect(buildBookLink({ book: 'bovada', player: 'Aaron Judge' })).toBeNull();
|
||||||
|
expect(buildBookLink({ book: 'prizepicks', player: 'Aaron Judge' })).toBeNull();
|
||||||
|
expect(buildBookLink({ book: 'draftkings', player: '' })).toBeNull();
|
||||||
|
expect(buildBookLink({})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('betrivers routes by two-letter state subdomain, else www', () => {
|
||||||
|
const mi = buildBookLink({ book: 'betrivers', player: 'Judge', state: 'MI' });
|
||||||
|
expect(mi.url).toContain('https://mi.betrivers.com/');
|
||||||
|
const bad = buildBookLink({ book: 'betrivers', player: 'Judge', state: 'Michigan' });
|
||||||
|
expect(bad.url).toContain('https://www.betrivers.com/');
|
||||||
|
const none = buildBookLink({ book: 'betrivers', player: 'Judge' });
|
||||||
|
expect(none.url).toContain('https://www.betrivers.com/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildBookLink — affiliate layer (config-flip)', () => {
|
||||||
|
const enabled = {
|
||||||
|
draftkings: { enabled: true, params: { irclickid: 'IRC123', sharedid: 'vyndr', wpsrc: 'vyndr-slate' } },
|
||||||
|
betmgm: { enabled: true, params: { btag: 'BT-42', afid: '' } },
|
||||||
|
caesars: { enabled: false, params: { btag: 'SHOULD-NOT-APPEAR' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('appends Impact-style params and reports tracking when enabled', () => {
|
||||||
|
const out = buildBookLink({ book: 'draftkings', player: 'Aaron Judge' }, enabled);
|
||||||
|
expect(out.tracking).toBe(true);
|
||||||
|
expect(out.url).toContain('irclickid=IRC123');
|
||||||
|
expect(out.url).toContain('sharedid=vyndr');
|
||||||
|
expect(out.url).toContain('wpsrc=vyndr-slate');
|
||||||
|
expect(out.url).toContain('search=Aaron+Judge');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips empty param values (never emits a fabricated id)', () => {
|
||||||
|
const out = buildBookLink({ book: 'betmgm', player: 'Judge' }, enabled);
|
||||||
|
expect(out.tracking).toBe(true);
|
||||||
|
expect(out.url).toContain('btag=BT-42');
|
||||||
|
expect(out.url).not.toContain('afid=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enabled with ONLY empty params stays organic (tracking false)', () => {
|
||||||
|
const out = buildBookLink({ book: 'fanduel', player: 'Judge' }, { fanduel: { enabled: true, params: { irclickid: ' ' } } });
|
||||||
|
expect(out.tracking).toBe(false);
|
||||||
|
expect(out.url).not.toContain('irclickid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabled book with params configured stays organic', () => {
|
||||||
|
const out = buildBookLink({ book: 'caesars', player: 'Judge' }, enabled);
|
||||||
|
expect(out.tracking).toBe(false);
|
||||||
|
expect(out.url).not.toContain('SHOULD-NOT-APPEAR');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rel="sponsored noopener noreferrer" on every book anchor', () => {
|
||||||
|
it('exports the exact rel contract', () => {
|
||||||
|
expect(BOOK_LINK_REL).toBe('sponsored noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('StatStrip BOOK IT anchor uses buildBookLink + BOOK_LINK_REL', () => {
|
||||||
|
const src = read('components/vyndr/StatStrip.tsx');
|
||||||
|
expect(src).toContain('buildBookLink');
|
||||||
|
expect(src).toContain('rel={BOOK_LINK_REL}');
|
||||||
|
expect(src).toContain('BOOK IT ⟶');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scan page hand-off anchors use buildBookLink + BOOK_LINK_REL', () => {
|
||||||
|
const src = read('app/scan/page.tsx');
|
||||||
|
expect(src).toContain('SUPPORTED_BOOKS');
|
||||||
|
expect(src).toContain('rel={BOOK_LINK_REL}');
|
||||||
|
// the old hardcoded organic builder is gone
|
||||||
|
expect(src).not.toContain('const deepLink =');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// A1 Session 3 — partner ref capture: ?ref=CODE → first-party vyndr_ref
|
||||||
|
// cookie (90d, first-touch) → signup metadata partner_ref.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const {
|
||||||
|
REF_COOKIE,
|
||||||
|
sanitizeRefCode,
|
||||||
|
parseRefFromSearch,
|
||||||
|
readRefCookie,
|
||||||
|
buildRefCookie,
|
||||||
|
captureRef,
|
||||||
|
} = require('../../web/src/lib/partnerRef');
|
||||||
|
|
||||||
|
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||||
|
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||||
|
|
||||||
|
describe('sanitizeRefCode', () => {
|
||||||
|
it('uppercases and accepts A-Z 0-9 - _', () => {
|
||||||
|
expect(sanitizeRefCode('hoopspod')).toBe('HOOPSPOD');
|
||||||
|
expect(sanitizeRefCode(' the-wire_22 ')).toBe('THE-WIRE_22');
|
||||||
|
});
|
||||||
|
it('rejects empty, too-long, and unsafe codes', () => {
|
||||||
|
expect(sanitizeRefCode('')).toBeNull();
|
||||||
|
expect(sanitizeRefCode(null)).toBeNull();
|
||||||
|
expect(sanitizeRefCode('a'.repeat(33))).toBeNull();
|
||||||
|
expect(sanitizeRefCode('bad code')).toBeNull();
|
||||||
|
expect(sanitizeRefCode('<script>')).toBeNull();
|
||||||
|
expect(sanitizeRefCode('a;b')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRefFromSearch / readRefCookie / buildRefCookie', () => {
|
||||||
|
it('parses ?ref= out of a search string', () => {
|
||||||
|
expect(parseRefFromSearch('?ref=hoopspod&utm_source=x')).toBe('HOOPSPOD');
|
||||||
|
expect(parseRefFromSearch('?utm_source=x')).toBeNull();
|
||||||
|
expect(parseRefFromSearch('')).toBeNull();
|
||||||
|
});
|
||||||
|
it('builds a 90-day first-party cookie string', () => {
|
||||||
|
const c = buildRefCookie('hoopspod');
|
||||||
|
expect(c).toContain(`${REF_COOKIE}=HOOPSPOD`);
|
||||||
|
expect(c).toContain(`Max-Age=${90 * 24 * 60 * 60}`);
|
||||||
|
expect(c).toContain('Path=/');
|
||||||
|
expect(c).toContain('SameSite=Lax');
|
||||||
|
expect(c).not.toContain('Secure');
|
||||||
|
expect(buildRefCookie('hoopspod', { secure: true })).toContain('; Secure');
|
||||||
|
expect(buildRefCookie('bad code')).toBeNull();
|
||||||
|
});
|
||||||
|
it('reads the cookie back out of a document.cookie string', () => {
|
||||||
|
expect(readRefCookie('foo=1; vyndr_ref=HOOPSPOD; bar=2')).toBe('HOOPSPOD');
|
||||||
|
expect(readRefCookie('foo=1; bar=2')).toBeNull();
|
||||||
|
expect(readRefCookie('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('captureRef — first touch wins', () => {
|
||||||
|
const fakeWindow = (search, cookie = '') => {
|
||||||
|
const doc = { cookie };
|
||||||
|
return { location: { search, protocol: 'https:' }, document: doc };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('sets the cookie on a first visit with ?ref=', () => {
|
||||||
|
const w = fakeWindow('?ref=hoopspod');
|
||||||
|
expect(captureRef(w)).toBe('HOOPSPOD');
|
||||||
|
expect(w.document.cookie).toContain('vyndr_ref=HOOPSPOD');
|
||||||
|
expect(w.document.cookie).toContain('; Secure'); // https origin
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT overwrite an existing ref cookie (first touch)', () => {
|
||||||
|
const w = fakeWindow('?ref=newpartner', 'vyndr_ref=ORIGINAL');
|
||||||
|
expect(captureRef(w)).toBeNull();
|
||||||
|
expect(w.document.cookie).toBe('vyndr_ref=ORIGINAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-ops without a ref param or without a window', () => {
|
||||||
|
const w = fakeWindow('?utm_source=x');
|
||||||
|
expect(captureRef(w)).toBeNull();
|
||||||
|
expect(w.document.cookie).toBe('');
|
||||||
|
expect(captureRef(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wiring (source-asserted)', () => {
|
||||||
|
it('PartnerRefCapture is mounted in the layout (GlobalHosts pattern)', () => {
|
||||||
|
const layout = read('app/layout.tsx');
|
||||||
|
expect(layout).toContain('<PartnerRefCapture />');
|
||||||
|
expect(layout).toContain("from '@/components/vyndr/PartnerRefCapture'");
|
||||||
|
});
|
||||||
|
it('signUp forwards the cookie as partner_ref signup metadata', () => {
|
||||||
|
const auth = read('contexts/AuthContext.tsx');
|
||||||
|
expect(auth).toContain('readRefCookie(document.cookie)');
|
||||||
|
expect(auth).toContain('partner_ref: partnerRef');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// A1 Session 3 — internal partner attribution report.
|
||||||
|
// Pure report math + route behavior with an injected Supabase client.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
const partnersRouter = require('../../src/routes/partners');
|
||||||
|
const { sanitizePartnerCode, buildPartnerReport, _setClientForTests } = partnersRouter;
|
||||||
|
|
||||||
|
const KEY = 'test-internal-key';
|
||||||
|
|
||||||
|
function makeApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use('/api/partners', partnersRouter);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sanitizePartnerCode', () => {
|
||||||
|
it('uppercases and accepts the cookie convention (A-Z 0-9 - _, ≤32)', () => {
|
||||||
|
expect(sanitizePartnerCode('hoopspod')).toBe('HOOPSPOD');
|
||||||
|
expect(sanitizePartnerCode('THE-WIRE_22')).toBe('THE-WIRE_22');
|
||||||
|
});
|
||||||
|
it('rejects unsafe input', () => {
|
||||||
|
expect(sanitizePartnerCode('a b')).toBeNull();
|
||||||
|
expect(sanitizePartnerCode('a'.repeat(33))).toBeNull();
|
||||||
|
expect(sanitizePartnerCode('')).toBeNull();
|
||||||
|
expect(sanitizePartnerCode("x';drop")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildPartnerReport', () => {
|
||||||
|
it('counts signups, active paid conversions, and founder-aware MRR', () => {
|
||||||
|
const rows = [
|
||||||
|
{ tier: 'free', subscription_status: 'none', founder_pricing: false },
|
||||||
|
{ tier: 'analyst', subscription_status: 'active', founder_pricing: false }, // 19.99
|
||||||
|
{ tier: 'analyst', subscription_status: 'active', founder_pricing: true }, // 14.99
|
||||||
|
{ tier: 'desk', subscription_status: 'active', founder_pricing: false }, // 49.99
|
||||||
|
{ tier: 'desk', subscription_status: 'canceled', founder_pricing: false }, // not active
|
||||||
|
];
|
||||||
|
expect(buildPartnerReport(rows)).toEqual({ signups: 5, conversions: 3, mrr_attributed: 84.97 });
|
||||||
|
});
|
||||||
|
it('empty / bad input → zeros', () => {
|
||||||
|
expect(buildPartnerReport([])).toEqual({ signups: 0, conversions: 0, mrr_attributed: 0 });
|
||||||
|
expect(buildPartnerReport(null)).toEqual({ signups: 0, conversions: 0, mrr_attributed: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/partners/report/:code', () => {
|
||||||
|
const OLD_KEY = process.env.VYNDR_INTERNAL_KEY;
|
||||||
|
beforeAll(() => { process.env.VYNDR_INTERNAL_KEY = KEY; });
|
||||||
|
afterAll(() => {
|
||||||
|
if (OLD_KEY === undefined) delete process.env.VYNDR_INTERNAL_KEY;
|
||||||
|
else process.env.VYNDR_INTERNAL_KEY = OLD_KEY;
|
||||||
|
_setClientForTests(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fakeClient = (result) => () => ({
|
||||||
|
from: () => ({ select: () => ({ eq: () => Promise.resolve(result) }) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401s without the internal key', async () => {
|
||||||
|
const res = await request(makeApp()).get('/api/partners/report/HOOPSPOD');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s an invalid code', async () => {
|
||||||
|
const res = await request(makeApp())
|
||||||
|
.get('/api/partners/report/bad%20code')
|
||||||
|
.set('x-internal-key', KEY);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns real attribution when the partner_ref column exists', async () => {
|
||||||
|
_setClientForTests(fakeClient({
|
||||||
|
data: [
|
||||||
|
{ tier: 'analyst', subscription_status: 'active', founder_pricing: true },
|
||||||
|
{ tier: 'free', subscription_status: 'none', founder_pricing: false },
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
const res = await request(makeApp())
|
||||||
|
.get('/api/partners/report/hoopspod')
|
||||||
|
.set('x-internal-key', KEY);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ ok: true, code: 'HOOPSPOD', signups: 2, conversions: 1, mrr_attributed: 14.99 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades to zeros + note when the column is not migrated yet', async () => {
|
||||||
|
_setClientForTests(fakeClient({ data: null, error: { message: 'column user_profiles.partner_ref does not exist' } }));
|
||||||
|
const res = await request(makeApp())
|
||||||
|
.get('/api/partners/report/HOOPSPOD')
|
||||||
|
.set('x-internal-key', KEY);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.signups).toBe(0);
|
||||||
|
expect(res.body.conversions).toBe(0);
|
||||||
|
expect(res.body.mrr_attributed).toBe(0);
|
||||||
|
expect(res.body.note).toContain('docs/PARTNERS.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// A1 Session 3 — best available price detection. Honesty rules: a best
|
||||||
|
// price only exists when ≥2 books post the SAME line for the side and
|
||||||
|
// their prices differ. Absent beats wrong.
|
||||||
|
|
||||||
|
const { detectBestBook, buildPlayerStripsFromProps } = require('../../web/src/lib/slateAdapter');
|
||||||
|
|
||||||
|
describe('detectBestBook', () => {
|
||||||
|
const rows = [
|
||||||
|
{ book: 'draftkings', line: 1.5, over_odds: -115, under_odds: -105 },
|
||||||
|
{ book: 'fanduel', line: 1.5, over_odds: -105, under_odds: -115 },
|
||||||
|
{ book: 'betmgm', line: 1.5, over_odds: -120, under_odds: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('picks the best over price at the shared line', () => {
|
||||||
|
const out = detectBestBook(rows, 'over', 1.5);
|
||||||
|
expect(out).toEqual({ book: 'fanduel', odds: -105 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the best under price independently', () => {
|
||||||
|
const out = detectBestBook(rows, 'under', 1.5);
|
||||||
|
expect(out).toEqual({ book: 'betmgm', odds: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for a single book (a lone price is not "best")', () => {
|
||||||
|
expect(detectBestBook([rows[0]], 'over', 1.5)).toBeNull();
|
||||||
|
expect(detectBestBook([], 'over', 1.5)).toBeNull();
|
||||||
|
expect(detectBestBook(null, 'over', 1.5)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when all books post the identical price', () => {
|
||||||
|
const flat = [
|
||||||
|
{ book: 'draftkings', line: 1.5, over_odds: -110 },
|
||||||
|
{ book: 'fanduel', line: 1.5, over_odds: -110 },
|
||||||
|
];
|
||||||
|
expect(detectBestBook(flat, 'over', 1.5)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when books only post DIFFERENT lines (odds are not comparable)', () => {
|
||||||
|
const split = [
|
||||||
|
{ book: 'draftkings', line: 1.5, over_odds: -200 },
|
||||||
|
{ book: 'fanduel', line: 2.5, over_odds: 120 },
|
||||||
|
];
|
||||||
|
expect(detectBestBook(split, 'over', 1.5)).toBeNull();
|
||||||
|
expect(detectBestBook(split, 'over', 2.5)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without a reference line, compares at the modal line', () => {
|
||||||
|
const mixed = [
|
||||||
|
{ book: 'draftkings', line: 1.5, over_odds: -115 },
|
||||||
|
{ book: 'fanduel', line: 1.5, over_odds: -105 },
|
||||||
|
{ book: 'betmgm', line: 2.5, over_odds: 200 },
|
||||||
|
];
|
||||||
|
expect(detectBestBook(mixed, 'over')).toEqual({ book: 'fanduel', odds: -105 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores rows with missing book / line / side odds', () => {
|
||||||
|
const dirty = [
|
||||||
|
{ book: 'draftkings', line: 1.5, over_odds: -115 },
|
||||||
|
{ book: null, line: 1.5, over_odds: -100 },
|
||||||
|
{ book: 'fanduel', line: 1.5, over_odds: null },
|
||||||
|
{ book: 'betmgm', over_odds: -105 },
|
||||||
|
];
|
||||||
|
expect(detectBestBook(dirty, 'over', 1.5)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildPlayerStripsFromProps — bestBook attachment', () => {
|
||||||
|
const books = [
|
||||||
|
{ book: 'draftkings', line: 1.5, over_odds: -115, under_odds: -105 },
|
||||||
|
{ book: 'fanduel', line: 1.5, over_odds: -105, under_odds: -115 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('attaches bestBook + book to a graded prop (graded side)', () => {
|
||||||
|
const props = [{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', book: 'draftkings', books }];
|
||||||
|
const gradeIndex = {
|
||||||
|
'aaron judge|total_bases': {
|
||||||
|
player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5,
|
||||||
|
direction: 'over', grade: 'A', gradedAt: { line: 1.5, odds: -115, timestamp: new Date().toISOString() },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const strips = buildPlayerStripsFromProps(props, gradeIndex, {});
|
||||||
|
expect(strips).toHaveLength(1);
|
||||||
|
const p = strips[0].props[0];
|
||||||
|
expect(p.book).toBe('draftkings');
|
||||||
|
expect(p.bestBook).toEqual({ book: 'fanduel', odds: -105 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches bestBook on awaiting props too (still real market data)', () => {
|
||||||
|
const props = [{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'under', book: 'draftkings', books }];
|
||||||
|
const strips = buildPlayerStripsFromProps(props, {}, {});
|
||||||
|
const p = strips[0].props[0];
|
||||||
|
expect(p.awaiting).toBe(true);
|
||||||
|
expect(p.bestBook).toEqual({ book: 'draftkings', odds: -105 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves bestBook null when only one book posted the prop', () => {
|
||||||
|
const props = [{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', book: 'draftkings', books: [books[0]] }];
|
||||||
|
const strips = buildPlayerStripsFromProps(props, {}, {});
|
||||||
|
expect(strips[0].props[0].bestBook).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves bestBook null when props carry no books array (legacy shape)', () => {
|
||||||
|
const props = [{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', book: 'draftkings' }];
|
||||||
|
const strips = buildPlayerStripsFromProps(props, {}, {});
|
||||||
|
expect(strips[0].props[0].bestBook).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -150,8 +150,12 @@ describe('Phase D — scan + landing wiring', () => {
|
|||||||
expect(src).toContain('mapScanToGradeResult');
|
expect(src).toContain('mapScanToGradeResult');
|
||||||
expect(src).not.toContain("from '@/components/GradeCard'");
|
expect(src).not.toContain("from '@/components/GradeCard'");
|
||||||
});
|
});
|
||||||
it('scan keeps sportsbook deep-links safe (noopener noreferrer)', () => {
|
it('scan keeps sportsbook deep-links safe (A1 S3: BOOK_LINK_REL = sponsored noopener noreferrer)', () => {
|
||||||
expect(read('app/scan/page.tsx')).toContain('noopener noreferrer');
|
const src = read('app/scan/page.tsx');
|
||||||
|
expect(src).toContain('rel={BOOK_LINK_REL}');
|
||||||
|
// the constant itself carries noopener noreferrer (+ sponsored)
|
||||||
|
const { BOOK_LINK_REL } = require('../../web/src/lib/bookLinks');
|
||||||
|
expect(BOOK_LINK_REL).toContain('noopener noreferrer');
|
||||||
});
|
});
|
||||||
it('landing mounts the ClaimMeter', () => {
|
it('landing mounts the ClaimMeter', () => {
|
||||||
expect(read('app/page.tsx')).toContain('ClaimMeter');
|
expect(read('app/page.tsx')).toContain('ClaimMeter');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Footer from '@/components/Footer';
|
|||||||
import AuthGate from '@/components/AuthGate';
|
import AuthGate from '@/components/AuthGate';
|
||||||
import HashRedirect from '@/components/vyndr/HashRedirect';
|
import HashRedirect from '@/components/vyndr/HashRedirect';
|
||||||
import GlobalHosts from '@/components/vyndr/GlobalHosts';
|
import GlobalHosts from '@/components/vyndr/GlobalHosts';
|
||||||
|
import PartnerRefCapture from '@/components/vyndr/PartnerRefCapture';
|
||||||
// Session 50 — the Parlay Lab supersedes the legacy ParlayTray.
|
// Session 50 — the Parlay Lab supersedes the legacy ParlayTray.
|
||||||
import ParlayPanel from '@/components/vyndr/ParlayPanel';
|
import ParlayPanel from '@/components/vyndr/ParlayPanel';
|
||||||
import BottomTabBar from '@/components/BottomTabBar';
|
import BottomTabBar from '@/components/BottomTabBar';
|
||||||
@@ -156,6 +157,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<SentryInit />
|
<SentryInit />
|
||||||
{/* Session 38 — prefs apply + paywall/checkout/prefs globals (§9/§10/§12) */}
|
{/* Session 38 — prefs apply + paywall/checkout/prefs globals (§9/§10/§12) */}
|
||||||
<GlobalHosts />
|
<GlobalHosts />
|
||||||
|
{/* A1 S3 — first-visit ?ref=CODE → vyndr_ref cookie (90d, first-touch) */}
|
||||||
|
<PartnerRefCapture />
|
||||||
</ParlayProvider>
|
</ParlayProvider>
|
||||||
</ExplainModeProvider>
|
</ExplainModeProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
+11
-13
@@ -17,6 +17,7 @@ import {
|
|||||||
trackUpgradeClicked,
|
trackUpgradeClicked,
|
||||||
} from '@/lib/analytics';
|
} from '@/lib/analytics';
|
||||||
import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot';
|
import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot';
|
||||||
|
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
|
||||||
|
|
||||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||||
|
|
||||||
@@ -103,15 +104,8 @@ const SPORT_ACCENT: Record<Sport, string> = {
|
|||||||
WNBA: '#FFB347',
|
WNBA: '#FFB347',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sportsbook deep-links — preserved from the legacy GradeCard so the new
|
// Sportsbook deep-links — A1 S3: built by lib/bookLinks (organic until the
|
||||||
// design keeps the book hand-off. target=_blank + noopener,noreferrer.
|
// affiliate config flips a book on). rel is BOOK_LINK_REL on every anchor.
|
||||||
const SPORTSBOOKS = [
|
|
||||||
{ id: 'draftkings', label: 'DraftKings', host: 'sportsbook.draftkings.com' },
|
|
||||||
{ id: 'fanduel', label: 'FanDuel', host: 'sportsbook.fanduel.com' },
|
|
||||||
{ id: 'betmgm', label: 'BetMGM', host: 'sports.betmgm.com' },
|
|
||||||
{ id: 'caesars', label: 'Caesars', host: 'sportsbook.caesars.com' },
|
|
||||||
];
|
|
||||||
const deepLink = (host: string, player: string) => `https://${host}/?search=${encodeURIComponent(player)}`;
|
|
||||||
|
|
||||||
export default function ScanPage() {
|
export default function ScanPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -775,18 +769,22 @@ export default function ScanPage() {
|
|||||||
|
|
||||||
{/* Sportsbook hand-off (preserved feature) */}
|
{/* Sportsbook hand-off (preserved feature) */}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
|
||||||
{SPORTSBOOKS.map((b) => (
|
{SUPPORTED_BOOKS.map((b) => {
|
||||||
|
const link = buildBookLink({ book: b.id, player: selectedPlayer, sport });
|
||||||
|
if (!link) return null;
|
||||||
|
return (
|
||||||
<a
|
<a
|
||||||
key={b.id}
|
key={b.id}
|
||||||
href={deepLink(b.host, selectedPlayer)}
|
href={link.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel={BOOK_LINK_REL}
|
||||||
className="mono"
|
className="mono"
|
||||||
style={{ padding: '7px 13px', fontSize: 11, fontWeight: 700, borderRadius: 6, border: '1px solid var(--border-hi)', color: 'var(--text-1)', textDecoration: 'none' }}
|
style={{ padding: '7px 13px', fontSize: 11, fontWeight: 700, borderRadius: 6, border: '1px solid var(--border-hi)', color: 'var(--text-1)', textDecoration: 'none' }}
|
||||||
>
|
>
|
||||||
{b.label} ↗
|
{b.label} ↗
|
||||||
</a>
|
</a>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Free-tier nudge — full paywall treatment returns in Phase G */}
|
{/* Free-tier nudge — full paywall treatment returns in Phase G */}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export interface PropRowProp {
|
|||||||
line: number;
|
line: number;
|
||||||
direction: PropDirection;
|
direction: PropDirection;
|
||||||
book?: string;
|
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.
|
// Stable key used by the parent to look up grade results.
|
||||||
key?: string;
|
key?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,6 +345,9 @@ function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
|
|||||||
line: lineInfo.line,
|
line: lineInfo.line,
|
||||||
direction: lineInfo.direction,
|
direction: lineInfo.direction,
|
||||||
book: lineInfo.book,
|
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.
|
// 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 ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
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 {
|
export interface StatCell {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,6 +24,10 @@ export interface StripProp {
|
|||||||
// Session 60 (Phase 2.5) — intraday movement + public revision.
|
// Session 60 (Phase 2.5) — intraday movement + public revision.
|
||||||
movement?: { kind: 'steam' | 'value' | 'against' | 'revised' | string; delta: number; currentLine: number } | null;
|
movement?: { kind: 'steam' | 'value' | 'against' | 'revised' | string; delta: number; currentLine: number } | null;
|
||||||
revisedFrom?: string | 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
|
/** Phase 2.5 movement chip: STEAM ▲ (market chasing), VALUE ▲ (better
|
||||||
@@ -75,6 +83,7 @@ const Sep = ({ ch = '|' }: { ch?: string }) => (
|
|||||||
export default function StatStrip({
|
export default function StatStrip({
|
||||||
player,
|
player,
|
||||||
team,
|
team,
|
||||||
|
sport,
|
||||||
archetype,
|
archetype,
|
||||||
stats,
|
stats,
|
||||||
last10,
|
last10,
|
||||||
@@ -125,17 +134,45 @@ export default function StatStrip({
|
|||||||
</span>
|
</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 }) => {
|
const BookItTeaser = ({ p }: { p: StripProp }) => {
|
||||||
if (!p.grade) return null;
|
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 (
|
return (
|
||||||
<span
|
<a
|
||||||
className="mono"
|
className="mono"
|
||||||
title="Push-to-Book coming soon — connect your sportsbook"
|
href={link.url}
|
||||||
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 }}
|
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 ⟶
|
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'
|
const last10Str = typeof last10 === 'string'
|
||||||
@@ -226,7 +263,7 @@ export default function StatStrip({
|
|||||||
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
{i > 0 && <span style={{ color: '#3A3A48' }}>·</span>}
|
{i > 0 && <span style={{ color: '#3A3A48' }}>·</span>}
|
||||||
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}>
|
<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} />
|
<ParlayBtn p={p} />
|
||||||
<BookItTeaser p={p} />
|
<BookItTeaser p={p} />
|
||||||
</span>
|
</span>
|
||||||
@@ -245,6 +282,7 @@ export default function StatStrip({
|
|||||||
return (
|
return (
|
||||||
<div key={i} className="mono" style={{ fontSize: 11, color: 'var(--text-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
<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>
|
<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>
|
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>{next ? `Grades post ${next}` : 'Awaiting next scan'}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -254,6 +292,7 @@ export default function StatStrip({
|
|||||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<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' }}>
|
<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>
|
<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. */}
|
{/* Phase 2.5 — a revised grade is PUBLIC: original struck through. */}
|
||||||
{p.revisedFrom && (
|
{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>
|
<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>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import type { Session, User } from '@supabase/supabase-js';
|
import type { Session, User } from '@supabase/supabase-js';
|
||||||
import { getBrowserSupabase } from '@/lib/supabase';
|
import { getBrowserSupabase } from '@/lib/supabase';
|
||||||
|
import { readRefCookie } from '@/lib/partnerRef';
|
||||||
|
|
||||||
export type Tier = 'free' | 'analyst' | 'desk';
|
export type Tier = 'free' | 'analyst' | 'desk';
|
||||||
|
|
||||||
@@ -135,10 +136,17 @@ export default function AuthProvider({ children }: { children: React.ReactNode }
|
|||||||
async (email, password, ageVerified) => {
|
async (email, password, ageVerified) => {
|
||||||
if (!ageVerified) return { error: 'You must confirm you are 21 or older.' };
|
if (!ageVerified) return { error: 'You must confirm you are 21 or older.' };
|
||||||
if (!supabase) return { error: 'Auth is not configured. Set Supabase env vars.' };
|
if (!supabase) return { error: 'Auth is not configured. Set Supabase env vars.' };
|
||||||
|
// A1 S3 — partner attribution: forward the first-touch vyndr_ref
|
||||||
|
// cookie (set by PartnerRefCapture on a ?ref=CODE visit) into the
|
||||||
|
// signup metadata. The internal /api/partners report reads it.
|
||||||
|
const partnerRef = readRefCookie(document.cookie);
|
||||||
const { error } = await supabase.auth.signUp({
|
const { error } = await supabase.auth.signUp({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
options: { emailRedirectTo: `${window.location.origin}/auth/callback` },
|
options: {
|
||||||
|
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||||
|
...(partnerRef ? { data: { partner_ref: partnerRef } } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (error) return { error: error.message };
|
if (error) return { error: error.message };
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Affiliate configuration (A1 Session 3).
|
||||||
|
|
||||||
|
NO AFFILIATE PROGRAM IS APPROVED YET. Every book ships
|
||||||
|
`enabled: false` — bookLinks.js builds clean ORGANIC links until
|
||||||
|
an operator flips a book on and fills its params. This file is the
|
||||||
|
single flip point; no component hardcodes tracking params.
|
||||||
|
|
||||||
|
Param shapes by network (fill when approved — placeholders only):
|
||||||
|
|
||||||
|
Impact Radius (DraftKings, FanDuel run on Impact):
|
||||||
|
{ irclickid: '<click-id template or static campaign id>',
|
||||||
|
sharedid: 'vyndr', // sub-affiliate / source tag
|
||||||
|
wpsrc: '<campaign source>' }
|
||||||
|
|
||||||
|
Partnerize / btag-style (BetMGM, Caesars, BetRivers):
|
||||||
|
{ btag: '<partner btag>', // BetMGM/Caesars partner tag
|
||||||
|
afid: '<affiliate id>', // network affiliate id
|
||||||
|
siteid:'<site/property id>' }
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- `tracking: true` is only reported when a book is enabled AND at
|
||||||
|
least one non-empty param actually lands on the URL.
|
||||||
|
- Never invent values here — empty string params are skipped by the
|
||||||
|
builder, so a half-filled config still degrades to organic-plus-
|
||||||
|
whatever-is-real, never a fabricated id.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
const AFFILIATE_CONFIG = {
|
||||||
|
draftkings: { enabled: false, params: {} }, // Impact — irclickid / sharedid / wpsrc
|
||||||
|
fanduel: { enabled: false, params: {} }, // Impact — irclickid / sharedid / wpsrc
|
||||||
|
betmgm: { enabled: false, params: {} }, // Partnerize — btag / afid / siteid
|
||||||
|
caesars: { enabled: false, params: {} }, // Partnerize — btag / afid / siteid
|
||||||
|
betrivers: { enabled: false, params: {} }, // btag / afid / siteid
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { AFFILIATE_CONFIG };
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/* ============================================================
|
||||||
|
BOOK IT deep-link builder (A1 Session 3).
|
||||||
|
|
||||||
|
Builds a per-book player-prop hand-off URL:
|
||||||
|
buildBookLink({ book, player, sport, state? }) → { url, tracking } | null
|
||||||
|
|
||||||
|
- Base hosts mirror the scan page's legacy SPORTSBOOKS list; the
|
||||||
|
organic URL shape (`/?search=<player>`) is the shape the product
|
||||||
|
has always shipped.
|
||||||
|
- The affiliate layer reads web/src/lib/affiliateConfig.js. Every
|
||||||
|
book is `enabled: false` today (no program approved), so links are
|
||||||
|
clean + organic. Flipping a book's config produces a tracked URL —
|
||||||
|
no component changes needed.
|
||||||
|
- Every rendered book anchor must carry BOOK_LINK_REL
|
||||||
|
("sponsored noopener noreferrer") — sponsored is required for
|
||||||
|
affiliate compliance and harmless on organic links.
|
||||||
|
- Unknown book → null. Absent beats wrong.
|
||||||
|
|
||||||
|
CommonJS so the plain-JS Jest suite requires it AND .tsx imports it
|
||||||
|
(allowJs) — same pattern as slateAdapter/books/playerName.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
const { bookKey } = require('./books');
|
||||||
|
const { AFFILIATE_CONFIG } = require('./affiliateConfig');
|
||||||
|
|
||||||
|
/** rel attribute every sportsbook anchor must render. */
|
||||||
|
const BOOK_LINK_REL = 'sponsored noopener noreferrer';
|
||||||
|
|
||||||
|
/** The books we build deep links for (order = display order). */
|
||||||
|
const SUPPORTED_BOOKS = [
|
||||||
|
{ id: 'draftkings', label: 'DraftKings', host: 'sportsbook.draftkings.com' },
|
||||||
|
{ id: 'fanduel', label: 'FanDuel', host: 'sportsbook.fanduel.com' },
|
||||||
|
{ id: 'betmgm', label: 'BetMGM', host: 'sports.betmgm.com' },
|
||||||
|
{ id: 'caesars', label: 'Caesars', host: 'sportsbook.caesars.com' },
|
||||||
|
// BetRivers routes by state subdomain (mi.betrivers.com); www works
|
||||||
|
// as the state-agnostic landing.
|
||||||
|
{ id: 'betrivers', label: 'BetRivers', host: 'www.betrivers.com' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HOSTS = SUPPORTED_BOOKS.reduce((m, b) => {
|
||||||
|
m[b.id] = b.host;
|
||||||
|
return m;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
/** Canonicalize any book spelling ("DK", "DraftKings", "draftkings") →
|
||||||
|
* a SUPPORTED_BOOKS id, or null when it isn't a book we link to. */
|
||||||
|
function resolveBookId(book) {
|
||||||
|
const k = bookKey(book);
|
||||||
|
return HOSTS[k] ? k : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two-letter US state code or null — anything else is dropped, never guessed. */
|
||||||
|
function cleanState(state) {
|
||||||
|
const s = String(state == null ? '' : state).trim().toLowerCase();
|
||||||
|
return /^[a-z]{2}$/.test(s) ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the deep link for one book + player.
|
||||||
|
*
|
||||||
|
* @param {{ book: string, player: string, sport?: string, state?: string }} input
|
||||||
|
* @param {object} [config] — injectable for tests; defaults to AFFILIATE_CONFIG.
|
||||||
|
* @returns {{ url: string, tracking: boolean } | null}
|
||||||
|
*/
|
||||||
|
function buildBookLink(input, config = AFFILIATE_CONFIG) {
|
||||||
|
const { book, player, state } = input || {};
|
||||||
|
const id = resolveBookId(book);
|
||||||
|
if (!id || !player) return null;
|
||||||
|
|
||||||
|
let host = HOSTS[id];
|
||||||
|
if (id === 'betrivers') {
|
||||||
|
const st = cleanState(state);
|
||||||
|
if (st) host = `${st}.betrivers.com`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(`https://${host}/`);
|
||||||
|
url.searchParams.set('search', String(player));
|
||||||
|
|
||||||
|
// Affiliate layer — only when the operator flipped this book on AND
|
||||||
|
// real (non-empty) params exist. Otherwise the link stays organic.
|
||||||
|
let tracking = false;
|
||||||
|
const cfg = config && config[id];
|
||||||
|
if (cfg && cfg.enabled === true && cfg.params && typeof cfg.params === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(cfg.params)) {
|
||||||
|
const v = String(value == null ? '' : value).trim();
|
||||||
|
if (!key || !v) continue; // never emit an empty/fabricated id
|
||||||
|
url.searchParams.set(key, v);
|
||||||
|
tracking = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url: url.toString(), tracking };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildBookLink, resolveBookId, SUPPORTED_BOOKS, BOOK_LINK_REL };
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Partner ref capture (A1 Session 3).
|
||||||
|
|
||||||
|
A partner shares vyndr.app/?ref=CODE. On first visit we set a
|
||||||
|
FIRST-PARTY cookie `vyndr_ref` (90 days). Signup forwards the code
|
||||||
|
into Supabase user metadata as `partner_ref`, which is what the
|
||||||
|
internal /api/partners/report/:code attribution reads.
|
||||||
|
|
||||||
|
Attribution model = FIRST TOUCH: an existing vyndr_ref cookie is
|
||||||
|
never overwritten by a later ?ref= visit. Codes are sanitized
|
||||||
|
(A-Z 0-9 - _, max 32) and stored UPPERCASE — the same convention
|
||||||
|
as Stripe promo codes (see docs/PARTNERS.md).
|
||||||
|
|
||||||
|
CommonJS so the plain-JS Jest suite exercises it directly.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
const REF_COOKIE = 'vyndr_ref';
|
||||||
|
const REF_TTL_DAYS = 90;
|
||||||
|
|
||||||
|
/** Sanitize a raw code → canonical UPPERCASE code, or null. */
|
||||||
|
function sanitizeRefCode(raw) {
|
||||||
|
const s = String(raw == null ? '' : raw).trim().toUpperCase();
|
||||||
|
if (!s || s.length > 32) return null;
|
||||||
|
return /^[A-Z0-9_-]+$/.test(s) ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull a sanitized ?ref= code out of a location.search string. */
|
||||||
|
function parseRefFromSearch(search) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(search || '');
|
||||||
|
return sanitizeRefCode(params.get('ref'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the vyndr_ref value out of a document.cookie string. */
|
||||||
|
function readRefCookie(cookieStr) {
|
||||||
|
const parts = String(cookieStr == null ? '' : cookieStr).split(';');
|
||||||
|
for (const part of parts) {
|
||||||
|
const [name, ...rest] = part.split('=');
|
||||||
|
if (name && name.trim() === REF_COOKIE) {
|
||||||
|
return sanitizeRefCode(decodeURIComponent(rest.join('=').trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the Set-Cookie string for a code (first-party, 90d, lax). */
|
||||||
|
function buildRefCookie(code, { days = REF_TTL_DAYS, secure = false } = {}) {
|
||||||
|
const clean = sanitizeRefCode(code);
|
||||||
|
if (!clean) return null;
|
||||||
|
const maxAge = Math.round(days * 24 * 60 * 60);
|
||||||
|
return `${REF_COOKIE}=${encodeURIComponent(clean)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${secure ? '; Secure' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture ?ref=CODE from the current visit into the cookie.
|
||||||
|
* First-touch: no-op when the cookie already exists. `win` is
|
||||||
|
* injectable for tests (defaults to the browser window).
|
||||||
|
* Returns the code that was captured, or null.
|
||||||
|
*/
|
||||||
|
function captureRef(win) {
|
||||||
|
const w = win || (typeof window !== 'undefined' ? window : null);
|
||||||
|
if (!w || !w.document || !w.location) return null;
|
||||||
|
const code = parseRefFromSearch(w.location.search);
|
||||||
|
if (!code) return null;
|
||||||
|
if (readRefCookie(w.document.cookie)) return null; // first touch wins
|
||||||
|
const cookie = buildRefCookie(code, { secure: w.location.protocol === 'https:' });
|
||||||
|
if (!cookie) return null;
|
||||||
|
w.document.cookie = cookie;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
REF_COOKIE,
|
||||||
|
REF_TTL_DAYS,
|
||||||
|
sanitizeRefCode,
|
||||||
|
parseRefFromSearch,
|
||||||
|
readRefCookie,
|
||||||
|
buildRefCookie,
|
||||||
|
captureRef,
|
||||||
|
};
|
||||||
@@ -51,6 +51,49 @@ function detectBestLines(books) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best available price across a prop's book rows (A1 Session 3).
|
||||||
|
*
|
||||||
|
* `rows` is the grouped odds shape the Express /api/odds proxy already
|
||||||
|
* ships the browser: [{ book, line, over_odds, under_odds }] — one row
|
||||||
|
* per book for the same player+stat.
|
||||||
|
*
|
||||||
|
* Data-semantics rule: a "best price" claim is only honest when ≥2 books
|
||||||
|
* post the SAME line for the side and their prices differ — comparing
|
||||||
|
* odds across different lines is meaningless, and a lone price isn't
|
||||||
|
* "best". Anything else → null. Absent beats wrong.
|
||||||
|
*
|
||||||
|
* @param {Array<{book?:string,line?:number,over_odds?:number|null,under_odds?:number|null}>} rows
|
||||||
|
* @param {string} [side] — 'over' | 'under' (default over)
|
||||||
|
* @param {number|null} [refLine] — the line the card displays; when given,
|
||||||
|
* only books at that exact line compete.
|
||||||
|
* @returns {{ book: string, odds: number } | null}
|
||||||
|
*/
|
||||||
|
function detectBestBook(rows, side = 'over', refLine = null) {
|
||||||
|
const key = String(side || 'over').toLowerCase().startsWith('u') ? 'under_odds' : 'over_odds';
|
||||||
|
const valid = (Array.isArray(rows) ? rows : []).filter(
|
||||||
|
(r) => r && r.book && Number.isFinite(r.line) && r[key] != null && parseAmericanOdds(r[key]) != null,
|
||||||
|
);
|
||||||
|
if (valid.length < 2) return null;
|
||||||
|
|
||||||
|
// Compare at ONE line: the displayed line when given, else the modal line.
|
||||||
|
let line = Number.isFinite(refLine) ? refLine : null;
|
||||||
|
if (line == null) {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const r of valid) counts.set(r.line, (counts.get(r.line) || 0) + 1);
|
||||||
|
let bestCount = 0;
|
||||||
|
for (const [ln, c] of counts) if (c > bestCount) { bestCount = c; line = ln; }
|
||||||
|
}
|
||||||
|
const atLine = valid.filter((r) => r.line === line);
|
||||||
|
if (atLine.length < 2) return null;
|
||||||
|
|
||||||
|
const decimals = atLine.map((r) => parseAmericanOdds(r[key]));
|
||||||
|
const max = Math.max(...decimals);
|
||||||
|
if (max === Math.min(...decimals)) return null; // identical prices → no "best"
|
||||||
|
const winner = atLine[decimals.indexOf(max)];
|
||||||
|
return { book: winner.book, odds: winner[key] };
|
||||||
|
}
|
||||||
|
|
||||||
function formatGameTime(iso) {
|
function formatGameTime(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
try {
|
try {
|
||||||
@@ -309,6 +352,11 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
|||||||
line: rec.line,
|
line: rec.line,
|
||||||
side,
|
side,
|
||||||
grade: rec.grade,
|
grade: rec.grade,
|
||||||
|
// A1 S3 — the prop's own book + the best available price across the
|
||||||
|
// game's book rows for the graded side (null unless ≥2 books at the
|
||||||
|
// same current line disagree — see detectBestBook).
|
||||||
|
book: p.book || null,
|
||||||
|
bestBook: detectBestBook(p.books, side === 'U' ? 'under' : 'over', p.line),
|
||||||
gradedAt: rec.gradedAt
|
gradedAt: rec.gradedAt
|
||||||
? { ...rec.gradedAt, ago: gradedAgo(rec.gradedAt.timestamp, now) }
|
? { ...rec.gradedAt, ago: gradedAgo(rec.gradedAt.timestamp, now) }
|
||||||
: null,
|
: null,
|
||||||
@@ -324,6 +372,8 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
|||||||
} else {
|
} else {
|
||||||
byPlayer[pk].props.push({
|
byPlayer[pk].props.push({
|
||||||
stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true,
|
stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true,
|
||||||
|
book: p.book || null,
|
||||||
|
bestBook: detectBestBook(p.books, p.direction || 'over', p.line),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,6 +424,7 @@ function pitchersForGameTeams(awayTeam, homeTeam, pitcherMap) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
parseAmericanOdds,
|
parseAmericanOdds,
|
||||||
detectBestLines,
|
detectBestLines,
|
||||||
|
detectBestBook,
|
||||||
mapGameLines,
|
mapGameLines,
|
||||||
mapScheduleToGameCards,
|
mapScheduleToGameCards,
|
||||||
formatGameTime,
|
formatGameTime,
|
||||||
|
|||||||
Reference in New Issue
Block a user