@akinon/pz-theme 2.0.163-beta.0 → 2.0.163

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,12 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.163-beta.0
3
+ ## 2.0.163
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.163-beta.0
9
- - @akinon/theme-contract@2.0.163-beta.0
7
+ - 2c4fae70: ZERO-5305: Report product lists as GA4 lists on the commerce channel. Collection sections, product sliders and recommendation strips send `view_item_list` once seen and `select_item` with the list id, list name and the card's position; the favourites grid reports when it is seen rather than on mount; `section.seen` carries the section label and its product count. Nothing is emitted inside the editor.
8
+ - @akinon/next@2.0.163
9
+ - @akinon/theme-contract@2.0.163
10
10
 
11
11
  ## 2.0.162
12
12
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
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.163-beta.0",
4
+ "version": "2.0.163",
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.163-beta.0",
11
+ "@akinon/next": "2.0.163",
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.163-beta.0",
16
+ "@akinon/theme-contract": "2.0.163",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.163-beta.0",
24
+ "@akinon/next": "2.0.163",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -30,11 +30,22 @@ const FROZEN_COMMERCE_CALL_SITES: Record<string, number> = {
30
30
  'blocks/auth-form-block.tsx': 3,
31
31
  'blocks/basket-items-block.tsx': 1,
32
32
  'blocks/favorite-button-block.tsx': 1,
33
- 'blocks/favourites-grid-block.tsx': 3,
33
+ // view_item_list moved to hooks/use-list-impression.ts (ZERO-5305): the
34
+ // grid reports when it is seen, as every other list does.
35
+ 'blocks/favourites-grid-block.tsx': 2,
34
36
  'blocks/group-block.tsx': 1,
35
37
  'blocks/product-grid-block.tsx': 2,
36
38
  'blocks/product-recommendations-block.tsx': 1,
37
39
  'blocks/search-box-block.tsx': 1,
40
+ // view_item_list for the product rows that are not a listing grid — a
41
+ // collection carousel, a recommendations strip (ZERO-5305). An existing GA4
42
+ // verb, not a new moment: a storefront bridge maps it as it maps the
43
+ // listing grid's.
44
+ 'hooks/use-list-impression.ts': 1,
45
+ // select_item for a product slider's card (ZERO-5305): the same verb the
46
+ // composed card's anchor reports from group-block, for the slides the
47
+ // slider draws itself.
48
+ 'sections/posts-slider-section.tsx': 1,
38
49
  // One call, four page-scope actions behind it (view_item, view_cart,
39
50
  // begin_checkout, purchase) — the action is a variable there.
40
51
  'theme-placeholder-wrapper.tsx': 1
@@ -0,0 +1,113 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ jest.mock('next/navigation', () => ({
5
+ usePathname: () => '/'
6
+ }));
7
+
8
+ jest.mock('react-multi-carousel', () => ({
9
+ __esModule: true,
10
+ default: ({ children }: { children?: React.ReactNode }) => (
11
+ <div>{children}</div>
12
+ )
13
+ }));
14
+
15
+ const wrapperProps: Array<Record<string, unknown>> = [];
16
+ jest.mock('../sections/section-wrapper', () => ({
17
+ __esModule: true,
18
+ default: (
19
+ props: Record<string, unknown> & { children?: React.ReactNode }
20
+ ) => {
21
+ wrapperProps.push(props);
22
+ return (
23
+ <section data-section-id={(props.section as { id: string }).id}>
24
+ {props.children}
25
+ </section>
26
+ );
27
+ }
28
+ }));
29
+
30
+ import ThemeSection from '../theme-section';
31
+ import { ThemeSettingsProvider } from '../theme-settings-context';
32
+
33
+ /**
34
+ * A section with a renderer of its own (a product slider) is drawn through
35
+ * `SectionWrapper`, not the generic branch's <section>. ThemeSection hands the
36
+ * wrapper what it needs to report the section as the generic branch does
37
+ * (ZERO-5305): the product count, so a product row is never taken for a
38
+ * banner, and the rows with the list they are — the collection, named after
39
+ * the section's label.
40
+ */
41
+
42
+ const ROWS = [
43
+ { pk: 1, base_code: 'B1', name: 'Polo', price: '1499' },
44
+ { pk: 2, base_code: 'B2', name: 'Shirt', price: '2499' }
45
+ ];
46
+
47
+ const slider = (blocks: unknown[] = []) =>
48
+ ({
49
+ id: 'section-1',
50
+ type: 'flash-deals-slider',
51
+ name: 'section-1',
52
+ label: 'Picks Slider',
53
+ order: 3,
54
+ hidden: false,
55
+ properties: {},
56
+ styles: {},
57
+ dataSourceId: 'datasource-1',
58
+ dataSource: {
59
+ id: 'datasource-1',
60
+ type: 'collection',
61
+ details: { collection: { data: { products: ROWS } } }
62
+ },
63
+ blocks
64
+ } as never);
65
+
66
+ const renderSection = (section: never) =>
67
+ render(
68
+ <ThemeSettingsProvider value={null}>
69
+ <ThemeSection
70
+ section={section}
71
+ placeholderId="home-body"
72
+ currentBreakpoint="desktop"
73
+ isDesigner={false}
74
+ />
75
+ </ThemeSettingsProvider>
76
+ );
77
+
78
+ describe('a section drawn through SectionWrapper (ZERO-5305)', () => {
79
+ beforeEach(() => {
80
+ wrapperProps.length = 0;
81
+ });
82
+
83
+ it('is handed its product count and its rows as a list', () => {
84
+ renderSection(slider());
85
+
86
+ const props = wrapperProps[wrapperProps.length - 1];
87
+ expect(props.productCount).toBe(2);
88
+ expect(props.listProducts).toEqual(ROWS);
89
+ expect(props.listId).toBe('datasource-1');
90
+ expect(props.listName).toBe('Picks Slider');
91
+ });
92
+
93
+ it('lists nothing when a grid inside reports its own impressions', () => {
94
+ renderSection(
95
+ slider([
96
+ {
97
+ id: 'grid',
98
+ type: 'product-grid',
99
+ label: 'Grid',
100
+ properties: {},
101
+ styles: {},
102
+ order: 0,
103
+ hidden: false,
104
+ blocks: []
105
+ }
106
+ ])
107
+ );
108
+
109
+ const props = wrapperProps[wrapperProps.length - 1];
110
+ expect(props.listProducts).toBeNull();
111
+ expect(props.productCount).toBe(2);
112
+ });
113
+ });
@@ -0,0 +1,217 @@
1
+ import React from 'react';
2
+ import { act, render } from '@testing-library/react';
3
+
4
+ import FavouritesGridBlock from '../favourites-grid-block';
5
+ import { COMMERCE_EVENT_NAME } from '../../utils/analytics';
6
+ import { LIST_IMPRESSION_DWELL_MS } from '../../hooks/use-list-impression';
7
+
8
+ /**
9
+ * The favourites grid reports its favourites as a list when the grid is seen
10
+ * (ZERO-5305), as every other list does — not when it mounts. A grid kept on
11
+ * a page only for its empty state, its list laid out with `display: none`
12
+ * beside a favourites strip, reported the favourites a second time.
13
+ */
14
+
15
+ jest.mock('next-auth/react', () => ({
16
+ useSession: () => ({ data: null, status: 'authenticated' })
17
+ }));
18
+
19
+ jest.mock('@akinon/next/hooks', () => ({
20
+ __esModule: true,
21
+ useLocalization: () => ({ locale: 'tr', currency: 'try' })
22
+ }));
23
+
24
+ jest.mock('@akinon/next/redux/hooks', () => ({
25
+ __esModule: true,
26
+ useAppSelector: () => undefined,
27
+ useAppDispatch: () => jest.fn()
28
+ }));
29
+
30
+ const favourites = {
31
+ results: [
32
+ {
33
+ pk: 11,
34
+ product: {
35
+ pk: 101,
36
+ base_code: 'B101',
37
+ name: 'Shirt',
38
+ absolute_url: '/gomlek/',
39
+ price: '3999.90',
40
+ currency_type: 'try'
41
+ }
42
+ },
43
+ {
44
+ pk: 12,
45
+ product: {
46
+ pk: 102,
47
+ base_code: 'B102',
48
+ name: 'Polo',
49
+ absolute_url: '/polo/',
50
+ price: '2499.00',
51
+ currency_type: 'try'
52
+ }
53
+ }
54
+ ]
55
+ };
56
+
57
+ // Every `@akinon/next/data/client/*` subpath maps to the same mock file.
58
+ jest.mock('@akinon/next/data/client/account', () => ({
59
+ __esModule: true,
60
+ useGetFavoritesQuery: () => ({ data: mockFavourites(), isLoading: false }),
61
+ useRemoveFavoriteMutation: () => [jest.fn(), { isLoading: false }],
62
+ useAddProductMutation: () => [jest.fn(), { isLoading: false }],
63
+ basketApi: { util: { invalidateTags: jest.fn() } }
64
+ }));
65
+
66
+ let current = favourites;
67
+ const mockFavourites = () => current;
68
+
69
+ class RecordingObserver {
70
+ static instances: RecordingObserver[] = [];
71
+ callback: IntersectionObserverCallback;
72
+ observed: Element[] = [];
73
+
74
+ constructor(callback: IntersectionObserverCallback) {
75
+ this.callback = callback;
76
+ RecordingObserver.instances.push(this);
77
+ }
78
+
79
+ observe(node: Element) {
80
+ this.observed.push(node);
81
+ }
82
+
83
+ unobserve() {}
84
+ disconnect() {}
85
+ takeRecords() {
86
+ return [];
87
+ }
88
+
89
+ cross() {
90
+ act(() => {
91
+ this.callback(
92
+ [
93
+ {
94
+ isIntersecting: true,
95
+ intersectionRatio: 1,
96
+ target: this.observed[0]
97
+ } as unknown as IntersectionObserverEntry
98
+ ],
99
+ this as unknown as IntersectionObserver
100
+ );
101
+ });
102
+ }
103
+ }
104
+
105
+ const block = {
106
+ id: 'favourites-grid-1',
107
+ type: 'favourites-grid',
108
+ label: 'Favourites Grid',
109
+ order: 0,
110
+ hidden: false,
111
+ properties: {},
112
+ styles: {},
113
+ blocks: []
114
+ };
115
+
116
+ describe('favourites-grid list impression (ZERO-5305)', () => {
117
+ let received: Array<Record<string, unknown>>;
118
+ let originalObserver: unknown;
119
+ const onCommerce = (event: Event) =>
120
+ received.push((event as CustomEvent<Record<string, unknown>>).detail);
121
+ const lists = () => received.filter((d) => d.action === 'view_item_list');
122
+ const latest = () =>
123
+ RecordingObserver.instances[RecordingObserver.instances.length - 1];
124
+
125
+ beforeEach(() => {
126
+ jest.useFakeTimers();
127
+ current = favourites;
128
+ received = [];
129
+ window.addEventListener(COMMERCE_EVENT_NAME, onCommerce);
130
+ RecordingObserver.instances = [];
131
+ originalObserver = (window as unknown as Record<string, unknown>)
132
+ .IntersectionObserver;
133
+ (window as unknown as Record<string, unknown>).IntersectionObserver =
134
+ RecordingObserver;
135
+ });
136
+
137
+ afterEach(() => {
138
+ window.removeEventListener(COMMERCE_EVENT_NAME, onCommerce);
139
+ (window as unknown as Record<string, unknown>).IntersectionObserver =
140
+ originalObserver;
141
+ jest.useRealTimers();
142
+ });
143
+
144
+ it('reports nothing until the grid is seen, then the favourites as one list', () => {
145
+ const { container } = render(
146
+ <FavouritesGridBlock
147
+ block={block}
148
+ placeholderId="p1"
149
+ sectionId="s1"
150
+ isDesigner={false}
151
+ />
152
+ );
153
+
154
+ expect(lists()).toEqual([]);
155
+ expect(latest().observed[0]).toBe(
156
+ container.querySelector('[data-favourites-grid]')
157
+ );
158
+
159
+ latest().cross();
160
+ act(() => {
161
+ jest.advanceTimersByTime(LIST_IMPRESSION_DWELL_MS);
162
+ });
163
+
164
+ expect(lists()).toHaveLength(1);
165
+ expect(lists()[0]).toMatchObject({ currency: 'try' });
166
+ expect(lists()[0].items).toEqual([
167
+ expect.objectContaining({
168
+ id: '101',
169
+ name: 'Shirt',
170
+ index: 0,
171
+ listId: 'favourites',
172
+ listName: 'Favourites'
173
+ }),
174
+ expect.objectContaining({
175
+ id: '102',
176
+ name: 'Polo',
177
+ index: 1,
178
+ listId: 'favourites',
179
+ listName: 'Favourites'
180
+ })
181
+ ]);
182
+ });
183
+
184
+ it('reports the new list once a favourite is removed', () => {
185
+ const { rerender } = render(
186
+ <FavouritesGridBlock
187
+ block={block}
188
+ placeholderId="p1"
189
+ sectionId="s1"
190
+ isDesigner={false}
191
+ />
192
+ );
193
+ latest().cross();
194
+ act(() => {
195
+ jest.advanceTimersByTime(LIST_IMPRESSION_DWELL_MS);
196
+ });
197
+
198
+ current = { results: [favourites.results[1]] };
199
+ rerender(
200
+ <FavouritesGridBlock
201
+ block={block}
202
+ placeholderId="p1"
203
+ sectionId="s1"
204
+ isDesigner={false}
205
+ />
206
+ );
207
+ latest().cross();
208
+ act(() => {
209
+ jest.advanceTimersByTime(LIST_IMPRESSION_DWELL_MS);
210
+ });
211
+
212
+ expect(lists()).toHaveLength(2);
213
+ expect(lists()[1].items).toEqual([
214
+ expect.objectContaining({ id: '102', index: 0 })
215
+ ]);
216
+ });
217
+ });
@@ -0,0 +1,90 @@
1
+ import React from 'react';
2
+ import { fireEvent, render } from '@testing-library/react';
3
+
4
+ jest.mock('../../theme-block', () => ({
5
+ __esModule: true,
6
+ default: () => null
7
+ }));
8
+
9
+ import GroupBlock from '../group-block';
10
+ import { ThemeItemScopeProvider } from '../product-block-utils';
11
+ import { ThemeSectionListProvider } from '../../theme-section-data-context';
12
+ import { COMMERCE_EVENT_NAME } from '../../utils/analytics';
13
+
14
+ /**
15
+ * A product card's click names the list the card sits in — the section's
16
+ * carousel, not the product's catalogue group — and its place in it
17
+ * (ZERO-5305), the identity the row's view_item_list carries.
18
+ */
19
+
20
+ const card = () =>
21
+ ({
22
+ block: {
23
+ id: 'card',
24
+ type: 'group',
25
+ label: 'card',
26
+ value: '',
27
+ properties: { tag: 'a', href: '#product' },
28
+ styles: {},
29
+ order: 0,
30
+ hidden: false,
31
+ blocks: []
32
+ },
33
+ placeholderId: 'home-body',
34
+ sectionId: 'sec_new_in',
35
+ isDesigner: false
36
+ } as any);
37
+
38
+ const PRODUCT = { pk: 7, base_code: 'B7', name: 'Polo', price: '1499' };
39
+
40
+ describe('a product card click (ZERO-5305)', () => {
41
+ let received: Array<Record<string, unknown>>;
42
+ const onCommerce = (event: Event) =>
43
+ received.push((event as CustomEvent<Record<string, unknown>>).detail);
44
+
45
+ beforeEach(() => {
46
+ received = [];
47
+ window.addEventListener(COMMERCE_EVENT_NAME, onCommerce);
48
+ });
49
+
50
+ afterEach(() => {
51
+ window.removeEventListener(COMMERCE_EVENT_NAME, onCommerce);
52
+ });
53
+
54
+ it('reports the section’s list and the card’s place in it', () => {
55
+ const { container } = render(
56
+ <ThemeSectionListProvider listId="collection-12" listName="New Arrivals">
57
+ <ThemeItemScopeProvider product={PRODUCT as never} index={3}>
58
+ <GroupBlock {...card()} />
59
+ </ThemeItemScopeProvider>
60
+ </ThemeSectionListProvider>
61
+ );
62
+
63
+ fireEvent.click(container.querySelector('a') as HTMLElement);
64
+
65
+ expect(received).toHaveLength(1);
66
+ expect(received[0]).toMatchObject({ action: 'select_item' });
67
+ expect(received[0].items).toEqual([
68
+ expect.objectContaining({
69
+ baseCode: 'B7',
70
+ index: 3,
71
+ listId: 'collection-12',
72
+ listName: 'New Arrivals'
73
+ })
74
+ ]);
75
+ });
76
+
77
+ it('still reports the product outside a section list', () => {
78
+ const { container } = render(
79
+ <ThemeItemScopeProvider product={PRODUCT as never}>
80
+ <GroupBlock {...card()} />
81
+ </ThemeItemScopeProvider>
82
+ );
83
+
84
+ fireEvent.click(container.querySelector('a') as HTMLElement);
85
+
86
+ expect(received[0].items).toEqual([
87
+ expect.not.objectContaining({ listId: expect.anything() })
88
+ ]);
89
+ });
90
+ });
@@ -25,6 +25,7 @@ import {
25
25
  readItemsCurrency
26
26
  } from '../utils/analytics';
27
27
  import { hasItemTemplate, ItemTemplateList } from './item-template';
28
+ import { useListImpression } from '../hooks/use-list-impression';
28
29
  import { BlockRendererProps } from './block-renderer-registry';
29
30
  import { DesignerPlaceholder, readToggle } from './product-block-utils';
30
31
 
@@ -283,25 +284,21 @@ const FavouritesGridBlock = ({
283
284
 
284
285
  // GA4 view_item_list (ZERO-4869), once per distinct wishlist content: the
285
286
  // favourites grid IS the list impression on this screen. Keyed by row keys
286
- // so a remove refires with the new list, a mere re-render does not. A ref,
287
- // not a global — each visit mounts a fresh grid and must report again.
287
+ // so a remove refires with the new list, a mere re-render does not. Sent
288
+ // when the grid is seen, as every other list is (ZERO-5305): a grid kept
289
+ // only for its empty state, its list laid out with `display: none` next to
290
+ // a favourites strip, used to report the favourites a second time.
288
291
  const rowsKey = rows.map((row) => row.key).join(',');
289
- const pushedListKey = React.useRef<string | null>(null);
290
- React.useEffect(() => {
291
- if (isDesigner || rows.length === 0) return;
292
- if (pushedListKey.current === rowsKey) return;
293
- pushedListKey.current = rowsKey;
294
- emitCommerceEvent('view_item_list', {
295
- currency: localeCurrency,
296
- items: rows.map((row) =>
297
- buildCommerceItem(row as Record<string, unknown>, {
298
- listId: 'favourites',
299
- listName: 'Favourites'
300
- })
301
- )
302
- });
303
- // eslint-disable-next-line react-hooks/exhaustive-deps
304
- }, [rowsKey, isDesigner]);
292
+ const listRef = React.useRef<HTMLDivElement>(null);
293
+ useListImpression({
294
+ ref: listRef,
295
+ products: rows as Array<Record<string, unknown>>,
296
+ listId: 'favourites',
297
+ listName: 'Favourites',
298
+ enabled: !isDesigner,
299
+ refreshKey: rowsKey,
300
+ currency: localeCurrency
301
+ });
305
302
 
306
303
  // A designer who marked a child as the card layout gets that layout repeated
307
304
  // per saved product; otherwise the built-in card renders unchanged.
@@ -402,6 +399,7 @@ const FavouritesGridBlock = ({
402
399
 
403
400
  return (
404
401
  <div
402
+ ref={listRef}
405
403
  data-favourites-grid=""
406
404
  data-favourites-grid-id={gridId}
407
405
  aria-busy={isBusy}
@@ -17,6 +17,7 @@ import { DesignerPlaceholder, useThemeItemScope } from './product-block-utils';
17
17
  import { renderComposedChildren } from './render-composed-children';
18
18
  import { CardListSlider, readListGap } from '../components/card-list-slider';
19
19
  import { CardListViews } from '../components/card-list-views';
20
+ import { useThemeSectionList } from '../theme-section-data-context';
20
21
 
21
22
  /**
22
23
  * Composed anchors to the storefront's own routes navigate client-side. A
@@ -59,17 +60,28 @@ const GroupBlock = ({
59
60
  const Tag = tag as keyof React.JSX.IntrinsicElements;
60
61
  const href = tag === 'a' ? block.properties?.href : undefined;
61
62
  const itemScope = useThemeItemScope();
63
+ const sectionList = useThemeSectionList();
62
64
 
63
65
  // GA4 select_item (ZERO-4869): a composed anchor inside a repeated product
64
66
  // row IS the product card's click. The row scope carries the product, so no
65
- // per-card wiring is needed — and outside a row this stays silent.
67
+ // per-card wiring is needed — and outside a row this stays silent. The card
68
+ // reports the list it sits in — the section's — and its place in it
69
+ // (ZERO-5305), the same identity the row's view_item_list carries.
66
70
  const handleAnchorAnalytics = () => {
67
71
  if (isDesigner) return;
68
72
  const scopedProduct = itemScope?.product;
69
73
  if (!scopedProduct) return;
70
74
  emitCommerceEvent('select_item', {
71
75
  currency: readItemsCurrency(scopedProduct as Record<string, unknown>),
72
- items: [buildCommerceItem(scopedProduct as Record<string, unknown>)]
76
+ items: [
77
+ buildCommerceItem(scopedProduct as Record<string, unknown>, {
78
+ ...(typeof itemScope?.index === 'number'
79
+ ? { index: itemScope.index }
80
+ : null),
81
+ ...(sectionList?.listId ? { listId: sectionList.listId } : null),
82
+ ...(sectionList?.listName ? { listName: sectionList.listName } : null)
83
+ })
84
+ ]
73
85
  });
74
86
  };
75
87
 
@@ -158,6 +158,7 @@ export const ItemTemplateList = ({
158
158
  product={
159
159
  scoped ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
160
160
  }
161
+ index={index}
161
162
  >
162
163
  {row}
163
164
  </ThemeItemScopeProvider>
@@ -99,6 +99,8 @@ export interface ThemeProduct {
99
99
  */
100
100
  interface ThemeItemScopeValue {
101
101
  product: ThemeProduct | null;
102
+ /** The row's place in its list (0-based), when the repeat knows it. */
103
+ index?: number;
102
104
  }
103
105
 
104
106
  const ThemeItemScopeContext = React.createContext<ThemeItemScopeValue | null>(
@@ -107,12 +109,14 @@ const ThemeItemScopeContext = React.createContext<ThemeItemScopeValue | null>(
107
109
 
108
110
  export const ThemeItemScopeProvider = ({
109
111
  product,
112
+ index,
110
113
  children
111
114
  }: {
112
115
  product: ThemeProduct | null;
116
+ index?: number;
113
117
  children: React.ReactNode;
114
118
  }) => {
115
- const value = React.useMemo(() => ({ product }), [product]);
119
+ const value = React.useMemo(() => ({ product, index }), [product, index]);
116
120
 
117
121
  return (
118
122
  <ThemeItemScopeContext.Provider value={value}>