PageSourceSearch

https://campus.gov.il/wp-content/themes/campusnew/js/analytics-handlers.js?ver=1790082021

js campus.gov.il collected 2026-10-02 06:35:14 UTC 27,126 bytes, 633 lines download raw bytes

1/**
2 * Campus IL — analytics event wiring.
3 *
4 * Wires all WP-only events in the GTM-MPGVQPXZ taxonomy via delegated
5 * listeners. Touches the DOM only by reading attributes — no markup changes
6 * required.
7 *
8 * All event names match the GTM container exactly. All handlers no-op
9 * gracefully when window.campusAnalytics is missing.
10 */
11(function () {
12	'use strict';
13
14	function track(eventName, params) {
15		var w = window;
16		if (!w.campusAnalytics || typeof w.campusAnalytics.track !== 'function') return false;
17		return w.campusAnalytics.track(eventName, params || {});
18	}
19
20	// Buffered track for callbacks that may run before analytics.js loads
21	// (e.g. IntersectionObserver firing on DOMContentLoaded race).
22	function trackWhenReady(eventName, params, attempt) {
23		if (track(eventName, params)) return true;
24		attempt = attempt || 0;
25		if (attempt >= 50) return false; // ~5s max
26		setTimeout(function () { trackWhenReady(eventName, params, attempt + 1); }, 100);
27		return false;
28	}
29
30	function ready(fn) {
31		if (document.readyState === 'loading') {
32			document.addEventListener('DOMContentLoaded', fn);
33		} else {
34			fn();
35		}
36	}
37
38	function host(url) {
39		try { return new URL(url, location.href).hostname; } catch (e) { return ''; }
40	}
41
42	function nearest(el, selector) {
43		while (el && el !== document) {
44			if (el.matches && el.matches(selector)) return el;
45			el = el.parentNode;
46		}
47		return null;
48	}
49
50	function trim100(s) { return (s || '').toString().trim().slice(0, 100); }
51
52	// -------------------------------------------------------------------------
53	// 1. Search form submits — search_submit_{header|mobile|homepage|catalog}
54	// -------------------------------------------------------------------------
55	// The homepage hero's field carries .cil-search — on the form, or on the bar around the legacy
56	// one — and nothing else on the site does. #homepageSearch stays beside it as the old wrapper.
57	ready(function () {
58		document.querySelectorAll('form').forEach(function (form) {
59			var id = form.id || '';
60			var event = null;
61			var onHomepage = nearest(form, '.cil-search') || nearest(form, '#homepageSearch');
62			if (id === 'headerSearchForm') event = 'search_submit_header';
63			else if (id === 'mobileSearchForm') event = 'search_submit_mobile';
64			else if (onHomepage) event = 'search_submit_homepage';
65			else if (id === 'newCatalogSearchForm') event = 'search_submit_catalog';
66			if (!event) return;
67			form.addEventListener('submit', function () {
68				var input = form.querySelector('input[type="text"], input[name="srch"], input[name="search_not_catalog"]');
69				var term  = input ? (input.value || '').trim() : '';
70				if (term) track(event, { search_term: term });
71			});
72		});
73	});
74
75	// -------------------------------------------------------------------------
76	// 2. Autocomplete result click — search_autocomplete_select
77	// -------------------------------------------------------------------------
78	// Reads the term from the input that owns the active autocomplete widget
79	// (the closest text input to the suggestion list, regardless of which form
80	// it lives in).
81	document.addEventListener('click', function (e) {
82		var link = nearest(e.target, '.autocompleteResult a, .ui-autocomplete .ui-menu-item a, .autocomplete-suggestion a');
83		if (!link) return;
84		var widget = link.closest('.ui-autocomplete, .autocompleteResult, .autocomplete-suggestion');
85		var owner = widget && widget.previousElementSibling;
86		var input =
87			(owner && owner.matches && owner.matches('input') ? owner : null) ||
88			(widget && widget.parentElement && widget.parentElement.querySelector('input[type="text"]')) ||
89			document.activeElement && document.activeElement.matches && document.activeElement.matches('input[type="text"]') ? document.activeElement : null;
90		var term = input ? (input.value || '').trim() : '';
91		track('search_autocomplete_select', {
92			search_term: term,
93			suggestion: trim100(link.textContent),
94		});
95	}, true);
96
97	// -------------------------------------------------------------------------
98	// 3. Catalog filter / sort / pagination
99	// -------------------------------------------------------------------------
100	// Map matches the abbreviated input names emitted by inc_catalog_search_and_filter.php
101	// (subj[], clang[], ccert[], drtn[], crdt[], cpacing[], ctype[]).
102	var FILTER_TAX_TO_EVENT = {
103		'subj':    'catalog_filter_change_subject',
104		'clang':   'catalog_filter_change_language',
105		'ccert':   'catalog_filter_change_certificate',
106		'drtn':    'catalog_filter_change_duration',
107		'crdt':    'catalog_filter_change_credit',
108		'cpacing': 'catalog_filter_change_pacing',
109		'ctype':   'catalog_filter_change_type',
110	};
111	var SORT_VALUE_TO_EVENT = {
112		'rel':  'catalog_sort_change_relevance',
113		'new':  'catalog_sort_change_newest',
114		'old':  'catalog_sort_change_oldest',
115		'az':   'catalog_sort_change_az',
116		'za':   'catalog_sort_change_za',
117	};
118
119	document.addEventListener('change', function (e) {
120		var el = e.target;
121		if (!el.name) return;
122
123		var rawName = el.name.replace(/\[\]$/, '');
124		if (FILTER_TAX_TO_EVENT[rawName]) {
125			track(FILTER_TAX_TO_EVENT[rawName], {
126				filter_value: trim100(el.value),
127			});
128			return;
129		}
130
131		if (el.name === 'sort' || el.id === 'newCatalogSortSelect') {
132			var ev = SORT_VALUE_TO_EVENT[el.value];
133			if (ev) track(ev, {});
134		}
135	}, true);
136
137	// Pagination clicks.
138	document.addEventListener('click', function (e) {
139		var pg = nearest(e.target, '.paging[data-page]');
140		if (!pg) return;
141		var n = parseInt(pg.getAttribute('data-page'), 10);
142		if (!isNaN(n)) track('catalog_pagination', { page_number: n });
143	}, true);
144
145	// -------------------------------------------------------------------------
146	// 4. Sharing — share_copy_link, share_facebook, share_linkedin
147	// -------------------------------------------------------------------------
148	document.addEventListener('click', function (e) {
149		var copy = nearest(e.target, '#shareLink, #shareLink1, [data-share="copy"]');
150		if (copy) { track('share_copy_link', {}); return; }
151
152		var fb = nearest(e.target, 'a[href*="facebook.com/sharer"]');
153		if (fb) { track('share_facebook', {}); return; }
154
155		var li = nearest(e.target, 'a[href*="linkedin.com/sharing"], a[href*="linkedin.com/shareArticle"]');
156		if (li) { track('share_linkedin', {}); return; }
157	}, true);
158
159	// -------------------------------------------------------------------------
160	// 5. Course CTA — click_continue_to_course
161	// -------------------------------------------------------------------------
162	// Three CTA classes:
163	//   .linkToEdx                              → enrolled course button (header)
164	//   .register_api_architect                 → unenrolled course enroll
165	//   .register_api_architect_on_program      → program-level enroll button
166	//   .register_api_architect_on_program_single_course → course-card inside program
167	document.addEventListener('click', function (e) {
168		var cta = nearest(e.target,
169			'.linkToEdx, .register_api_architect, .register_api_architect_on_program, .register_api_architect_on_program_single_course'
170		);
171		if (!cta) return;
172		var id = cta.getAttribute('data-id') || '';
173		var ctxItemId = readDataLayerCtx('item_id') || '';
174		var ctaLocation;
175		if (cta.classList.contains('linkToEdx')) ctaLocation = 'item_header';
176		else if (cta.classList.contains('register_api_architect_on_program')) ctaLocation = 'program_enroll';
177		else if (cta.classList.contains('register_api_architect_on_program_single_course')) ctaLocation = 'item_card';
178		else ctaLocation = 'item_card';
179		track('click_continue_to_course', {
180			item_id: (id || ctxItemId || '').toString(),
181			cta_location: ctaLocation,
182		});
183	}, true);
184
185	function readDataLayerCtx(key) {
186		try {
187			var dl = window.dataLayer || [];
188			for (var i = dl.length - 1; i >= 0; i--) {
189				var row = dl[i];
190				if (row && row.event === 'page_context' && key in row) return row[key];
191			}
192		} catch (e) {}
193		return undefined;
194	}
195
196	// -------------------------------------------------------------------------
197	// 6. Contact page — phone, email, social, form submit/failure
198	// -------------------------------------------------------------------------
199	document.addEventListener('click', function (e) {
200		var link = nearest(e.target, 'a[href^="tel:"], a[href^="mailto:"]');
201		if (link) {
202			if (link.href.indexOf('tel:') === 0)    track('contact_phone_click', {});
203			if (link.href.indexOf('mailto:') === 0) track('contact_email_click', {});
204			return;
205		}
206		var contactSocial = nearest(e.target, '.template-page-contact a, [data-contact-social] a, .contact-page a, .the-link.campus-seo-strip-click');
207		if (contactSocial) {
208			var href = contactSocial.getAttribute('href') || '';
209			var platform = detectPlatform(href);
210			if (platform) track('contact_social_click_' + platform, {});
211			return;
212		}
213	}, true);
214
215	function detectPlatform(href) {
216		if (/facebook\.com/i.test(href))   return 'facebook';
217		if (/linkedin\.com/i.test(href))   return 'linkedin';
218		if (/(t\.me|telegram\.me)/i.test(href)) return 'telegram';
219		if (/(twitter\.com|x\.com)/i.test(href)) return 'twitter';
220		if (/instagram\.com/i.test(href))  return 'instagram';
221		if (/(youtube\.com|youtu\.be)/i.test(href)) return 'youtube';
222		return null;
223	}
224
225	// Contact form success/failure are wired directly inside the form's
226	// jQuery validate config (invalidHandler + submitHandler) in
227	// template-page-contact.php — this avoids the dual-listener duplicate-fire.
228
229	// -------------------------------------------------------------------------
230	// 7. Footer menu + footer socials
231	// -------------------------------------------------------------------------
232	document.addEventListener('click', function (e) {
233		var footer = nearest(e.target, 'footer, .footer, .siteFooter');
234		if (!footer) return;
235		var a = nearest(e.target, 'a');
236		if (!a) return;
237		var platform = detectPlatform(a.getAttribute('href') || '');
238		if (platform) {
239			track('footer_social_click_' + platform, {});
240		} else {
241			track('footer_menu_click', {
242				link_text: trim100(a.textContent),
243				link_url:  a.getAttribute('href') || '',
244			});
245		}
246	}, true);
247
248	// -------------------------------------------------------------------------
249	// 8. Mobile menu open / close
250	// -------------------------------------------------------------------------
251	// Direct click listeners only — the previous MutationObserver fallback
252	// caused double-fires.
253	// The drawer's own controls: the burger opens it, and everything that closes it carries
254	// data-cil-drawer-close — the scrim as well as the cross.
255	ready(function () {
256		var btn = document.getElementById('cilMobileMenuToggle');
257		if (btn) btn.addEventListener('click', function () { track('mobile_menu_open', {}); });
258		document.querySelectorAll('[data-cil-drawer-close]').forEach(function (close) {
259			close.addEventListener('click', function () { track('mobile_menu_close', {}); });
260		});
261	});
262
263	// -------------------------------------------------------------------------
264	// 9. Language switch — language_change_to_{he|en|ar|ru}
265	// -------------------------------------------------------------------------
266	// WPML renders the Hebrew variant with href="/" (no /he/ segment) and the
267	// language code only on a child span[lang]. Read all known sources.
268	document.addEventListener('click', function (e) {
269		var item = nearest(e.target, '#languagesMenu a, #cilMobileLanguageSheet a, .language-switch a, [data-lang]');
270		if (!item) return;
271		var inner = item.querySelector('[lang]');
272		// WPML's Hebrew (default) anchor has no data-lang/hreflang/inner-lang and href "/"
273		// — read the parent <li class="wpml-ls-item-{he|en|ar|ru}"> as a fallback.
274		var li = item.closest ? item.closest('li[class*="wpml-ls-item-"]') : null;
275		var liLang = li ? (((li.className || '').match(/wpml-ls-item-(he|en|ar|ru)\b/) || [])[1] || '') : '';
276		var lang =
277			(item.getAttribute('data-lang') || '').toLowerCase() ||
278			(item.getAttribute('hreflang') || '').toLowerCase() ||
279			(inner ? (inner.getAttribute('lang') || '').toLowerCase() : '') ||
280			(item.getAttribute('lang') || '').toLowerCase() ||
281			((item.getAttribute('href') || '').match(/\/(he|en|ar|ru)(?:\/|$)/i) || [])[1] ||
282			liLang ||
283			'';
284		lang = lang.toLowerCase();
285		if (/^(he|en|ar|ru)$/.test(lang)) {
286			track('language_change_to_' + lang, {});
287		}
288	}, true);
289
290	// -------------------------------------------------------------------------
291	// 10. Events archive — event_card_click_{upcoming|past}
292	// -------------------------------------------------------------------------
293	document.addEventListener('click', function (e) {
294		var card = nearest(e.target, '.events2Item, .event-card');
295		if (!card) return;
296		var id = ((card.id || '').match(/(\d+)/) || [])[1] || '';
297		var status = /Upcoing|Upcoming/i.test(card.id) ? 'upcoming' : (/Past/i.test(card.id) ? 'past' : null);
298		if (!status || !id) return;
299		track('event_card_click_' + status, { event_id: id });
300	}, true);
301
302	// -------------------------------------------------------------------------
303	// 11. Course / item cards — select_item
304	// -------------------------------------------------------------------------
305	// Catalog cards use [data-event-course-name] without data-id; fall back to
306	// extracting the course/program post ID from anchor URL or query string.
307	document.addEventListener('click', function (e) {
308		var card = nearest(e.target, '.courseCard, .courseCardNew, .courseInfoBlock, [data-event-course-name]');
309		if (!card) return;
310		// Walk up to a wrapper with data-id (catalog uses `[data-event-course-name]`
311		// on the inner anchor without data-id, but `.courseCardNew` carries it).
312		var idHolder = card.hasAttribute('data-id') ? card : (card.closest ? card.closest('[data-id]') : null);
313		var id = (idHolder ? idHolder.getAttribute('data-id') : '')
314			|| (card.id ? ((card.id.match(/(\d+)$/) || [])[1] || '') : '')
315			|| readPostIdFromAnchor(e.target)
316			|| '';
317		if (!id) return;
318		track('select_item', { item_id: id.toString() });
319	}, true);
320
321	function readPostIdFromAnchor(target) {
322		var a = nearest(target, 'a[href]');
323		if (!a) return '';
324		var href = a.getAttribute('href') || '';
325		var m = href.match(/\/courses?\/(\d+)|\/programs?\/(\d+)/i);
326		if (m) return m[1] || m[2] || '';
327		var pid = href.match(/[?&]pid=(\d+)/i);
328		return pid ? pid[1] : '';
329	}
330
331	// -------------------------------------------------------------------------
332	// 12. Subject / lecturer / institution cards
333	// -------------------------------------------------------------------------
334	document.addEventListener('click', function (e) {
335		var subj = nearest(e.target, 'a.mainSubject, a.subSubject, a[href*="/course_subjects/"]');
336		if (subj) {
337			track('select_subject', { subject_name: trim100(subj.textContent) });
338			return;
339		}
340		var lec = nearest(e.target, '.lecturerCard a, a[href*="/lecturers/"], a[href*="/lecturer/"]');
341		if (lec) {
342			var lecCard = nearest(lec, '.lecturerCard');
343			var lecName = lecCard ? trim100((lecCard.querySelector('h3, .text h3, .name') || {}).textContent) : trim100(lec.textContent);
344			track('select_lecturer', { lecturer_name: lecName });
345			return;
346		}
347		var inst = nearest(e.target, '.institutionCard a, a[href*="/institution/"]');
348		if (inst) {
349			var instCard = nearest(inst, '.institutionCard');
350			var instName = instCard ? trim100((instCard.querySelector('h3, .text h3, .name') || {}).textContent) : trim100(inst.textContent);
351			track('select_institution', { institution_name: instName });
352			return;
353		}
354	}, true);
355
356	// -------------------------------------------------------------------------
357	// 13. Outbound clicks — outbound_click
358	// -------------------------------------------------------------------------
359	// Fires when a user clicks a link to a different host. Internal subdomains
360	// of campus.gov.il are treated as internal.
361	document.addEventListener('click', function (e) {
362		var a = nearest(e.target, 'a[href]');
363		if (!a) return;
364		var href = a.getAttribute('href') || '';
365		if (!/^https?:\/\//i.test(href)) return;
366		var h = host(href);
367		if (!h) return;
368		var current = location.hostname;
369		// Treat any *.campus.gov.il host as internal.
370		if (h === current || /(^|\.)campus\.gov\.il$/i.test(h)) return;
371		// Suppress when a more specific handler already claims the click
372		// (share/social platforms, footer menu, contact socials, share dialogs).
373		if (detectPlatform(href)) return;
374		if (/facebook\.com\/sharer|linkedin\.com\/(sharing|shareArticle)/i.test(href)) return;
375		if (nearest(a, 'footer, .footer, .siteFooter, .template-page-contact, .contact-page, [data-contact-social]')) return;
376		track('outbound_click', {
377			link_url: href,
378			link_domain: h,
379		});
380	}, true);
381
382	// -------------------------------------------------------------------------
383	// 14. List impressions — view_item_list_*
384	// -------------------------------------------------------------------------
385	// IntersectionObserver fires once per matching container per page load.
386	ready(function () {
387		if (typeof IntersectionObserver !== 'function') return;
388
389		var lists = [
390			{ selector: '#catalogSearchResults, #catalogSearchResults2', event: function () {
391				return /[?&]srch=/.test(location.search) ? 'view_item_list_search_results' : 'view_item_list_catalog';
392			}, cardSelector: '.courseCard, .courseCardNew, .courseInfoBlock, [data-event-course-name]' },
393			{ selector: '.stripeCoursesCarusel, #myCourses', event: 'view_item_list_home_strip', cardSelector: '.courseCard, .courseCardNew' },
394			{ selector: '.featuringSubject', event: 'view_item_list_featured', cardSelector: '.courseCard, .courseCardNew, a', extraParams: function (container) {
395				var h = container.querySelector('h3, h2, .text h3');
396				return h ? { list_id: trim100(h.textContent) } : {};
397			} },
398			// view_item_list_related is server-pushed from single-program.php
399			// (IO race made client-side observation unreliable).
400		];
401
402		function tryFire(container, cfg) {
403			if (container.__campusListTracked) return;
404			var count = container.querySelectorAll(cfg.cardSelector).length;
405			if (count === 0) return;
406			var rect = container.getBoundingClientRect();
407			if (rect.bottom < 0 || rect.top > (window.innerHeight || 0)) return;
408			var name = typeof cfg.event === 'function' ? cfg.event() : cfg.event;
409			var params = { item_count: count };
410			if (typeof cfg.extraParams === 'function') {
411				var extra = cfg.extraParams(container) || {};
412				Object.keys(extra).forEach(function (k) { params[k] = extra[k]; });
413			}
414			trackWhenReady(name, params);
415			container.__campusListTracked = true;
416			if (container.__campusListIO) container.__campusListIO.disconnect();
417			if (container.__campusListMO) container.__campusListMO.disconnect();
418		}
419
420		function attachObservers() {
421			lists.forEach(function (cfg) {
422				document.querySelectorAll(cfg.selector).forEach(function (container) {
423					if (container.__campusListTracked) return;
424					tryFire(container, cfg);
425					if (container.__campusListTracked) return;
426					if (!container.__campusListIO) {
427						container.__campusListIO = new IntersectionObserver(function (entries) {
428							entries.forEach(function (entry) {
429								if (entry.isIntersecting) tryFire(container, cfg);
430							});
431						}, { threshold: 0.1 });
432						container.__campusListIO.observe(container);
433					}
434					if (!container.__campusListMO && typeof MutationObserver === 'function') {
435						container.__campusListMO = new MutationObserver(function () { tryFire(container, cfg); });
436						container.__campusListMO.observe(container, { childList: true, subtree: true });
437					}
438				});
439			});
440		}
441
442		// Initial pass + retries to catch AJAX-loaded containers (catalog results
443		// arrive after DOMContentLoaded).
444		attachObservers();
445		[500, 1500, 3500].forEach(function (delay) {
446			setTimeout(attachObservers, delay);
447		});
448
449		// Watch for late DOM additions of list containers.
450		if (typeof MutationObserver === 'function') {
451			new MutationObserver(attachObservers).observe(document.body, { childList: true, subtree: true });
452		}
453	});
454
455	// -------------------------------------------------------------------------
456	// Cross-page attribution: municipality → course/program.
457	// On a municipality page, capture clicks on tagged cards and stash a
458	// short-lived referrer record. On the next course/program page, replay the
459	// referrer onto view_item and click_continue_to_course.
460	// -------------------------------------------------------------------------
461	var ATTRIB_KEY = 'campus_attrib_referrer';
462	var ATTRIB_TTL_MS = 30 * 60 * 1000; // 30 min
463
464	function readAttrib() {
465		try {
466			var raw = window.sessionStorage.getItem(ATTRIB_KEY);
467			if (!raw) return null;
468			var parsed = JSON.parse(raw);
469			if (!parsed || !parsed.ts || (Date.now() - parsed.ts) > ATTRIB_TTL_MS) {
470				window.sessionStorage.removeItem(ATTRIB_KEY);
471				return null;
472			}
473			return parsed;
474		} catch (e) { return null; }
475	}
476
477	function writeAttrib(rec) {
478		try {
479			window.sessionStorage.setItem(ATTRIB_KEY, JSON.stringify(rec));
480		} catch (e) { /* sessionStorage disabled — silent */ }
481	}
482
483	function attribParams() {
484		var r = readAttrib();
485		if (!r) return null;
486		var out = {};
487		if (r.list_id) out.referrer_list_id = r.list_id;
488		if (r.municipality_id) out.referrer_municipality_id = r.municipality_id;
489		if (r.municipality_name) out.referrer_municipality_name = r.municipality_name;
490		return out;
491	}
492
493	ready(function () {
494		// Capture on municipality pages.
495		document.addEventListener('click', function (ev) {
496			var card = nearest(ev.target, '[data-campus-attrib-card]');
497			if (!card) return;
498			var carousel = nearest(card, '[data-campus-attrib-list-id]');
499			if (!carousel) return;
500			var rec = {
501				ts: Date.now(),
502				list_id: carousel.getAttribute('data-campus-attrib-list-id') || '',
503				municipality_id: carousel.getAttribute('data-campus-attrib-municipality-id') || '',
504				municipality_name: carousel.getAttribute('data-campus-attrib-municipality-name') || ''
505			};
506			writeAttrib(rec);
507			track('select_item', {
508				item_id: card.getAttribute('data-campus-item-id') || '',
509				item_name: card.getAttribute('data-campus-item-name') || '',
510				item_type: card.getAttribute('data-campus-item-type') || '',
511				list_id: rec.list_id,
512				municipality_id: rec.municipality_id,
513				municipality_name: rec.municipality_name,
514				position: parseInt(card.getAttribute('data-campus-position') || '0', 10) || undefined
515			});
516		}, true);
517
518		// Replay on the next page: stamp view_item (if server pushed one) and
519		// click_continue_to_course with referrer_* params. We re-push view_item
520		// with attribution as an additive event (lightweight; same item_id) only
521		// when an attribution exists. To avoid double-counting we send a dedicated
522		// param-only enrichment by re-using view_item with the same item_id —
523		// safe because GA4 dedupes by event+timestamp+params and analysts can
524		// filter on referrer_municipality_id IS NOT NULL.
525		var attrib = attribParams();
526		if (!attrib) return;
527
528		var dl = window.dataLayer || [];
529		var lastViewItem = null;
530		for (var i = dl.length - 1; i >= 0; i--) {
531			if (dl[i] && dl[i].event === 'view_item') { lastViewItem = dl[i]; break; }
532		}
533		if (lastViewItem && (lastViewItem.item_type === 'course' || lastViewItem.item_type === 'program')) {
534			track('view_item', Object.assign({
535				item_type: lastViewItem.item_type,
536				item_id: lastViewItem.item_id,
537				item_name: lastViewItem.item_name
538			}, attrib));
539		}
540
541		// Wrap click_continue_to_course pushes elsewhere by intercepting CTA
542		// clicks here as a safety net. Existing click_continue_to_course wiring
543		// pushes its own event; we additionally listen for the same selectors and
544		// push an attribution-stamped variant.
545		document.addEventListener('click', function (ev) {
546			var cta = nearest(ev.target, '.continue-to-course, [data-campus-cta="continue_to_course"], a.courseActionBtnMain');
547			if (!cta) return;
548			if (!lastViewItem || lastViewItem.item_type !== 'course') return;
549			track('click_continue_to_course', Object.assign({
550				item_id: lastViewItem.item_id,
551				cta_location: cta.getAttribute('data-campus-cta-location') || 'course_page'
552			}, attrib));
553		}, true);
554	});
555
556	// -------------------------------------------------------------------------
557	// Municipality grid catalog (.courseCardNew[data-id]) — covers muni pages
558	// whose courses come from the AJAX grid rather than the curated carousel.
559	// Fires view_item_list_municipality once the grid populates and stamps
560	// attribution + select_item on card click.
561	// -------------------------------------------------------------------------
562	function muniCtx() {
563		var dl = window.dataLayer || [];
564		var ctx = dl.find(function (e) { return e && e.event === 'page_context' && e.page_type === 'municipality'; });
565		if (!ctx) return null;
566		var slug = '';
567		try {
568			var path = location.pathname.replace(/\/$/, '').split('/');
569			slug = decodeURIComponent(path[path.length - 1] || '');
570		} catch (e) { slug = ''; }
571		return {
572			municipality_id: ctx.municipality_id || ctx.item_id || '',
573			municipality_name: ctx.municipality_name || ctx.item_name || '',
574			list_id: 'municipality_' + (slug || ctx.item_id || ''),
575			list_name: ctx.municipality_name || ctx.item_name || ''
576		};
577	}
578
579	ready(function () {
580		var ctx = muniCtx();
581		if (!ctx) return;
582
583		// One-time impression once grid cards are present.
584		var impressionSent = false;
585		function maybeFireImpression() {
586			if (impressionSent) return;
587			var cards = document.querySelectorAll('.courseCardNew[data-id]');
588			if (!cards.length) return;
589			impressionSent = true;
590			track('view_item_list_municipality', {
591				list_id: ctx.list_id,
592				list_name: ctx.list_name,
593				municipality_id: ctx.municipality_id,
594				municipality_name: ctx.municipality_name,
595				item_count: cards.length
596			});
597		}
598		maybeFireImpression();
599		[500, 1500, 3500].forEach(function (d) { setTimeout(maybeFireImpression, d); });
600		if (typeof MutationObserver === 'function') {
601			new MutationObserver(maybeFireImpression).observe(document.body, { childList: true, subtree: true });
602		}
603
604		// Click capture: stamp attribution and fire select_item.
605		document.addEventListener('click', function (ev) {
606			var card = nearest(ev.target, '.courseCardNew[data-id]');
607			if (!card) return;
608			var itemId = card.getAttribute('data-id') || '';
609			var nameEl = card.querySelector('[data-event-course-name]');
610			var itemName = (nameEl && nameEl.getAttribute('data-event-course-name')) || trim100(card.querySelector('h3')?.textContent || '');
611			// Position: index among siblings.
612			var position = 0;
613			var siblings = document.querySelectorAll('.courseCardNew[data-id]');
614			for (var i = 0; i < siblings.length; i++) { if (siblings[i] === card) { position = i + 1; break; } }
615			writeAttrib({
616				ts: Date.now(),
617				list_id: ctx.list_id,
618				municipality_id: ctx.municipality_id,
619				municipality_name: ctx.municipality_name
620			});
621			track('select_item', {
622				item_id: itemId,
623				item_name: itemName,
624				item_type: 'course',
625				list_id: ctx.list_id,
626				municipality_id: ctx.municipality_id,
627				municipality_name: ctx.municipality_name,
628				position: position || undefined
629			});
630		}, true);
631	});
632
633}());

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.