@akinon/pz-theme 2.0.160 → 2.0.161-rc.22

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,5 +1,56 @@
1
1
  # @akinon/pz-theme
2
2
 
3
+ ## 2.0.161-rc.22
4
+
5
+ ### Patch Changes
6
+
7
+ - 8e040731: ZERO-5235: let brands hang actions on order detail lines
8
+ - c761b9fd: ZERO-5296: fall back to the storefront default language in the text block
9
+ - 17a598da: ZERO-5296: report the storefront default language to the editor
10
+ - 3b05a407: ZERO-5232: allow richtext settings on custom blocks
11
+ - ad5240c40: ZERO-5254: add custom render and theme editor support to pz-rate-review
12
+ - Updated dependencies [86187b1c2]
13
+ - Updated dependencies [08390a87]
14
+ - Updated dependencies [0cf9ea239]
15
+ - Updated dependencies [97d29893b]
16
+ - Updated dependencies [324f97d55]
17
+ - Updated dependencies [4c3465ae]
18
+ - Updated dependencies [51ea06888]
19
+ - Updated dependencies
20
+ - Updated dependencies [549da545]
21
+ - Updated dependencies [2fea41430]
22
+ - Updated dependencies [b55acb768]
23
+ - Updated dependencies [760258c1c]
24
+ - Updated dependencies [143be2b9d]
25
+ - Updated dependencies [7889b08fe]
26
+ - Updated dependencies [9f8cd3bc5]
27
+ - Updated dependencies [d635d345]
28
+ - Updated dependencies [bfafa3f49]
29
+ - Updated dependencies [d860c917]
30
+ - Updated dependencies [ac6cada1]
31
+ - Updated dependencies [e90c0e02]
32
+ - Updated dependencies [dc7f9ca5]
33
+ - Updated dependencies [57d7eb305]
34
+ - Updated dependencies [0145dc3f]
35
+ - Updated dependencies [d99a6a7d5]
36
+ - Updated dependencies [227b7408]
37
+ - Updated dependencies [9db81a714]
38
+ - Updated dependencies [591e345e1]
39
+ - Updated dependencies [4de5303c5]
40
+ - Updated dependencies [95b139dc1]
41
+ - Updated dependencies [1d00f2d06]
42
+ - Updated dependencies [4ac7b2a1e]
43
+ - Updated dependencies [4998a9631]
44
+ - Updated dependencies [804d2bd6c]
45
+ - Updated dependencies [3909d3224]
46
+ - Updated dependencies [6a3d8a631]
47
+ - Updated dependencies [e18836b20]
48
+ - Updated dependencies [b1111d7b1]
49
+ - Updated dependencies [1595d458e]
50
+ - Updated dependencies [dca128112]
51
+ - @akinon/next@2.0.161-rc.22
52
+ - @akinon/theme-contract@2.0.161-rc.22
53
+
3
54
  ## 2.0.160
4
55
 
5
56
  ### Patch Changes
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.160",
4
+ "version": "2.0.161-rc.22",
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.160",
11
+ "@akinon/next": "2.0.161-rc.22",
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.160",
16
+ "@akinon/theme-contract": "2.0.161-rc.22",
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.160",
24
+ "@akinon/next": "2.0.161-rc.22",
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",
@@ -28,6 +28,7 @@ const SETTINGS_LOCALES = [
28
28
  const mockLocalization = {
29
29
  locale: 'tr',
30
30
  locales: SETTINGS_LOCALES as Array<Record<string, unknown>>,
31
+ defaultLocaleValue: 'tr',
31
32
  setLocale: jest.fn()
32
33
  };
33
34
 
@@ -106,6 +107,7 @@ beforeEach(() => {
106
107
  postMessage.mockClear();
107
108
  mockLocalization.locale = 'tr';
108
109
  mockLocalization.locales = SETTINGS_LOCALES;
110
+ mockLocalization.defaultLocaleValue = 'tr';
109
111
  mockLocalization.setLocale = jest.fn();
110
112
  delete (window as { __iframeReadySent?: boolean }).__iframeReadySent;
111
113
  });
@@ -144,7 +146,8 @@ describe('inside the editor', () => {
144
146
  { value: 'en', label: 'English' },
145
147
  { value: 'tr', label: 'Türkçe' },
146
148
  { value: 'de', label: 'de' }
147
- ]
149
+ ],
150
+ defaultLocale: 'tr'
148
151
  }
