@akinon/pz-theme 2.0.35-rc.0 → 2.0.35

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -28
  2. package/package.json +4 -3
  3. package/src/__tests__/theme-contract.test.ts +130 -0
  4. package/src/blocks/__tests__/text-block.test.tsx +8 -1
  5. package/src/blocks/__tests__/video-block.test.tsx +14 -7
  6. package/src/blocks/counter-block.tsx +44 -56
  7. package/src/blocks/group-block.tsx +23 -22
  8. package/src/blocks/hotspot-block.tsx +171 -80
  9. package/src/blocks/input-block.tsx +15 -17
  10. package/src/blocks/link-block.tsx +18 -21
  11. package/src/blocks/slider-block.tsx +15 -12
  12. package/src/components/with-designer-features.tsx +2 -0
  13. package/src/editor-bridge.ts +160 -0
  14. package/src/hooks/use-designer-features.tsx +30 -37
  15. package/src/placeholder-registry.ts +5 -7
  16. package/src/sections/__tests__/countdown-campaign-banner-section.test.tsx +8 -4
  17. package/src/sections/__tests__/coupon-banner-section.test.tsx +4 -2
  18. package/src/sections/before-after-section.tsx +189 -134
  19. package/src/sections/contact-form-section.tsx +185 -60
  20. package/src/sections/countdown-campaign-banner-section.tsx +73 -27
  21. package/src/sections/coupon-banner-section.tsx +152 -48
  22. package/src/sections/featured-product-spotlight-section.tsx +67 -66
  23. package/src/sections/find-in-store-section.tsx +37 -25
  24. package/src/sections/hover-showcase-section.tsx +221 -89
  25. package/src/sections/image-hotspot-section.tsx +208 -94
  26. package/src/sections/installment-options-section.tsx +268 -259
  27. package/src/sections/notification-banner-section.tsx +111 -25
  28. package/src/sections/order-tracking-lookup-section.tsx +272 -247
  29. package/src/sections/posts-slider-section.tsx +17 -16
  30. package/src/sections/pre-order-launch-banner-section.tsx +31 -50
  31. package/src/sections/section-wrapper.tsx +9 -10
  32. package/src/sections/shipping-threshold-progress-section.tsx +72 -29
  33. package/src/sections/stats-counter-section.tsx +68 -30
  34. package/src/sections/tabs-section.tsx +124 -147
  35. package/src/theme-placeholder-client.tsx +11 -14
  36. package/src/theme-placeholder-wrapper.tsx +42 -51
  37. package/src/theme-section.tsx +27 -34
  38. package/src/utils/__tests__/transform-styles.test.ts +97 -0
  39. package/src/utils/index.ts +9 -0
package/CHANGELOG.md CHANGED
@@ -1,33 +1,17 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.35-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 [b55acb768]
12
- - Updated dependencies [760258c1c]
13
- - Updated dependencies [143be2b9d]
14
- - Updated dependencies [7889b08f]
15
- - Updated dependencies [9f8cd3bc5]
16
- - Updated dependencies [bfafa3f49]
17
- - Updated dependencies [57d7eb30]
18
- - Updated dependencies [d99a6a7d5]
19
- - Updated dependencies [9db81a71]
20
- - Updated dependencies [591e345e1]
21
- - Updated dependencies [4de5303c5]
22
- - Updated dependencies [95b139dc1]
23
- - Updated dependencies [1d00f2d0]
24
- - Updated dependencies [4ac7b2a1e]
25
- - Updated dependencies [4998a9631]
26
- - Updated dependencies [804d2bd6]
27
- - Updated dependencies [3909d3224]
28
- - Updated dependencies [6a3d8a63]
29
- - Updated dependencies [e18836b20]
30
- - @akinon/next@2.0.35-rc.0
3
+ ## 2.0.35
4
+
5
+ ### Patch Changes
6
+
7
+ - 2f500a32: ZERO-4657: Render scale, rotate and translate style keys from the theme editor and skip them in inline styles to prevent double application
8
+ - 8dca03f2: ZERO-4663: introduce shared wire contract for theme editor and storefront
9
+
10
+ New `@akinon/theme-contract` package: shared wire types, versioned postMessage envelope (`protocolVersion`), runtime validation (`parseThemeMessage`) and origin helpers. `@akinon/pz-theme` now routes all designer postMessage traffic through an origin-guarded `editor-bridge` — inbound designer messages are validated against the editor-origin allowlist (`NEXT_PUBLIC_THEME_EDITOR_ORIGINS`, localhost auto-trusted outside production) and nothing is posted to `'*'` anymore.
11
+
12
+ - Updated dependencies [8dca03f2]
13
+ - @akinon/next@2.0.35
14
+ - @akinon/theme-contract@2.0.35
31
15
 
