@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
@@ -10,6 +10,7 @@ import { twMerge } from 'tailwind-merge';
10
10
  import clsx from 'clsx';
11
11
  import { useThemeSettingsContext } from '../theme-settings-context';
12
12
 
13
+ import { postToEditor } from '../editor-bridge';
13
14
  interface TabsSectionProps {
14
15
  section: Section;
15
16
  currentBreakpoint?: string;
@@ -30,16 +31,21 @@ const TabsSection: React.FC<TabsSectionProps> = ({
30
31
 
31
32
  // Separate tab blocks from non-tab blocks (like headers)
32
33
  const nonTabBlocks = section.blocks
33
- .filter((block) => block.type !== 'tab' && (isDesigner ? true : !block.hidden))
34
+ .filter(
35
+ (block) => block.type !== 'tab' && (isDesigner ? true : !block.hidden)
36
+ )
34
37
  .sort((a, b) => (a.order || 0) - (b.order || 0));
35
38
 
36
39
  const tabBlocks = section.blocks
37
- .filter((block) => block.type === 'tab' && (isDesigner ? true : !block.hidden))
40
+ .filter(
41
+ (block) => block.type === 'tab' && (isDesigner ? true : !block.hidden)
42
+ )
38
43
  .sort((a, b) => (a.order || 0) - (b.order || 0));
39
44
 
40
45
  const getResponsiveValue = useCallback(
41
46
  <T,>(value: unknown, fallback: T): T => {
42
- if (value === undefined || value === null || value === '') return fallback;
47
+ if (value === undefined || value === null || value === '')
48
+ return fallback;
43
49
  if (typeof value === 'object' && !Array.isArray(value)) {
44
50
  const responsiveValue = value as Record<string, T | undefined> & {
45
51
  desktop?: T;
@@ -93,12 +99,7 @@ const TabsSection: React.FC<TabsSectionProps> = ({
93
99
  if (activeTabIndex > tabBlocks.length - 1) {
94
100
  setActiveTabIndex(tabBlocks.length - 1);
95
101
  }
96
- }, [
97
- activeTabIndex,
98
- findTabIndexBySelectedBlock,
99
- selectedBlockId,
100
- tabBlocks
101
- ]);
102
+ }, [activeTabIndex, findTabIndexBySelectedBlock, selectedBlockId, tabBlocks]);
102
103
 
103
104
  const activeTabColor = resolveThemeCssVariables(
104
105
  getResponsiveValue<string>(
@@ -218,93 +219,75 @@ const TabsSection: React.FC<TabsSectionProps> = ({
218
219
  selectedBlockId={selectedBlockId}
219
220
  onMoveUp={() => {
220
221
  if (window.parent) {
221
- window.parent.postMessage(
222
- {
223
- type: 'MOVE_BLOCK_UP',
224
- data: {
225
- placeholderId,
226
- sectionId: section.id,
227
- blockId: block.id
228
- }
229
- },
230
- '*'
231
- );
222
+ postToEditor({
223
+ type: 'MOVE_BLOCK_UP',
224
+ data: {
225
+ placeholderId,
226
+ sectionId: section.id,
227
+ blockId: block.id
228
+ }
229
+ });
232
230
  }
233
231
  }}
234
232
  onMoveDown={() => {
235
233
  if (window.parent) {
236
- window.parent.postMessage(
237
- {
238
- type: 'MOVE_BLOCK_DOWN',
239
- data: {
240
- placeholderId,
241
- sectionId: section.id,
242
- blockId: block.id
243
- }
244
- },
245
- '*'
246
- );
234
+ postToEditor({
235
+ type: 'MOVE_BLOCK_DOWN',
236
+ data: {
237
+ placeholderId,
238
+ sectionId: section.id,
239
+ blockId: block.id
240
+ }
241
+ });
247
242
  }
248
243
  }}
249
244
  onDuplicate={() => {
250
245
  if (window.parent) {
251
- window.parent.postMessage(
252
- {
253
- type: 'DUPLICATE_BLOCK',
254
- data: {
255
- placeholderId,
256
- sectionId: section.id,
257
- blockId: block.id
258
- }
259
- },
260
- '*'
261
- );
246
+ postToEditor({
247
+ type: 'DUPLICATE_BLOCK',
248
+ data: {
249
+ placeholderId,
250
+ sectionId: section.id,
251
+ blockId: block.id
252
+ }
253
+ });
262
254
  }
263
255
  }}
264
256
  onToggleVisibility={() => {
265
257
  if (window.parent) {
266
- window.parent.postMessage(
267
- {
268
- type: 'TOGGLE_BLOCK_VISIBILITY',
269
- data: {
270
- placeholderId,
271
- sectionId: section.id,
272
- blockId: block.id
273
- }
274
- },
275
- '*'
276
- );
258
+ postToEditor({
259
+ type: 'TOGGLE_BLOCK_VISIBILITY',
260
+ data: {
261
+ placeholderId,
262
+ sectionId: section.id,
263
+ blockId: block.id
264
+ }
265
+ });
277
266
  }
278
267
  }}
279
268
  onDelete={() => {
280
269
  if (window.parent) {
281
- window.parent.postMessage(
282
- {
283
- type: 'DELETE_BLOCK',
284
- data: {
285
- placeholderId,
286
- sectionId: section.id,
287
- blockId: block.id
288
- }
289
- },
290
- '*'
291
- );
270
+ postToEditor({
271
+ type: 'DELETE_BLOCK',
272
+ data: {
273
+ placeholderId,
274
+ sectionId: section.id,
275
+ blockId: block.id
276
+ }
277
+ });
292
278
  }
293
279
  }}
294
280
  onRename={(newLabel) => {
295
281
  if (window.parent) {
296
- window.parent.postMessage(
297
- {
298
- type: 'RENAME_BLOCK',
299
- data: {
300
- placeholderId,
301
- sectionId: section.id,
302
- blockId: block.id,
303
- label: newLabel
304
- }
305
- },
306
- '*'
307
- );
282
+ postToEditor({
283
+ type: 'RENAME_BLOCK',
284
+ data: {
285
+ placeholderId,
286
+ sectionId: section.id,
287
+ blockId: block.id,
288
+ label: newLabel
289
+ }
290
+ });
308
291
  }
309
292
  }}
310
293
  />
@@ -328,7 +311,11 @@ const TabsSection: React.FC<TabsSectionProps> = ({
328
311
 
329
312
  const isActive = index === activeTabIndex;
330
313
  const headerStyles = resolveThemeStyleObject(
331
- getCSSStyles(headerBlock.styles || {}, themeSettings, currentBreakpoint),
314
+ getCSSStyles(
315
+ headerBlock.styles || {},
316
+ themeSettings,
317
+ currentBreakpoint
318
+ ),
332
319
  themeSettings
333
320
  );
334
321
  const cleanHeaderStyles = {
@@ -370,93 +357,75 @@ const TabsSection: React.FC<TabsSectionProps> = ({
370
357
  selectedBlockId={selectedBlockId}
371
358
  onMoveUp={() => {
372
359
  if (window.parent) {
373
- window.parent.postMessage(
374
- {
375
- type: 'MOVE_BLOCK_UP',
376
- data: {
377
- placeholderId,
378
- sectionId: section.id,
379
- blockId: tabBlock.id
380
- }
381
- },
382
- '*'
383
- );
360
+ postToEditor({
361
+ type: 'MOVE_BLOCK_UP',
362
+ data: {
363
+ placeholderId,
364
+ sectionId: section.id,
365
+ blockId: tabBlock.id
366
+ }
367
+ });
384
368
  }
385
369
  }}
386
370
  onMoveDown={() => {
387
371
  if (window.parent) {
388
- window.parent.postMessage(
389
- {
390
- type: 'MOVE_BLOCK_DOWN',
391
- data: {
392
- placeholderId,
393
- sectionId: section.id,
394
- blockId: tabBlock.id
395
- }
396
- },
397
- '*'
398
- );
372
+ postToEditor({
373
+ type: 'MOVE_BLOCK_DOWN',
374
+ data: {
375
+ placeholderId,
376
+ sectionId: section.id,
377
+ blockId: tabBlock.id
378
+ }
379
+ });
399
380
  }
400
381
  }}
401
382
  onDuplicate={() => {
402
383
  if (window.parent) {
403
- window.parent.postMessage(
404
- {
405
- type: 'DUPLICATE_BLOCK',
406
- data: {
407
- placeholderId,
408
- sectionId: section.id,
409
- blockId: tabBlock.id
410
- }
411
- },
412
- '*'
413
- );
384
+ postToEditor({
385
+ type: 'DUPLICATE_BLOCK',
386
+ data: {
387
+ placeholderId,
388
+ sectionId: section.id,
389
+ blockId: tabBlock.id
390
+ }
391
+ });
414
392
  }
415
393
  }}
416
394
  onToggleVisibility={() => {
417
395
  if (window.parent) {
418
- window.parent.postMessage(
419
- {
420
- type: 'TOGGLE_BLOCK_VISIBILITY',
421
- data: {
422
- placeholderId,
423
- sectionId: section.id,
424
- blockId: tabBlock.id
425
- }
426
- },
427
- '*'
428
- );
396
+ postToEditor({
397
+ type: 'TOGGLE_BLOCK_VISIBILITY',
398
+ data: {
399
+ placeholderId,
400
+ sectionId: section.id,
401
+ blockId: tabBlock.id
402
+ }
403
+ });
429
404
  }
430
405
  }}
431
406
  onDelete={() => {
432
407
  if (window.parent) {
433
- window.parent.postMessage(
434
- {
435
- type: 'DELETE_BLOCK',
436
- data: {
437
- placeholderId,
438
- sectionId: section.id,
439
- blockId: tabBlock.id
440
- }
441
- },
442
- '*'
443
- );
408
+ postToEditor({
409
+ type: 'DELETE_BLOCK',
410
+ data: {
411
+ placeholderId,
412
+ sectionId: section.id,
413
+ blockId: tabBlock.id
414
+ }
415
+ });
444
416
  }
445
417
  }}
446
418
  onRename={(newLabel) => {
447
419
  if (window.parent) {
448
- window.parent.postMessage(
449
- {
450
- type: 'RENAME_BLOCK',
451
- data: {
452
- placeholderId,
453
- sectionId: section.id,
454
- blockId: tabBlock.id,
455
- label: newLabel
456
- }
457
- },
458
- '*'
459
- );
420
+ postToEditor({
421
+ type: 'RENAME_BLOCK',
422
+ data: {
423
+ placeholderId,
424
+ sectionId: section.id,
425
+ blockId: tabBlock.id,
426
+ label: newLabel
427
+ }
428
+ });
460
429
  }
461
430
  }}
462
431
  />
@@ -485,7 +454,9 @@ const TabsSection: React.FC<TabsSectionProps> = ({
485
454
  onClick={() => setActiveTabIndex(index)}
486
455
  >
487
456
  {headerBlock.blocks
488
- ?.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
457
+ ?.filter((childBlock) =>
458
+ isDesigner ? true : !childBlock.hidden
459
+ )
489
460
  .sort((a, b) => (a.order || 0) - (b.order || 0))
490
461
  .map((childBlock, childIndex) => {
491
462
  // If tab is active, we want to override the child's color with the active tab color
@@ -537,7 +508,11 @@ const TabsSection: React.FC<TabsSectionProps> = ({
537
508
  if (!contentBlock) return null;
538
509
 
539
510
  const contentStyles = resolveThemeStyleObject(
540
- getCSSStyles(contentBlock.styles || {}, themeSettings, currentBreakpoint),
511
+ getCSSStyles(
512
+ contentBlock.styles || {},
513
+ themeSettings,
514
+ currentBreakpoint
515
+ ),
541
516
  themeSettings
542
517
  );
543
518
 
@@ -547,7 +522,9 @@ const TabsSection: React.FC<TabsSectionProps> = ({
547
522
  style={contentStyles}
548
523
  >
549
524
  {contentBlock.blocks
550
- ?.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
525
+ ?.filter((childBlock) =>
526
+ isDesigner ? true : !childBlock.hidden
527
+ )
551
528
  .sort((a, b) => (a.order || 0) - (b.order || 0))
552
529
  .map((childBlock, childIndex) => (
553
530
  <ThemeBlock
@@ -11,6 +11,8 @@ import { useVisibilityContext } from './hooks/use-visibility-context';
11
11
  import { applyVisibilityRulesToSections } from './utils/visibility-rules';
12
12
  import { isPublishWindowVisible } from './utils/publish-window';
13
13
 
14
+ import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
15
+ import { PreviewToEditorMessageType } from '@akinon/theme-contract';
14
16
  // Global flags that must survive component remounts (e.g. in-iframe route
15
17
  // changes): IFRAME_READY is sent once per document, and the designer flag
16
18
  // arrives once from the editor in response — remounted components must not
@@ -64,12 +66,9 @@ export default function ThemePlaceholderClient({
64
66
  if (isInIframe && window.parent) {
65
67
  // Only send IFRAME_READY once per page session to prevent re-initialization
66
68
  if (!window.__iframeReadySent) {
67
- window.parent.postMessage(
68
- {
69
- type: 'IFRAME_READY'
70
- },
71
- '*'
72
- );
69
+ postToEditor({
70
+ type: 'IFRAME_READY'
71
+ });
73
72
  window.__iframeReadySent = true;
74
73
  }
75
74
 
@@ -77,6 +76,7 @@ export default function ThemePlaceholderClient({
77
76
  }
78
77
 
79
78
  const handleMessage = (event: MessageEvent) => {
79
+ if (!isTrustedEditorEvent(event)) return;
80
80
  switch (event.data?.type) {
81
81
  case 'SET_THEME_EDITOR_COOKIE':
82
82
  if (event.data.data) {
@@ -144,7 +144,7 @@ export default function ThemePlaceholderClient({
144
144
  }, [slug]);
145
145
 
146
146
  const sendAction = (
147
- action: string,
147
+ action: PreviewToEditorMessageType,
148
148
  data: {
149
149
  placeholderId?: string;
150
150
  sectionId?: string;
@@ -153,13 +153,10 @@ export default function ThemePlaceholderClient({
153
153
  }
154
154
  ) => {
155
155
  if (window.parent) {
156
- window.parent.postMessage(
157
- {
158
- type: action,
159
- data
160
- },
161
- '*'
162
- );
156
+ postToEditor({
157
+ type: action,
158
+ data
159
+ });
163
160
  }
164
161
  };
165
162
 
@@ -16,6 +16,8 @@ import { createPageContextDiscovery } from './utils/page-context-discovery';
16
16
  import { applyVisibilityRulesToSections } from './utils/visibility-rules';
17
17
  import { isPublishWindowVisible } from './utils/publish-window';
18
18
  import { buildClientRequestUrl } from '@akinon/next/utils';
19
+ import { PreviewToEditorMessageType } from '@akinon/theme-contract';
20
+ import { parseEditorMessage, postToEditor } from './editor-bridge';
19
21
 
20
22
  // Helper function to inject theme colors as CSS custom properties
21
23
  const injectThemeColors = (settings: Record<string, unknown>) => {
@@ -166,22 +168,15 @@ const postProductPickerResponse = (
166
168
  result?: unknown,
167
169
  error?: string
168
170
  ) => {
169
- if (!window.parent) {
170
- return;
171
- }
172
-
173
- window.parent.postMessage(
174
- {
175
- type: 'PRODUCT_PICKER_RESPONSE',
176
- data: {
177
- requestId,
178
- ok,
179
- result,
180
- error
181
- }
182
- },
183
- '*'
184
- );
171
+ postToEditor({
172
+ type: 'PRODUCT_PICKER_RESPONSE',
173
+ data: {
174
+ requestId,
175
+ ok,
176
+ result,
177
+ error
178
+ }
179
+ });
185
180
  };
186
181
 
187
182
  const attachProductPickerPreviewListener = () => {
@@ -195,8 +190,14 @@ const attachProductPickerPreviewListener = () => {
195
190
  window.__productPickerPreviewListenerAttached = true;
196
191
 
197
192
  window.addEventListener('message', async (event) => {
198
- const type = event.data?.type;
199
- const requestId = event.data?.data?.requestId;
193
+ // This listener performs storefront fetches on request — never service
194
+ // a frame from an untrusted embedder.
195
+ const message = parseEditorMessage(event);
196
+ if (!message) return;
197
+
198
+ const type = message.type;
199
+ const requestId = (message.data as { requestId?: unknown } | undefined)
200
+ ?.requestId;
200
201
 
201
202
  if (
202
203
  ![
@@ -374,15 +375,12 @@ export default function ThemePlaceholderWrapper({
374
375
  if (typeof window === 'undefined') return;
375
376
  if (window.self === window.top || !window.parent) return;
376
377
 
377
- window.parent.postMessage(
378
- {
379
- type: 'PAGE_CONTEXT_DISCOVERY',
380
- data: {
381
- discovery: createPageContextDiscovery(resolvedPageContext)
382
- }
383
- },
384
- '*'
385
- );
378
+ postToEditor({
379
+ type: 'PAGE_CONTEXT_DISCOVERY',
380
+ data: {
381
+ discovery: createPageContextDiscovery(resolvedPageContext)
382
+ }
383
+ });
386
384
  }, [resolvedPageContext]);
387
385
 
388
386
  // Load fonts from initial sections on mount (for production site, not iframe)
@@ -494,12 +492,7 @@ export default function ThemePlaceholderWrapper({
494
492
 
495
493
  // Only send IFRAME_READY once per page session to prevent re-initialization
496
494
  if (!window.__iframeReadySent) {
497
- window.parent.postMessage(
498
- {
499
- type: 'IFRAME_READY'
500
- },
501
- '*'
502
- );
495
+ postToEditor({ type: 'IFRAME_READY' });
503
496
  window.__iframeReadySent = true;
504
497
  }
505
498
 
@@ -507,9 +500,15 @@ export default function ThemePlaceholderWrapper({
507
500
  }
508
501
 
509
502
  const handleMessage = (event: MessageEvent) => {
510
- switch (event.data?.type) {
503
+ const message = parseEditorMessage(event);
504
+ if (!message) return;
505
+
506
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
507
+ const messageData: any = message.data;
508
+
509
+ switch (message.type) {
511
510
  case 'SET_THEME_EDITOR_COOKIE':
512
- if (event.data.data) {
511
+ if (messageData) {
513
512
  window.__themeEditorDesigner = true;
514
513
  setIsDesigner(true);
515
514
  }
@@ -517,7 +516,7 @@ export default function ThemePlaceholderWrapper({
517
516
 
518
517
  case 'LOAD_THEME':
519
518
  case 'UPDATE_THEME': {
520
- const theme = event.data.data?.theme;
519
+ const theme = messageData?.theme;
521
520
 
522
521
  // Inject theme colors as CSS custom properties
523
522
  if (theme?.settings) {
@@ -621,7 +620,7 @@ export default function ThemePlaceholderWrapper({
621
620
  }
622
621
 
623
622
  case 'SELECT_SECTION': {
624
- const { sectionId } = event.data.data || {};
623
+ const { sectionId } = messageData || {};
625
624
  if (sectionId) {
626
625
  setSelectedSectionId(sectionId);
627
626
  setSelectedBlockId(null);
@@ -630,7 +629,7 @@ export default function ThemePlaceholderWrapper({
630
629
  }
631
630
 
632
631
  case 'SELECT_BLOCK': {
633
- const { blockId } = event.data.data || {};
632
+ const { blockId } = messageData || {};
634
633
  if (blockId) {
635
634
  setSelectedBlockId(blockId);
636
635
  setSelectedSectionId(null);
@@ -639,7 +638,7 @@ export default function ThemePlaceholderWrapper({
639
638
  }
640
639
 
641
640
  case 'CHANGE_BREAKPOINT': {
642
- const { breakpoint } = event.data.data || {};
641
+ const { breakpoint } = messageData || {};
643
642
  if (breakpoint) {
644
643
  setCurrentBreakpoint(breakpoint);
645
644
  }
@@ -653,7 +652,7 @@ export default function ThemePlaceholderWrapper({
653
652
  }
654
653
 
655
654
  case 'LOAD_FONT': {
656
- const { fontFamily, fontWeight } = event.data.data || {};
655
+ const { fontFamily, fontWeight } = messageData || {};
657
656
  if (fontFamily) {
658
657
  loadGoogleFont(fontFamily, fontWeight);
659
658
  }
@@ -661,7 +660,7 @@ export default function ThemePlaceholderWrapper({
661
660
  }
662
661
 
663
662
  case 'LOAD_CUSTOM_FONT': {
664
- const { name, sources } = event.data.data || {};
663
+ const { name, sources } = messageData || {};
665
664
  if (!name || !Array.isArray(sources) || sources.length === 0) break;
666
665
 
667
666
  const styleId = `custom-font-${name
@@ -693,7 +692,7 @@ export default function ThemePlaceholderWrapper({
693
692
  }, [slug]);
694
693
 
695
694
  const sendAction = (
696
- action: string,
695
+ action: PreviewToEditorMessageType,
697
696
  data: {
698
697
  placeholderId?: string;
699
698
  sectionId?: string;
@@ -701,15 +700,7 @@ export default function ThemePlaceholderWrapper({
701
700
  label?: string;
702
701
  }
703
702
  ) => {
704
- if (window.parent) {
705
- window.parent.postMessage(
706
- {
707
- type: action,
708
- data
709
- },
710
- '*'
711
- );
712
- }
703
+ postToEditor({ type: action, data });
713
704
  };
714
705
 
715
706
  const dynamicCSS = useMemo(