PageSourceSearch

https://www.georgiaaquarium.org/wp-content/plugins/tessitura-gai/js/ga4-ecommerce.js?ver=20260924202036

js georgiaaquarium.org collected 2026-09-25 08:41:01 UTC 17,929 bytes, 314 lines download raw bytes

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.