PageSourceSearch

https://30a.com/wp-content/themes/30A/assets/js/listing-tracking.js?ver=1785782956

js 30a.com collected 2026-10-03 22:31:46 UTC 5,923 bytes, 201 lines download raw bytes

1/**
2 * 30A listing carousel tracking (2026-08-03).
3 *
4 * Pushes GA4 recommended-ecommerce events to the GTM dataLayer for the three
5 * pinned listing carousels. Site-side output is dataLayer ONLY -- the tags,
6 * triggers and variables that consume these pushes are configured in the GTM UI
7 * (container GTM-5KF8VS9B -> GA4 G-95KMBQLCNH). Nothing here creates a tag.
8 *
9 * Events
10 *   view_item_list  once per carousel per pageview, at 30% visibility
11 *   select_item     on any click inside a card
12 *
13 * Contract -- all values are server-rendered as data attributes:
14 *   <section data-gtm-list-id data-gtm-list-name>
15 *     <div class="rental-slide"
16 *          data-gtm-item-id        WP post ID (numeric, stable)
17 *          data-gtm-item-name      listing title
18 *          data-gtm-item-brand     "Listed by" company / agency   (may be absent)
19 *          data-gtm-item-category  "Rental" | "Real Estate"
20 *          data-gtm-index          1-based render position
21 *          data-gtm-price          digits only, Real Estate only  (may be absent)
22 *
23 * An attribute that would have been empty is OMITTED server-side, so a missing
24 * dataset key means "no value", never "".
25 *
26 * Vanilla JS on purpose. The theme loads jQuery three times (hardcoded CDN tags
27 * in header.php and footer.php plus the WP-bundled copy) -- this file must not
28 * depend on any of them. It is also loaded site-wide from footer.php, so it has
29 * to be inert on the ~99% of pages that have no carousel.
30 *
31 * @package WordPress
32 * @subpackage 30A
33 */
34( function () {
35	'use strict';
36
37	window.dataLayer = window.dataLayer || [];
38
39	var SECTION_SELECTOR = '[data-gtm-list-id]';
40	var CARD_SELECTOR = '.rental-slide';
41
42	/*
43	 * Clicks on carousel navigation must not read as listing clicks.
44	 * .swiper-pagination matters most: the per-card photo slider renders its
45	 * dots INSIDE .rental-slide, so closest() alone would not exclude them.
46	 * The outer .realestate-button-* arrows sit outside the card and would bail
47	 * on closest() anyway; they are listed for completeness.
48	 */
49	var NAV_SELECTOR = [
50		'.swiper-button-next',
51		'.swiper-button-prev',
52		'.realestate-button-prev',
53		'.realestate-button-next',
54		'.swiper-pagination'
55	].join( ',' );
56
57	/**
58	 * Build one GA4 item object from a card and its owning section.
59	 * Keys whose source attribute is absent are omitted, never sent empty.
60	 *
61	 * @param {Element} card    The .rental-slide element.
62	 * @param {Element} section The carousel <section>.
63	 * @return {Object} GA4 item.
64	 */
65	function buildItem( card, section ) {
66		var card_data = card.dataset;
67		var list_data = section.dataset;
68		var item = {};
69
70		if ( card_data.gtmItemId ) {
71			item.item_id = card_data.gtmItemId;
72		}
73		if ( card_data.gtmItemName ) {
74			item.item_name = card_data.gtmItemName;
75		}
76		if ( card_data.gtmItemBrand ) {
77			item.item_brand = card_data.gtmItemBrand;
78		}
79		if ( card_data.gtmItemCategory ) {
80			item.item_category = card_data.gtmItemCategory;
81		}
82		if ( list_data.gtmListId ) {
83			item.item_list_id = list_data.gtmListId;
84		}
85		if ( list_data.gtmListName ) {
86			item.item_list_name = list_data.gtmListName;
87		}
88		// index and price are numbers in GA4, not strings.
89		if ( card_data.gtmIndex ) {
90			item.index = Number( card_data.gtmIndex );
91		}
92		if ( card_data.gtmPrice ) {
93			item.price = Number( card_data.gtmPrice );
94		}
95
96		return item;
97	}
98
99	/**
100	 * Push a GA4 ecommerce event, clearing the previous ecommerce object first
101	 * so values never bleed between pushes.
102	 *
103	 * @param {string} event_name GA4 event name.
104	 * @param {Element} section   The carousel <section>.
105	 * @param {Array} items       GA4 item objects.
106	 */
107	function pushEcommerce( event_name, section, items ) {
108		var list_data = section.dataset;
109		var ecommerce = {};
110
111		if ( list_data.gtmListId ) {
112			ecommerce.item_list_id = list_data.gtmListId;
113		}
114		if ( list_data.gtmListName ) {
115			ecommerce.item_list_name = list_data.gtmListName;
116		}
117		ecommerce.items = items;
118
119		window.dataLayer.push( { ecommerce: null } );
120		window.dataLayer.push( {
121			event: event_name,
122			ecommerce: ecommerce
123		} );
124	}
125
126	function init() {
127		var sections = document.querySelectorAll( SECTION_SELECTOR );
128
129		// No carousel on this page: bind nothing, observe nothing.
130		if ( ! sections.length ) {
131			return;
132		}
133
134		// --- select_item: one delegated listener on document ---
135		document.addEventListener( 'click', function ( event ) {
136			var target = event.target;
137
138			if ( ! target || 'function' !== typeof target.closest ) {
139				return;
140			}
141			if ( target.closest( NAV_SELECTOR ) ) {
142				return;
143			}
144
145			var card = target.closest( CARD_SELECTOR );
146			if ( ! card ) {
147				return;
148			}
149
150			var section = card.closest( SECTION_SELECTOR );
151			if ( ! section ) {
152				return;
153			}
154
155			pushEcommerce( 'select_item', section, [ buildItem( card, section ) ] );
156		} );
157
158		// --- view_item_list: once per carousel per pageview ---
159		// No fallback if IntersectionObserver is missing: a load-time fire would
160		// report impressions that never happened.
161		if ( 'function' !== typeof window.IntersectionObserver ) {
162			return;
163		}
164
165		var observer = new IntersectionObserver( function ( entries, self ) {
166			var i;
167			for ( i = 0; i < entries.length; i++ ) {
168				if ( ! entries[ i ].isIntersecting ) {
169					continue;
170				}
171
172				var section = entries[ i ].target;
173				self.unobserve( section );
174
175				var cards = section.querySelectorAll( CARD_SELECTOR );
176				if ( ! cards.length ) {
177					continue;
178				}
179
180				var items = [];
181				var j;
182				for ( j = 0; j < cards.length; j++ ) {
183					items.push( buildItem( cards[ j ], section ) );
184				}
185
186				pushEcommerce( 'view_item_list', section, items );
187			}
188		}, { threshold: 0.3 } );
189
190		var k;
191		for ( k = 0; k < sections.length; k++ ) {
192			observer.observe( sections[ k ] );
193		}
194	}
195
196	if ( 'loading' === document.readyState ) {
197		document.addEventListener( 'DOMContentLoaded', init );
198	} else {
199		init();
200	}
201}() );

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.