@akinon/pz-theme 2.0.80-rc.0 → 2.0.81-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,38 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.80-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea4143]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a63]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b]
34
- - @akinon/next@2.0.80-rc.0
35
- - @akinon/theme-contract@2.0.80-rc.0
3
+ ## 2.0.81-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.81-beta.0
9
+ - @akinon/theme-contract@2.0.81-beta.0
10
+
11
+ ## 2.0.80
12
+
13
+ ### Patch Changes
14
+
15
+ - 6fb7f650: ZERO-4869: Implement GA4 ecommerce event tracking across various components
16
+ - @akinon/next@2.0.80
17
+ - @akinon/theme-contract@2.0.80
36
18
 
37
19
  ## 2.0.79
38
20
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.80-rc.0",
4
+ "version": "2.0.81-beta.0",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.80-rc.0",
11
+ "@akinon/next": "2.0.81-beta.0",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.80-rc.0",
16
+ "@akinon/theme-contract": "2.0.81-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.80-rc.0",
23
+ "@akinon/next": "2.0.81-beta.0",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -5,6 +5,11 @@ import { useAddProductMutation } from '@akinon/next/data/client/product';
5
5
  import { basketApi } from '@akinon/next/data/client/basket';
6
6
  import { useAppDispatch } from '@akinon/next/redux/hooks';
7
7
  import { getResponsiveValue, partAttrs } from '../utils';
8
+ import {
9
+ buildCommerceItem,
10
+ emitCommerceEvent,
11
+ readItemsCurrency
12
+ } from '../utils/analytics';
8
13
  import { BlockRendererProps } from './block-renderer-registry';
