1/** 2 * Affiliate by Jalpi â referral tracking snippet 3 * ============================================== 4 * Embed this ONCE in the <head> of every page on your site. 5 * 6 * <script src="https://affiliate.jalpi.com/cp-referral.js?tenant=YOUR_TENANT_ID&pk=pk_live_..." async></script> 7 * 8 * The `pk` is your PUBLISHABLE key, from the API Keys page. It is safe to put in 9 * page source â that is the whole point of it. It can only record clicks and 10 * signups, only from the origins you registered against it, and it can never 11 * read your data or touch money. 12 * 13 * Do NOT put your secret key (ak_...) here. It would be visible to every visitor, 14 * and the server rejects secret keys sent from a browser anyway. 15 * 16 * The script automatically: 17 * - Reads ?ref=CODE (and ?aff_pid=PRODUCT) from the URL 18 * - Stores them in localStorage + cookie (survives navigation for 30 days) 19 * - Records the click via /track-click 20 * - Injects the code into your signup form as a hidden field named `ref` 21 * (also as `cp_ref`, the previous name, so existing integrations keep working) 22 * - Injects the click id as a hidden field named `click_id` â pass it to 23 * reportSignup() so the referral links back to the click that caused it 24 * 25 * That last one is the point. SIGNUPS AND PURCHASES ARE NOT REPORTED FROM HERE â 26 * both decide who gets paid, and this key is public, so neither may be written by 27 * a browser. Your server does it, using your SECRET key: 28 * 29 * npm i @jalpi/affiliate 30 * 31 * // after registration â `ref` and `click_id` arrive with your signup form POST 32 * await jalpi.integrations.reportSignup({ 33 * userId, email, referralCode: form.ref, clickId: form.click_id, 34 * }) 35 * 36 * // after your payment provider confirms the charge 37 * await jalpi.integrations.reportPurchase({ userId, orderId, amount, ... }) 38 * 39 * If a browser could write those, anyone could read this key out of your page, 40 * become one of your partners, and claim your customers' commissions. 41 * See https://affiliate.jalpi.com/developers 42 */ 43 44(function (window, document) { 45 'use strict'; 46 47 // ââ Config ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 48 // Stored under plain names ('ref', 'pid', 'click') rather than the old cp_ 49 // prefix. The LEGACY_ names are still READ so a visitor who was tagged before 50 // this change keeps their referral for the rest of their 30 days â dropping 51 // them would silently un-attribute everyone mid-window. 52 const STORAGE_KEY = 'ref'; 53 const TS_KEY = 'ref_ts'; 54 const TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days 55 const COOKIE_NAME = 'ref'; 56 const PID_KEY = 'pid'; // product id (?aff_pid=) for product-level attribution 57 const CLICK_KEY = 'click'; // click id returned by /track-click, threaded into signup 58 59 const LEGACY_STORAGE_KEY = 'cp_ref'; 60 const LEGACY_TS_KEY = 'cp_ref_ts'; 61 const LEGACY_COOKIE_NAME = 'cp_ref'; 62 const LEGACY_PID_KEY = 'cp_pid'; 63 const LEGACY_CLICK_KEY = 'cp_click'; 64 65 // The hidden form field keeps its old name TOO. Integrators' servers read 66 // `form.cp_ref` today, and renaming it would mean no code reaches their signup 67 // handler, no referral, no commission â and no error anywhere to notice it by. 68 // Both fields are injected: new integrations use `ref`, existing ones are 69 // untouched. 70 const FORM_FIELD = 'ref'; 71 const LEGACY_FORM_FIELD = 'cp_ref'; 72 // Carries the click id into the signup POST alongside the code, so the server 73 // can pass it to /report-signup. No legacy name â this was never emitted before. 74 const CLICK_FORM_FIELD = 'click_id'; 75 76 // Read tenant from script tag query param: ?tenant=XXXX 77 const scriptTag = document.currentScript || (function () { 78 var scripts = document.getElementsByTagName('script'); 79 return scripts[scripts.length - 1]; 80 })(); 81 const scriptUrl = new URL(scriptTag.src); 82 const TENANT_ID = scriptUrl.searchParams.get('tenant') || ''; 83 // Publishable key. Accept it from ?pk= or a data-pk attribute, whichever the 84 // integrator finds more natural. 85 const PUBLIC_KEY = scriptUrl.searchParams.get('pk') 86 || scriptTag.getAttribute('data-pk') 87 || ''; 88 const API_BASE = scriptUrl.origin; // Same origin as the script 89 90 if (!PUBLIC_KEY) { 91 console.warn( 92 '[Jalpi] No publishable key. Add ?pk=pk_live_... to the script URL, or clicks and ' + 93 'signups will not be recorded. Get one from the API Keys page.' 94 ); 95 } else if (PUBLIC_KEY.indexOf('pk_') !== 0) { 96 // Loud, because the likely cause is that someone pasted their secret key into 97 // a public web page and it is now compromised. 98 console.error( 99 '[Jalpi] That is not a publishable key. Only pk_... keys belong in a browser. ' + 100 'If you pasted a secret key (ak_...), it is now exposed to every visitor â ' + 101 'revoke it on the API Keys page immediately.' 102 ); 103 } 104 105 // ââ Utilities âââââââââââââââââââââââââââââââââââââââââââââââââââââ 106 function setCookie(name, value, days) { 107 var expires = new Date(Date.now() + days * 864e5).toUTCString(); 108 document.cookie = name + '=' + encodeURIComponent(value) + 109 '; expires=' + expires + '; path=/; SameSite=Lax'; 110 } 111 112 function getCookie(name) { 113 return document.cookie.split('; ').reduce(function (r, v) { 114 var parts = v.split('='); 115 return parts[0] === name ? decodeURIComponent(parts[1]) : r; 116 }, ''); 117 } 118 119 function deleteCookie(name) { 120 document.cookie = name + '=; Max-Age=0; path=/'; 121 } 122 123 function saveRef(code) { 124 try {
125 localStorage.setItem(STORAGE_KEY, code); 126 localStorage.setItem(TS_KEY, String(Date.now())); 127 } catch (e) { /* private browsing */ } 128 setCookie(COOKIE_NAME, code, 30); 129 } 130 131 function loadRef() { 132 var code = null; 133 try { 134 // Fall back to the pre-rename timestamp so an existing visitor's 30-day 135 // window is measured from when they ACTUALLY clicked, not from now. 136 var ts = parseInt(localStorage.getItem(TS_KEY) || localStorage.getItem(LEGACY_TS_KEY) || '0', 10); 137 if (ts && Date.now() - ts < TTL_MS) { 138 code = localStorage.getItem(STORAGE_KEY) || localStorage.getItem(LEGACY_STORAGE_KEY); 139 } else { 140 localStorage.removeItem(STORAGE_KEY); 141 localStorage.removeItem(TS_KEY); 142 localStorage.removeItem(LEGACY_STORAGE_KEY); 143 localStorage.removeItem(LEGACY_TS_KEY); 144 } 145 } catch (e) { /* private browsing */ } 146 return code || getCookie(COOKIE_NAME) || getCookie(LEGACY_COOKIE_NAME) || null; 147 } 148 149 function clearRef() { 150 try { 151 [STORAGE_KEY, TS_KEY, PID_KEY, CLICK_KEY, 152 LEGACY_STORAGE_KEY, LEGACY_TS_KEY, LEGACY_PID_KEY, LEGACY_CLICK_KEY] 153 .forEach(function (k) { localStorage.removeItem(k); }); 154 } catch (e) { /* */ } 155 [COOKIE_NAME, PID_KEY, CLICK_KEY, 156 LEGACY_COOKIE_NAME, LEGACY_PID_KEY, LEGACY_CLICK_KEY] 157 .forEach(deleteCookie); 158 } 159 160 function savePid(pid) { 161 try { localStorage.setItem(PID_KEY, pid); } catch (e) { /* private browsing */ } 162 setCookie(PID_KEY, pid, 30); 163 } 164 function loadPid() { 165 try { 166 return localStorage.getItem(PID_KEY) || getCookie(PID_KEY) 167 || localStorage.getItem(LEGACY_PID_KEY) || getCookie(LEGACY_PID_KEY) || null; 168 } catch (e) { return getCookie(PID_KEY) || getCookie(LEGACY_PID_KEY) || null; } 169 } 170 function saveClick(id) { 171 try { localStorage.setItem(CLICK_KEY, id); } catch (e) { /* private browsing */ } 172 setCookie(CLICK_KEY, id, 30); 173 } 174 function loadClick() { 175 try { 176 return localStorage.getItem(CLICK_KEY) || getCookie(CLICK_KEY) 177 || localStorage.getItem(LEGACY_CLICK_KEY) || getCookie(LEGACY_CLICK_KEY) || null; 178 } catch (e) { return getCookie(CLICK_KEY) || getCookie(LEGACY_CLICK_KEY) || null; } 179 } 180 181 // Record an anonymous click so product (?aff_pid=) and S2S attribution work, 182 // and persist the returned clickId for report-signup. Mirrors the perk SDK and 183 // the store snippet; previously this script never called track-click, so clicks 184 // and product attribution were lost for SaaS integrations. 185 function trackClick() { 186 var ref = loadRef(); 187 if (!ref) return Promise.resolve(null); 188 return post('/api/integrations/track-click', { 189 referralCode: ref, 190 landingUrl: window.location.href, // server also parses aff_pid from this 191 productId: loadPid() || null 192 }).then(function (res) { return explainFailure(res, 'track-click'); }) 193 .then(function (res) { return res.ok ? res.json() : null; }) 194 .then(function (data) { 195 if (data && data.clickId) { 196 saveClick(data.clickId); 197 // track-click is async, so the FIRST injectIntoForms() ran before the 198 // click id existed and could only add `ref`. Re-run now that we have it, 199 // otherwise the very visit that created the click never carries it into 200 // the signup form. The injector skips fields it already added, so this 201 // is cheap and safe to repeat. 202 injectIntoForms(); 203 } 204 return data; 205 }).catch(function (err) { 206 console.error('[Jalpi] track-click failed:', err); 207 }); 208 } 209 210 function post(path, body) { 211 return fetch(API_BASE + path, { 212 method: 'POST', 213 headers: { 214 'Content-Type': 'application/json', 215 'X-Tenant-Id': TENANT_ID, 216 'X-Publishable-Key': PUBLIC_KEY 217 }, 218 body: JSON.stringify(body) 219 }); 220 } 221 222 // A 401/403 here is almost always a setup mistake, not a transient failure, and 223 // the old snippet swallowed it into console.debug â which is how this went 224 // unnoticed. Say what is wrong and how to fix it. 225 function explainFailure(res, what) { 226 if (res.status === 401 || res.status === 403) { 227 console.error( 228 '[Jalpi] ' + what + ' rejected (' + res.status + '). Either the publishable key is wrong, ' + 229 'or ' + window.location.origin + ' is not in its allowed origins. Add it on the API Keys page.' 230 ); 231 } else if (!res.ok) { 232 console.error('[Jalpi] ' + what + ' failed (' + res.status + ').'); 233 } 234 return res; 235 } 236 237 // ââ Step 1: Capture ?ref= (+ ?aff_pid=) from URL, else from the referrer ââ 238 // Pull ref/aff_pid out of any URL string. 239 function refFrom(urlString) { 240 try { 241 var q = new URL(urlString).searchParams; 242 return { ref: q.get('ref') || q.get('referral'), pid: q.get('aff_pid') }; 243 } catch (e) { return { ref: null, pid: null }; } 244 } 245 246 function capture(found) { 247 if (!found || !found.ref) return; 248 saveRef(found.ref); 249 if (found.pid) savePid(found.pid); 250 trackClick(); // records the click + product so conversions attribute correctly 251 } 252 253 var here = refFrom(window.location.href); 254 if (here.ref) { 255 capture(here); 256 } else if (!loadRef() && document.referrer) { 257 // No ref in THIS url and nothing stored yet. Recover it from the page that sent 258 // the visitor here. This rescues the common misinstall where the snippet is on 259 // (say) only the login page but the shared link lands on the marketing page: 260 // the browser passes "landing?ref=CODE" as this page's referrer, so we can still 261 // read the code. Best-effort only â a strict Referrer-Policy can strip the query, 262 // which is why site-wide placement (snippet in the header of EVERY page) is the 263 // reliable path. Guarded by !loadRef() so a real URL ref is never overwritten. 264 capture(refFrom(document.referrer)); 265 } 266 267 // ââ Step 2: Auto-inject hidden field into any <form> âââââââââââââ 268 // This means clients don't need to write any extra JS â 269 // the code will be automatically submitted with every form. 270 function injectIntoForms() { 271 var ref = loadRef(); 272 if (!ref) return; 273 274 // The click id, when we have one. /report-signup takes it and links the 275 // referral back to the originating row in attribution_clicks â that linkage 276 // is what marks the click converted and lets click-level product attribution 277 // work. We were storing it and never handing it over, so for every 278 // integration using this snippet it was silently thrown away. 279 var clickId = loadClick(); 280 281 var forms = document.querySelectorAll('form');
282 forms.forEach(function (form) { 283 // Both names, every time. `ref` is what we document now; `cp_ref` is what 284 // every existing integrator's server already reads. Dropping the old one 285 // would break them with no error to notice it by. 286 [FORM_FIELD, LEGACY_FORM_FIELD].forEach(function (name) { 287 if (!form.querySelector('input[name="' + name + '"]')) { 288 var input = document.createElement('input'); 289 input.type = 'hidden'; 290 input.name = name; 291 input.value = ref; 292 form.appendChild(input); 293 } 294 }); 295 296 if (clickId && !form.querySelector('input[name="' + CLICK_FORM_FIELD + '"]')) { 297 var clickInput = document.createElement('input'); 298 clickInput.type = 'hidden'; 299 clickInput.name = CLICK_FORM_FIELD; 300 clickInput.value = clickId; 301 form.appendChild(clickInput); 302 } 303 }); 304 } 305 306 // Run now and watch for new forms added dynamically 307 if (document.readyState === 'loading') { 308 document.addEventListener('DOMContentLoaded', injectIntoForms); 309 } else { 310 injectIntoForms(); 311 } 312 313 // Watch for dynamically added forms (SPAs) 314 if (window.MutationObserver) { 315 var observer = new MutationObserver(injectIntoForms); 316 observer.observe(document.body, { childList: true, subtree: true }); 317 } 318 319 // ââ Public API ââââââââââââââââââââââââââââââââââââââââââââââââââââ 320 window.CouponPlatform = { 321 322 /** 323 * Get the currently stored referral code. 324 * Use this when you want to send the code from your own JS code 325 * instead of relying on the hidden form field. 326 * 327 * Example: 328 * var code = CouponPlatform.getRef(); 329 * // Include 'code' in your own API call to your backend 330 */ 331 getRef: function () { 332 return loadRef(); 333 }, 334 335 /** 336 * The click id recorded for this visit, or null. 337 * 338 * Pass it to /report-signup as `clickId` together with the referral code. 339 * That links the referral back to the row in attribution_clicks, which is 340 * what marks the click converted and lets product-level attribution resolve 341 * from the click. It arrives with your signup form as the hidden field 342 * `click_id`, so normally you do not need to read it yourself. 343 * 344 * Returns null when the visitor arrived without ?ref= (nothing was tracked) 345 * or when /track-click has not answered yet. 346 */ 347 getClickId: function () { 348 return loadClick(); 349 }, 350 351 /** 352 * Manually (re)record a click â normally fired automatically on page load 353 * when ?ref= is present. Returns { tracked, clickId }. 354 */ 355 trackClick: function () { 356 return trackClick(); 357 }, 358 359 /** 360 * Report that a new user has registered via a referral link. 361 * Call this immediately after registration is confirmed. 362 * 363 * Example: 364 * CouponPlatform.trackSignup('user-123', '[email protected]'); 365 * 366 * @param {string} userId â Your internal user ID 367 * @param {string} [email] â User's email (optional) 368 */ 369 /** 370 * Removed â on purpose, for the same reason as trackPurchase(). 371 * 372 * Reporting a signup is not a tracking call: it is the BINDING that decides 373 * which partner earns the commission for this user, and the first write wins. 374 * A publishable key sits in your page source where anyone can read it â so if 375 * the browser could write that binding, anyone could take the key, become one 376 * of your partners, and claim other people's users before they sign up. 377 * 378 * Your server already has everything it needs. This snippet adds the referral 379 * code to your signup form as a hidden field named `ref`, so it arrives with 380 * the registration POST. Report it from there: 381 * 382 * import { JalpiClient } from '@jalpi/affiliate'; 383 * await jalpi.integrations.reportSignup({ 384 * userId, email, referralCode: form.ref, 385 * }); 386 * 387 * Or read it yourself with CouponPlatform.getRef(). 388 */ 389 trackSignup: function () { 390 console.error( 391 '[Jalpi] trackSignup() has been removed from the browser snippet. Reporting a signup decides ' + 392 'which partner gets paid for this user, so it must come from your server with your secret key â ' + 393 'otherwise anyone could claim your users. The referral code is already on your signup form as the ' + 394 'hidden field "ref" (or read it with CouponPlatform.getRef()); pass it to ' + 395 'jalpi.integrations.reportSignup(). See https://affiliate.jalpi.com/developers' 396 );
397 return Promise.resolve({ tracked: false, reason: 'server_side_only' }); 398 }, 399 400 /** 401 * Removed â on purpose. 402 * 403 * Reporting a purchase creates a commission, i.e. it moves money. If a browser 404 * could do that, any visitor could open devtools and invent an order. So 405 * /report-purchase only accepts a SECRET key, which must never be in a page. 406 * 407 * Do it from your server, after your payment provider confirms the charge: 408 * 409 * import { JalpiClient } from '@jalpi/affiliate'; 410 * await jalpi.integrations.reportPurchase({ 411 * userId, orderId, amount, isFirstPurchase: true, currency: 'USD', 412 * }); 413 */ 414 trackPurchase: function () { 415 console.error( 416 '[Jalpi] trackPurchase() has been removed from the browser snippet. A purchase creates a ' + 417 'commission, so it must be reported from your server with your secret key â otherwise any ' + 418 'visitor could fake an order. Use @jalpi/affiliate: jalpi.integrations.reportPurchase(...). ' + 419 'See https://affiliate.jalpi.com/developers' 420 ); 421 return Promise.resolve({ tracked: false, reason: 'server_side_only' }); 422 } 423 }; 424 425})(window, document);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.