@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.
- package/CHANGELOG.md +9 -3
- package/package.json +4 -3
- package/src/__tests__/theme-contract.test.ts +130 -0
- package/src/blocks/__tests__/text-block.test.tsx +8 -1
- package/src/blocks/__tests__/video-block.test.tsx +14 -7
- package/src/blocks/counter-block.tsx +44 -56
- package/src/blocks/group-block.tsx +23 -22
- package/src/blocks/hotspot-block.tsx +171 -80
- package/src/blocks/input-block.tsx +15 -17
- package/src/blocks/link-block.tsx +18 -21
- package/src/blocks/slider-block.tsx +15 -12
- package/src/components/with-designer-features.tsx +2 -0
- package/src/editor-bridge.ts +160 -0
- package/src/hooks/use-designer-features.tsx +30 -37
- package/src/placeholder-registry.ts +5 -7
- package/src/sections/__tests__/countdown-campaign-banner-section.test.tsx +8 -4
- package/src/sections/__tests__/coupon-banner-section.test.tsx +4 -2
- package/src/sections/before-after-section.tsx +189 -134
- package/src/sections/contact-form-section.tsx +185 -60
- package/src/sections/countdown-campaign-banner-section.tsx +73 -27
- package/src/sections/coupon-banner-section.tsx +152 -48
- package/src/sections/featured-product-spotlight-section.tsx +67 -66
- package/src/sections/find-in-store-section.tsx +37 -25
- package/src/sections/hover-showcase-section.tsx +221 -89
- package/src/sections/image-hotspot-section.tsx +208 -94
- package/src/sections/installment-options-section.tsx +268 -259
- package/src/sections/notification-banner-section.tsx +111 -25
- package/src/sections/order-tracking-lookup-section.tsx +272 -247
- package/src/sections/posts-slider-section.tsx +17 -16
- package/src/sections/pre-order-launch-banner-section.tsx +31 -50
- package/src/sections/section-wrapper.tsx +9 -10
- package/src/sections/shipping-threshold-progress-section.tsx +72 -29
- package/src/sections/stats-counter-section.tsx +68 -30
- package/src/sections/tabs-section.tsx +124 -147
- package/src/theme-placeholder-client.tsx +11 -14
- package/src/theme-placeholder-wrapper.tsx +42 -51
- package/src/theme-section.tsx +27 -34
- package/src/utils/__tests__/transform-styles.test.ts +97 -0
- 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] ||
|
|
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(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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(
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
79
|
+
if (isDesigner) return;
|
|
80
|
+
e.stopPropagation();
|
|
81
|
+
setIsOpen(!isOpen);
|
|
59
82
|
};
|
|
60
83
|
|
|
61
84
|
return (
|
|
62
|
-
<div
|
|
63
|
-
|
|
64
|
-
|
|
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 ||
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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} →
|
|
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(
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 =
|
|
159
|
-
|
|
160
|
-
window.parent
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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 = (
|
|
168
|
+
const blockAction = (
|
|
169
|
+
action: PreviewToEditorMessageType,
|
|
170
|
+
blockId: string,
|
|
171
|
+
label?: string
|
|
172
|
+
) => {
|
|
167
173
|
if (!isDesigner) return;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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';
|