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,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,
|
||||
};
|
||||
Reference in New Issue
Block a user