@akinon/pz-theme 2.0.38-rc.0 → 2.0.39-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,36 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.38-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d55]
9
- - Updated dependencies [51ea0688]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08fe]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f4]
18
- - Updated dependencies [57d7eb30]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a71]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d0]
25
- - Updated dependencies [4ac7b2a1e]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a9631]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b20]
32
- - @akinon/next@2.0.38-rc.0
33
- - @akinon/theme-contract@2.0.38-rc.0
3
+ ## 2.0.39-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd75]
8
+ - @akinon/next@2.0.39-beta.0
9
+ - @akinon/theme-contract@2.0.39-beta.0
10
+
11
+ ## 2.0.38
12
+
13
+ ### Patch Changes
14
+
15
+ - 11f6cc0b: ZERO-4702: Make a brand's chrome and pages composable — ChromeSlot with a project fallback and an explicit takeover, the chrome block vocabulary, and a slot per storefront page
16
+ - @akinon/next@2.0.38
17
+ - @akinon/theme-contract@2.0.38
34
18
 
35
19
  ## 2.0.37
36
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.38-rc.0",
4
+ "version": "2.0.39-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.38-rc.0",
11
+ "@akinon/next": "2.0.39-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.38-rc.0",
16
+ "@akinon/theme-contract": "2.0.39-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.38-rc.0",
23
+ "@akinon/next": "2.0.39-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",
@@ -0,0 +1,252 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, act } from '@testing-library/react';
3
+
4
+ import { ChromeFallback } from '../chrome/chrome-fallback';
5
+ import {
6
+ readCompositionState,
7
+ warnAboutUncomposedSlot
8
+ } from '../chrome/composition-state';
9
+ import { resetDevWarnCacheForTests } from '../utils/dev-warn';
10
+
11
+ /**
12
+ * The takeover decision is made twice — once on the server, from the persisted
13
+ * placeholder order, and once in the designer, from the theme pushed into the
14
+ * preview. The two must agree; when they do not, the editor shows the designer
15
+ * something no visitor will ever see.
16
+ *
17
+ * These tests pin both halves:
18
+ * - `readCompositionState` — what the server concludes, including the
19
+ * difference between a widget that is absent and one that is empty.
20
+ * - `ChromeFallback` — what the designer sees, which must follow the same rule
21
+ * about which sections count.
22
+ */
23
+
24
+ const asWidget = (layout: unknown) => ({
25
+ attributes: { theme_editor_placeholder: layout }
26
+ });
27
+
28
+ describe('readCompositionState', () => {
29
+ it('separates a missing widget from an empty layout', () => {
30
+ // Both reach the renderer as "no composition", but only one of them means
31
+ // the storefront may be reading a different store than the editor writes.
32
+ expect(readCompositionState(null)).toEqual({ kind: 'missing' });
33
+ expect(readCompositionState(undefined)).toEqual({ kind: 'missing' });
34
+ expect(readCompositionState(asWidget('[]'))).toEqual({ kind: 'empty' });
35
+ expect(readCompositionState(asWidget([]))).toEqual({ kind: 'empty' });
36
+ expect(readCompositionState(asWidget(undefined))).toEqual({ kind: 'empty' });
37
+ expect(readCompositionState(asWidget(' '))).toEqual({ kind: 'empty' });
38
+ });
39
+
40
+ it('reads a composition from every shape a backend sends', () => {
41
+ const order = [{ section_slug: 'section-abc', order: 0 }];
42
+
43
+ expect(readCompositionState(asWidget(JSON.stringify(order)))).toEqual({
44
+ kind: 'composed',
45
+ sectionCount: 1
46
+ });
47
+ expect(readCompositionState(asWidget(order))).toEqual({
48
+ kind: 'composed',
49
+ sectionCount: 1
50
+ });
51
+ // v1 attribute shape: the value is wrapped.
52
+ expect(
53
+ readCompositionState(asWidget({ value: JSON.stringify(order) }))
54
+ ).toEqual({ kind: 'composed', sectionCount: 1 });
55
+ });
56
+
57
+ it('reports a layout it cannot use as malformed rather than empty', () => {
58
+ expect(readCompositionState(asWidget('{not json'))).toEqual({
59
+ kind: 'malformed'
60
+ });
61
+ expect(readCompositionState(asWidget('{"a":1}'))).toEqual({
62
+ kind: 'malformed'
63
+ });
64
+ });
65
+ });
66
+
67
+ describe('warnAboutUncomposedSlot', () => {
68
+ let warn: jest.SpyInstance;
69
+
70
+ beforeEach(() => {
71
+ resetDevWarnCacheForTests();
72
+ warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
73
+ });
74
+
75
+ afterEach(() => warn.mockRestore());
76
+
77
+ it('names the store mismatch when the widget is absent', () => {
78
+ warnAboutUncomposedSlot('header', { kind: 'missing' });
79
+
80
+ expect(warn).toHaveBeenCalledTimes(1);
81
+ const message = String(warn.mock.calls[0][0]);
82
+ expect(message).toContain('header');
83
+ expect(message).toContain('different store');
84
+ });
85
+
86
+ it('says empty, not missing, when the widget exists', () => {
87
+ warnAboutUncomposedSlot('footer', { kind: 'empty' });
88
+
89
+ const message = String(warn.mock.calls[0][0]);
90
+ expect(message).toContain('layout is empty');
91
+ expect(message).not.toContain('different store');
92
+ });
93
+
94
+ it('stays quiet for a composed slot', () => {
95
+ warnAboutUncomposedSlot('header', { kind: 'composed', sectionCount: 2 });
96
+ expect(warn).not.toHaveBeenCalled();
97
+ });
98
+ });
99
+
100
+ describe('ChromeFallback takeover gate', () => {
101
+ // The bridge only accepts frames from a trusted editor origin, so the theme
102
+ // has to arrive from one. localhost is trusted outside production.
103
+ const EDITOR_ORIGIN = 'http://localhost:5174';
104
+
105
+ const pushTheme = (sections: unknown[], origin = EDITOR_ORIGIN) =>
106
+ act(() => {
107
+ fireEvent(
108
+ window,
109
+ new MessageEvent('message', {
110
+ origin,
111
+ data: {
112
+ type: 'UPDATE_THEME',
113
+ data: {
114
+ theme: {
115
+ placeholders: [{ slug: 'header', sections }]
116
+ }
117
+ }
118
+ }
119
+ })
120
+ );
121
+ });
122
+
123
+ beforeAll(() => {
124
+ // The fallback only offers the takeover affordance inside the designer,
125
+ // which it detects by being framed.
126
+ Object.defineProperty(window, 'top', {
127
+ configurable: true,
128
+ value: { name: 'designer' }
129
+ });
130
+ });
131
+
132
+ it('asks the editor to take the area over when the affordance is used', async () => {
133
+ // What the designer clicks has to arrive as a contract message, not as a
134
+ // raw frame posted to '*'. The whole affordance is dead if the type is not
135
+ // one the editor's validator knows — which is why it is asserted here and
136
+ // not just eyeballed in the browser.
137
+ const postMessage = jest.fn();
138
+ Object.defineProperty(window, 'parent', {
139
+ configurable: true,
140
+ value: { postMessage }
141
+ });
142
+
143
+ render(
144
+ <ChromeFallback slug="header">
145
+ <div data-testid="project-header">project header</div>
146
+ </ChromeFallback>
147
+ );
148
+
149
+ // A frame from the editor is what proves its origin to the bridge; without
150
+ // one there is no trusted target and the message is dropped by design.
151
+ pushTheme([]);
152
+
153
+ fireEvent.mouseEnter(screen.getByTestId('project-header').parentElement!);
154
+ fireEvent.click(
155
+ await screen.findByRole('button', { name: /edit in theme editor/i })
156
+ );
157
+
158
+ expect(postMessage).toHaveBeenCalledTimes(1);
159
+ const [envelope, targetOrigin] = postMessage.mock.calls[0];
160
+ expect(envelope).toMatchObject({
161
+ type: 'REQUEST_CHROME_OVERRIDE',
162
+ data: { slug: 'header' }
163
+ });
164
+ // Stamped, and addressed to the editor rather than to any embedding page.
165
+ expect(envelope.protocolVersion).toBeGreaterThan(0);
166
+ expect(targetOrigin).toBe(EDITOR_ORIGIN);
167
+ });
168
+
169
+ it('withdraws for a composed section', () => {
170
+ render(
171
+ <ChromeFallback slug="header">
172
+ <div data-testid="project-header">project header</div>
173
+ </ChromeFallback>
174
+ );
175
+
176
+ expect(screen.getByTestId('project-header')).toBeInTheDocument();
177
+
178
+ pushTheme([{ id: 'section-abc', type: 'custom' }]);
179
+
180
+ expect(screen.queryByTestId('project-header')).not.toBeInTheDocument();
181
+ });
182
+
183
+ it('KEEPS the project markup for a placeholder holding only native sections', () => {
184
+ // A native section restyles the project's own chrome instead of replacing
185
+ // it, and never enters the saved order — so the server keeps rendering the
186
+ // project header. Withdrawing here would hide the very element being
187
+ // styled and show an empty area the visitor never sees.
188
+ render(
189
+ <ChromeFallback slug="header">
190
+ <div data-testid="project-header">project header</div>
191
+ </ChromeFallback>
192
+ );
193
+
194
+ pushTheme([{ id: 'layout-header-styles', type: 'native' }]);
195
+
196
+ expect(screen.getByTestId('project-header')).toBeInTheDocument();
197
+ });
198
+
199
+ it('withdraws for a mixed placeholder, because the order carries the composed section', () => {
200
+ render(
201
+ <ChromeFallback slug="header">
202
+ <div data-testid="project-header">project header</div>
203
+ </ChromeFallback>
204
+ );
205
+
206
+ pushTheme([
207
+ { id: 'layout-header-styles', type: 'native' },
208
+ { id: 'section-abc', type: 'custom' }
209
+ ]);
210
+
211
+ expect(screen.queryByTestId('project-header')).not.toBeInTheDocument();
212
+ });
213
+
214
+ it('ignores hidden sections', () => {
215
+ render(
216
+ <ChromeFallback slug="header">
217
+ <div data-testid="project-header">project header</div>
218
+ </ChromeFallback>
219
+ );
220
+
221
+ pushTheme([{ id: 'section-abc', type: 'custom', hidden: true }]);
222
+
223
+ expect(screen.getByTestId('project-header')).toBeInTheDocument();
224
+ });
225
+
226
+ it('ignores a theme pushed from an untrusted origin', () => {
227
+ // An embedding page must not be able to make the project chrome disappear.
228
+ render(
229
+ <ChromeFallback slug="header">
230
+ <div data-testid="project-header">project header</div>
231
+ </ChromeFallback>
232
+ );
233
+
234
+ pushTheme([{ id: 'section-abc', type: 'custom' }], 'https://evil.example');
235
+
236
+ expect(screen.getByTestId('project-header')).toBeInTheDocument();
237
+ });
238
+
239
+ it('comes back when the designer removes the composition again', () => {
240
+ render(
241
+ <ChromeFallback slug="header">
242
+ <div data-testid="project-header">project header</div>
243
+ </ChromeFallback>
244
+ );
245
+
246
+ pushTheme([{ id: 'section-abc', type: 'custom' }]);
247
+ expect(screen.queryByTestId('project-header')).not.toBeInTheDocument();
248
+
249
+ pushTheme([]);
250
+ expect(screen.getByTestId('project-header')).toBeInTheDocument();
251
+ });
252
+ });
@@ -26,9 +26,11 @@ const extractRegisteredTypes = (source: string): string[] => {
26
26
 
27
27
  const EXPECTED_BLOCK_TYPES = [
28
28
  'accordion',
29
+ 'account-button',
29
30
  'account-menu',
30
31
  'add-to-cart',
31
32
  'address-list',
33
+ 'announcement-bar',
32
34
  'applied-filters',
33
35
  'auth-form',
34
36
  'basket-items',
@@ -40,6 +42,7 @@ const EXPECTED_BLOCK_TYPES = [
40
42
  'countdown',
41
43
  'counter',
42
44
  'coupon-list',
45
+ 'currency-selector',
43
46
  'divider',
44
47
  'embed',
45
48
  'facet-filters',
@@ -48,11 +51,13 @@ const EXPECTED_BLOCK_TYPES = [
48
51
  'favourites-grid',
49
52
  'forgot-password',
50
53
  'group',
54
+ 'hamburger-menu',
51
55
  'hotspot',
52
56
  'icon',
53
57
  'image',
54
58
  'image-gallery',
55
59
  'input',
60
+ 'language-selector',
56
61
  'link',
57
62
  'listing-breadcrumb',
58
63
  'listing-pagination',
@@ -60,11 +65,16 @@ const EXPECTED_BLOCK_TYPES = [
60
65
  'listing-title',
61
66
  'lottie',
62
67
  'map',
68
+ 'mega-menu',
69
+ 'mini-basket',
70
+ 'nav-menu',
71
+ 'newsletter-form',
63
72
  'oauth-buttons',
64
73
  'order-detail',
65
74
  'order-list',
66
75
  'order-success',
67
76
  'order-tracking',
77
+ 'payment-badges',
68
78
  'product-attributes',
69
79
  'product-breadcrumb',
70
80
  'product-description',
@@ -75,16 +85,19 @@ const EXPECTED_BLOCK_TYPES = [
75
85
  'product-title',
76
86
  'profile-form',
77
87
  'register-form',
88
+ 'search-box',
78
89
  'share',
79
90
  'size-guide',
80
91
  'slider',
92
+ 'social-links',
81
93
  'sort-select',
82
94
  'stock-status',
83
95
  'store-locator',
84
96
  'tab',
85
97
  'text',
86
98
  'variant-selector',
87
- 'video'
99
+ 'video',
100
+ 'wishlist-button'
88
101
  ];
89
102
 
90
103
  const EXPECTED_SECTION_TYPES = [
@@ -0,0 +1,116 @@
1
+ import { PLACEHOLDER_SLUGS } from '../chrome/placeholder-slugs';
2
+ import {
3
+ normalizePath,
4
+ registerRouteSlugRules,
5
+ resetRouteSlugRules,
6
+ resolveBodySlug,
7
+ resolveBodySlugFromUrl
8
+ } from '../chrome/route-slug';
9
+
10
+ describe('resolveBodySlug', () => {
11
+ afterEach(() => resetRouteSlugRules());
12
+
13
+ it('gives every account screen its own placeholder', () => {
14
+ // One shared slug would leak a single composition across all of them.
15
+ const seen = new Set<string>();
16
+ const accountPaths = [
17
+ '/account',
18
+ '/account/profile',
19
+ '/account/address',
20
+ '/account/coupons',
21
+ '/account/change-password/',
22
+ '/account/change-email',
23
+ '/account/favourite-products/',
24
+ '/account/stock-waiting-products/',
25
+ '/account/notification-settings/',
26
+ '/users/orders',
27
+ '/users/orders/12345'
28
+ ];
29
+
30
+ accountPaths.forEach((path) => {
31
+ const slug = resolveBodySlug(path);
32
+ expect(slug).toBeTruthy();
33
+ seen.add(slug as string);
34
+ });
35
+
36
+ expect(seen.size).toBe(accountPaths.length);
37
+ });
38
+
39
+ it('matches the deeper route first', () => {
40
+ expect(resolveBodySlug('/users/orders')).toBe(
41
+ PLACEHOLDER_SLUGS.accountOrders
42
+ );
43
+ expect(resolveBodySlug('/users/orders/9911')).toBe(
44
+ PLACEHOLDER_SLUGS.accountOrderDetail
45
+ );
46
+ });
47
+
48
+ it('accepts the path variants brands actually ship', () => {
49
+ // Same screen, three different brands.
50
+ expect(resolveBodySlug('/basket')).toBe(PLACEHOLDER_SLUGS.basket);
51
+ expect(resolveBodySlug('/baskets/basket')).toBe(PLACEHOLDER_SLUGS.basket);
52
+ expect(resolveBodySlug('/users/auth')).toBe(PLACEHOLDER_SLUGS.auth);
53
+ expect(resolveBodySlug('/auth')).toBe(PLACEHOLDER_SLUGS.auth);
54
+ expect(resolveBodySlug('/users/password/reset')).toBe(
55
+ PLACEHOLDER_SLUGS.forgotPassword
56
+ );
57
+ expect(resolveBodySlug('/auth/register')).toBe(PLACEHOLDER_SLUGS.register);
58
+ });
59
+
60
+ it('does not let a login path swallow register or guest', () => {
61
+ expect(resolveBodySlug('/users/auth/guest')).toBe(
62
+ PLACEHOLDER_SLUGS.guestLogin
63
+ );
64
+ expect(resolveBodySlug('/users/auth/register')).toBe(
65
+ PLACEHOLDER_SLUGS.register
66
+ );
67
+ });
68
+
69
+ it('resolves commerce and content pages', () => {
70
+ expect(resolveBodySlug('/product/1/')).toBe(
71
+ PLACEHOLDER_SLUGS.productDetail
72
+ );
73
+ expect(resolveBodySlug('/group-product/1/')).toBe(
74
+ PLACEHOLDER_SLUGS.productDetail
75
+ );
76
+ expect(resolveBodySlug('/category/55/')).toBe(
77
+ PLACEHOLDER_SLUGS.productListing
78
+ );
79
+ expect(resolveBodySlug('/special-page/7/')).toBe(
80
+ PLACEHOLDER_SLUGS.productListing
81
+ );
82
+ expect(resolveBodySlug('/orders/completed/abc123')).toBe(
83
+ PLACEHOLDER_SLUGS.orderSuccess
84
+ );
85
+ expect(resolveBodySlug('/stores')).toBe(PLACEHOLDER_SLUGS.stores);
86
+ expect(resolveBodySlug('/address/stores')).toBe(PLACEHOLDER_SLUGS.stores);
87
+ expect(resolveBodySlug('/')).toBe(PLACEHOLDER_SLUGS.homepage);
88
+ });
89
+
90
+ it('returns null for a path with no placeholder', () => {
91
+ expect(resolveBodySlug('/kampanya-bilgilendirme')).toBeNull();
92
+ });
93
+
94
+ it('lets a brand override with its own paths', () => {
95
+ registerRouteSlugRules([[/^\/hesabim/, PLACEHOLDER_SLUGS.accountShell]]);
96
+ expect(resolveBodySlug('/hesabim')).toBe(PLACEHOLDER_SLUGS.accountShell);
97
+ resetRouteSlugRules();
98
+ expect(resolveBodySlug('/hesabim')).toBeNull();
99
+ });
100
+
101
+ it('ignores query strings and trailing slashes', () => {
102
+ expect(normalizePath('/account/profile/?tab=1')).toBe('/account/profile');
103
+ expect(resolveBodySlug('/users/orders/?page=2')).toBe(
104
+ PLACEHOLDER_SLUGS.accountOrders
105
+ );
106
+ });
107
+
108
+ it('reads the path out of a full segment URL', () => {
109
+ expect(
110
+ resolveBodySlugFromUrl('https://www.example.com/account/profile/')
111
+ ).toBe(PLACEHOLDER_SLUGS.accountProfile);
112
+ expect(resolveBodySlugFromUrl('/account/profile')).toBe(
113
+ PLACEHOLDER_SLUGS.accountProfile
114
+ );
115
+ });
116
+ });