1/* CSL web analytics â GA4 event layer (Session A Phase 2a, 2026-05-25). 2 * 3 * Loaded sitewide via hooks.py `web_include_js`. The GA4 base tag + Consent 4 * Mode v2 defaults are emitted server-side into <head> by 5 * get_web_analytics_head() (utils/website.py); THIS file fires only EVENTS. 6 * 7 * window.cslTrack(event, params): safe wrapper â no-ops if gtag is absent. 8 * (Consent is enforced by gtag/Consent Mode itself, set in the <head> defaults; 9 * EEA/UK visitors are default-denied until a banner grants â gtag handles that.) 10 * 11 * Conversion events handled here: 12 * - purchase (RETAIL): landing pages set window.cslPurchaseData = {...}; we fire 13 * once, dedup by transaction_id (sessionStorage), then strip ?purchased=1 from 14 * the URL so a refresh / later revisit cannot re-fire. 15 * - wholesale_order (custom, NEVER purchase): on /me?gap_f_order=ok, fire a 16 * DISTINCT event (wholesale draft order, no card charged) so wholesale never 17 * pollutes retail conversion / revenue metrics. 18 * 19 * Phase 2b will add funnel events (view_item, add_to_cart, view_cart, 20 * begin_checkout, add_payment_info) through the same window.cslTrack wrapper. 21 */ 22(function () { 23 'use strict'; 24 25 var FIRED_KEY = 'csl_ga_fired'; // sessionStorage map of already-fired ids 26 27 function gtagReady() { 28 return typeof window.gtag === 'function'; 29 } 30 31 function alreadyFired(id) { 32 if (!id) return false; 33 try { 34 var raw = sessionStorage.getItem(FIRED_KEY); 35 var set = raw ? JSON.parse(raw) : {}; 36 return !!set[id]; 37 } catch (e) { return false; } 38 } 39 40 function markFired(id) { 41 if (!id) return; 42 try { 43 var raw = sessionStorage.getItem(FIRED_KEY); 44 var set = raw ? JSON.parse(raw) : {}; 45 set[id] = 1; 46 sessionStorage.setItem(FIRED_KEY, JSON.stringify(set)); 47 } catch (e) { /* private mode / quota â degrade silently */ } 48 } 49 50 // Public safe event wrapper (used here + by Phase 2b funnel events). 51 window.cslTrack = function (event, params) { 52 if (!gtagReady()) return; 53 try { window.gtag('event', event, params || {}); } catch (e) { /* noop */ } 54 }; 55 56 // GA4 standard event name -> Meta Pixel standard event name. 57 var GA4_TO_META = { 58 view_item: 'ViewContent', 59 add_to_cart: 'AddToCart', 60 begin_checkout: 'InitiateCheckout', 61 purchase: 'Purchase' 62 }; 63 64 // Map our item rows ({item_id,item_name,price,quantity}) -> Meta content_ids. 65 // PII-safe: only product codes leave the page, never customer data. 66 function metaContentIds(items) { 67 if (!Array.isArray(items)) return []; 68 return items.map(function (i) { return i && (i.item_id || i.id); }) 69 .filter(Boolean); 70 } 71 72 // Fire a standard ecommerce funnel event to BOTH GA4 and Meta Pixel in one 73 // call. data = { value, currency, items:[{item_id,item_name,price,quantity}] }. 74 // PII-safe by contract: pass product ids / value / currency only â NEVER an 75 // email, name, address, or any card data. Used by the (Phase 2b) funnel hooks 76 // on PDP / cart / checkout. No-ops silently if the tag libs aren't present. 77 window.cslTrackEcommerce = function (ga4Event, data) { 78 data = data || {}; 79 var items = Array.isArray(data.items) ? data.items : []; 80 window.cslTrack(ga4Event, { 81 value: Number(data.value) || 0, 82 currency: data.currency || 'USD', 83 items: items 84 }); 85 var metaEvent = GA4_TO_META[ga4Event]; 86 if (metaEvent && typeof window.fbq === 'function') { 87 try { 88 window.fbq('track', metaEvent, { 89 value: Number(data.value) || 0, 90 currency: data.currency || 'USD', 91 content_type: 'product', 92 content_ids: metaContentIds(items), 93 num_items: items.length 94 }); 95 } catch (e) { /* noop */ } 96 } 97 }; 98 99 function stripQueryFlag(flag) { 100 try { 101 var url = new URL(window.location.href); 102 if (url.searchParams.has(flag)) { 103 url.searchParams.delete(flag); 104 window.history.replaceState({}, document.title, 105 url.pathname + (url.search || '') + url.hash); 106 } 107 } catch (e) { /* noop */ } 108 } 109 110 // --- Retail purchase: data set inline by the confirmation templates --------- 111 function firePurchase() { 112 var d = window.cslPurchaseData; 113 if (!d || !d.transaction_id) return; 114 if (alreadyFired(d.transaction_id)) { stripQueryFlag('purchased'); return; } 115 window.cslTrack('purchase', { 116 transaction_id: d.transaction_id, 117 value: Number(d.value) || 0, 118 tax: Number(d.tax) || 0, 119 shipping: Number(d.shipping) || 0, 120 currency: d.currency || 'USD', 121 items: Array.isArray(d.items) ? d.items : [] 122 }); 123 // Google Ads conversion â shares the same gtag; the AW config is emitted in 124 // <head> (get_web_analytics_head). Fires only when both the conversion id 125 // and the purchase label are configured. transaction_id dedups in Ads too. 126 var cfg = window.cslAnalyticsCfg || {}; 127 if (cfg.google_ads_id && cfg.google_ads_purchase_label && gtagReady()) {
128 try { 129 window.gtag('event', 'conversion', { 130 send_to: cfg.google_ads_id + '/' + cfg.google_ads_purchase_label, 131 value: Number(d.value) || 0, 132 currency: d.currency || 'USD', 133 transaction_id: d.transaction_id 134 }); 135 } catch (e) { /* noop */ } 136 } 137 // Meta Pixel Purchase â PII-safe (product ids + value + currency only). 138 if (typeof window.fbq === 'function') { 139 try { 140 window.fbq('track', 'Purchase', { 141 value: Number(d.value) || 0, 142 currency: d.currency || 'USD', 143 content_type: 'product', 144 content_ids: metaContentIds(Array.isArray(d.items) ? d.items : []), 145 num_items: Array.isArray(d.items) ? d.items.length : 0 146 }); 147 } catch (e) { /* noop */ } 148 } 149 markFired(d.transaction_id); 150 stripQueryFlag('purchased'); // belt-and-suspenders: revisit w/o flag won't refire 151 } 152 153 // --- Wholesale order: distinct event, never `purchase` ---------------------- 154 function fireWholesaleOrder() { 155 var path = window.location.pathname.replace(/\/+$/, ''); 156 if (path !== '/me') return; 157 var params = new URLSearchParams(window.location.search); 158 if (params.get('gap_f_order') !== 'ok') return; 159 var si = params.get('si'); 160 var so = params.get('so'); 161 if (!si && !so) return; 162 var key = 'wholesale:' + (si || '') + ':' + (so || ''); 163 if (alreadyFired(key)) return; 164 var wtotal = parseFloat(params.get('wtotal')); 165 window.cslTrack('wholesale_order', { 166 customer_type: 'wholesale', 167 value: isNaN(wtotal) ? 0 : wtotal, 168 currency: 'USD', 169 sales_invoice: si || '', 170 sales_order: so || '' 171 }); 172 markFired(key); 173 } 174 175 function run() { 176 firePurchase(); 177 fireWholesaleOrder(); 178 } 179 180 if (document.readyState === 'loading') { 181 document.addEventListener('DOMContentLoaded', run); 182 } else { 183 run(); 184 } 185})();
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.