1/** 2 * GA4 e-commerce data layer helper (GAQ-2251 â GA4 EE epic). 3 * 4 * Single source of truth for building GA4-spec `items[]` objects from the 5 * Tessitura line items the purchase flows assemble, and for pushing GA4 6 * ecommerce events to the data layer. Every cart-addition flow funnels through 7 * addEEC() (utils.js), which delegates here; future events (view_item_list, 8 * select_item, view_item, view_cart, begin_checkout, add_payment_info, 9 * purchase, remove_from_cart) call gaiEcommerce.push() directly. 10 * 11 * Spec: Google GA4 ecommerce + the GAQ tracking sheet, incl. its review 12 * decisions: 13 * A) Time is a single item-scoped `time_slot` (24-hour HH:mm) for any timed 14 * item â ticket/pass arrival or meal pickup. 15 * B) `donation_amount` is NOT sent (redundant with price/value). 16 * C) `resident_status` is set only when the GA-resident discount applies; 17 * `guest_type` is deferred (no per-guest-type line items exist today). 18 * - Visit date / times are item-scoped CUSTOM params, never item_category2. 19 * - item_variant is reserved (intentionally omitted). 20 * 21 * IMPORTANT â item_id: default behavior (non-ticket items â parking, food, 22 * experiences, contributions) is unchanged: item_id is the Tessitura id â perfId 23 * (performance line items), FundId (contributions), 'gc' (gift certs). A flow 24 * can still override via `lineItem.ga4.item_id` if a slug is wanted. 25 * 26 * Ticket line items (item|admission) are the exception: pass `ga4.item_id: 27 * false` to omit item_id entirely (GAQ ticket-funnel item-identity fix). A 28 * Tessitura perfId is per-performance (a new one exists per admission day), so 29 * using it as item_id would either be unknown pre-resolution (forcing a fake 30 * DOM-derived slug, the original bug) or fragment one product like "General 31 * Admission" into one report row per date once resolved. Identity for tickets 32 * is keyed on the stable `item_name` instead (sourced from each ticket-type 33 * card's `data-product-name` attribute); the resolved perfId still rides every 34 * event as the separate `performance_id` param, alongside `price_type_id` / 35 * `price_type_name` / `mode_of_sale` â a composite, since no single id 36 * identifies a resolved Tessitura ticket (Performance à Price Type à Zone). 37 */ 38var gaiEcommerce = (function () { 39 'use strict'; 40 var t = {}; 41 42 t.CURRENCY = 'USD'; 43 44 // Internal pipe-category (as built by the flows) â GA4 taxonomy. 45 // The spec's item_category set is: ticket / pass / membership / combo / addon 46 // / contribution. Two of those ('pass', 'combo') can't be mapped here because 47 // passes and Coca-Cola combos come through the code as 'ticket|admission', 48 // distinguished only by the flow mode (e.g. aqua-pass / worldofcoke) â not by 49 // a distinct pipe-category. Those flows must set lineItem.ga4.item_category to 50 // 'pass' / 'combo'. That per-mode wiring is not yet implemented (open item). 51 // Note: addons_experience, addons_merchandise and giftcerts below aren't in the 52 // spec's original Parameter Values list; confirmed/approved as-is for this epic. 53 var CATEGORY_MAP = { 54 'ticket|admission': { item_category: 'ticket', item_list_id: 'main_ticket_options', item_list_name: 'Main Ticket Options' }, 55 'ticket|booking': { item_category: 'ticket', item_list_id: 'main_ticket_options', item_list_name: 'Main Ticket Options' }, 56 'ticket|event': { item_category: 'ticket', item_list_id: 'events', item_list_name: 'Events' }, 57 'ticket|experience': { item_category: 'addon', item_list_id: 'addons_experience', item_list_name: 'Experiences' }, 58 'ticket|parking': { item_category: 'addon', item_list_id: 'addons_parking', item_list_name: 'Parking' }, 59 'ticket|food': { item_category: 'addon', item_list_id: 'addons_food', item_list_name: 'Coastline Cafe' }, 60 'ticket|merchandise': { item_category: 'addon', item_list_id: 'addons_merchandise', item_list_name: 'Merchandise' }, 61 'ticket|membership': { item_category: 'membership', item_list_id: 'memberships', item_list_name: 'Annual Memberships' }, 62 'contribution|donation': { item_category: 'contribution', item_list_id: 'addons_donation', item_list_name: 'Conservation Donation' }, 63 'contribution|giftcert': { item_category: 'contribution', item_list_id: 'giftcerts', item_list_name: 'Gift Certificates' } 64 }; 65 66 // Sanitize a value bound for analytics: strip HTML and redact emails. Line 67 // item `note`s are free-form and can carry markup or PII (e.g. the post-dive 68 // merchandise note embeds a customer email). Mitigation, not a guarantee â 69 // `note` must never carry names, phone numbers, plates, etc. 70 t.scrubPII = function (value) { 71 return (value == null ? '' : value.toString()) 72 .replace(/<[^>]*>/g, '') 73 .replace(/[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}/gi, '[redacted]') 74 .trim(); 75 }; 76 77 function slugify(str) { 78 return (str == null ? '' : str.toString()) 79 .toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); 80 } 81 82 function taxonomyFor(internalCat) { 83 if (CATEGORY_MAP[internalCat]) { return CATEGORY_MAP[internalCat]; } 84 // Unknown category: derive a best-effort single-token category. 85 var first = internalCat.split('|')[0] || 'item'; 86 return { item_category: first, item_list_id: '', item_list_name: '' }; 87 } 88 89 function resolvePrice(item) { 90 var p = item.price != null ? item.price 91 : item.amount != null ? item.amount // gift certificate 92 : item.Amount != null ? item.Amount // contribution / donation 93 : 0; 94 return Number(p) || 0; 95 } 96 97 function resolveQuantity(item) { 98 // Tickets/add-ons carry `count`; contributions & gift certs are single. 99 return item.count != null ? (parseInt(item.count, 10) || 0) : 1; 100 } 101 102 // Client decision (for now): item_id is the Tessitura id â perfId 103 // (performance line items), FundId (contributions), 'gc' (gift certificates). 104 // A flow can override with an authoritative lineItem.ga4.item_id later if 105 // stable slugs are wanted. NOTE: perfIds are per-date and differ testâlive. 106 function resolveItemId(item, internalCat, taxonomy) { 107 if (internalCat.indexOf('giftcert') !== -1) { return 'gc'; } 108 if (item.perfId != null && item.perfId !== '') { return item.perfId.toString(); } 109 if (item.FundId != null && item.FundId !== '') { return item.FundId.toString(); } 110 // Last-resort fallback so item_id is never empty; scrub first so PII in a 111 // free-form note can't leak into item_id. 112 var name = slugify(t.scrubPII(item.note)); 113 return name ? (taxonomy.item_category + '_' + name) : (taxonomy.item_category || 'unknown_item'); 114 } 115 116 function addIf(obj, key, value) { 117 if (value !== undefined && value !== null && value !== '') { obj[key] = value; } 118 } 119 120 /** 121 * Map one Tessitura line item to a GA4-spec item object. A flow may pass an 122 * authoritative `lineItem.ga4` override and item-scoped custom params 123 * (visit_date, time_slot, resident_status, discount, index). 124 */ 125 t.mapLineItem = function (item, opts) { 126 if (item == null) { return null; } 127 opts = opts || {}; 128 var internalCat = (item.category == null ? 'item|uncategorized' : item.category.toString()); 129 var taxonomy = taxonomyFor(internalCat); 130 var ga4 = item.ga4 || {}; 131 132 var out = { 133 item_name: t.scrubPII(ga4.item_name || item.note),
134 item_category: ga4.item_category || taxonomy.item_category, 135 price: resolvePrice(item) 136 }; 137 // item_id: dropped for ticket line items (pass `ga4: { item_id: false }`). 138 // A stable id doesn't exist pre-resolution (no performance chosen yet), and 139 // the post-resolution Tessitura perfId differs per performance/date, which 140 // would fragment one product (e.g. "General Admission") into many report 141 // rows. The resolved perfId still rides every event as `performance_id` 142 // below â it's just no longer what GA4 keys the item identity on. Anything 143 // that doesn't pass `item_id: false` keeps the prior behavior unchanged. 144 if (ga4.item_id !== false) { 145 out.item_id = ga4.item_id || resolveItemId(item, internalCat, taxonomy); 146 } 147 // Quantity is omitted for list/detail views (view_item_list, select_item, 148 // view_item) per spec; included for cart/checkout events. 149 if (!opts.omitQuantity) { out.quantity = resolveQuantity(item); } 150 // item_list_id/item_list_name: same false-sentinel as item_id. Ticket-funnel 151 // spec: these belong on the pre-resolution selector events only (where the 152 // list genuinely groups the cards); once past the selector (view_item 153 // onward), the taxonomy default ("main_ticket_options") is stale and 154 // misattributes, so resolved ticket events pass `ga4: { item_list_id: 155 // false, item_list_name: false }` to omit both entirely. 156 if (ga4.item_list_id !== false) { addIf(out, 'item_list_id', ga4.item_list_id || taxonomy.item_list_id); } 157 if (ga4.item_list_name !== false) { addIf(out, 'item_list_name', ga4.item_list_name || taxonomy.item_list_name); } 158 // item_variant intentionally omitted (reserved per spec). 159 160 // Item-scoped custom params â only when the flow supplies them. 161 addIf(out, 'index', item.index); 162 addIf(out, 'visit_date', item.visit_date); // flow must supply ISO (YYYY-MM-DD) 163 addIf(out, 'time_slot', item.time_slot); // 24-hour HH:mm â ticket arrival or meal pickup 164 addIf(out, 'resident_status', item.resident_status); 165 // ticket_type: stable category dimension (e.g. "Fixed Date", "Anytime"), 166 // stamped on every event from view_item_list onward â flow must supply it. 167 addIf(out, 'ticket_type', ga4.ticket_type); 168 // Resolved Tessitura identity cluster â only exists once a performance/price 169 // type has actually resolved (never on the pre-resolution ticket-type cards). 170 // Carried as separate params, not item_id/item_variant (composite key: a 171 // single Tessitura line item can mix price types/zones across sub-line-items). 172 // Gated on item_id having been dropped: performance_id exists to REPLACE 173 // item_id for ticket items, not to ride alongside it. An add-on (Parking, 174 // Combo Meal, etc.) that kept its real item_id has no need for a second, 175 // duplicate identifier carrying the same value. 176 if (ga4.item_id === false) { 177 addIf(out, 'performance_id', item.perfId != null && item.perfId !== '' ? item.perfId.toString() : undefined); 178 } 179 // zone: raw Tessitura zone/entry-window id â flow must supply it explicitly 180 // (the raw attribute name varies by counter: zoneId vs zone vs resPoolZone). 181 // Stringified like performance_id/price_type_id: some sources hand this back 182 // as a jQuery .val() (already a string), others as a raw number straight off 183 // the parsed JSON response â without this it types inconsistently per ticket 184 // type (e.g. zone: "1029" on Fixed Date vs zone: 980 on Anytime). 185 addIf(out, 'zone', ga4.zone != null && ga4.zone !== '' ? ga4.zone.toString() : undefined); 186 addIf(out, 'price_type_id', ga4.price_type_id != null ? ga4.price_type_id.toString() : (item.priceId != null && item.priceId !== '' ? item.priceId.toString() : undefined)); 187 addIf(out, 'price_type_name', ga4.price_type_name); 188 addIf(out, 'mode_of_sale', ga4.mode_of_sale); 189 if (item.discount != null && Number(item.discount) > 0) { out.discount = Number(item.discount); } 190 191 // GA4 drops an item with neither id nor name; guarantee one. 192 if (!out.item_id && !out.item_name) { out.item_name = 'unknown_item'; } 193 return out; 194 }; 195 196 // Sum of price à quantity across items, rounded to cents (GA4 `value`). 197 t.sumValue = function (items) { 198 var v = 0;
199 (items || []).forEach(function (i) { v += (Number(i.price) || 0) * (Number(i.quantity) || 0); }); 200 return Math.round(v * 100) / 100; 201 }; 202 203 // Gate: fire on live, or anywhere with the ?enableATCDLV QA flag. 204 t.enabled = function () { 205 return (typeof env !== 'undefined' && env.live) || 206 (typeof getURLParameter === 'function' && getURLParameter('enableATCDLV')); 207 }; 208 209 /** 210 * Push a GA4 ecommerce event to the data layer. 211 * @param {string} eventName e.g. 'add_to_cart', 'view_item_list' 212 * @param {Array} items already-mapped GA4 item objects 213 * @param {Object} eventParams event-level params (currency, value, coupon, 214 * transaction_id, tax, customer_type, â¦) 215 */ 216 t.push = function (eventName, items, eventParams) { 217 if (!Array.isArray(items) || !items.length) { return; } 218 var ecommerce = {}; 219 if (eventParams) { 220 for (var k in eventParams) { 221 if (eventParams.hasOwnProperty(k) && eventParams[k] !== undefined) { ecommerce[k] = eventParam
221s[k]; } 222 } 223 } 224 // Per GA4 docs, list/selection events carry item_list_id/item_list_name at 225 // the EVENT (ecommerce) level too â they describe the whole list. Hoist them 226 // from the items when they share one list and weren't set via eventParams. 227 if ((eventName === 'view_item_list' || eventName === 'select_item') && ecommerce.item_list_id === undefined) { 228 var lid = items[0].item_list_id, lname = items[0].item_list_name, sameList = true; 229 for (var j = 1; j < items.length; j++) { 230 if (items[j].item_list_id !== lid) { sameList = false; break; } 231 } 232 if (sameList && lid) { 233 ecommerce.item_list_id = lid; 234 if (lname) { ecommerce.item_list_name = lname; } 235 } 236 } 237 ecommerce.items = items; 238 var payload = { event: eventName, ecommerce: ecommerce }; 239 // Log an immutable deep-clone, not the live object: GTM mutates/clears the 240 // pushed object after it processes it, which otherwise shows as "No 241 // properties" in the console when the log is expanded later (e.g. for the 242 // last event fired right before a page navigation). The clone stays intact. 243 if (typeof debug_log === 'function') { 244 var _snap; try { _snap = JSON.parse(JSON.stringify(payload)); } catch (e) { _snap = payload; } 245 debug_log('[GA4 EE] ' + eventName, _snap); 246 } 247 if (t.enabled()) { 248 window.dataLayer = window.dataLayer || []; 249 // Clear the previous ecommerce object so it does not merge into this 250 // event (per GA4 spec), then push. 251 window.dataLayer.push({ ecommerce: null }); 252 window.dataLayer.push(payload); 253 } 254 }; 255 256 /** 257 * Convenience: map raw line items and push in one call. 258 * @param {Object} opts passed to mapLineItem (e.g. { omitQuantity: true } for 259 * list/detail events). 260 */ 261 t.pushFromLineItems = function (eventName, lineItems, eventParams, opts) { 262 var items = (lineItems || []).map(function (li) { return t.mapLineItem(li, opts); }).filter(Boolean); 263 t.push(eventName, items, eventParams); 264 }; 265 266 /** 267 * Push a NON-ecommerce GA4 event (select_content, login, sign_up, â¦). These 268 * carry only event-level params â no items[]/ecommerce object â so there's no 269 * ecommerce:null clear. Same env gate + immutable debug snapshot as push(). 270 * @param {string} eventName e.g. 'select_content', 'login', 'sign_up' 271 * @param {Object} params event-level params (method, content_type, content_id, â¦) 272 */ 273 t.pushEvent = function (eventName, params) { 274 if (!eventName) { return; } 275 var payload = { event: eventName }; 276 if (params) { 277 for (var k in params) { 278 if (params.hasOwnProperty(k) && params[k] !== undefined && params[k] !== null && params[k] !== '') { 279 payload[k] = params[k]; 280 } 281 } 282 } 283 if (typeof debug_log === 'function') { 284 var _snap; try { _snap = JSON.parse(JSON.stringify(payload)); } catch (e) { _snap = payload; } 285 debug_log('[GA4 EE] ' + eventName, _snap); 286 } 287 if (t.enabled()) { 288 window.dataLayer = window.dataLayer || []; 289 window.dataLayer.push(payload); 290 } 291 }; 292 293 /** 294 * Push a GA4 `exception` event (https://developers.google.com/analytics/devguides/collection/ga4/exceptions). 295 * Replaces the old gaiAnalytics/`type:'tessError'` error beacon, which routed 296 * through the now-defunct Universal Analytics `ga()` global and produced no 297 * analytics value. `description` is free text (reuse the caller's existing 298 * `<operation>-errorResponse`/`-badResponse`/`-noSessAssert`-style action 299 * string verbatim); `fatal` defaults false since every known call site is an 300 * already-handled AJAX failure with graceful fallback UI, never an 301 * unrecoverable crash. 302 * @param {string} description e.g. 'getOrderDetails-errorResponse' 303 * @param {Object} [opts] { fatal: boolean, ...any other custom params to ride along } 304 */ 305 t.pushException = function (description, opts) { 306 opts = opts || {}; 307 t.pushEvent('exception', Object.assign({}, opts, { 308 description: t.scrubPII(description), 309 fatal: !!opts.fatal 310 })); 311 }; 312 313 return t; 314})();
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.