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.