PageSourceSearch

https://www.addcomposites.com/media/site-events.js

js addcomposites.com collected 2026-10-02 05:45:55 UTC 13,307 bytes, 358 lines download raw bytes

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.