@akinon/pz-theme 2.0.80-beta.0 → 2.0.80
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 +4 -4
- package/package.json +4 -4
- package/src/blocks/add-to-cart-block.tsx +9 -0
- package/src/blocks/auth-form-block.tsx +7 -0
- package/src/blocks/basket-items-block.tsx +20 -0
- package/src/blocks/favorite-button-block.tsx +9 -0
- package/src/blocks/favourites-grid-block.tsx +52 -1
- package/src/blocks/group-block.tsx +29 -1
- package/src/blocks/listing-context.ts +8 -0
- package/src/blocks/product-grid-block.tsx +41 -1
- package/src/blocks/product-recommendations-block.tsx +19 -1
- package/src/blocks/search-box-block.tsx +2 -0
- package/src/chrome/page-context.ts +30 -1
- package/src/theme-placeholder-wrapper.tsx +112 -1
- package/src/utils/__tests__/analytics.test.ts +263 -0
- package/src/utils/analytics.ts +339 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.80
|
|
3
|
+
## 2.0.80
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.80
|
|
9
|
-
- @akinon/theme-contract@2.0.80
|
|
7
|
+
- 6fb7f650: ZERO-4869: Implement GA4 ecommerce event tracking across various components
|
|
8
|
+
- @akinon/next@2.0.80
|
|
9
|
+
- @akinon/theme-contract@2.0.80
|
|
10
10
|
|
|
11
11
|
## 2.0.79
|
|
12
12
|
|
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
|
|
4
|
+
"version": "2.0.80",
|
|
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
|
|
11
|
+
"@akinon/next": "2.0.80",
|
|
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
|
|
16
|
+
"@akinon/theme-contract": "2.0.80",
|
|
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
|
|
23
|
+
"@akinon/next": "2.0.80",
|
|
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={
|
|
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
|
|
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)
|
|
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)
|
|
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
|
+
};
|