PageSourceSearch

https://affiliate.jalpi.com/cp-referral.js?tenant=YOUR_TENANT_ID&pk=pk_live_...

js jalpi.com collected 2026-10-02 03:47:37 UTC 18,341 bytes, 425 lines download raw bytes

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.