@akinon/pz-theme 2.0.35-beta.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 +9 -3
  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
@@ -7,6 +7,7 @@ import { useThemeSettingsContext } from '../theme-settings-context';
7
7
  import { useLocalization } from '@akinon/next/hooks';
8
8
  import ThemeBlock from '../theme-block';
9
9
 
10
+ import { postToEditor } from '../editor-bridge';
10
11
  const HotspotBlock = ({
11
12
  block,
12
13
  currentBreakpoint = 'desktop',
@@ -19,7 +20,7 @@ const HotspotBlock = ({
19
20
  const themeSettings = useThemeSettingsContext();
20
21
  const { locale } = useLocalization();
21
22
  const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE || 'en';
22
-
23
+
23
24
  const styles = block.styles || {};
24
25
  const properties = block.properties || {};
25
26
 
@@ -27,7 +28,10 @@ const HotspotBlock = ({
27
28
  if (typeof content === 'string') return content;
28
29
  if (typeof content === 'object' && content !== null) {
29
30
  return (
30
- content[locale] || content[defaultLocale] || Object.values(content)[0] || ''
31
+ content[locale] ||
32
+ content[defaultLocale] ||
33
+ Object.values(content)[0] ||
34
+ ''
31
35
  );
32
36
  }
33
37
  return '';
@@ -38,106 +42,193 @@ const HotspotBlock = ({
38
42
  const link = getLocalizedContent(properties.link);
39
43
  const linkText = getLocalizedContent(properties.linkText || 'View Details');
40
44
 
41
- const backgroundColor = getResponsiveValue(styles['background-color'], currentBreakpoint, '#ffffff');
42
- const color = getResponsiveValue(styles.color, currentBreakpoint, '#000000');
43
-
44
- const formatPercentage = (val: string | number | undefined, defaultVal: string): string => {
45
- if (val === undefined || val === null || val === '') return defaultVal;
46
- const strVal = String(val);
47
- if (strVal.endsWith('%')) return strVal;
48
- const num = parseFloat(strVal);
49
- return isNaN(num) ? defaultVal : `${num}%`;
45
+ const backgroundColor = getResponsiveValue(
46
+ styles['background-color'],
47
+ currentBreakpoint,
48
+ '#ffffff'
49
+ );
50
+ const color = getResponsiveValue(styles.color, currentBreakpoint, '#000000');
51
+
52
+ const formatPercentage = (
53
+ val: string | number | undefined,
54
+ defaultVal: string
55
+ ): string => {
56
+ if (val === undefined || val === null || val === '') return defaultVal;
57
+ const strVal = String(val);
58
+ if (strVal.endsWith('%')) return strVal;
59
+ const num = parseFloat(strVal);
60
+ return isNaN(num) ? defaultVal : `${num}%`;
50
61
  };
51
62
 
52
- const top = formatPercentage(getResponsiveValue(styles.top, currentBreakpoint) as string | number | undefined, '50%');
53
- const left = formatPercentage(getResponsiveValue(styles.left, currentBreakpoint) as string | number | undefined, '50%');
54
-
63
+ const top = formatPercentage(
64
+ getResponsiveValue(styles.top, currentBreakpoint) as
65
+ | string
66
+ | number
67
+ | undefined,
68
+ '50%'
69
+ );
70
+ const left = formatPercentage(
71
+ getResponsiveValue(styles.left, currentBreakpoint) as
72
+ | string
73
+ | number
74
+ | undefined,
75
+ '50%'
76
+ );
77
+
55
78
  const handleToggle = (e: React.MouseEvent) => {
56
- if (isDesigner) return;
57
- e.stopPropagation();
58
- setIsOpen(!isOpen);
79
+ if (isDesigner) return;
80
+ e.stopPropagation();
81
+ setIsOpen(!isOpen);
59
82
  };
60
83
 
61
84
  return (
62
- <div
63
- className="absolute group pointer-events-auto"
64
- style={{ top: top as string, left: left as string }}
85
+ <div
86
+ className="absolute group pointer-events-auto"
87
+ style={{ top: top as string, left: left as string }}
65
88
  >
66
89
  <button
67
90
  onClick={handleToggle}
68
91
  className="relative flex items-center justify-center w-8 h-8 -ml-4 -mt-4 cursor-pointer outline-none focus:outline-none z-10 pointer-events-auto"
69
- aria-label={title || "Hotspot"}
92
+ aria-label={title || 'Hotspot'}
70
93
  >
71
- <span
94
+ <span
72
95
  className="absolute w-full h-full rounded-full animate-ping opacity-75"
73
96
  style={{ backgroundColor: backgroundColor as string }}
74
97
  ></span>
75
-
76
- <span
98
+
99
+ <span
77
100
  className="relative w-4 h-4 rounded-full shadow-md"
78
101
  style={{ backgroundColor: backgroundColor as string }}
79
102
  ></span>
80
103
  </button>
81
104
 
82
105
  {(isOpen || (isDesigner && styles.forceOpen)) && (
83
- <div
84
- className="absolute left-1/2 bottom-full mb-2 -translate-x-1/2 w-64 bg-white p-4 rounded-lg shadow-xl z-20 text-left text-sm pointer-events-auto"
85
- >
86
- <div className="flex justify-between items-start mb-2">
87
- {title && <h3 className="font-bold text-gray-900">{title}</h3>}
88
- <button onClick={(e) => { e.stopPropagation(); setIsOpen(false); }} className="text-gray-400 hover:text-gray-600">
89
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
90
- <line x1="18" y1="6" x2="6" y2="18"></line>
91
- <line x1="6" y1="6" x2="18" y2="18"></line>
92
- </svg>
93
- </button>
94
- </div>
95
- {description && <p className="text-gray-600 mb-3">{description}</p>}
96
-
97
- {block.blocks && block.blocks.map((childBlock) => (
98
- <div key={childBlock.id} className="mb-2">
99
- <ThemeBlock
100
- block={childBlock}
101
- placeholderId={placeholderId}
102
- sectionId={sectionId}
103
- isDesigner={isDesigner}
104
- currentBreakpoint={currentBreakpoint}
105
- isSelected={selectedBlockId === childBlock.id}
106
- selectedBlockId={selectedBlockId}
107
- onMoveUp={() => {
108
- if (window.parent) window.parent.postMessage({ type: 'MOVE_BLOCK_UP', data: { placeholderId, sectionId, blockId: childBlock.id, parentBlockId: block.id } }, '*');
109
- }}
110
- onMoveDown={() => {
111
- if (window.parent) window.parent.postMessage({ type: 'MOVE_BLOCK_DOWN', data: { placeholderId, sectionId, blockId: childBlock.id, parentBlockId: block.id } }, '*');
112
- }}
113
- onDuplicate={() => {
114
- if (window.parent) window.parent.postMessage({ type: 'DUPLICATE_BLOCK', data: { placeholderId, sectionId, blockId: childBlock.id, parentBlockId: block.id } }, '*');
115
- }}
116
- onToggleVisibility={() => {
117
- if (window.parent) window.parent.postMessage({ type: 'TOGGLE_BLOCK_VISIBILITY', data: { placeholderId, sectionId, blockId: childBlock.id, parentBlockId: block.id } }, '*');
118
- }}
119
- onDelete={() => {
120
- if (window.parent) window.parent.postMessage({ type: 'DELETE_BLOCK_FROM_PARENT', data: { placeholderId, sectionId, blockId: childBlock.id, parentBlockId: block.id } }, '*');
121
- }}
122
- onRename={(newLabel) => {
123
- if (window.parent) window.parent.postMessage({ type: 'RENAME_BLOCK', data: { placeholderId, sectionId, blockId: childBlock.id, label: newLabel } }, '*');
124
- }}
125
- />
126
- </div>
106
+ <div className="absolute left-1/2 bottom-full mb-2 -translate-x-1/2 w-64 bg-white p-4 rounded-lg shadow-xl z-20 text-left text-sm pointer-events-auto">
107
+ <div className="flex justify-between items-start mb-2">
108
+ {title && <h3 className="font-bold text-gray-900">{title}</h3>}
109
+ <button
110
+ onClick={(e) => {
111
+ e.stopPropagation();
112
+ setIsOpen(false);
113
+ }}
114
+ className="text-gray-400 hover:text-gray-600"
115
+ >
116
+ <svg
117
+ width="14"
118
+ height="14"
119
+ viewBox="0 0 24 24"
120
+ fill="none"
121
+ stroke="currentColor"
122
+ strokeWidth="2"
123
+ strokeLinecap="round"
124
+ strokeLinejoin="round"
125
+ >
126
+ <line x1="18" y1="6" x2="6" y2="18"></line>
127
+ <line x1="6" y1="6" x2="18" y2="18"></line>
128
+ </svg>
129
+ </button>
130
+ </div>
131
+ {description && <p className="text-gray-600 mb-3">{description}</p>}
132
+
133
+ {block.blocks &&
134
+ block.blocks.map((childBlock) => (
135
+ <div key={childBlock.id} className="mb-2">
136
+ <ThemeBlock
137
+ block={childBlock}
138
+ placeholderId={placeholderId}
139
+ sectionId={sectionId}
140
+ isDesigner={isDesigner}
141
+ currentBreakpoint={currentBreakpoint}
142
+ isSelected={selectedBlockId === childBlock.id}
143
+ selectedBlockId={selectedBlockId}
144
+ onMoveUp={() => {
145
+ if (window.parent)
146
+ postToEditor({
147
+ type: 'MOVE_BLOCK_UP',
148
+ data: {
149
+ placeholderId,
150
+ sectionId,
151
+ blockId: childBlock.id,
152
+ parentBlockId: block.id
153
+ }
154
+ });
155
+ }}
156
+ onMoveDown={() => {
157
+ if (window.parent)
158
+ postToEditor({
159
+ type: 'MOVE_BLOCK_DOWN',
160
+ data: {
161
+ placeholderId,
162
+ sectionId,
163
+ blockId: childBlock.id,
164
+ parentBlockId: block.id
165
+ }
166
+ });
167
+ }}
168
+ onDuplicate={() => {
169
+ if (window.parent)
170
+ postToEditor({
171
+ type: 'DUPLICATE_BLOCK',
172
+ data: {
173
+ placeholderId,
174
+ sectionId,
175
+ blockId: childBlock.id,
176
+ parentBlockId: block.id
177
+ }
178
+ });
179
+ }}
180
+ onToggleVisibility={() => {
181
+ if (window.parent)
182
+ postToEditor({
183
+ type: 'TOGGLE_BLOCK_VISIBILITY',
184
+ data: {
185
+ placeholderId,
186
+ sectionId,
187
+ blockId: childBlock.id,
188
+ parentBlockId: block.id
189
+ }
190
+ });
191
+ }}
192
+ onDelete={() => {
193
+ if (window.parent)
194
+ postToEditor({
195
+ type: 'DELETE_BLOCK_FROM_PARENT',
196
+ data: {
197
+ placeholderId,
198
+ sectionId,
199
+ blockId: childBlock.id,
200
+ parentBlockId: block.id
201
+ }
202
+ });
203
+ }}
204
+ onRename={(newLabel) => {
205
+ if (window.parent)
206
+ postToEditor({
207
+ type: 'RENAME_BLOCK',
208
+ data: {
209
+ placeholderId,
210
+ sectionId,
211
+ blockId: childBlock.id,
212
+ label: newLabel
213
+ }
214
+ });
215
+ }}
216
+ />
217
+ </div>
127
218
  ))}
128
219
 
129
- {link && (
130
- <a
131
- href={link}
132
- className="text-blue-600 hover:underline font-medium block mt-2"
133
- onClick={(e) => e.stopPropagation()}
134
- >
135
- {linkText} &rarr;
136
- </a>
137
- )}
138
-
139
- {/* Arrow */}
140
- <div className="absolute left-1/2 top-full -translate-x-1/2 -mt-1 border-8 border-transparent border-t-white"></div>
220
+ {link && (
221
+ <a
222
+ href={link}
223
+ className="text-blue-600 hover:underline font-medium block mt-2"
224
+ onClick={(e) => e.stopPropagation()}
225
+ >
226
+ {linkText} &rarr;
227
+ </a>
228
+ )}
229
+
230
+ {/* Arrow */}
231
+ <div className="absolute left-1/2 top-full -translate-x-1/2 -mt-1 border-8 border-transparent border-t-white"></div>
141
232
  </div>
142
233
  )}
143
234
  </div>
@@ -6,6 +6,7 @@ import { getResponsiveValue, resolveThemeCssVariables } from '../utils';
6
6
  import { BlockRendererProps } from './block-renderer-registry';
7
7
  import { useThemeSettingsContext } from '../theme-settings-context';
8
8
 
9
+ import { postToEditor } from '../editor-bridge';
9
10
  const InputBlock = ({
10
11
  block,
11
12
  currentBreakpoint = 'desktop',
@@ -15,10 +16,7 @@ const InputBlock = ({
15
16
  const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE || 'en';
16
17
  const themeSettings = useThemeSettingsContext();
17
18
 
18
- const resolveLocalizedValue = (
19
- value: unknown,
20
- fallback: string
21
- ): string => {
19
+ const resolveLocalizedValue = (value: unknown, fallback: string): string => {
22
20
  if (value == null) return fallback;
23
21
 
24
22
  if (typeof value === 'object' && !Array.isArray(value)) {
@@ -33,7 +31,7 @@ const InputBlock = ({
33
31
  return String(responsive);
34
32
  }
35
33
 
36
- const firstValue = Object.values(localized).find(item => item != null);
34
+ const firstValue = Object.values(localized).find((item) => item != null);
37
35
  if (firstValue != null) return String(firstValue);
38
36
  }
39
37
 
@@ -70,7 +68,10 @@ const InputBlock = ({
70
68
  }
71
69
 
72
70
  Object.keys(block.styles).forEach((key) => {
73
- const styleValue = getResponsiveValue(block.styles[key], currentBreakpoint);
71
+ const styleValue = getResponsiveValue(
72
+ block.styles[key],
73
+ currentBreakpoint
74
+ );
74
75
  if (styleValue === undefined || styleValue === null) return;
75
76
 
76
77
  const camelKey = key.replace(/-([a-z])/g, (_, letter) =>
@@ -93,16 +94,13 @@ const InputBlock = ({
93
94
  setLocalValue(nextValue);
94
95
 
95
96
  if (isDesigner && window.parent) {
96
- window.parent.postMessage(
97
- {
98
- type: 'UPDATE_BLOCK_VALUE',
99
- data: {
100
- blockId: block.id,
101
- value: nextValue
102
- }
103
- },
104
- '*'
105
- );
97
+ postToEditor({
98
+ type: 'UPDATE_BLOCK_VALUE',
99
+ data: {
100
+ blockId: block.id,
101
+ value: nextValue
102
+ }
103
+ });
106
104
  }
107
105
  };
108
106
 
@@ -120,4 +118,4 @@ const InputBlock = ({
120
118
  );
121
119
  };
122
120
 
123
- export default InputBlock;
121
+ export default InputBlock;
@@ -9,6 +9,8 @@ import { getResponsiveValue } from '../utils';
9
9
  import { BlockRendererProps } from './block-renderer-registry';
10
10
  import ThemeBlock from '../theme-block';
11
11
 
12
+ import { postToEditor } from '../editor-bridge';
13
+ import { PreviewToEditorMessageType } from '@akinon/theme-contract';
12
14
  const LinkBlock = ({
13
15
  block,
14
16
  placeholderId,
@@ -155,36 +157,31 @@ const LinkBlock = ({
155
157
  .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
156
158
  .sort((a, b) => (a.order || 0) - (b.order || 0))
157
159
  .map((childBlock, index) => {
158
- const createActionHandler = (actionType: string) => () => {
159
- if (window.parent) {
160
- window.parent.postMessage(
161
- {
160
+ const createActionHandler =
161
+ (actionType: PreviewToEditorMessageType) => () => {
162
+ if (window.parent) {
163
+ postToEditor({
162
164
  type: actionType,
163
165
  data: {
164
166
  placeholderId,
165
167
  sectionId,
166
168
  blockId: childBlock.id
167
169
  }
168
- },
169
- '*'
170
- );
171
- }
172
- };
170
+ });
171
+ }
172
+ };
173
173
 
174
174
  const handleRename = (newLabel: string) => {
175
175
  if (window.parent) {
176
- window.parent.postMessage(
177
- {
178
- type: 'RENAME_BLOCK',
179
- data: {
180
- placeholderId,
181
- sectionId,
182
- blockId: childBlock.id,
183
- label: newLabel
184
- }
185
- },
186
- '*'
187
- );
176
+ postToEditor({
177
+ type: 'RENAME_BLOCK',
178
+ data: {
179
+ placeholderId,
180
+ sectionId,
181
+ blockId: childBlock.id,
182
+ label: newLabel
183
+ }
184
+ });
188
185
  }
189
186
  };
190
187
 
@@ -14,6 +14,8 @@ import {
14
14
  } from '../utils';
15
15
  import { BlockRendererProps } from './block-renderer-registry';
16
16
 
17
+ import { postToEditor } from '../editor-bridge';
18
+ import { PreviewToEditorMessageType } from '@akinon/theme-contract';
17
19
  const parseNumber = (value: unknown, fallback: number) => {
18
20
  if (value === undefined || value === null || value === '') return fallback;
19
21
  const parsed = typeof value === 'string' ? Number(value) : Number(value);
@@ -163,19 +165,20 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
163
165
  );
164
166
  const maxWidthClass = hasMaxWidth ? `max-w-[${maxWidthValue}]` : '';
165
167
 
166
- const blockAction = (action: string, blockId: string, label?: string) => {
168
+ const blockAction = (
169
+ action: PreviewToEditorMessageType,
170
+ blockId: string,
171
+ label?: string
172
+ ) => {
167
173
  if (!isDesigner) return;
168
- window.parent.postMessage(
169
- {
170
- type: 'BLOCK_ACTION',
171
- action,
172
- blockId,
173
- sectionId,
174
- placeholderId,
175
- label
176
- },
177
- '*'
178
- );
174
+ postToEditor({
175
+ type: 'BLOCK_ACTION',
176
+ action,
177
+ blockId,
178
+ sectionId,
179
+ placeholderId,
180
+ label
181
+ });
179
182
  };
180
183
 
181
184
  const modifySVGColor = (svgContent: string, color: string) => {
@@ -6,6 +6,7 @@ import { getResponsiveValue } from '../utils';
6
6
  import DesignerOverlay from './designer-overlay';
7
7
  import { useDesignerFeatures } from '../hooks/use-designer-features';
8
8
 
9
+ import { isTrustedEditorEvent } from '../editor-bridge';
9
10
  interface WithDesignerFeaturesProps {
10
11
  block: any;
11
12
  placeholderId: string;
@@ -60,6 +61,7 @@ export function WithDesignerFeatures({
60
61
  if (!isDesigner) return;
61
62
 
62
63
  const handleMessage = (event: MessageEvent) => {
64
+ if (!isTrustedEditorEvent(event)) return;
63
65
  if (
64
66
  event.data?.type === 'SCROLL_TO_BLOCK' &&
65
67
  event.data?.data?.blockId === actionBlockId
@@ -0,0 +1,160 @@
1
+ import {
2
+ OutboundThemeMessage,
3
+ ParsedThemeMessageRejected,
4
+ THEME_CONTRACT_PROTOCOL_VERSION,
5
+ ThemeMessage,
6
+ createOriginGuard,
7
+ parseOriginList,
8
+ parseThemeMessage,
9
+ safeOrigin,
10
+ stampThemeMessage
11
+ } from '@akinon/theme-contract';
12
+
13
+ /**
14
+ * The preview side of the editor ⇄ preview postMessage boundary. Every
15
+ * message listener validates inbound events through `isTrustedEditorEvent`
16
+ * / `parseEditorMessage`, and every sender goes through `postToEditor` —
17
+ * so no designer code accepts frames from an arbitrary embedding page, and
18
+ * nothing is ever posted to `'*'`.
19
+ *
20
+ * The editor's origin comes from `NEXT_PUBLIC_THEME_EDITOR_ORIGINS`
21
+ * (comma-separated). Outside production, localhost origins are additionally
22
+ * trusted so local editor + storefront development needs no configuration.
23
+ */
24
+ const guard = createOriginGuard({
25
+ allowedOrigins: parseOriginList(process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS),
26
+ allowLocalDev: process.env.NODE_ENV !== 'production'
27
+ });
28
+
29
+ /**
30
+ * The editor origin proven by a previously accepted inbound message. This
31
+ * is the preferred postMessage target — the allowlist may hold several
32
+ * origins, but only one of them is the live editor.
33
+ */
34
+ let activeEditorOrigin: string | null = null;
35
+
36
+ const warnedOnce = new Set<string>();
37
+ const warnOnce = (key: string, message: string) => {
38
+ if (warnedOnce.has(key)) return;
39
+ warnedOnce.add(key);
40
+ // eslint-disable-next-line no-console
41
+ console.warn(message);
42
+ };
43
+
44
+ /**
45
+ * Whether a message event comes from a trusted editor origin. Call this
46
+ * FIRST in every designer message listener. Accepting an event also records
47
+ * its origin as the postMessage target for subsequent `postToEditor` calls.
48
+ */
49
+ export function isTrustedEditorEvent(event: MessageEvent): boolean {
50
+ if (guard.isTrusted(event.origin)) {
51
+ activeEditorOrigin = event.origin;
52
+ return true;
53
+ }
54
+
55
+ // Only surface rejections that actually look like contract traffic —
56
+ // window.postMessage noise from extensions/devtools should stay silent.
57
+ const parsed = parseThemeMessage(event.data);
58
+ if (parsed.ok) {
59
+ warnOnce(
60
+ `origin:${event.origin}`,
61
+ `[theme-editor] Ignoring "${parsed.message.type}" from untrusted origin "${event.origin}". ` +
62
+ 'Add the editor origin to NEXT_PUBLIC_THEME_EDITOR_ORIGINS to allow it.'
63
+ );
64
+ }
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Origin check + contract validation in one step. Returns the validated
70
+ * message, or `null` for untrusted/foreign/unknown frames. A protocol
71
+ * version mismatch still returns the message (the envelope is additive)
72
+ * but warns once so a drifted deploy is visible.
73
+ */
74
+ export function parseEditorMessage(event: MessageEvent): ThemeMessage | null {
75
+ if (!isTrustedEditorEvent(event)) return null;
76
+
77
+ const parsed = parseThemeMessage(event.data);
78
+ if (parsed.ok) {
79
+ if (parsed.versionMismatch) {
80
+ warnOnce(
81
+ `version:${parsed.remoteVersion}`,
82
+ `[theme-editor] Protocol version mismatch (remote ${parsed.remoteVersion}, local ${THEME_CONTRACT_PROTOCOL_VERSION}). Update @akinon/theme-contract on the older side.`
83
+ );
84
+ }
85
+ return parsed.message;
86
+ }
87
+
88
+ // Cast instead of narrowing: consumers compile with strict mode off,
89
+ // where the boolean-discriminant union does not narrow.
90
+ const rejection = parsed as ParsedThemeMessageRejected;
91
+ if (rejection.reason === 'unknown-type') {
92
+ warnOnce(
93
+ `unknown:${rejection.type}`,
94
+ `[theme-editor] Ignoring unknown message type "${rejection.type}" — editor and storefront builds may be out of sync.`
95
+ );
96
+ }
97
+ return null;
98
+ }
99
+
100
+ const resolveTargetOrigins = (): string[] => {
101
+ if (activeEditorOrigin) return [activeEditorOrigin];
102
+
103
+ // Before the editor's first message (e.g. IFRAME_READY on mount), the
104
+ // embedding page's origin is only known via the referrer — trust it only
105
+ // if the guard does.
106
+ const referrerOrigin = safeOrigin(document.referrer);
107
+ if (referrerOrigin && guard.isTrusted(referrerOrigin)) {
108
+ return [referrerOrigin];
109
+ }
110
+
111
+ // Last resort: address every allowlisted origin. postMessage delivers
112
+ // only to the one that actually matches the parent, so this is a targeted
113
+ // send, not a broadcast.
114
+ return [...guard.allowedOrigins];
115
+ };
116
+
117
+ /**
118
+ * Send a message up to the editor. Safe to call anywhere: it no-ops outside
119
+ * the browser and outside an iframe, stamps the protocol version, and never
120
+ * posts to `'*'` — if no trusted target can be resolved, the message is
121
+ * dropped with a one-time warning instead of leaked to an unknown embedder.
122
+ */
123
+ export function postToEditor<TData>(
124
+ message: OutboundThemeMessage<TData>
125
+ ): void {
126
+ if (typeof window === 'undefined') return;
127
+ if (window.self === window.top || !window.parent) return;
128
+
129
+ const envelope = stampThemeMessage(message);
130
+ const targets = resolveTargetOrigins();
131
+
132
+ if (targets.length === 0) {
133
+ warnOnce(
134
+ 'no-target',
135
+ `[theme-editor] Dropping "${envelope.type}" — no trusted editor origin known. ` +
136
+ 'Set NEXT_PUBLIC_THEME_EDITOR_ORIGINS to the theme editor origin.'
137
+ );
138
+ return;
139
+ }
140
+
141
+ targets.forEach((origin) => {
142
+ window.parent.postMessage(envelope, origin);
143
+ });
144
+ }
145
+
146
+ /** Test-only: reset module state between cases. */
147
+ export function __resetEditorBridgeForTests(): void {
148
+ activeEditorOrigin = null;
149
+ warnedOnce.clear();
150
+ }
151
+
152
+ // Re-exported so app-level senders can type their message helpers without
153
+ // depending on @akinon/theme-contract directly.
154
+ export type {
155
+ EditorToPreviewMessageType,
156
+ OutboundThemeMessage,
157
+ PreviewToEditorMessageType,
158
+ ThemeMessage,
159
+ ThemeMessageType
160
+ } from '@akinon/theme-contract';