149
152
  })
150
153
  ]);
@@ -154,6 +157,34 @@ describe('inside the editor', () => {
154
157
  expect(targetOrigin).toBe(EDITOR_ORIGIN);
155
158
  });
156
159
 
160
+ it('posts the languages before the document reports ready', () => {
161
+ // The editor holds every per-language write until IFRAME_READY and takes
162
+ // "ready without languages" to mean they are never coming, so the order
163
+ // of the two frames is part of the contract.
164
+ render(placeholder('header'));
165
+
166
+ const types = postMessage.mock.calls.map(([message]) => message?.type);
167
+ expect(types).toContain('LOCALE_UPDATE');
168
+ expect(types).toContain('IFRAME_READY');
169
+ expect(types.indexOf('LOCALE_UPDATE')).toBeLessThan(
170
+ types.indexOf('IFRAME_READY')
171
+ );
172
+ });
173
+
174
+ it("posts the brand's default language, whichever it is", () => {
175
+ // The editor's per-language text controls take the plain-string form to
176
+ // mean this language; left to guess, the editor assumed English.
177
+ mockLocalization.defaultLocaleValue = 'de';
178
+
179
+ render(<EditorLocaleBridge />);
180
+
181
+ expect(localeFrames()).toEqual([
182
+ expect.objectContaining({
183
+ data: expect.objectContaining({ locale: 'tr', defaultLocale: 'de' })
184
+ })
185
+ ]);
186
+ });
187
+
157
188
  it("follows the editor's switch exactly once, with several placeholders mounted", () => {
158
189
  render(
159
190
  <>
@@ -0,0 +1,118 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { ItemTemplateList } from '../blocks/item-template';
4
+ import { useThemeProduct } from '../blocks/product-block-utils';
5
+ import { useThemeRowScope } from '../blocks/row-scope';
6
+ import blockRendererRegistry from '../blocks/block-renderer-registry';
7
+ import { resetCustomCatalog } from '../custom/catalog';
8
+ import { resetCustomCollection } from '../custom/collect';
9
+ import { isCustomThemeType, registerCustomBlocks } from '../custom';
10
+ import type { Block } from '../theme-block';
11
+
12
+ const READER = 'custom:acme:row-reader';
13
+
14
+ function RowReader() {
15
+ const scope = useThemeRowScope();
16
+ const product = useThemeProduct();
17
+
18
+ return (
19
+ <span
20
+ data-testid="reader"
21
+ data-list={scope?.listType ?? ''}
22
+ data-index={scope?.index ?? ''}
23
+ data-number={String(scope?.row.number ?? '')}
24
+ data-product={String(product?.pk ?? '')}
25
+ />
26
+ );
27
+ }
28
+
29
+ const template = (): Block =>
30
+ ({
31
+ id: 'tpl',
32
+ type: 'group',
33
+ label: 'Row',
34
+ order: 0,
35
+ hidden: false,
36
+ value: '',
37
+ properties: { role: { desktop: 'item-template' } },
38
+ styles: {},
39
+ blocks: [
40
+ {
41
+ id: 'tpl-reader',
42
+ type: READER,
43
+ label: 'Reader',
44
+ order: 0,
45
+ hidden: false,
46
+ value: '',
47
+ properties: {},
48
+ styles: {},
49
+ blocks: []
50
+ }
51
+ ]
52
+ } as Block);
53
+
54
+ const list = (type: string): Block =>
55
+ ({
56
+ id: 'list',
57
+ type,
58
+ label: 'List',
59
+ order: 0,
60
+ hidden: false,
61
+ value: '',
62
+ properties: {},
63
+ styles: {},
64
+ blocks: [template()]
65
+ } as Block);
66
+
67
+ const renderRows = (type: string, items: Record<string, unknown>[]) =>
68
+ render(
69
+ <ItemTemplateList
70
+ block={list(type)}
71
+ items={items}
72
+ placeholderId="p1"
73
+ sectionId="s1"
74
+ />
75
+ );
76
+
77
+ beforeAll(() => {
78
+ registerCustomBlocks('acme', { 'row-reader': RowReader });
79
+ });
80
+
81
+ afterAll(() => {
82
+ const map = (
83
+ blockRendererRegistry as unknown as { renderers: Map<string, unknown> }
84
+ ).renderers;
85
+ Array.from(map.keys())
86
+ .filter(isCustomThemeType)
87
+ .forEach((key) => map.delete(key));
88
+ resetCustomCatalog();
89
+ resetCustomCollection();
90
+ });
91
+
92
+ describe('the row scope', () => {
93
+ it('hands every repeated row to the blocks inside it, on any list', () => {
94
+ renderRows('order-list', [{ number: 'ORD-1' }, { number: 'ORD-2' }]);
95
+
96
+ const readers = screen.getAllByTestId('reader');
97
+ expect(readers).toHaveLength(2);
98
+ expect(readers[0]).toHaveAttribute('data-list', 'order-list');
99
+ expect(readers[0]).toHaveAttribute('data-index', '0');
100
+ expect(readers[0]).toHaveAttribute('data-number', 'ORD-1');
101
+ expect(readers[1]).toHaveAttribute('data-number', 'ORD-2');
102
+ expect(readers[0]).toHaveAttribute('data-product', '');
103
+ });
104
+
105
+ it('keeps the product scope on product lists next to the row scope', () => {
106
+ renderRows('product-grid', [{ pk: 7, name: 'Sneaker' }]);
107
+
108
+ const reader = screen.getByTestId('reader');
109
+ expect(reader).toHaveAttribute('data-list', 'product-grid');
110
+ expect(reader).toHaveAttribute('data-product', '7');
111
+ });
112
+
113
+ it('is closed outside a repeated row', () => {
114
+ render(<RowReader />);
115
+
116
+ expect(screen.getByTestId('reader')).toHaveAttribute('data-list', '');
117
+ });
118
+ });
@@ -0,0 +1,54 @@
1
+ import React from 'react';
2
+ import {
3
+ registerOrderLineActions,
4
+ renderOrderLineActions
5
+ } from '../order-line-actions';
6
+
7
+ const context = {
8
+ order: { id: 7, number: '20920397829', created_date: '2026-10-01' },
9
+ line: {
10
+ key: '1',
11
+ name: 'Product',
12
+ image: '',
13
+ href: '',
14
+ price: '100',
15
+ total: 100,
16
+ quantity: 1,
17
+ item: { id: 1, status: { value: '550', label: 'Delivered' } }
18
+ },
19
+ isDesigner: false
20
+ };
21
+
22
+ describe('order line actions registry', () => {
23
+ it('renders nothing until a brand registers', () => {
24
+ expect(renderOrderLineActions(context)).toBeNull();
25
+ });
26
+
27
+ it('hands the line and order to the registered renderer', () => {
28
+ const renderer = jest.fn(() => <span>action</span>);
29
+ const unregister = registerOrderLineActions(renderer);
30
+
31
+ expect(renderOrderLineActions(context)).toEqual(<span>action</span>);
32
+ expect(renderer).toHaveBeenCalledWith(context);
33
+
34
+ unregister();
35
+ expect(renderOrderLineActions(context)).toBeNull();
36
+ });
37
+
38
+ it('swallows a throwing renderer so a brand bug cannot break the line', () => {
39
+ const unregister = registerOrderLineActions(() => {
40
+ throw new Error('boom');
41
+ });
42
+
43
+ expect(renderOrderLineActions(context)).toBeNull();
44
+ unregister();
45
+ });
46
+
47
+ it('only forgets the renderer it was asked to forget', () => {
48
+ const first = registerOrderLineActions(() => 'first');
49
+ registerOrderLineActions(() => 'second');
50
+
51
+ first();
52
+ expect(renderOrderLineActions(context)).toBe('second');
53
+ });
54
+ });
@@ -0,0 +1,112 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import OrderListBlock from '../order-list-block';
4
+
5
+ const captured: { items: Record<string, unknown>[] } = { items: [] };
6
+
7
+ jest.mock('../item-template', () => ({
8
+ __esModule: true,
9
+ hasItemTemplate: () => true,
10
+ ItemTemplateList: ({ items }: { items: Record<string, unknown>[] }) => {
11
+ captured.items = items;
12
+ return <div data-testid="item-template-list" />;
13
+ }
14
+ }));
15
+
16
+ jest.mock('next-auth/react', () => ({
17
+ useSession: () => ({ data: null, status: 'authenticated' })
18
+ }));
19
+
20
+ jest.mock('@akinon/next/hooks', () => ({
21
+ __esModule: true,
22
+ useLocalization: () => ({ locale: 'tr', currency: 'try' })
23
+ }));
24
+
25
+ jest.mock('@akinon/next/redux/hooks', () => ({
26
+ __esModule: true,
27
+ useAppSelector: () => undefined,
28
+ useAppDispatch: () => jest.fn()
29
+ }));
30
+
31
+ jest.mock('@akinon/next/data/client/account', () => ({
32
+ __esModule: true,
33
+ useGetOrdersQuery: () => ({
34
+ data: {
35
+ count: 1,
36
+ results: [
37
+ {
38
+ id: 42,
39
+ number: 'ORD-42',
40
+ amount: '100.00',
41
+ created_date: '2026-09-01T10:00:00Z',
42
+ status: { value: '550', label: 'Delivered' },
43
+ orderitem_set: [
44
+ {
45
+ id: 7,
46
+ status: { value: 550, label: 'Delivered' },
47
+ product: {
48
+ sku: 'SKU-1',
49
+ base_code: 'BASE-1',
50
+ name: 'Sneaker',
51
+ image: '/s.jpg'
52
+ }
53
+ },
54
+ {
55
+ status: { value: '400', label: 'Approved' },
56
+ product: { name: 'Boot' }
57
+ }
58
+ ]
59
+ }
60
+ ]
61
+ },
62
+ isLoading: false
63
+ })
64
+ }));
65
+
66
+ describe('order list rows', () => {
67
+ it('publishes the order items with their product codes and status code', () => {
68
+ render(
69
+ <OrderListBlock
70
+ block={{
71
+ id: 'order-list-1',
72
+ type: 'order-list',
73
+ properties: {},
74
+ styles: {},
75
+ blocks: []
76
+ }}
77
+ placeholderId="p1"
78
+ sectionId="s1"
79
+ />
80
+ );
81
+
82
+ expect(captured.items).toHaveLength(1);
83
+ expect(captured.items[0]).toMatchObject({
84
+ id: 42,
85
+ number: 'ORD-42',
86
+ createdDate: '2026-09-01T10:00:00Z'
87
+ });
88
+ expect(captured.items[0].items).toEqual([
89
+ {
90
+ id: 7,
91
+ name: 'Sneaker',
92
+ image: '/s.jpg',
93
+ status: 'Delivered',
94
+ statusValue: '550',
95
+ product: {
96
+ sku: 'SKU-1',
97
+ base_code: 'BASE-1',
98
+ name: 'Sneaker',
99
+ image: '/s.jpg'
100
+ }
101
+ },
102
+ {
103
+ id: 1,
104
+ name: 'Boot',
105
+ image: '',
106
+ status: 'Approved',
107
+ statusValue: '400',
108
+ product: { sku: '', base_code: '', name: 'Boot', image: '' }
109
+ }
110
+ ]);
111
+ });
112
+ });
@@ -2,9 +2,31 @@ import React from 'react';
2
2
  import { render } from '@testing-library/react';
3
3
  import TextBlock from '../text-block';
4
4
 
5
- // The mocked useLocalization (jest/__mocks__/akinon-next-hooks.js) returns
6
- // locale: 'en-us'. process.env.NEXT_PUBLIC_DEFAULT_LOCALE is unset in the
7
- // jest env, so the component's defaultLocale falls back to 'en'.
5
+ // The page's language and the storefront's default, as the localization
6
+ // provider reports them. Most cases run on an English-default store showing
7
+ // English; the fallback case moves both.
8
+ const mockLocalization = { locale: 'en-us', defaultLocaleValue: 'en-us' };
9
+
10
+ jest.mock('@akinon/next/hooks', () => ({
11
+ __esModule: true,
12
+ useLocalization: () => ({
13
+ t: (path: string) => path,
14
+ setLocale: () => {},
15
+ setCurrency: () => {},
16
+ locale: mockLocalization.locale,
17
+ currency: 'USD',
18
+ locales: [{ value: 'en-us' }, { value: 'tr-tr' }],
19
+ currencies: ['USD'],
20
+ defaultLocaleValue: mockLocalization.defaultLocaleValue,
21
+ defaultCurrencyCode: 'USD',
22
+ localeUrlStrategy: 'hide-default-locale'
23
+ })
24
+ }));
25
+
26
+ beforeEach(() => {
27
+ mockLocalization.locale = 'en-us';
28
+ mockLocalization.defaultLocaleValue = 'en-us';
29
+ });
8
30
 
9
31
  const renderBlock = (value: unknown, styles?: Record<string, unknown>) => {
10
32
  const block: any = { id: 'b1', type: 'text', value, styles };
@@ -34,10 +56,16 @@ describe('TextBlock value-shape resolution', () => {
34
56
  );
35
57
  });
36
58
 
37
- it('falls back to defaultLocale ("en") when active locale is absent', () => {
38
- const { container } = renderBlock({ en: 'EN', fr: 'FR' });
59
+ it("falls back to the storefront's default language when the active one is absent", () => {
60
+ // A Turkish-default store showing German. The entry under `en` is what
61
+ // the block used to show: its default was read from an env variable
62
+ // that falls back to English, not from the storefront (ZERO-5296).
63
+ mockLocalization.locale = 'de-de';
64
+ mockLocalization.defaultLocaleValue = 'tr-tr';
65
+
66
+ const { container } = renderBlock({ en: 'EN', 'tr-tr': 'TR' });
39
67
  expect((container.querySelector('div') as HTMLDivElement).innerHTML).toBe(
40
- 'EN'
68
+ 'TR'
41
69
  );
42
70
  });
43
71
 
@@ -15,6 +15,7 @@ import {
15
15
  resolveItemProduct
16
16
  } from './item-template-utils';
17
17
  import { ThemeItemScopeProvider } from './product-block-utils';
18
+ import { ThemeRowScopeProvider } from './row-scope';
18
19
 
19
20
  export {
20
21
  findItemTemplate,
@@ -120,20 +121,15 @@ export const ItemTemplateList = ({
120
121
  const scopesProducts = publishesProductItems(block.type);
121
122
  // The same slice the iterator applied, so clone N is handed the row it
122
123
  // was built from rather than row N.
123
- const scopedRows = scopesProducts
124
- ? sliceIteratorItems(block, rows).items
125
- : rows;
124
+ const slicedRows = sliceIteratorItems(block, rows).items;
126
125
 
127
126
  return (
128
127
  <>
129
128
  {clones.map((clone, index) => {
130
129
  const key = clone.id || `${template.id}-item-${index}`;
130
+ const rowData = slicedRows[index] ?? {};
131
131
  const row = (
132
132
  <ThemeBlock
133
- // Keyed here so the non-scoped branch is the element it always
134
- // was: wrapping it in anything, even a fragment, would change the
135
- // element type at this position and remount every existing row.
136
- key={key}
137
133
  block={clone}
138
134
  placeholderId={placeholderId}
139
135
  sectionId={sectionId}
@@ -143,22 +139,32 @@ export const ItemTemplateList = ({
143
139
  currentBreakpoint={currentBreakpoint}
144
140
  />
145
141
  );
142
+ const scoped = scopesProducts
143
+ ? resolveItemProduct(block.type, rowData)
144
+ : null;
146
145
 
147
- if (!scopesProducts) return row;
148
-
149
- const scoped = resolveItemProduct(block.type, scopedRows[index]);
150
-
151
- // Context only — the provider renders no element, so the markup a
152
- // published row produces is unchanged.
146
+ // Context only — the providers render no element, so the markup a
147
+ // published row produces is unchanged. Every list opens the row
148
+ // scope; only product lists open the product scope on top of it.
153
149
  return (
154
- <ThemeItemScopeProvider
150
+ <ThemeRowScopeProvider
155
151
  key={key}
156
- product={
157
- scoped ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
158
- }
152
+ listType={block.type}
153
+ row={rowData}
154
+ index={index}
159
155
  >
160
- {row}
161
- </ThemeItemScopeProvider>
156
+ {scopesProducts ? (
157
+ <ThemeItemScopeProvider
158
+ product={
159
+ scoped ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
160
+ }
161
+ >
162
+ {row}
163
+ </ThemeItemScopeProvider>
164
+ ) : (
165
+ row
166
+ )}
167
+ </ThemeRowScopeProvider>
162
168
  );
163
169
  })}
164
170
  </>