32
16
  ## 2.0.34
33
17
 
package/package.json CHANGED
@@ -1,25 +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.35-rc.0",
4
+ "version": "2.0.35",
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.35-rc.0",
11
+ "@akinon/next": "2.0.35",
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.35",
16
17
  "clsx": "2.1.1",
17
18
  "lottie-web": "^5.13.0",
18
19
  "tailwind-merge": "^2.5.4",
19
20
  "zod": "4.4.1"
20
21
  },
21
22
  "devDependencies": {
22
- "@akinon/next": "2.0.35-rc.0",
23
+ "@akinon/next": "2.0.35",
23
24
  "@testing-library/jest-dom": "5.16.4",
24
25
  "@testing-library/react": "16.3.2",
25
26
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,130 @@
1
+ import {
2
+ LEGACY_PROTOCOL_VERSION,
3
+ ParsedThemeMessageRejected,
4
+ THEME_CONTRACT_PROTOCOL_VERSION,
5
+ createOriginGuard,
6
+ isKnownThemeMessageType,
7
+ parseOriginList,
8
+ parseThemeMessage,
9
+ safeOrigin,
10
+ stampThemeMessage
11
+ } from '@akinon/theme-contract';
12
+
13
+ describe('theme-contract envelope', () => {
14
+ it('stamps outbound messages with the current protocol version, preserving legacy top-level fields', () => {
15
+ const stamped = stampThemeMessage({
16
+ type: 'ROUTE_CHANGE_COMPLETE',
17
+ url: '/en/tr/',
18
+ layoutSegment: 'pages'
19
+ });
20
+ expect(stamped).toEqual({
21
+ type: 'ROUTE_CHANGE_COMPLETE',
22
+ url: '/en/tr/',
23
+ layoutSegment: 'pages',
24
+ protocolVersion: THEME_CONTRACT_PROTOCOL_VERSION
25
+ });
26
+ });
27
+
28
+ it('round-trips a stamped message through parseThemeMessage', () => {
29
+ const parsed = parseThemeMessage(
30
+ stampThemeMessage({ type: 'UPDATE_THEME', data: { theme: {} } })
31
+ );
32
+ expect(parsed.ok).toBe(true);
33
+ if (parsed.ok) {
34
+ expect(parsed.message.type).toBe('UPDATE_THEME');
35
+ expect(parsed.remoteVersion).toBe(THEME_CONTRACT_PROTOCOL_VERSION);
36
+ expect(parsed.versionMismatch).toBe(false);
37
+ }
38
+ });
39
+
40
+ it('accepts pre-contract messages (no protocolVersion) as the legacy version', () => {
41
+ const parsed = parseThemeMessage({ type: 'IFRAME_READY' });
42
+ expect(parsed.ok).toBe(true);
43
+ if (parsed.ok) {
44
+ expect(parsed.remoteVersion).toBe(LEGACY_PROTOCOL_VERSION);
45
+ expect(parsed.versionMismatch).toBe(false);
46
+ }
47
+ });
48
+
49
+ it('flags a version mismatch but still returns the message', () => {
50
+ const parsed = parseThemeMessage({
51
+ type: 'UPDATE_THEME',
52
+ data: {},
53
+ protocolVersion: THEME_CONTRACT_PROTOCOL_VERSION + 1
54
+ });
55
+ expect(parsed.ok).toBe(true);
56
+ if (parsed.ok) {
57
+ expect(parsed.versionMismatch).toBe(true);
58
+ }
59
+ });
60
+
61
+ it.each([
62
+ [null, 'not-an-object'],
63
+ ['LOAD_THEME', 'not-an-object'],
64
+ [['LOAD_THEME'], 'not-an-object'],
65
+ [{}, 'missing-type'],
66
+ [{ type: 42 }, 'missing-type'],
67
+ [{ type: 'WIDGET_COPY' }, 'unknown-type'],
68
+ [{ type: 'LOAD_THEME', data: 'nope' }, 'invalid-data']
69
+ ])('rejects %j with reason %s', (raw, reason) => {
70
+ const parsed = parseThemeMessage(raw);
71
+ expect(parsed.ok).toBe(false);
72
+ // Cast instead of narrowing — this tsconfig compiles with strict off,
73
+ // where the boolean-discriminant union does not narrow.
74
+ expect((parsed as ParsedThemeMessageRejected).reason).toBe(reason);
75
+ });
76
+
77
+ it('accepts the legacy boolean data used by SET_THEME_EDITOR_COOKIE', () => {
78
+ expect(
79
+ parseThemeMessage({ type: 'SET_THEME_EDITOR_COOKIE', data: true }).ok
80
+ ).toBe(true);
81
+ });
82
+
83
+ it('knows both directions of the protocol', () => {
84
+ expect(isKnownThemeMessageType('SCROLL_TO_SECTION')).toBe(true);
85
+ expect(isKnownThemeMessageType('REGISTER_NATIVE_WIDGETS')).toBe(true);
86
+ expect(isKnownThemeMessageType('WIDGET_MOVE')).toBe(false);
87
+ });
88
+ });
89
+
90
+ describe('theme-contract origin helpers', () => {
91
+ it('derives origins the same way on both sides', () => {
92
+ expect(safeOrigin('https://shop.example.com/en/page?x=1')).toBe(
93
+ 'https://shop.example.com'
94
+ );
95
+ expect(safeOrigin('not a url')).toBeNull();
96
+ expect(safeOrigin('')).toBeNull();
97
+ });
98
+
99
+ it('parses env-style origin lists', () => {
100
+ expect(
101
+ parseOriginList(
102
+ 'https://editor.akinon.com, https://staging-editor.akinon.com/path'
103
+ )
104
+ ).toEqual([
105
+ 'https://editor.akinon.com',
106
+ 'https://staging-editor.akinon.com'
107
+ ]);
108
+ expect(parseOriginList(undefined)).toEqual([]);
109
+ });
110
+
111
+ it('trusts only allowlisted origins by default', () => {
112
+ const guard = createOriginGuard({
113
+ allowedOrigins: ['https://editor.akinon.com']
114
+ });
115
+ expect(guard.isTrusted('https://editor.akinon.com')).toBe(true);
116
+ expect(guard.isTrusted('https://evil.example.com')).toBe(false);
117
+ expect(guard.isTrusted('http://localhost:5173')).toBe(false);
118
+ });
119
+
120
+ it('additionally trusts localhost when allowLocalDev is on', () => {
121
+ const guard = createOriginGuard({
122
+ allowedOrigins: [],
123
+ allowLocalDev: true
124
+ });
125
+ expect(guard.isTrusted('http://localhost:5173')).toBe(true);
126
+ expect(guard.isTrusted('http://127.0.0.1:3000')).toBe(true);
127
+ expect(guard.isTrusted('https://evil.example.com')).toBe(false);
128
+ expect(guard.isTrusted('http://localhost.evil.com')).toBe(false);
129
+ });
130
+ });
@@ -8,7 +8,14 @@ import TextBlock from '../text-block';
8
8
 
9
9
  const renderBlock = (value: unknown, styles?: Record<string, unknown>) => {
10
10
  const block: any = { id: 'b1', type: 'text', value, styles };
11
- return render(<TextBlock block={block} />);
11
+ return render(
12
+ <TextBlock
13
+ block={block}
14
+ placeholderId="p1"
15
+ sectionId="s1"
16
+ isDesigner={false}
17
+ />
18
+ );
12
19
  };
13
20
 
14
21
  describe('TextBlock value-shape resolution', () => {
@@ -3,7 +3,14 @@ import { render, screen } from '@testing-library/react';
3
3
  import VideoBlock from '../video-block';
4
4
 
5
5
  const renderBlock = (value: any, properties?: any) =>
6
- render(<VideoBlock block={{ value, properties } as any} />);
6
+ render(
7
+ <VideoBlock
8
+ block={{ value, properties } as any}
9
+ placeholderId="p1"
10
+ sectionId="s1"
11
+ isDesigner={false}
12
+ />
13
+ );
7
14
 
8
15
  describe('VideoBlock', () => {
9
16
  describe('YouTube URLs -> iframe', () => {
@@ -115,9 +122,9 @@ describe('VideoBlock', () => {
115
122
  'https://www.youtube.com/watch?v=abc123',
116
123
  { muted: true }
117
124
  );
118
- expect(
119
- container.querySelector('iframe')?.getAttribute('src')
120
- ).toContain('mute=1');
125
+ expect(container.querySelector('iframe')?.getAttribute('src')).toContain(
126
+ 'mute=1'
127
+ );
121
128
  });
122
129
 
123
130
  it('appends controls=0 only when controls === false', () => {
@@ -125,9 +132,9 @@ describe('VideoBlock', () => {
125
132
  'https://www.youtube.com/watch?v=abc123',
126
133
  { controls: false }
127
134
  );
128
- expect(
129
- container.querySelector('iframe')?.getAttribute('src')
130
- ).toContain('controls=0');
135
+ expect(container.querySelector('iframe')?.getAttribute('src')).toContain(
136
+ 'controls=0'
137
+ );
131
138
  });
132
139
 
133
140
  it('does not append controls when controls is true', () => {
@@ -7,6 +7,8 @@ import { useThemeSettingsContext } from '../theme-settings-context';
7
7
  import { getCSSStyles, getResponsiveValue } from '../utils';
8
8
  import { BlockRendererProps } from './block-renderer-registry';
9
9
 
10
+ import { postToEditor } from '../editor-bridge';
11
+ import { PreviewToEditorMessageType } from '@akinon/theme-contract';
10
12
  type CounterUnit = 'days' | 'hours' | 'minutes' | 'seconds';
11
13
 
12
14
  type CounterValues = Record<CounterUnit, string>;
@@ -170,18 +172,15 @@ const CounterBlock = ({
170
172
  if (onCompleteAction === 'hide_self') {
171
173
  // In designer (iframe) mode: use editor's show/hide so the user can still select/manage it
172
174
  if (isDesigner && window.parent && window.parent !== window) {
173
- window.parent.postMessage(
174
- {
175
- type: 'SET_BLOCK_VISIBILITY',
176
- data: {
177
- placeholderId,
178
- sectionId,
179
- blockId: block.id,
180
- hidden: true
181
- }
182
- },
183
- '*'
184
- );
175
+ postToEditor({
176
+ type: 'SET_BLOCK_VISIBILITY',
177
+ data: {
178
+ placeholderId,
179
+ sectionId,
180
+ blockId: block.id,
181
+ hidden: true
182
+ }
183
+ });
185
184
  return;
186
185
  }
187
186
 
@@ -198,17 +197,14 @@ const CounterBlock = ({
198
197
  if (onCompleteAction === 'hide_section') {
199
198
  // In designer (iframe) mode: use editor's show/hide
200
199
  if (isDesigner && window.parent && window.parent !== window) {
201
- window.parent.postMessage(
202
- {
203
- type: 'SET_SECTION_VISIBILITY',
204
- data: {
205
- placeholderId,
206
- sectionId,
207
- hidden: true
208
- }
209
- },
210
- '*'
211
- );
200
+ postToEditor({
201
+ type: 'SET_SECTION_VISIBILITY',
202
+ data: {
203
+ placeholderId,
204
+ sectionId,
205
+ hidden: true
206
+ }
207
+ });
212
208
  return;
213
209
  }
214
210
 
@@ -242,18 +238,15 @@ const CounterBlock = ({
242
238
  if (didAutoShowRef.current) return;
243
239
  didAutoShowRef.current = true;
244
240
 
245
- window.parent.postMessage(
246
- {
247
- type: 'SET_BLOCK_VISIBILITY',
248
- data: {
249
- placeholderId,
250
- sectionId,
251
- blockId: block.id,
252
- hidden: false
253
- }
254
- },
255
- '*'
256
- );
241
+ postToEditor({
242
+ type: 'SET_BLOCK_VISIBILITY',
243
+ data: {
244
+ placeholderId,
245
+ sectionId,
246
+ blockId: block.id,
247
+ hidden: false
248
+ }
249
+ });
257
250
  }, [
258
251
  block.hidden,
259
252
  block.id,
@@ -289,36 +282,31 @@ const CounterBlock = ({
289
282
  return (
290
283
  <div style={innerStyles}>
291
284
  {renderedBlocks.map((childBlock) => {
292
- const createActionHandler = (actionType: string) => () => {
293
- if (window.parent) {
294
- window.parent.postMessage(
295
- {
285
+ const createActionHandler =
286
+ (actionType: PreviewToEditorMessageType) => () => {
287
+ if (window.parent) {
288
+ postToEditor({
296
289
  type: actionType,
297
290
  data: {
298
291
  placeholderId,
299
292
  sectionId,
300
293
  blockId: childBlock.id
301
294
  }
302
- },
303
- '*'
304
- );
305
- }
306
- };
295
+ });
296
+ }
297
+ };
307
298
 
308
299
  const handleRename = (newLabel: string) => {
309
300
  if (window.parent) {
310
- window.parent.postMessage(
311
- {
312
- type: 'RENAME_BLOCK',
313
- data: {
314
- placeholderId,
315
- sectionId,
316
- blockId: childBlock.id,
317
- label: newLabel
318
- }
319
- },
320
- '*'
321
- );
301
+ postToEditor({
302
+ type: 'RENAME_BLOCK',
303
+ data: {
304
+ placeholderId,
305
+ sectionId,
306
+ blockId: childBlock.id,
307
+ label: newLabel
308
+ }
309
+ });
322
310
  }
323
311
  };
324
312
 
@@ -3,6 +3,8 @@ import { getResponsiveValue } from '../utils';
3
3
  import { BlockRendererProps } from './block-renderer-registry';
4
4
  import ThemeBlock from '../theme-block';
5
5
 
6
+ import { postToEditor } from '../editor-bridge';
7
+ import { PreviewToEditorMessageType } from '@akinon/theme-contract';
6
8
  const GroupBlock = ({
7
9
  block,
8
10
  placeholderId,
@@ -11,7 +13,11 @@ const GroupBlock = ({
11
13
  selectedBlockId,
12
14
  currentBreakpoint = 'desktop'
13
15
  }: BlockRendererProps) => {
14
- const tag = getResponsiveValue(block.properties?.tag, currentBreakpoint, 'div');
16
+ const tag = getResponsiveValue(
17
+ block.properties?.tag,
18
+ currentBreakpoint,
19
+ 'div'
20
+ );
15
21
  const Tag = tag as keyof React.JSX.IntrinsicElements;
16
22
  const href = tag === 'a' ? block.properties?.href : undefined;
17
23
 
@@ -55,36 +61,31 @@ const GroupBlock = ({
55
61
  .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
56
62
  .sort((a, b) => (a.order || 0) - (b.order || 0))
57
63
  .map((childBlock, index) => {
58
- const createActionHandler = (actionType: string) => () => {
59
- if (window.parent) {
60
- window.parent.postMessage(
61
- {
64
+ const createActionHandler =
65
+ (actionType: PreviewToEditorMessageType) => () => {
66
+ if (window.parent) {
67
+ postToEditor({
62
68
  type: actionType,
63
69
  data: {
64
70
  placeholderId,
65
71
  sectionId,
66
72
  blockId: childBlock.id
67
73
  }
68
- },
69
- '*'
70
- );
71
- }
72
- };
74
+ });
75
+ }
76
+ };
73
77
 
74
78
  const handleRename = (newLabel: string) => {
75
79
  if (window.parent) {
76
- window.parent.postMessage(
77
- {
78
- type: 'RENAME_BLOCK',
79
- data: {
80
- placeholderId,
81
- sectionId,
82
- blockId: childBlock.id,
83
- label: newLabel
84
- }
85
- },
86
- '*'
87
- );
80
+ postToEditor({
81
+ type: 'RENAME_BLOCK',
82
+ data: {
83
+ placeholderId,
84
+ sectionId,
85
+ blockId: childBlock.id,
86
+ label: newLabel
87
+ }
88
+ });
88
89
  }
89
90
  };
90
91