9
14
  import {
10
15
  DesignerPlaceholder,
@@ -281,6 +286,10 @@ const AddToCartBlock = ({
281
286
  // by the mini-basket chrome block) has to be refreshed explicitly.
282
287
  dispatch(basketApi.util.invalidateTags(['Basket', 'MiniBasket']));
283
288
  setFeedback('added');
289
+ emitCommerceEvent('add_to_cart', {
290
+ currency: readItemsCurrency(product),
291
+ items: [buildCommerceItem(product, { quantity })]
292
+ });
284
293
  } catch {
285
294
  setFeedback('error');
286
295
  }
@@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation';
6
6
  import { signIn } from 'next-auth/react';
7
7
  import { useLocalization } from '@akinon/next/hooks';
8
8
  import { getResponsiveValue, partAttrs } from '../utils';
9
+ import { emitCommerceEvent } from '../utils/analytics';
9
10
  import { readToggle } from './product-block-utils';
10
11
  import { BlockRendererProps } from './block-renderer-registry';
11
12
 
@@ -156,6 +157,12 @@ const AuthFormBlock = ({
156
157
  return;
157
158
  }
158
159
 
160
+ // GA4 auth events, on success only: a failed submit is an error state,
161
+ // not a login. `method` mirrors what the credentials provider is.
162
+ emitCommerceEvent(mode === 'register' ? 'sign_up' : 'login', {
163
+ method: providerId
164
+ });
165
+
159
166
  router.refresh();
160
167
  router.push(callbackUrl);
161
168
  } catch {
@@ -15,6 +15,11 @@ import {
15
15
  import { useLocalization } from '@akinon/next/hooks';
16
16
  import PluginModule, { Component } from '@akinon/next/components/plugin-module';
17
17
  import { getResponsiveValue, partAttrs } from '../utils';
18
+ import {
19
+ buildCommerceItem,
20
+ emitCommerceEvent,
21
+ readItemsCurrency
22
+ } from '../utils/analytics';
18
23
  import { useThemePageContext } from '../theme-page-context';
19
24
  import {
20
25
  formatMoney,
@@ -614,6 +619,21 @@ const BasketItemsBlock = ({
614
619
 
615
620
  if (isDesigner || productPk === undefined || nextQuantity < 0) return;
616
621
 
622
+ if (nextQuantity === 0) {
623
+ const removedQuantity = Number(item.quantity ?? 1) || 1;
624
+ emitCommerceEvent('remove_from_cart', {
625
+ currency: readItemsCurrency(
626
+ item as Record<string, unknown>,
627
+ localeCurrency
628
+ ),
629
+ items: [
630
+ buildCommerceItem(item.product as Record<string, unknown>, {
631
+ quantity: removedQuantity
632
+ })
633
+ ]
634
+ });
635
+ }
636
+
617
637
  updateQuantity({
618
638
  product: productPk,
619
639
  quantity: nextQuantity,
@@ -9,6 +9,11 @@ import {
9
9
  useRemoveFavoriteMutation
10
10
  } from '@akinon/next/data/client/wishlist';
11
11
  import { getResponsiveValue, partAttrs } from '../utils';
12
+ import {
13
+ buildCommerceItem,
14
+ emitCommerceEvent,
15
+ readItemsCurrency
16
+ } from '../utils/analytics';
12
17
  import { BlockRendererProps } from './block-renderer-registry';
13
18
  import {
14
19
  DesignerPlaceholder,
@@ -219,6 +224,10 @@ const FavoriteButtonBlock = ({
219
224
  await removeFavorite(favorite.pk).unwrap();
220
225
  } else {
221
226
  await addFavorite(productPk).unwrap();
227
+ emitCommerceEvent('add_to_wishlist', {
228
+ currency: readItemsCurrency(product),
229
+ items: [buildCommerceItem(product)]
230
+ });
222
231
  }
223
232
  } catch {
224
233
  // Commerce rejected the toggle; the list query stays authoritative so
@@ -17,6 +17,11 @@ import {
17
17
  TABLET_MEDIA_QUERY_COMPACT
18
18
  } from '../utils/breakpoints';
19
19
  import { normalizeListingProduct } from './listing-context';
20
+ import {
21
+ buildCommerceItem,
22
+ emitCommerceEvent,
23
+ readItemsCurrency
24
+ } from '../utils/analytics';
20
25
  import { hasItemTemplate, ItemTemplateList } from './item-template';
21
26
  import { BlockRendererProps } from './block-renderer-registry';
22
27
  import { DesignerPlaceholder, readToggle } from './product-block-utils';
@@ -214,6 +219,13 @@ const FavouritesGridBlock = ({
214
219
  // addProduct only invalidates MiniBasket, so the full basket query (used
215
220
  // by the mini-basket chrome block) has to be refreshed explicitly.
216
221
  dispatch(basketApi.util.invalidateTags(['Basket', 'MiniBasket']));
222
+ emitCommerceEvent('add_to_cart', {
223
+ currency: readItemsCurrency(
224
+ product as Record<string, unknown>,
225
+ localeCurrency
226
+ ),
227
+ items: [buildCommerceItem(product as Record<string, unknown>)]
228
+ });
217
229
  } catch {
218
230
  // Commerce owns the failure message (out of stock, variant required);
219
231
  // the grid stays as it was rather than inventing an error state.
@@ -264,6 +276,28 @@ const FavouritesGridBlock = ({
264
276
  .filter((favourite) => Boolean(favourite.product))
265
277
  .map(describe);
266
278
 
279
+ // GA4 view_item_list (ZERO-4869), once per distinct wishlist content: the
280
+ // favourites grid IS the list impression on this screen. Keyed by row keys
281
+ // so a remove refires with the new list, a mere re-render does not. A ref,
282
+ // not a global — each visit mounts a fresh grid and must report again.
283
+ const rowsKey = rows.map((row) => row.key).join(',');
284
+ const pushedListKey = React.useRef<string | null>(null);
285
+ React.useEffect(() => {
286
+ if (isDesigner || rows.length === 0) return;
287
+ if (pushedListKey.current === rowsKey) return;
288
+ pushedListKey.current = rowsKey;
289
+ emitCommerceEvent('view_item_list', {
290
+ currency: localeCurrency,
291
+ items: rows.map((row) =>
292
+ buildCommerceItem(row as Record<string, unknown>, {
293
+ listId: 'favourites',
294
+ listName: 'Favourites'
295
+ })
296
+ )
297
+ });
298
+ // eslint-disable-next-line react-hooks/exhaustive-deps
299
+ }, [rowsKey, isDesigner]);
300
+
267
301
  // A designer who marked a child as the card layout gets that layout repeated
268
302
  // per saved product; otherwise the built-in card renders unchanged.
269
303
  const customCards = hasItemTemplate(block, currentBreakpoint) ? (
@@ -326,6 +360,23 @@ const FavouritesGridBlock = ({
326
360
  if (isDesigner) event.preventDefault();
327
361
  };
328
362
 
363
+ // GA4 select_item for the BUILT-IN card; custom item-template cards get the
364
+ // same push from the group anchor via the row scope.
365
+ const cardClick = (card: ReturnType<typeof describe>) =>
366
+ (event: React.MouseEvent) => {
367
+ guard(event);
368
+ if (isDesigner) return;
369
+ emitCommerceEvent('select_item', {
370
+ currency: localeCurrency,
371
+ items: [
372
+ buildCommerceItem(card as unknown as Record<string, unknown>, {
373
+ listId: 'favourites',
374
+ listName: 'Favourites'
375
+ })
376
+ ]
377
+ });
378
+ };
379
+
329
380
  const imageStyle: React.CSSProperties = {
330
381
  width: '100%',
331
382
  height: 'auto',
@@ -386,7 +437,7 @@ const FavouritesGridBlock = ({
386
437
  >
387
438
  <NextLink
388
439
  href={isDesigner ? '#' : card.href || '#'}
389
- onClick={guard}
440
+ onClick={cardClick(card)}
390
441
  data-favourites-card-link=""
391
442
  {...partAttrs('link')}
392
443
  style={{
@@ -1,7 +1,13 @@
1
1
  import React from 'react';
2
2
  import NextLink from 'next/link';
3
3
  import { getResponsiveValue } from '../utils';
4
+ import {
5
+ buildCommerceItem,
6
+ emitCommerceEvent,
7
+ readItemsCurrency
8
+ } from '../utils/analytics';
4
9
  import { BlockRendererProps } from './block-renderer-registry';
10
+ import { useThemeItemScope } from './product-block-utils';
5
11
  import { renderComposedChildren } from './render-composed-children';
6
12
 
7
13
  /**
@@ -29,6 +35,20 @@ const GroupBlock = ({
29
35
  );
30
36
  const Tag = tag as keyof React.JSX.IntrinsicElements;
31
37
  const href = tag === 'a' ? block.properties?.href : undefined;
38
+ const itemScope = useThemeItemScope();
39
+
40
+ // GA4 select_item (ZERO-4869): a composed anchor inside a repeated product
41
+ // row IS the product card's click. The row scope carries the product, so no
42
+ // per-card wiring is needed — and outside a row this stays silent.
43
+ const handleAnchorAnalytics = () => {
44
+ if (isDesigner) return;
45
+ const scopedProduct = itemScope?.product;
46
+ if (!scopedProduct) return;
47
+ emitCommerceEvent('select_item', {
48
+ currency: readItemsCurrency(scopedProduct as Record<string, unknown>),
49
+ items: [buildCommerceItem(scopedProduct as Record<string, unknown>)]
50
+ });
51
+ };
32
52
 
33
53
  const tagProps: Record<string, unknown> = {
34
54
  className: 'contents'
@@ -37,6 +57,9 @@ const GroupBlock = ({
37
57
  if (tag === 'a' && href && !useRouterLink) {
38
58
  tagProps.href = href;
39
59
  }
60
+ if (tag === 'a' && !isDesigner) {
61
+ tagProps.onClick = handleAnchorAnalytics;
62
+ }
40
63
  if (tag === 'form') {
41
64
  tagProps.onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
42
65
  event.preventDefault();
@@ -76,7 +99,12 @@ const GroupBlock = ({
76
99
 
77
100
  if (useRouterLink) {
78
101
  return (
79
- <NextLink href={href as string} prefetch={false} className="contents">
102
+ <NextLink
103
+ href={href as string}
104
+ prefetch={false}
105
+ className="contents"
106
+ onClick={handleAnchorAnalytics}
107
+ >
80
108
  {renderedChildren}
81
109
  </NextLink>
82
110
  );
@@ -173,6 +173,12 @@ export interface ListingProduct {
173
173
  images?: string[];
174
174
  price: string;
175
175
  retailPrice: string;
176
+ /**
177
+ * The same two prices as plain numbers, for consumers that compute rather
178
+ * than display — analytics items, sort keys. Absent when unparseable.
179
+ */
180
+ priceAmount?: number;
181
+ retailPriceAmount?: number;
176
182
  /**
177
183
  * `price_extra_field.previous_price` — the price-history line several
178
184
  * markets require by law. Absent when commerce did not send one.
@@ -652,6 +658,8 @@ const normalizeProduct = (
652
658
  options.money
653
659
  ) || undefined,
654
660
  hasDiscount,
661
+ ...(numericPrice !== null ? { priceAmount: numericPrice } : null),
662
+ ...(numericRetail !== null ? { retailPriceAmount: numericRetail } : null),
655
663
  // The grid already derived this to draw its badge; publishing it means a
656
664
  // designer-authored card does not have to re-derive it from two strings.
657
665
  discountPercent: percentFromRetail,
@@ -3,6 +3,10 @@
3
3
  import React from 'react';
4
4
  import NextLink from 'next/link';
5
5
  import { getResponsiveValue, partAttrs } from '../utils';
6
+ import {
7
+ buildCommerceItem,
8
+ emitCommerceEvent
9
+ } from '../utils/analytics';
6
10
  import {
7
11
  DESKTOP_MIN_WIDTH,
8
12
  MOBILE_MEDIA_QUERY_COMPACT,
@@ -577,6 +581,28 @@ const ProductGridBlock = ({
577
581
  const allProducts = listing?.products || [];
578
582
  const products = limit > 0 ? allProducts.slice(0, limit) : allProducts;
579
583
 
584
+ // GA4 view_item_list (ZERO-4869), once per distinct result set: keyed by the
585
+ // product keys so pagination and refiltering refire while re-renders do not.
586
+ // A ref, not a global — each client-side visit mounts a fresh grid and must
587
+ // report its own impression.
588
+ const productsKey = products.map((product) => product.key).join(',');
589
+ const pushedListKey = React.useRef<string | null>(null);
590
+ React.useEffect(() => {
591
+ if (isDesigner || products.length === 0) return;
592
+ if (pushedListKey.current === productsKey) return;
593
+ pushedListKey.current = productsKey;
594
+ emitCommerceEvent('view_item_list', {
595
+ items: products.map((product, index) =>
596
+ buildCommerceItem(product as unknown as Record<string, unknown>, {
597
+ index,
598
+ listId: 'product_grid',
599
+ listName: listing?.title || 'Product Grid'
600
+ })
601
+ )
602
+ });
603
+ // eslint-disable-next-line react-hooks/exhaustive-deps
604
+ }, [productsKey, isDesigner]);
605
+
580
606
  // A designer who marked a child as the card layout gets that layout
581
607
  // repeated per product; otherwise the built-in card renders unchanged.
582
608
  const customCards = hasItemTemplate(block, currentBreakpoint) ? (
@@ -784,7 +810,21 @@ const ProductGridBlock = ({
784
810
  // Navigating inside the designer iframe would drop the author out of
785
811
  // the page they are editing, so cards are inert there.
786
812
  onClick={(event) => {
787
- if (isDesigner) event.preventDefault();
813
+ if (isDesigner) {
814
+ event.preventDefault();
815
+ return;
816
+ }
817
+ emitCommerceEvent('select_item', {
818
+ items: [
819
+ buildCommerceItem(
820
+ product as unknown as Record<string, unknown>,
821
+ {
822
+ listId: 'product_grid',
823
+ listName: listing?.title || 'Product Grid'
824
+ }
825
+ )
826
+ ]
827
+ });
788
828
  }}
789
829
  style={{
790
830
  display: 'flex',
@@ -11,6 +11,10 @@ import { useLocalization } from '@akinon/next/hooks';
11
11
  import { useOverlay } from '../hooks/use-overlay';
12
12
  import { useThemeSectionData } from '../theme-section-data-context';
13
13
  import { getResponsiveValue, partAttrs } from '../utils';
14
+ import {
15
+ buildCommerceItem,
16
+ emitCommerceEvent
17
+ } from '../utils/analytics';
14
18
  import { BlockRendererProps } from './block-renderer-registry';
15
19
  import { hasItemTemplate, ItemTemplateList } from './item-template';
16
20
  import { DESIGNER_SAMPLE_PRODUCT } from './item-template-utils';
@@ -1160,7 +1164,21 @@ const ProductRecommendationsBlock = ({
1160
1164
  // Navigating inside the designer iframe would drop the author out
1161
1165
  // of the page they are editing, so cards are inert there.
1162
1166
  onClick={(event) => {
1163
- if (isDesigner) event.preventDefault();
1167
+ if (isDesigner) {
1168
+ event.preventDefault();
1169
+ return;
1170
+ }
1171
+ emitCommerceEvent('select_item', {
1172
+ items: [
1173
+ buildCommerceItem(
1174
+ product as unknown as Record<string, unknown>,
1175
+ {
1176
+ listId: 'product_recommendations',
1177
+ listName: 'Product Recommendations'
1178
+ }
1179
+ )
1180
+ ]
1181
+ });
1164
1182
  }}
1165
1183
  style={{
1166
1184
  display: 'flex',
@@ -3,6 +3,7 @@
3
3
  import React, { useRef, useState } from 'react';
4
4
  import { useRouter } from 'next/navigation';
5
5
  import { getResponsiveValue, partAttrs, resolveForceOpen } from '../utils';
6
+ import { emitCommerceEvent } from '../utils/analytics';
6
7
  import { useOverlay } from '../hooks/use-overlay';
7
8
  import ThemeBlock from '../theme-block';
8
9
  import { BlockRendererProps } from './block-renderer-registry';
@@ -184,6 +185,7 @@ const SearchBoxBlock = ({
184
185
  event.preventDefault();
185
186
  const value = term.trim();
186
187
  if (!value || isDesigner) return;
188
+ emitCommerceEvent('search', { searchTerm: value });
187
189
  const separator = searchUrl.includes('?') ? '&' : '?';
188
190
  router.push(
189
191
  `${searchUrl}${separator}${queryParam}=${encodeURIComponent(value)}`
@@ -131,10 +131,39 @@ export const normalizeProductEntry = (
131
131
  retailPrice: activePrice.retail_price ?? product.retail_price,
132
132
  currency: activePrice.currency_type ?? product.currency_type ?? '',
133
133
  description: product.description || '',
134
- attributes: normalizeAttributeEntries(product.attributes_kwargs)
134
+ attributes: normalizeAttributeEntries(product.attributes_kwargs),
135
+ // The same attributes as a keyed map, kwargs merged over the raw
136
+ // `attributes` payload — technical keys (integration_model_renk…) often
137
+ // live only in the raw map, and analytics bridges key off them. Additive:
138
+ // the positional `attributes` list above is what the PDP blocks render.
139
+ attributesKeyed: buildKeyedAttributes(product)
135
140
  };
136
141
  };
137
142
 
143
+ const buildKeyedAttributes = (
144
+ product: Product & UnknownRecord
145
+ ): Record<string, string> => {
146
+ const keyed: Record<string, string> = {};
147
+
148
+ const raw = product.attributes;
149
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
150
+ for (const [key, value] of Object.entries(raw as UnknownRecord)) {
151
+ const text = toStringValue(value);
152
+ if (text) keyed[key] = text;
153
+ }
154
+ }
155
+
156
+ const kwargs = product.attributes_kwargs;
157
+ if (kwargs && typeof kwargs === 'object' && !Array.isArray(kwargs)) {
158
+ for (const [key, entry] of Object.entries(kwargs as UnknownRecord)) {
159
+ const text = toStringValue(toRecord(entry).value);
160
+ if (text) keyed[key] = text;
161
+ }
162
+ }
163
+
164
+ return keyed;
165
+ };
166
+
138
167
  /**
139
168
  * `breadcrumbData` is fetched next to the product by `getProductData()` and was
140
169
  * previously handed only to the brand's own PDP layout, which left the PDP the
@@ -1,6 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useState, useMemo } from 'react';
3
+ import { useEffect, useState, useMemo, useRef } from 'react';
4
+ import {
5
+ buildCommerceItem,
6
+ emitCommerceEvent,
7
+ readItemsCurrency
8
+ } from './utils/analytics';
4
9
  import ThemeSection, { Section } from './theme-section';
5
10
  import SectionErrorBoundary from './components/section-error-boundary';
6
11
  import { safeDeepClone } from './utils/safe-clone';
@@ -389,6 +394,112 @@ export default function ThemePlaceholderWrapper({
389
394
  };
390
395
  }, [inheritedPageContext, initialPageContext]);
391
396
 
397
+ // GA4 page-scope ecommerce events (ZERO-4869), pushed off the page context
398
+ // rather than off any single block: what the page is ABOUT is a
399
+ // composition-level fact, so a PDP fires view_item and a basket fires
400
+ // view_cart no matter which blocks the designer placed. The guard is a ref,
401
+ // not a global: each client-side visit mounts a fresh slot and must refire,
402
+ // while re-renders of the same mount (breakpoint, context identity churn)
403
+ // must not — the acceptance criteria forbid duplicate hits.
404
+ const pushedPageEventKey = useRef<string | null>(null);
405
+ useEffect(() => {
406
+ if (isDesigner || !resolvedPageContext) return;
407
+
408
+ const context = resolvedPageContext as Record<string, any>;
409
+ const pageType = context.page?.type;
410
+
411
+ const emitOnce = (
412
+ key: string,
413
+ action: string,
414
+ detail: Parameters<typeof emitCommerceEvent>[1]
415
+ ) => {
416
+ const fullKey = `${action}:${key}`;
417
+ if (pushedPageEventKey.current === fullKey) return;
418
+ pushedPageEventKey.current = fullKey;
419
+ emitCommerceEvent(action, detail);
420
+ };
421
+
422
+ const basketLineItems = (lines: any[]) =>
423
+ lines.map((line: any) =>
424
+ buildCommerceItem(line?.product ?? line, {
425
+ quantity: Number(line?.quantity) || 1
426
+ })
427
+ );
428
+
429
+ if (pageType === 'product' && context.product) {
430
+ const item = buildCommerceItem(context.product);
431
+ if (!item) return;
432
+ emitOnce(String(item.id ?? item.name), 'view_item', {
433
+ currency: readItemsCurrency(context.product),
434
+ ...(typeof item.price === 'number' ? { value: item.price } : null),
435
+ items: [item]
436
+ });
437
+ return;
438
+ }
439
+
440
+ if (pageType === 'basket' && Array.isArray(context.basket?.items)) {
441
+ // The live storefronts report view_cart on an EMPTY basket too
442
+ // (value 0, items []), so the empty case is emitted rather than skipped.
443
+ const lines = context.basket.items;
444
+ emitOnce(
445
+ lines
446
+ .map(
447
+ (line: any) => `${line?.product?.pk ?? ''}x${line?.quantity ?? ''}`
448
+ )
449
+ .join(','),
450
+ 'view_cart',
451
+ {
452
+ currency: readItemsCurrency(context.basket),
453
+ items: basketLineItems(lines)
454
+ }
455
+ );
456
+ return;
457
+ }
458
+
459
+ // Checkout funnel page signals. Only the moments the PAGE can know are
460
+ // emitted here — reaching checkout and completing the order. The
461
+ // step-level moments (add_shipping_info, add_payment_info) belong to the
462
+ // checkout machinery the brand mounts, which already reports them from
463
+ // its own code; a brand whose bridge would double-report these two page
464
+ // signals excludes them there (see Ga4BridgeOptions.excludeActions).
465
+ if (pageType === 'checkout' && Array.isArray(context.basket?.items)) {
466
+ const lines = context.basket.items;
467
+ if (lines.length === 0) return;
468
+ emitOnce(
469
+ lines
470
+ .map(
471
+ (line: any) => `${line?.product?.pk ?? ''}x${line?.quantity ?? ''}`
472
+ )
473
+ .join(','),
474
+ 'begin_checkout',
475
+ {
476
+ currency: readItemsCurrency(context.basket),
477
+ items: basketLineItems(lines)
478
+ }
479
+ );
480
+ return;
481
+ }
482
+
483
+ if (
484
+ (pageType === 'order-success' || pageType === 'order') &&
485
+ Array.isArray(context.order?.items) &&
486
+ context.order.items.length > 0
487
+ ) {
488
+ const order = context.order;
489
+ const totalAmount = Number(String(order.totalAmount ?? '').replace(',', '.'));
490
+ emitOnce(String(order.number ?? order.id ?? ''), 'purchase', {
491
+ currency: readItemsCurrency(order),
492
+ ...(Number.isFinite(totalAmount) ? { value: totalAmount } : null),
493
+ transactionId: String(order.number ?? order.id ?? ''),
494
+ items: order.items.map((line: any) =>
495
+ buildCommerceItem(line?.product ?? line, {
496
+ quantity: Number(line?.quantity) || 1
497
+ })
498
+ )
499
+ });
500
+ }
501
+ }, [isDesigner, resolvedPageContext]);
502
+
392
503
  // Inject theme colors when settings change
393
504
  useEffect(() => {
394
505
  if (themeSettings) {
@@ -0,0 +1,263 @@
1
+ import {
2
+ attachGa4DataLayerBridge,
3
+ buildCommerceItem,
4
+ COMMERCE_EVENT_NAME,
5
+ emitCommerceEvent,
6
+ readItemsCurrency
7
+ } from '../analytics';
8
+
9
+ declare global {
10
+ interface Window {
11
+ dataLayer?: Array<Record<string, unknown>>;
12
+ }
13
+ }
14
+
15
+ describe('neutral commerce events (ZERO-4869)', () => {
16
+ let received: Array<Record<string, unknown>>;
17
+ let listener: (event: Event) => void;
18
+
19
+ beforeEach(() => {
20
+ received = [];
21
+ listener = (event) => received.push((event as CustomEvent).detail);
22
+ window.addEventListener(COMMERCE_EVENT_NAME, listener);
23
+ });
24
+
25
+ afterEach(() => {
26
+ window.removeEventListener(COMMERCE_EVENT_NAME, listener);
27
+ });
28
+
29
+ it('emits normalized commerce facts, not GA4 fields', () => {
30
+ emitCommerceEvent('add_to_cart', {
31
+ currency: 'try',
32
+ items: [
33
+ buildCommerceItem({
34
+ pk: 42,
35
+ sku: '4798121',
36
+ base_code: '2210',
37
+ name: 'GANT Polo',
38
+ price: '5499.00'
39
+ })
40
+ ]
41
+ });
42
+
43
+ expect(received).toEqual([
44
+ {
45
+ action: 'add_to_cart',
46
+ currency: 'try',
47
+ items: [
48
+ {
49
+ id: '2210',
50
+ baseCode: '2210',
51
+ sku: '4798121',
52
+ name: 'GANT Polo',
53
+ price: 5499,
54
+ quantity: 1
55
+ }
56
+ ]
57
+ }
58
+ ]);
59
+ });
60
+
61
+ it('builds an item off a normalized listing row', () => {
62
+ expect(
63
+ buildCommerceItem(
64
+ {
65
+ key: '54964',
66
+ productPk: 54964,
67
+ name: 'GANT Polo',
68
+ price: '5.499,00 TL',
69
+ priceAmount: 5499
70
+ },
71
+ { index: 2, listId: 'product_grid' }
72
+ )
73
+ ).toEqual({
74
+ id: '54964',
75
+ name: 'GANT Polo',
76
+ price: 5499,
77
+ quantity: 1,
78
+ index: 2,
79
+ listId: 'product_grid'
80
+ });
81
+ });
82
+
83
+ it('refuses an item that identifies nothing and skips empty emits', () => {
84
+ expect(buildCommerceItem({ image: 'x.jpg' })).toBeNull();
85
+
86
+ emitCommerceEvent('add_to_cart', { items: [null] });
87
+ expect(received).toEqual([]);
88
+ });
89
+
90
+ it('reads the per-product currency before the active one', () => {
91
+ expect(readItemsCurrency({ currency_type: 'usd' }, 'try')).toBe('usd');
92
+ expect(readItemsCurrency({}, 'try')).toBe('try');
93
+ expect(readItemsCurrency(null)).toBeUndefined();
94
+ });
95
+ });
96
+
97
+ describe('default GA4 dataLayer bridge', () => {
98
+ beforeEach(() => {
99
+ window.dataLayer = [];
100
+ });
101
+
102
+ it('maps neutral facts onto the flat brand GA4 shape', () => {
103
+ const detach = attachGa4DataLayerBridge();
104
+
105
+ emitCommerceEvent('add_to_cart', {
106
+ currency: 'try',
107
+ items: [
108
+ buildCommerceItem({
109
+ base_code: '2210',
110
+ sku: '4798121',
111
+ name: 'GANT Polo',
112
+ price: 5499
113
+ })
114
+ ]
115
+ });
116
+ emitCommerceEvent('search', { searchTerm: 'polo' });
117
+
118
+ expect(window.dataLayer).toEqual([
119
+ {
120
+ event: 'add_to_cart',
121
+ currency: 'try',
122
+ items: [
123
+ {
124
+ item_id: '2210',
125
+ item_name: 'GANT Polo',
126
+ price: 5499,
127
+ quantity: 1,
128
+ item_variant: '4798121'
129
+ }
130
+ ]
131
+ },
132
+ { event: 'search', search_term: 'polo' }
133
+ ]);
134
+
135
+ detach();
136
+ });
137
+
138
+ it('maps purchase with transaction id and value', () => {
139
+ const detach = attachGa4DataLayerBridge();
140
+
141
+ emitCommerceEvent('purchase', {
142
+ currency: 'try',
143
+ value: 5999,
144
+ transactionId: '#1042',
145
+ items: [buildCommerceItem({ pk: 1, name: 'A', price: 5999 })]
146
+ });
147
+
148
+ expect(window.dataLayer).toEqual([
149
+ {
150
+ event: 'purchase',
151
+ currency: 'try',
152
+ value: 5999,
153
+ transaction_id: '#1042',
154
+ items: [{ item_id: '1', item_name: 'A', price: 5999, quantity: 1 }]
155
+ }
156
+ ]);
157
+
158
+ detach();
159
+ });
160
+
161
+ it('does not forward excluded actions (brand-owned funnel moments)', () => {
162
+ const detach = attachGa4DataLayerBridge({
163
+ excludeActions: ['begin_checkout', 'purchase']
164
+ });
165
+
166
+ emitCommerceEvent('begin_checkout', {
167
+ items: [buildCommerceItem({ pk: 1, name: 'A' })]
168
+ });
169
+ emitCommerceEvent('select_item', {
170
+ items: [buildCommerceItem({ pk: 1, name: 'A' })]
171
+ });
172
+
173
+ expect(window.dataLayer?.map((e) => e.event)).toEqual(['select_item']);
174
+
175
+ detach();
176
+ });
177
+
178
+ it('transformItem tweaks one field and keeps the rest default', () => {
179
+ const detach = attachGa4DataLayerBridge({
180
+ transformItem: (item, facts) => ({
181
+ ...item,
182
+ item_category: facts.listName ?? 'unknown'
183
+ })
184
+ });
185
+
186
+ emitCommerceEvent('select_item', {
187
+ items: [
188
+ buildCommerceItem(
189
+ { pk: 1, name: 'A', price: 10 },
190
+ { listId: 'favourites', listName: 'Favourites' }
191
+ )
192
+ ]
193
+ });
194
+
195
+ expect(window.dataLayer?.[0]).toMatchObject({
196
+ event: 'select_item',
197
+ items: [
198
+ {
199
+ item_id: '1',
200
+ item_name: 'A',
201
+ item_list_id: 'favourites',
202
+ item_category: 'Favourites'
203
+ }
204
+ ]
205
+ });
206
+
207
+ detach();
208
+ });
209
+
210
+ it('transformEvent renames an event and can drop one', () => {
211
+ const detach = attachGa4DataLayerBridge({
212
+ transformEvent: (event) => {
213
+ if (event.event === 'add_to_wishlist') {
214
+ return { ...event, event: 'wishlist_add', wishlist_source: 'pdp' };
215
+ }
216
+ if (event.event === 'search') return null;
217
+ return event;
218
+ }
219
+ });
220
+
221
+ emitCommerceEvent('add_to_wishlist', {
222
+ items: [buildCommerceItem({ pk: 1, name: 'A' })]
223
+ });
224
+ emitCommerceEvent('search', { searchTerm: 'polo' });
225
+
226
+ expect(window.dataLayer?.map((e) => e.event)).toEqual(['wishlist_add']);
227
+ expect(window.dataLayer?.[0]).toMatchObject({ wishlist_source: 'pdp' });
228
+
229
+ detach();
230
+ });
231
+
232
+ it('a throwing transform falls back to the default push', () => {
233
+ const detach = attachGa4DataLayerBridge({
234
+ transformEvent: () => {
235
+ throw new Error('brand bug');
236
+ },
237
+ transformItem: () => {
238
+ throw new Error('brand bug');
239
+ }
240
+ });
241
+
242
+ emitCommerceEvent('add_to_cart', {
243
+ items: [buildCommerceItem({ pk: 1, name: 'A', price: 10 })]
244
+ });
245
+
246
+ expect(window.dataLayer).toEqual([
247
+ {
248
+ event: 'add_to_cart',
249
+ items: [{ item_id: '1', item_name: 'A', price: 10, quantity: 1 }]
250
+ }
251
+ ]);
252
+
253
+ detach();
254
+ });
255
+
256
+ it('is silent for brands that attached nothing', () => {
257
+ emitCommerceEvent('add_to_cart', {
258
+ items: [buildCommerceItem({ pk: 1, name: 'A' })]
259
+ });
260
+
261
+ expect(window.dataLayer).toEqual([]);
262
+ });
263
+ });
@@ -0,0 +1,339 @@
1
+ import { parseAmount } from '../blocks/commerce-format';
2
+
3
+ /**
4
+ * Neutral commerce signal layer (ZERO-4869).
5
+ *
6
+ * The theme ships to every brand, and analytics SCHEMA is a brand decision —
7
+ * so the blocks never talk to GTM directly. At each commerce moment (an add
8
+ * to cart that succeeded, a product card click, a viewed listing) they emit a
9
+ * `pz:commerce` CustomEvent carrying normalized commerce FACTS, and whatever
10
+ * the brand mounted decides what becomes of them: the default GA4 bridge
11
+ * below, the brand's own mapper in its repo, a different TMS, or nothing at
12
+ * all — a brand that attaches no listener gets exactly zero behavior change.
13
+ *
14
+ * The emit is fire-and-forget: analytics must never take a block down, so a
15
+ * malformed product just drops the item and an items-carrying action with no
16
+ * surviving item is not emitted at all.
17
+ */
18
+
19
+ export const COMMERCE_EVENT_NAME = 'pz:commerce';
20
+
21
+ /** Normalized product facts. Neutral names — GA4's item_* is the bridge's job. */
22
+ export interface CommerceItem {
23
+ id?: string;
24
+ baseCode?: string;
25
+ sku?: string;
26
+ name?: string;
27
+ price?: number;
28
+ /** The struck list price, for bridges that report a discount amount. */
29
+ retailPrice?: number;
30
+ quantity?: number;
31
+ index?: number;
32
+ listId?: string;
33
+ listName?: string;
34
+ /**
35
+ * The product's keyed commerce attributes (`integration_renk_tonu`,
36
+ * `integration_model_renk`, size…), exactly as commerce names them. Brand
37
+ * bridges derive their own fields from these — the live GANT schema builds
38
+ * item_id and item_variant off them.
39
+ */
40
+ attributes?: Record<string, string>;
41
+ }
42
+
43
+ export interface CommerceEventDetail {
44
+ action: string;
45
+ currency?: string;
46
+ value?: number;
47
+ items?: CommerceItem[];
48
+ searchTerm?: string;
49
+ method?: string;
50
+ transactionId?: string;
51
+ [key: string]: unknown;
52
+ }
53
+
54
+ const toText = (value: unknown): string | undefined => {
55
+ if (typeof value === 'string' && value.trim()) return value.trim();
56
+ if (typeof value === 'number' && Number.isFinite(value)) {
57
+ return String(value);
58
+ }
59
+ return undefined;
60
+ };
61
+
62
+ /**
63
+ * One commerce item off whatever product-shaped object a block holds: a raw
64
+ * commerce payload (`sku`, `base_code`, `retail_price`), a normalized listing
65
+ * row (`productPk`, `priceAmount`) or a ThemeProduct. Null when nothing
66
+ * identifies the product — an item that names nothing is noise for every
67
+ * bridge downstream.
68
+ */
69
+ /**
70
+ * The keyed attribute map off whatever shape the source carries: a raw payload
71
+ * (`attributes_kwargs` of `{value}` records over a raw `attributes` map), a
72
+ * normalized listing row (already keyed strings) or a ThemeProduct (an array
73
+ * of `{key, value}` entries).
74
+ */
75
+ const readItemAttributes = (
76
+ source: Record<string, unknown>
77
+ ): Record<string, string> | undefined => {
78
+ const keyed: Record<string, string> = {};
79
+
80
+ // A page-context product carries the merged map ready-made.
81
+ const preKeyed = source.attributesKeyed;
82
+ if (preKeyed && typeof preKeyed === 'object' && !Array.isArray(preKeyed)) {
83
+ for (const [key, value] of Object.entries(
84
+ preKeyed as Record<string, unknown>
85
+ )) {
86
+ const text = toText(value);
87
+ if (text) keyed[key] = text;
88
+ }
89
+ }
90
+
91
+ const kwargs = source.attributes_kwargs;
92
+ if (kwargs && typeof kwargs === 'object' && !Array.isArray(kwargs)) {
93
+ for (const [key, entry] of Object.entries(
94
+ kwargs as Record<string, unknown>
95
+ )) {
96
+ const value = toText((entry as Record<string, unknown>)?.value);
97
+ if (value) keyed[key] = value;
98
+ }
99
+ }
100
+
101
+ const raw = source.attributes;
102
+ if (Array.isArray(raw)) {
103
+ for (const entry of raw) {
104
+ const record = entry as Record<string, unknown>;
105
+ const key = toText(record?.key);
106
+ const value = toText(record?.value);
107
+ if (key && value) keyed[key] = value;
108
+ }
109
+ } else if (raw && typeof raw === 'object') {
110
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
111
+ const text = toText(value);
112
+ if (text) keyed[key] = text;
113
+ }
114
+ }
115
+
116
+ return Object.keys(keyed).length > 0 ? keyed : undefined;
117
+ };
118
+
119
+ export const buildCommerceItem = (
120
+ source: Record<string, unknown> | null | undefined,
121
+ extra: Partial<CommerceItem> = {}
122
+ ): CommerceItem | null => {
123
+ if (!source || typeof source !== 'object') return null;
124
+
125
+ const baseCode = toText(source.base_code);
126
+ const sku = toText(source.sku);
127
+ const id =
128
+ baseCode ?? sku ?? toText(source.productPk) ?? toText(source.pk);
129
+ const name = toText(source.name) ?? toText(source.title);
130
+
131
+ if (!id && !name) return null;
132
+
133
+ const amount =
134
+ parseAmount(source.priceAmount) ??
135
+ parseAmount(source.price) ??
136
+ parseAmount((source.active_price as Record<string, unknown>)?.price);
137
+ const retailAmount =
138
+ parseAmount(source.retailPriceAmount) ??
139
+ parseAmount(source.retail_price) ??
140
+ parseAmount(source.retailPrice) ??
141
+ parseAmount(
142
+ (source.active_price as Record<string, unknown>)?.retail_price
143
+ );
144
+ const attributes = readItemAttributes(source);
145
+
146
+ return {
147
+ ...(id ? { id } : null),
148
+ ...(baseCode ? { baseCode } : null),
149
+ ...(sku ? { sku } : null),
150
+ ...(name ? { name } : null),
151
+ ...(amount !== null ? { price: amount } : null),
152
+ ...(retailAmount !== null ? { retailPrice: retailAmount } : null),
153
+ quantity: 1,
154
+ ...(attributes ? { attributes } : null),
155
+ ...extra
156
+ };
157
+ };
158
+
159
+ export const readItemsCurrency = (
160
+ source: Record<string, unknown> | null | undefined,
161
+ fallback?: string
162
+ ): string | undefined =>
163
+ toText(source?.currency_type) ?? toText(source?.currency) ?? toText(fallback);
164
+
165
+ /**
166
+ * Emit one commerce moment. `items` may carry nulls from the builder; they are
167
+ * dropped, and an action whose items ALL dropped is not emitted.
168
+ */
169
+ export const emitCommerceEvent = (
170
+ action: string,
171
+ detail: Omit<CommerceEventDetail, 'action'> & {
172
+ items?: Array<CommerceItem | null>;
173
+ } = {}
174
+ ): void => {
175
+ if (typeof window === 'undefined') return;
176
+
177
+ try {
178
+ const { items, value, currency, ...rest } = detail;
179
+ const presentItems = items?.filter(
180
+ (item): item is CommerceItem => item !== null
181
+ );
182
+ // An empty basket's view_cart is a real report (the live storefronts push
183
+ // it with items: []); only item-CARRYING actions with nothing left after
184
+ // the builder are dropped.
185
+ if (
186
+ items &&
187
+ items.length > 0 &&
188
+ (!presentItems || presentItems.length === 0)
189
+ ) {
190
+ return;
191
+ }
192
+
193
+ window.dispatchEvent(
194
+ new CustomEvent<CommerceEventDetail>(COMMERCE_EVENT_NAME, {
195
+ detail: {
196
+ action,
197
+ ...(currency ? { currency } : null),
198
+ ...(typeof value === 'number' && Number.isFinite(value)
199
+ ? { value }
200
+ : null),
201
+ ...(presentItems ? { items: presentItems } : null),
202
+ ...rest
203
+ }
204
+ })
205
+ );
206
+ } catch {
207
+ // Observability must never take a block down with it.
208
+ }
209
+ };
210
+
211
+ /* ------------------------------------------------------------------------ *
212
+ * Default GA4 dataLayer bridge.
213
+ *
214
+ * A brand opts in by CALLING this from its own code (client root/template) —
215
+ * ownership of the schema stays in the brand repo even when the default
216
+ * mapping is used. The shape is the flat one the hand-coded brand storefronts
217
+ * already push (`{event, currency, value, items}`, no `ecommerce` wrapper),
218
+ * with the same field sources: item_id = base_code first, sku on item_variant.
219
+ * ------------------------------------------------------------------------ */
220
+
221
+ declare global {
222
+ interface Window {
223
+ dataLayer?: Array<Record<string, unknown>>;
224
+ }
225
+ }
226
+
227
+ export interface Ga4BridgeOptions {
228
+ /**
229
+ * Actions this bridge must NOT forward — for moments the brand already
230
+ * reports from its own code. GANT excludes the checkout funnel: its
231
+ * begin_checkout/add_shipping_info/add_payment_info/purchase pushes live in
232
+ * the brand checkout views and forwarding the page-level signals too would
233
+ * double-report the funnel.
234
+ */
235
+ excludeActions?: string[];
236
+ /**
237
+ * Small per-brand differences without rewriting the whole mapping: receives
238
+ * the default GA4 item plus the neutral facts it was built from, returns the
239
+ * item to push. Add a field, rename one, drop one — the rest stays default.
240
+ */
241
+ transformItem?: (
242
+ item: Record<string, unknown>,
243
+ facts: CommerceItem,
244
+ detail: CommerceEventDetail
245
+ ) => Record<string, unknown>;
246
+ /**
247
+ * Same idea at the event level: receives the default GA4 event object plus
248
+ * the neutral detail, returns what to push — or null to drop this one event.
249
+ * A transform that throws is ignored and the default event is pushed, so a
250
+ * brand callback bug can never silence analytics.
251
+ */
252
+ transformEvent?: (
253
+ event: Record<string, unknown>,
254
+ detail: CommerceEventDetail
255
+ ) => Record<string, unknown> | null;
256
+ }
257
+
258
+ const toGa4Item = (item: CommerceItem): Record<string, unknown> => ({
259
+ ...(item.baseCode ?? item.sku ?? item.id
260
+ ? { item_id: item.baseCode ?? item.sku ?? item.id }
261
+ : null),
262
+ ...(item.name ? { item_name: item.name } : null),
263
+ ...(typeof item.price === 'number' ? { price: item.price } : null),
264
+ quantity: item.quantity ?? 1,
265
+ ...(item.sku ? { item_variant: item.sku } : null),
266
+ ...(typeof item.index === 'number' ? { index: item.index } : null),
267
+ ...(item.listId ? { item_list_id: item.listId } : null),
268
+ ...(item.listName ? { item_list_name: item.listName } : null)
269
+ });
270
+
271
+ let ga4BridgeDetach: (() => void) | null = null;
272
+
273
+ /** Attach the default GA4 mapping. Idempotent; returns a detach function. */
274
+ export const attachGa4DataLayerBridge = (
275
+ options: Ga4BridgeOptions = {}
276
+ ): (() => void) => {
277
+ if (typeof window === 'undefined') return () => {};
278
+ if (ga4BridgeDetach) return ga4BridgeDetach;
279
+
280
+ const excluded = new Set(options.excludeActions ?? []);
281
+
282
+ const onCommerce = (event: Event) => {
283
+ const detail = (event as CustomEvent<CommerceEventDetail>).detail;
284
+ if (!detail?.action || excluded.has(detail.action)) return;
285
+
286
+ const {
287
+ action,
288
+ currency,
289
+ value,
290
+ items,
291
+ searchTerm,
292
+ method,
293
+ transactionId
294
+ } = detail;
295
+
296
+ try {
297
+ const mapItem = (item: CommerceItem): Record<string, unknown> => {
298
+ const ga4Item = toGa4Item(item);
299
+ if (!options.transformItem) return ga4Item;
300
+ try {
301
+ return options.transformItem(ga4Item, item, detail);
302
+ } catch {
303
+ return ga4Item;
304
+ }
305
+ };
306
+
307
+ let ga4Event: Record<string, unknown> | null = {
308
+ event: action,
309
+ ...(currency ? { currency } : null),
310
+ ...(typeof value === 'number' ? { value } : null),
311
+ ...(items && items.length > 0 ? { items: items.map(mapItem) } : null),
312
+ ...(searchTerm ? { search_term: searchTerm } : null),
313
+ ...(method ? { method } : null),
314
+ ...(transactionId ? { transaction_id: transactionId } : null)
315
+ };
316
+
317
+ if (options.transformEvent) {
318
+ try {
319
+ ga4Event = options.transformEvent(ga4Event, detail);
320
+ } catch {
321
+ // Keep the default event — a brand callback bug must not mute analytics.
322
+ }
323
+ }
324
+ if (!ga4Event) return;
325
+
326
+ window.dataLayer = window.dataLayer || [];
327
+ window.dataLayer.push(ga4Event);
328
+ } catch {
329
+ // Never let a mapping failure surface as a storefront error.
330
+ }
331
+ };
332
+
333
+ window.addEventListener(COMMERCE_EVENT_NAME, onCommerce);
334
+ ga4BridgeDetach = () => {
335
+ window.removeEventListener(COMMERCE_EVENT_NAME, onCommerce);
336
+ ga4BridgeDetach = null;
337
+ };
338
+ return ga4BridgeDetach;
339
+ };