PageSourceSearch

https://www.trackply.com/src/js/attribution.js

js trackply.com collected 2026-10-02 00:57:55 UTC 6,613 bytes, 144 lines download raw bytes

1// Signup-source attribution.
2//
3// captureAttribution()         — call once at boot, before any URL manipulation.
4//                                Stashes UTMs / Google click IDs + referrer in
5//                                localStorage (first-touch, tagged visits upgrade).
6// maybeWriteAttribution(user)  — call after every SIGNED_IN event.
7//                                Writes stored data to profiles while unset.
8//                                Always fire-and-forget — never throws, never blocks login.
9
10import { supabase } from './supabase.js';
11
12const ATTR_KEY = 'trackply_attribution';
13const REF_KEY  = 'trackply_ref';
14
15// ── Referral capture (persisted in localStorage so it survives the
16//    signup → email-confirm → sign-in gap, unlike sessionStorage) ──────
17export function captureReferral() {
18  try {
19    const code = new URLSearchParams(window.location.search).get('ref');
20    if (code) localStorage.setItem(REF_KEY, code.trim().toUpperCase().slice(0, 16));
21  } catch { /* localStorage unavailable */ }
22}
23export function getStoredReferral() {
24  try { return localStorage.getItem(REF_KEY) || ''; } catch { return ''; }
25}
26export function clearStoredReferral() {
27  try { localStorage.removeItem(REF_KEY); } catch {}
28}
29
30// ── Capture ────────────────────────────────────────────────────────
31
32export function captureAttribution() {
33  try {
34    const params       = new URLSearchParams(window.location.search);
35    let utm_source     = params.get('utm_source')   || '';
36    let utm_medium     = params.get('utm_medium')   || '';
37    const utm_campaign = params.get('utm_campaign') || '';
38    const referrer     = document.referrer           || '';
39    const landing_page = window.location.pathname   || '/';
40    // Google Ads auto-tagging sends ?gclid= (or gbraid/wbraid on iOS) and NO
41    // utm_* unless they're manually baked into the ad's Final URL. Every ad
42    // click therefore arrived utm-less and recorded as plain google referrer
43    // traffic. A click ID is definitive proof of a paid Google click — derive
44    // the utms from it when they're absent.
45    const gclid = params.get('gclid') || params.get('gbraid') || params.get('wbraid') || '';
46    if (!utm_source && gclid) {
47      utm_source = 'google';
48      utm_medium = 'cpc';
49    }
50    // Raw first-touch query string (ground truth for debugging attribution —
51    // shows exactly what real visitors arrive with).
52    const landing_query = (window.location.search || '').slice(0, 500);
53
54    // localStorage, not sessionStorage: the blob must survive the signup →
55    // email-confirm → new-tab gap AND "browse today, sign up tomorrow"
56    // (sessionStorage is per-tab — it silently dropped both; see REF_KEY
57    // above, which always used localStorage for exactly this reason).
58    // Read sessionStorage once as a legacy carry-over for in-flight visitors.
59    let stored = null;
60    try { stored = JSON.parse(localStorage.getItem(ATTR_KEY) || sessionStorage.getItem(ATTR_KEY) || 'null'); } catch {}
61
62    // First touch wins — EXCEPT a tagged visit (utm or click ID) upgrades an
63    // untagged first touch. Without this, an earlier organic/direct visit's
64    // blob permanently ate the ad click's utm_* (reproduced live; zeroed utm
65    // capture in prod).
66    if (stored && (stored.utm_source || !utm_source)) {
67      try { localStorage.setItem(ATTR_KEY, JSON.stringify(stored)); } catch {} // migrate legacy session blob
68      return;
69    }
70
71    localStorage.setItem(ATTR_KEY, JSON.stringify({
72      signup_source:   _deriveSource(utm_source, referrer),
73      signup_referrer: referrer,
74      utm_source,
75      utm_medium,
76      utm_campaign,
77      landing_page,
78      gclid,
79      landing_query,
80    }));
81  } catch { /* storage unavailable — skip silently */ }
82}
83
84function _deriveSource(utm_source, referrer) {
85  if (utm_source) return utm_source;
86  if (!referrer)  return 'direct';
87  try {
88    if (new URL(referrer).hostname === window.location.hostname) return 'direct';
89  } catch {}
90  const r = referrer.toLowerCase();
91  if (r.includes('reddit'))                                       return 'reddit';
92  if (r.includes('linkedin'))                                     return 'linkedin';
93  if (r.includes('producthunt'))                                  return 'producthunt';
94  if (r.includes('twitter') || r.includes('x.com') || r.includes('t.co')) return 'twitter';
95  if (r.includes('google'))                                       return 'google';
96  return 'organic';
97}
98
99// ── Write ──────────────────────────────────────────────────────────
100
101export async function maybeWriteAttribution(user) {
102  if (!user) return;
103  try {
104    const raw = localStorage.getItem(ATTR_KEY) || sessionStorage.getItem(ATTR_KEY);
105    if (!raw) return;
106
107    const attr = JSON.parse(raw);
108    const n    = v => v || null; // empty string → null
109
110    const { data: prof } = await supabase
111      .from('profiles')
112      .select('signup_source, utm_source')
113      .eq('id', user.id)
114      .maybeSingle();
115
116    const patch = {};
117    // First-touch source/referrer: only when never written.
118    if (!prof || !prof.signup_source) {
119      patch.signup_source   = n(attr.signup_source);
120      patch.signup_referrer = n(attr.signup_referrer);
121      patch.landing_page    = n(attr.landing_page);
122      patch.landing_query   = n(attr.landing_query);
123    }
124    // UTMs fill independently: the old single guard (signup_source IS NULL)
125    // froze rows forever once an utm-less blob wrote signup_source='google'
126    // via the referrer heuristic — utm_* could then never be recorded. Now a
127    // tagged blob can fill utm_* on any later login while they're still null.
128    if (!prof?.utm_source && attr.utm_source) {
129      patch.utm_source   = n(attr.utm_source);
130      patch.utm_medium   = n(attr.utm_medium);
131      patch.utm_campaign = n(attr.utm_campaign);
132      patch.gclid        = n(attr.gclid);
133    }
134    if (!Object.keys(patch).length) return;
135
136    // Upsert, not update: at the very first SIGNED_IN of a brand-new user the
137    // profiles row may not exist yet — an UPDATE would silently no-op and the
138    // attribution write never retried with a session-scoped blob.
139    await supabase
140      .from('profiles')
141      .upsert({ id: user.id, ...patch, updated_at: new Date().toISOString() });
142
143  } catch { /* attribution is nice-to-have — never block */ }
144}

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.