1/*! Addcomposites site event contract â privacy-first, disabled by default. 2 * 3 * This file defines WHAT the site considers a meaningful event. It deliberately 4 * does not define WHERE those events go. There is no vendor here, no provider 5 * script, no persistence, no identifier, and no reading of anything the visitor 6 * typed. Nothing leaves the page unless an operator wires up a transport AND 7 * the visitor has agreed during this page lifecycle. 8 * 9 * Public API â window.acAnalytics 10 * track(name, properties) -> boolean true when the name is part of the contract 11 * setConsent(boolean) -> boolean this page lifecycle only, never remembered 12 * getState() -> object { enabled, consent, ready, events } 13 * 14 * Every track() call dispatches a same-page CustomEvent('ac:analytics') on 15 * document, whether or not anything is switched on. That is the observable 16 * surface: QA can assert on the contract, and a future adapter can subscribe to 17 * it, without a single byte going anywhere. 18 * 19 * Activation requires all three of these, checked on every call: 20 * 1. window.ADDCOMPOSITES_ANALYTICS.enabled === true 21 * 2. window.ADDCOMPOSITES_ANALYTICS.transport is a callable 22 * 3. acAnalytics.setConsent(true) has run on this page 23 * None of them is true by default, so out of the box this file is an event 24 * vocabulary and nothing more. On the public site media/consent.js is the operator 25 * that supplies the transport and calls setConsent() from the visitor's stored 26 * cookie choice; this file itself still stores and sends nothing. 27 * 28 * Properties are allowlisted, not filtered. An unknown key is dropped rather 29 * than sanitised, which is the only way to be sure a form value, an address or 30 * a search term never reaches a property bag by accident. 31 */ 32(function (window, document) { 33 'use strict'; 34 35 if (!window || !document || window.acAnalytics) return; 36 37 /* The whole vocabulary. track() refuses anything not on this list, so a typo 38 in a caller is a silent no-op rather than a new, undocumented event. */ 39 var EVENTS = [ 40 'hero_cta', 41 'product_cta', 42 'rfq_start', 43 'enquiry_open', 44 'enquiry_submit', 45 'enquiry_success', 46 'enquiry_error', 47 'resource_request', 48 'meeting_booking', 49 'video_play', 50 'video_open', 51 'video_dwell_open', 52 'video_close', 53 'section_view', 54 'story_nav', 55 'app_launch', 56 'blog_to_product', 57 'newsletter_submit', 58 'newsletter_success', 59 'newsletter_error' 60 ]; 61 62 /* Everything a caller is allowed to describe an event with. Note what is not 63 here: anything free-text from a form, any address, any query string, any 64 referrer, any client identifier. */ 65 var ALLOWED_PROPERTIES = [ 66 'label', 67 'section', 68 'cta', 69 'product', 70 'app', 71 'resource', 72 'project', 73 'request_type', 74 'destination_path', 75 'destination_kind', 76 'destination_origin', 77 'media', 78 'reason', 79 /* Journey/heatmap analysis. All low-cardinality: a bounded input family, a 80 bounded section index, a chapter label, a first-party video id, a trigger 81 family and a dwell boolean. No path, no free text, no identifier. */ 82 'nav_input', 83 'section_index', 84 'video', 85 'trigger', 86 'dwell' 87 ]; 88 89 var CHANNEL = 'ac:analytics'; 90 var MAX_TEXT = 80; 91 92 /* Per page lifecycle. Deliberately a plain variable: consent is not written 93 anywhere, so a reload starts from no again and the visitor is never 94 silently remembered. */ 95 var consent = false; 96 97 // ---- value handling ------------------------------------------------------ 98 99 function clean(value) { 100 if (value === true || value === false) return value; 101 if (typeof value === 'number') return isFinite(value) ? value : null; 102 if (typeof value !== 'string') return null; 103 var text = value.replace(/\s+/g, ' ').trim(); 104 if (!text) return null; 105 // An '@' is an address, a '?' or '#' is a query or a fragment. None of the 106 // three belongs in a recorded property, and each is a plausible way for a 107 // form value or a tracking parameter to sneak in through a label. 108 if (text.indexOf('@') !== -1 || text.indexOf('?') !== -1 || text.indexOf('#') !== -1) return null; 109 return text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text; 110 } 111 112 function sanitize(properties) { 113 var safe = {}; 114 if (!properties || typeof properties !== 'object') return safe; 115 for (var i = 0; i < ALLOWED_PROPERTIES.length; i++) { 116 var key = ALLOWED_PROPERTIES[i]; 117 if (!Object.prototype.hasOwnProperty.call(properties, key)) continue; 118 var value = clean(properties[key]); 119 if (value !== null) safe[key] = value; 120 } 121 return safe; 122 } 123 124 function stripPath(path) { 125 if (typeof path !== 'string' || !path) return ''; 126 return path.split('?')[0].split('#')[0]; 127 } 128 129 function pagePath() { 130 return stripPath((window.location && window.location.pathname) || '') || '/'; 131 } 132 133 // ---- activation ---------------------------------------------------------- 134 135 function settings() { 136 var config = window.ADDCOMPOSITES_ANALYTICS; 137 return (config && typeof config === 'object') ? config : null; 138 } 139 140 function hasTransport() { 141 var config = settings(); 142 return !!(config && typeof config.transport === 'function'); 143 } 144 145 function isEnabled() { 146 var config = settings(); 147 return !!(config && config.enabled === true && typeof config.transport === 'function'); 148 } 149 150 // ---- the API ------------------------------------------------------------ 151 152 function track(name, properties) { 153 if (typeof name !== 'string' || EVENTS.indexOf(name) === -1) return false;
154 155 var enabled = isEnabled(); 156 var event = { 157 name: name, 158 properties: sanitize(properties), 159 page: pagePath(), 160 consent: consent, 161 enabled: enabled 162 }; 163 164 try { 165 document.dispatchEvent(new window.CustomEvent(CHANNEL, { detail: event, bubbles: false })); 166 } catch (error) { 167 /* No CustomEvent constructor: the contract degrades to a no-op rather 168 than throwing inside somebody's click handler. */ 169 } 170 171 if (enabled && consent) { 172 try { 173 settings().transport(event); 174 } catch (error) { 175 /* A broken adapter is the adapter's problem, never the page's. */ 176 } 177 } 178 return true; 179 } 180 181 function setConsent(value) { 182 consent = value === true; 183 return consent; 184 } 185 186 function getState() { 187 return { 188 enabled: isEnabled(), 189 consent: consent, 190 ready: hasTransport(), 191 events: EVENTS.slice() 192 }; 193 } 194 195 window.acAnalytics = { track: track, setConsent: setConsent, getState: getState }; 196 197 // ---- delegated classification ------------------------------------------- 198 // 199 // Everything below is convenience. It turns ordinary markup into contract 200 // events without the page authors having to call track() by hand, and it is 201 // conservative on purpose: when a link does not clearly match a rule, no 202 // event is produced at all. 203 204 /* Matches both the file form (products/afp-xs.html, used by a local preview) and the 205 public extensionless form (/products/afp-xs, and /products for the index). */ 206 var PRODUCT_PAGE = /(?:^|\/)products(?:\/([a-z0-9][a-z0-9-]*?)(?:\.html)?)?\/?$/; 207 var RESOURCE_FILE = /(?:^|\/)media\/downloads\/([^\/]+)$/; 208 var BLOG_PAGE = /^\/post(?:\/|$)/; 209 var FIRST_PARTY = /(?:^|\.)addcomposites\.com$/; 210 var SITE_HOSTS = { 'www.addcomposites.com': 1, 'addcomposites.com': 1 }; 211 /* Google Calendar appointment pages (calendar.app.google/<id>) are the team's booking 212 links. The booking id is never recorded: the destination is external, so only its 213 category reaches the event. */ 214 var BOOKING_HOSTS = { 'calendar.app.google': 1, 'calendly.com': 1, 'www.calendly.com': 1 }; 215 216 function attr(element, name) { 217 return (element.getAttribute && element.getAttribute(name)) || ''; 218 } 219 220 function labelOf(element) { 221 var text = attr(element, 'aria-label') || element.textContent || ''; 222 return text.replace(/\s+/g, ' ').trim(); 223 } 224 225 function parseUrl(element) { 226 var raw = attr(element, 'href'); 227 if (!raw) return null; 228 try { 229 return new window.URL(raw, window.location.href); 230 } catch (error) { 231 return null; 232 } 233 } 234 235 function originCategory(url) { 236 var host = (url.hostname || '').toLowerCase(); 237 if (!host || host === (window.location.hostname || '').toLowerCase()) return 'internal'; 238 if (SITE_HOSTS[host]) return 'internal'; 239 if (FIRST_PARTY.test(host)) return 'addcomposites'; 240 return 'external'; 241 } 242 243 /* The first hostname label of a first-party subdomain is the app's own slug 244 (addwind, addstack, resin-flow-simulator...). It is our own naming, not a 245 third party's, so it is safe to record as an identifier. Third-party hosts 246 are never recorded â they collapse into the 'external' category. */ 247 function appSlug(element, url) { 248 var tagged = attr(element, 'data-ac-app'); 249 if (tagged) return tagged; 250 var host = (url.hostname || '').toLowerCase(); 251 if (SITE_HOSTS[host] || !FIRST_PARTY.test(host)) return ''; 252 return host.split('.')[0]; 253 } 254 255 function isEnquiryTrigger(element) { 256 if (element.closest('#ac-enquiry-root')) return true; 257 if (element.hasAttribute('data-ac-enquiry-open') || element.hasAttribute('data-ac-request')) return true; 258 return attr(element, 'href') === '#ask-addcomposites'; 259 } 260 261 /* Returns [name, extraProperties] or null when nothing on this element is 262 recognisably one of our events. */ 263 function classify(element, url) { 264 var tagged = attr(element, 'data-ac-event'); 265 if (tagged) { 266 return EVENTS.indexOf(tagged) === -1 ? null : [tagged, { 267 cta: attr(element, 'data-ac-cta'), 268 product: attr(element, 'data-ac-product'), 269 app: attr(element, 'data-ac-app'), 270 resource: attr(element, 'data-ac-resource-key'), 271 section: attr(element, 'data-ac-section') 272 }]; 273 } 274 275 // The enquiry layer reports its own lifecycle. Classifying its triggers too 276 // would double count every open. 277 if (isEnquiryTrigger(element)) return null; 278 if (!url) return null; 279 280 var host = (url.hostname || '').toLowerCase(); 281 var path = stripPath(url.pathname || ''); 282 283 if (BOOKING_HOSTS[host]) return ['meeting_booking', {}]; 284 285 if (originCategory(url) === 'addcomposites') { 286 var slug = appSlug(element, url); 287 return slug ? ['app_launch', { app: slug }] : null; 288 } 289 290 if (originCategory(url) !== 'internal') return null; 291 292 var product = PRODUCT_PAGE.exec(path); 293 if (product) { 294 var name = (!product[1] || product[1] === 'index') ? 'products-index' : product[1]; 295 // The same destination means something different when the visitor came 296 // from an article: that is the blog earning its keep, not a product page 297 // cross-sell. 298 return [BLOG_PAGE.test(pagePath()) ? 'blog_to_product' : 'product_cta', { product: name }]; 299 } 300 301 var resource = RESOURCE_FILE.exec(path); 302 if (resource || element.hasAttribute('download')) { 303 var file = resource ? resource[1] : path.split('/').pop(); 304 return file ? ['resource_request', { resource: file.replace(/\.[a-z0-9]+$/i, '') }] : null; 305 } 306 307 return null; 308 } 309 310 function onClick(event) { 311 if (event.defaultPrevented) return; 312 var target = event.target; 313 if (!target || !target.closest) return; 314 var element = target.closest('a[href],button,[data-ac-event]'); 315 if (!element) return; 316 317 var url = parseUrl(element); 318 var result = classify(element, url); 319 if (!result) return; 320 321 var properties = result[1] || {}; 322 properties.label = labelOf(element); 323 324 if (url) { 325 var category = originCategory(url); 326 var protocol = (url.protocol || '').toLowerCase(); 327 properties.destination_origin = category; 328 if (protocol === 'mailto:') { 329 properties.destination_kind = 'mail'; 330 } else if (protocol === 'tel:') { 331 properties.destination_kind = 'tel'; 332 } else if (category === 'internal' && stripPath(url.pathname || '') === pagePath()) { 333 properties.destination_kind = 'anchor'; 334 } else { 335 properties.destination_kind = category === 'internal' ? 'page' : 'offsite'; 336 // Path only, and only for destinations we own. An external path can 337 // carry identifiers we have no business recording. 338 if (category !== 'external') properties.destination_path = stripPath(url.pathname || ''); 339 } 340 } 341 342 track(result[0], properties); 343 } 344 345 /* 'play' does not bubble, so this listens in the capture phase instead of on 346 each element. The identifier is the file's own name, never a client one. */ 347 function onPlay(event) { 348 var media = event.target; 349 if (!media || !media.tagName || media.tagName.toLowerCase() !== 'video') return; 350 var tagged = attr(media, 'data-ac-media');
351 var source = tagged || stripPath(media.currentSrc || media.getAttribute('poster') || ''); 352 var file = tagged || (source ? source.split('/').pop().replace(/\.[a-z0-9]+$/i, '') : ''); 353 track('video_play', { media: file, label: attr(media, 'aria-label') }); 354 } 355 356 document.addEventListener('click', onClick, false); 357 document.addEventListener('play', onPlay, true); 358})(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.