@pushwoosh/websdk-common 0.0.0 → 6.17.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/package.json +30 -5
- package/subscription-widget/SubscriptionWidgetReader.d.ts +23 -0
- package/subscription-widget/SubscriptionWidgetReader.js +307 -0
- package/subscription-widget/constants.d.ts +19 -0
- package/subscription-widget/constants.js +61 -0
- package/subscription-widget/helpers.d.ts +16 -0
- package/subscription-widget/helpers.js +110 -0
- package/subscription-widget/index.d.ts +4 -0
- package/subscription-widget/index.js +3 -0
- package/subscription-widget/types.d.ts +90 -0
- package/subscription-widget/types.js +1 -0
- package/web-popups/WebPopupReader.d.ts +4 -0
- package/web-popups/WebPopupReader.js +1394 -0
- package/web-popups/colorScheme.d.ts +2 -0
- package/web-popups/colorScheme.js +22 -0
- package/web-popups/constants.d.ts +71 -0
- package/web-popups/constants.js +216 -0
- package/web-popups/glyphs.d.ts +8 -0
- package/web-popups/glyphs.js +30 -0
- package/web-popups/helpers.d.ts +84 -0
- package/web-popups/helpers.js +687 -0
- package/web-popups/index.d.ts +9 -0
- package/web-popups/index.js +8 -0
- package/web-popups/layout.d.ts +39 -0
- package/web-popups/layout.js +290 -0
- package/web-popups/palette.d.ts +34 -0
- package/web-popups/palette.js +157 -0
- package/web-popups/templates.d.ts +3 -0
- package/web-popups/templates.js +125 -0
- package/web-popups/timer.d.ts +16 -0
- package/web-popups/timer.js +194 -0
- package/web-popups/types.d.ts +660 -0
- package/web-popups/types.js +10 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import { SAFE_URL_PROTOCOL, WEB_POPUP_CHOICE_DEFAULTS, WEB_POPUP_DEFAULT_COLORS, WEB_POPUP_FORM_DEFAULTS, WEB_POPUP_MAX_CHOICE_OPTIONS, WEB_POPUP_RATING_DEFAULTS, WEB_POPUP_VERSION } from './constants';
|
|
2
|
+
import { WEB_POPUP_GLYPH_NAMES, WEB_POPUP_GLYPH_SIZES } from './glyphs';
|
|
3
|
+
import { normalizeWebPopupLayout, substituteWebPopupLayoutValues, WEB_POPUP_VALUE_KEY_PATTERN } from './layout';
|
|
4
|
+
import { autoDarkWebPopupColors } from './palette';
|
|
5
|
+
import { webPopupTimeZoneSupported, webPopupWallClockMs } from './timer';
|
|
6
|
+
// Sanitize a user-entered button URL before it lands in an href: https/http/
|
|
7
|
+
// mailto/tel and page-relative links only.
|
|
8
|
+
export function safeUrl(url) {
|
|
9
|
+
const trimmed = (url || '').trim();
|
|
10
|
+
if (!trimmed) return undefined;
|
|
11
|
+
if (trimmed.startsWith('#') || trimmed.startsWith('/') || trimmed.startsWith('?')) return trimmed;
|
|
12
|
+
if (SAFE_URL_PROTOCOL.test(trimmed)) return trimmed;
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The target/rel pair a popup link renders with: only an exact '_self' opts
|
|
17
|
+
* out of the default new tab (and of its noreferrer, which same-tab
|
|
18
|
+
* navigation doesn't need). One rule for both link kinds — the reader's
|
|
19
|
+
* button anchors and the sanitizer's inline text links.
|
|
20
|
+
*/
|
|
21
|
+
export function webPopupLinkTargetAttrs(target) {
|
|
22
|
+
return target === '_self' ? {
|
|
23
|
+
target: '_self'
|
|
24
|
+
} : {
|
|
25
|
+
target: '_blank',
|
|
26
|
+
rel: 'noreferrer'
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const SANITIZE_ALLOWED_TAGS = new Set(['STRONG', 'B', 'EM', 'I', 'U', 'BR', 'A']);
|
|
30
|
+
function copySanitized(from, to, doc) {
|
|
31
|
+
for (const child of Array.from(from.childNodes)) {
|
|
32
|
+
if (child.nodeType === 3 /* text */) {
|
|
33
|
+
to.appendChild(doc.createTextNode(child.textContent ?? ''));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (child.nodeType !== 1 /* element */) continue;
|
|
37
|
+
const el = child;
|
|
38
|
+
// Unknown tags are unwrapped — their children survive as plain content.
|
|
39
|
+
if (!SANITIZE_ALLOWED_TAGS.has(el.tagName)) {
|
|
40
|
+
copySanitized(el, to, doc);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const clean = doc.createElement(el.tagName.toLowerCase());
|
|
44
|
+
if (el.tagName === 'A') {
|
|
45
|
+
const href = safeUrl(el.getAttribute('href') ?? undefined);
|
|
46
|
+
if (href) clean.setAttribute('href', href);
|
|
47
|
+
const {
|
|
48
|
+
target,
|
|
49
|
+
rel
|
|
50
|
+
} = webPopupLinkTargetAttrs(el.getAttribute('target'));
|
|
51
|
+
clean.setAttribute('target', target);
|
|
52
|
+
if (rel) clean.setAttribute('rel', rel);
|
|
53
|
+
}
|
|
54
|
+
to.appendChild(clean);
|
|
55
|
+
copySanitized(el, clean, doc);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Sanitize a text slot's html before it's injected into the page. The editor
|
|
60
|
+
* only ever writes inline formatting, but the json travels through a public
|
|
61
|
+
* API and renders on customers' sites — so on render only the inline tags
|
|
62
|
+
* survive (anything else is unwrapped to its text), every attribute is
|
|
63
|
+
* dropped, and links keep only a safeUrl-approved href plus their tab choice
|
|
64
|
+
* (target="_self" stays; everything else opens in a new tab).
|
|
65
|
+
*/
|
|
66
|
+
export function sanitizeWebPopupHtml(html) {
|
|
67
|
+
// No '<' → no elements can form; entities in plain text are already-valid
|
|
68
|
+
// html and pass through untouched.
|
|
69
|
+
if (!html.includes('<')) return html;
|
|
70
|
+
const parsed = new DOMParser().parseFromString(html, 'text/html');
|
|
71
|
+
const out = parsed.createElement('div');
|
|
72
|
+
copySanitized(parsed.body, out, parsed);
|
|
73
|
+
return out.innerHTML;
|
|
74
|
+
}
|
|
75
|
+
function colorString(value) {
|
|
76
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The popup palette from API-writable params: `colors` entries when present,
|
|
80
|
+
* the legacy top-level background/accent underneath, palette defaults for the
|
|
81
|
+
* rest — always a complete WebPopupColors.
|
|
82
|
+
*/
|
|
83
|
+
export function normalizeWebPopupColors(params) {
|
|
84
|
+
const colors = typeof params.colors === 'object' && params.colors !== null ? params.colors : {};
|
|
85
|
+
return {
|
|
86
|
+
background: colorString(colors.background) ?? colorString(params.background) ?? WEB_POPUP_DEFAULT_COLORS.background,
|
|
87
|
+
accent: colorString(colors.accent) ?? colorString(params.accent) ?? WEB_POPUP_DEFAULT_COLORS.accent,
|
|
88
|
+
text: colorString(colors.text) ?? WEB_POPUP_DEFAULT_COLORS.text,
|
|
89
|
+
textSecondary: colorString(colors.textSecondary) ?? WEB_POPUP_DEFAULT_COLORS.textSecondary,
|
|
90
|
+
textInverse: colorString(colors.textInverse) ?? WEB_POPUP_DEFAULT_COLORS.textInverse
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// A page dim from API-writable json: both fields or nothing, opacity clamped.
|
|
94
|
+
function normalizeOverlay(raw) {
|
|
95
|
+
if (typeof raw !== 'object' || raw === null) return undefined;
|
|
96
|
+
const overlay = raw;
|
|
97
|
+
const color = colorString(overlay.color);
|
|
98
|
+
if (!color) return undefined;
|
|
99
|
+
const opacity = typeof overlay.opacity === 'number' && Number.isFinite(overlay.opacity) ? Math.max(0, Math.min(1, overlay.opacity)) : 1;
|
|
100
|
+
return {
|
|
101
|
+
color,
|
|
102
|
+
opacity
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// Authored dark entries over the ones autogenerated from the light palette.
|
|
106
|
+
// Absent `dark` = no dark mode; an authored palette switched off is not that.
|
|
107
|
+
export function normalizeWebPopupDark(params, light) {
|
|
108
|
+
const raw = params.dark;
|
|
109
|
+
if (typeof raw !== 'object' || raw === null) return undefined;
|
|
110
|
+
const dark = raw;
|
|
111
|
+
const authored = typeof dark.colors === 'object' && dark.colors !== null ? dark.colors : {};
|
|
112
|
+
const auto = autoDarkWebPopupColors(light);
|
|
113
|
+
const overlay = normalizeOverlay(dark.overlay);
|
|
114
|
+
return {
|
|
115
|
+
enabled: dark.enabled === true,
|
|
116
|
+
colors: {
|
|
117
|
+
background: colorString(authored.background) ?? auto.background,
|
|
118
|
+
accent: colorString(authored.accent) ?? auto.accent,
|
|
119
|
+
text: colorString(authored.text) ?? auto.text,
|
|
120
|
+
textSecondary: colorString(authored.textSecondary) ?? auto.textSecondary,
|
|
121
|
+
textInverse: colorString(authored.textInverse) ?? auto.textInverse
|
|
122
|
+
},
|
|
123
|
+
...(overlay ? {
|
|
124
|
+
overlay
|
|
125
|
+
} : {}),
|
|
126
|
+
...(colorString(dark.boxShadow) ? {
|
|
127
|
+
boxShadow: dark.boxShadow
|
|
128
|
+
} : {})
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Parse stored PopupFormContent.json into the popup model. Returns null for
|
|
132
|
+
// anything that isn't a version-2 popup with at least one page — including
|
|
133
|
+
// the retired version-1 flat-layout format and pre-engine content with no
|
|
134
|
+
// `version` field at all, which callers should treat as broken. The palette
|
|
135
|
+
// comes out complete: colors entries → legacy top-level background/accent →
|
|
136
|
+
// defaults.
|
|
137
|
+
export function parseWebPopupJson(json) {
|
|
138
|
+
let raw;
|
|
139
|
+
try {
|
|
140
|
+
raw = JSON.parse(json);
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (typeof raw !== 'object' || raw === null) return null;
|
|
145
|
+
const candidate = raw;
|
|
146
|
+
if (candidate.version !== WEB_POPUP_VERSION) return null;
|
|
147
|
+
const params = candidate.params;
|
|
148
|
+
if (typeof params !== 'object' || params === null) return null;
|
|
149
|
+
if (!Array.isArray(params.pages) || params.pages.length === 0) return null;
|
|
150
|
+
const colors = normalizeWebPopupColors(params);
|
|
151
|
+
const dark = normalizeWebPopupDark(params, colors);
|
|
152
|
+
return {
|
|
153
|
+
version: WEB_POPUP_VERSION,
|
|
154
|
+
params: {
|
|
155
|
+
...params,
|
|
156
|
+
colors,
|
|
157
|
+
...(dark ? {
|
|
158
|
+
dark
|
|
159
|
+
} : {})
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** The editor's save path: model → PopupFormContent.json string. */
|
|
164
|
+
export function stringifyWebPopup(params) {
|
|
165
|
+
const compat = {
|
|
166
|
+
...params,
|
|
167
|
+
background: params.colors.background,
|
|
168
|
+
accent: params.colors.accent
|
|
169
|
+
};
|
|
170
|
+
const payload = {
|
|
171
|
+
version: WEB_POPUP_VERSION,
|
|
172
|
+
params: compat
|
|
173
|
+
};
|
|
174
|
+
return JSON.stringify(payload);
|
|
175
|
+
}
|
|
176
|
+
const IMAGE_FITS = ['cover', 'contain'];
|
|
177
|
+
const MAX_IMAGE_BLUR = 100;
|
|
178
|
+
// Bomb protection for the page content, mirroring the layout tree's
|
|
179
|
+
// depth/node caps: the tree allows up to ~100 Content nodes and several may
|
|
180
|
+
// reference the same zone, so unbounded zones/slots would amplify into
|
|
181
|
+
// thousands of sanitize+render passes on the customer's page.
|
|
182
|
+
const MAX_IMAGES = 20;
|
|
183
|
+
const MAX_ZONES = 20;
|
|
184
|
+
const MAX_ZONE_SLOTS = 50;
|
|
185
|
+
// A media param's video, or undefined when it has none / an unusable url —
|
|
186
|
+
// then the param stays a plain image and the poster is all that renders.
|
|
187
|
+
function normalizeVideo(raw) {
|
|
188
|
+
if (typeof raw !== 'object' || raw === null) return undefined;
|
|
189
|
+
const video = raw;
|
|
190
|
+
const src = safeUrl(typeof video.src === 'string' ? video.src : undefined);
|
|
191
|
+
if (!src) return undefined;
|
|
192
|
+
return {
|
|
193
|
+
src,
|
|
194
|
+
...(video.controls === true ? {
|
|
195
|
+
controls: true
|
|
196
|
+
} : {}),
|
|
197
|
+
...(video.loop === false ? {
|
|
198
|
+
loop: false
|
|
199
|
+
} : {})
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// The images come from API-writable json — keep only well-formed entries,
|
|
203
|
+
// with the presentation props (fit/blur) validated like the layout tree's.
|
|
204
|
+
function normalizeImages(raw) {
|
|
205
|
+
if (!Array.isArray(raw)) return [];
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const img of raw) {
|
|
208
|
+
if (out.length >= MAX_IMAGES) break;
|
|
209
|
+
if (typeof img !== 'object' || img === null) continue;
|
|
210
|
+
if (typeof img.key !== 'string' || !img.key) continue;
|
|
211
|
+
if (typeof img.src !== 'string') continue;
|
|
212
|
+
const video = normalizeVideo(img.video);
|
|
213
|
+
out.push({
|
|
214
|
+
key: img.key,
|
|
215
|
+
label: typeof img.label === 'string' ? img.label : '',
|
|
216
|
+
src: img.src,
|
|
217
|
+
...(typeof img.srcDark === 'string' && img.srcDark ? {
|
|
218
|
+
srcDark: img.srcDark
|
|
219
|
+
} : {}),
|
|
220
|
+
...(video ? {
|
|
221
|
+
video
|
|
222
|
+
} : {}),
|
|
223
|
+
...(typeof img.mediaId === 'string' && img.mediaId ? {
|
|
224
|
+
mediaId: img.mediaId
|
|
225
|
+
} : {}),
|
|
226
|
+
...(typeof img.mediaIdDark === 'string' && img.mediaIdDark ? {
|
|
227
|
+
mediaIdDark: img.mediaIdDark
|
|
228
|
+
} : {}),
|
|
229
|
+
...(IMAGE_FITS.includes(img.fit) ? {
|
|
230
|
+
fit: img.fit
|
|
231
|
+
} : {}),
|
|
232
|
+
...(typeof img.blur === 'number' && Number.isFinite(img.blur) && img.blur > 0 ? {
|
|
233
|
+
blur: Math.min(img.blur, MAX_IMAGE_BLUR)
|
|
234
|
+
} : {}),
|
|
235
|
+
...(typeof img.dim === 'number' && Number.isFinite(img.dim) && img.dim > 0 ? {
|
|
236
|
+
dim: Math.min(img.dim, 1)
|
|
237
|
+
} : {})
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
const MAX_VALUES = 20;
|
|
243
|
+
const MAX_VALUE_LENGTH = 64;
|
|
244
|
+
const MAX_SELECT_OPTIONS = 20;
|
|
245
|
+
const LENGTH_CONTROL_UNITS = ['px', '%', 'em', 'rem', 'fr'];
|
|
246
|
+
function finiteNumber(value) {
|
|
247
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
248
|
+
}
|
|
249
|
+
// Per-unit { min?, max? } overrides for a length control, keyed by a
|
|
250
|
+
// whitelisted unit. Non-object input, unknown unit keys and non-finite bounds
|
|
251
|
+
// are dropped; an all-empty map returns undefined so the field is omitted.
|
|
252
|
+
function normalizeControlRanges(raw) {
|
|
253
|
+
if (typeof raw !== 'object' || raw === null) return undefined;
|
|
254
|
+
const out = {};
|
|
255
|
+
for (const unit of LENGTH_CONTROL_UNITS) {
|
|
256
|
+
const entry = raw[unit];
|
|
257
|
+
if (typeof entry !== 'object' || entry === null) continue;
|
|
258
|
+
const min = finiteNumber(entry.min);
|
|
259
|
+
const max = finiteNumber(entry.max);
|
|
260
|
+
if (min === undefined && max === undefined) continue;
|
|
261
|
+
out[unit] = {
|
|
262
|
+
...(min !== undefined ? {
|
|
263
|
+
min
|
|
264
|
+
} : {}),
|
|
265
|
+
...(max !== undefined ? {
|
|
266
|
+
max
|
|
267
|
+
} : {})
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
271
|
+
}
|
|
272
|
+
// The control is editor metadata from API-writable json: enum-whitelisted
|
|
273
|
+
// kind, clamped numbers, capped options. Anything malformed drops to
|
|
274
|
+
// undefined — the editor falls back to a plain text input.
|
|
275
|
+
function normalizeValueControl(raw) {
|
|
276
|
+
if (typeof raw !== 'object' || raw === null) return undefined;
|
|
277
|
+
const control = raw;
|
|
278
|
+
if (control.kind === 'text') return {
|
|
279
|
+
kind: 'text'
|
|
280
|
+
};
|
|
281
|
+
if (control.kind === 'length' || control.kind === 'percent') {
|
|
282
|
+
const min = finiteNumber(control.min);
|
|
283
|
+
const max = finiteNumber(control.max);
|
|
284
|
+
const units = control.kind === 'length' && Array.isArray(control.units) ? control.units.filter(u => LENGTH_CONTROL_UNITS.includes(u)) : [];
|
|
285
|
+
// Per-unit min/max overrides: whitelisted unit keys, finite bounds. Any
|
|
286
|
+
// malformed entry is skipped; an empty result drops the whole field.
|
|
287
|
+
const ranges = control.kind === 'length' ? normalizeControlRanges(control.ranges) : undefined;
|
|
288
|
+
return {
|
|
289
|
+
kind: control.kind,
|
|
290
|
+
...(min !== undefined ? {
|
|
291
|
+
min
|
|
292
|
+
} : {}),
|
|
293
|
+
...(max !== undefined ? {
|
|
294
|
+
max
|
|
295
|
+
} : {}),
|
|
296
|
+
...(units.length > 0 ? {
|
|
297
|
+
units
|
|
298
|
+
} : {}),
|
|
299
|
+
...(ranges ? {
|
|
300
|
+
ranges
|
|
301
|
+
} : {})
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
if (control.kind === 'select' && Array.isArray(control.options)) {
|
|
305
|
+
const options = [];
|
|
306
|
+
for (const option of control.options) {
|
|
307
|
+
if (options.length >= MAX_SELECT_OPTIONS) break;
|
|
308
|
+
if (typeof option !== 'object' || option === null) continue;
|
|
309
|
+
if (typeof option.value !== 'string' || option.value.length > MAX_VALUE_LENGTH) continue;
|
|
310
|
+
options.push({
|
|
311
|
+
value: option.value,
|
|
312
|
+
label: typeof option.label === 'string' ? option.label : option.value
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (options.length > 0) return {
|
|
316
|
+
kind: 'select',
|
|
317
|
+
options
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* The page's named layout values from API-writable json: capped count,
|
|
324
|
+
* whitelisted keys, short '$'-free value strings (substitution is single-pass
|
|
325
|
+
* and this keeps refs from even *looking* chainable), validated controls.
|
|
326
|
+
*/
|
|
327
|
+
export function normalizeWebPopupValues(raw) {
|
|
328
|
+
if (!Array.isArray(raw)) return [];
|
|
329
|
+
const out = [];
|
|
330
|
+
for (const value of raw) {
|
|
331
|
+
if (out.length >= MAX_VALUES) break;
|
|
332
|
+
if (typeof value !== 'object' || value === null) continue;
|
|
333
|
+
if (typeof value.key !== 'string' || !WEB_POPUP_VALUE_KEY_PATTERN.test(value.key)) continue;
|
|
334
|
+
if (typeof value.value !== 'string' || value.value.length > MAX_VALUE_LENGTH || value.value.includes('$')) continue;
|
|
335
|
+
if (out.some(v => v.key === value.key)) continue;
|
|
336
|
+
const control = normalizeValueControl(value.control);
|
|
337
|
+
out.push({
|
|
338
|
+
key: value.key,
|
|
339
|
+
label: typeof value.label === 'string' ? value.label : '',
|
|
340
|
+
value: value.value,
|
|
341
|
+
...(control ? {
|
|
342
|
+
control
|
|
343
|
+
} : {})
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
/** The values as a substitution map (see substituteWebPopupLayoutValues). */
|
|
349
|
+
export function webPopupValuesMap(values) {
|
|
350
|
+
return Object.fromEntries(values.map(v => [v.key, v.value]));
|
|
351
|
+
}
|
|
352
|
+
const FORM_FIELD_KINDS = ['email', 'firstName', 'lastName', 'phone', 'custom'];
|
|
353
|
+
const BUTTON_VARIANTS = ['primary', 'secondary'];
|
|
354
|
+
const MAX_FORM_FIELDS = 10;
|
|
355
|
+
// A form slot's fields from API-writable json: capped, whitelisted kinds,
|
|
356
|
+
// deduped keys. The submit contract needs an email — when none survives, one
|
|
357
|
+
// is injected up front (the slot stays renderable/editable rather than being
|
|
358
|
+
// dropped: the editor canvas resolves through this same pipeline). Email
|
|
359
|
+
// fields are always required — the payload can't form without a value.
|
|
360
|
+
function normalizeFormFields(raw) {
|
|
361
|
+
const out = [];
|
|
362
|
+
if (Array.isArray(raw)) {
|
|
363
|
+
for (const field of raw) {
|
|
364
|
+
if (out.length >= MAX_FORM_FIELDS) break;
|
|
365
|
+
if (typeof field !== 'object' || field === null) continue;
|
|
366
|
+
if (typeof field.key !== 'string' || !field.key) continue;
|
|
367
|
+
if (!FORM_FIELD_KINDS.includes(field.kind)) continue;
|
|
368
|
+
if (out.some(f => f.key === field.key)) continue;
|
|
369
|
+
out.push({
|
|
370
|
+
key: field.key,
|
|
371
|
+
kind: field.kind,
|
|
372
|
+
label: typeof field.label === 'string' ? field.label : '',
|
|
373
|
+
placeholder: typeof field.placeholder === 'string' ? field.placeholder : '',
|
|
374
|
+
required: field.kind === 'email' || field.required === true,
|
|
375
|
+
...(field.kind === 'custom' && typeof field.tag === 'string' && field.tag ? {
|
|
376
|
+
tag: field.tag
|
|
377
|
+
} : {})
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (!out.some(f => f.kind === 'email')) {
|
|
382
|
+
let key = 'field-email';
|
|
383
|
+
while (out.some(f => f.key === key)) key = `${key}-1`;
|
|
384
|
+
out.unshift({
|
|
385
|
+
key,
|
|
386
|
+
kind: 'email',
|
|
387
|
+
label: 'Email',
|
|
388
|
+
placeholder: '',
|
|
389
|
+
required: true
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
return out.slice(0, MAX_FORM_FIELDS);
|
|
393
|
+
}
|
|
394
|
+
const CHOICE_CONTROLS = ['checkbox', 'chips'];
|
|
395
|
+
const RATING_SCALES = ['nps', 'stars', 'thumbs'];
|
|
396
|
+
const RATING_SUBMIT_MODES = ['instant', 'button'];
|
|
397
|
+
const TIMER_EXPIRE_ACTIONS = ['keep', 'hide-popup', 'page'];
|
|
398
|
+
const LANGUAGE_CODE = /^[a-z]{2}$/i;
|
|
399
|
+
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
400
|
+
// Authored strings from API-writable json land in the popup's own markup on a
|
|
401
|
+
// customer's page — capped so a 100kB "label" can't be one.
|
|
402
|
+
const MAX_SLOT_STRING = 120;
|
|
403
|
+
const MAX_SLOT_MESSAGE = 500;
|
|
404
|
+
function slotString(value, max = MAX_SLOT_STRING) {
|
|
405
|
+
return typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
406
|
+
}
|
|
407
|
+
// A capped string with a fallback for when the json omits it (or sends blanks).
|
|
408
|
+
function slotStringOr(value, fallback, max = MAX_SLOT_STRING) {
|
|
409
|
+
return slotString(value, max) || fallback;
|
|
410
|
+
}
|
|
411
|
+
/** An ISO 639-1 code, lowercased; anything else = '' (the renderer's 'en'). */
|
|
412
|
+
function normalizeLanguage(value) {
|
|
413
|
+
const code = slotString(value, 8);
|
|
414
|
+
return LANGUAGE_CODE.test(code) ? code.toLowerCase() : '';
|
|
415
|
+
}
|
|
416
|
+
// Capped, non-empty values, deduped, label falling back to the value. An
|
|
417
|
+
// option-less slot survives, like an unbound form: the editor must still edit it.
|
|
418
|
+
function normalizeChoiceOptions(raw) {
|
|
419
|
+
if (!Array.isArray(raw)) return [];
|
|
420
|
+
const out = [];
|
|
421
|
+
for (const option of raw) {
|
|
422
|
+
if (out.length >= WEB_POPUP_MAX_CHOICE_OPTIONS) break;
|
|
423
|
+
if (typeof option !== 'object' || option === null) continue;
|
|
424
|
+
const value = slotString(option.value);
|
|
425
|
+
if (!value) continue;
|
|
426
|
+
if (out.some(existing => existing.value === value)) continue;
|
|
427
|
+
const tagValue = slotString(option.tagValue);
|
|
428
|
+
out.push({
|
|
429
|
+
value,
|
|
430
|
+
label: slotStringOr(option.label, value),
|
|
431
|
+
...(tagValue ? {
|
|
432
|
+
tagValue
|
|
433
|
+
} : {})
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
// The selection bounds against the surviving option count: 0 = unbounded, and
|
|
439
|
+
// a max under the min would leave the slot unsubmittable — the min wins.
|
|
440
|
+
function normalizeChoiceBounds(slot, options) {
|
|
441
|
+
const bound = value => {
|
|
442
|
+
const n = finiteNumber(value);
|
|
443
|
+
return n === undefined || n <= 0 ? 0 : Math.min(Math.floor(n), options);
|
|
444
|
+
};
|
|
445
|
+
const min = bound(slot.min);
|
|
446
|
+
const max = bound(slot.max);
|
|
447
|
+
return {
|
|
448
|
+
min,
|
|
449
|
+
max: max > 0 ? Math.max(min, max) : 0
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
// Per-type slot validation. Text and button entries pass through UNTOUCHED
|
|
453
|
+
// when well-formed (stored popups predate this check — don't rewrite them);
|
|
454
|
+
// a form slot is rebuilt field by field; an unknown slot type is dropped so
|
|
455
|
+
// slot types added after this engine build degrade to absence, not a crash.
|
|
456
|
+
function normalizeSlots(raw) {
|
|
457
|
+
const out = [];
|
|
458
|
+
for (const slot of raw) {
|
|
459
|
+
if (out.length >= MAX_ZONE_SLOTS) break;
|
|
460
|
+
if (typeof slot !== 'object' || slot === null) continue;
|
|
461
|
+
if (typeof slot.key !== 'string' || !slot.key) continue;
|
|
462
|
+
if (slot.type === 'text') {
|
|
463
|
+
if (typeof slot.html === 'string') out.push(slot);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (slot.type === 'button') {
|
|
467
|
+
// An action-less button would crash the renderer on the action switch.
|
|
468
|
+
if (typeof slot.action === 'object' && slot.action !== null && typeof slot.action.type === 'string') {
|
|
469
|
+
out.push(slot);
|
|
470
|
+
}
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (slot.type === 'timer') {
|
|
474
|
+
// An unsupported zone name degrades to the contract's UTC rather than
|
|
475
|
+
// dropping the countdown.
|
|
476
|
+
const zone = slotString(slot.timezone, 64);
|
|
477
|
+
const timezone = webPopupTimeZoneSupported(zone) ? zone : '';
|
|
478
|
+
const endTime = slotString(slot.endTime, 32);
|
|
479
|
+
// No computable deadline = nothing to count down to.
|
|
480
|
+
if (webPopupWallClockMs(endTime, timezone) === null) continue;
|
|
481
|
+
const expirePage = slotString(slot.expirePage);
|
|
482
|
+
const onExpire = TIMER_EXPIRE_ACTIONS.includes(slot.onExpire) ? slot.onExpire : 'keep';
|
|
483
|
+
// 'page' without a target page is 'keep' — the renderer never has to
|
|
484
|
+
// guess where a pageless jump should land.
|
|
485
|
+
const expires = onExpire === 'page' && !expirePage ? 'keep' : onExpire;
|
|
486
|
+
out.push({
|
|
487
|
+
type: 'timer',
|
|
488
|
+
key: slot.key,
|
|
489
|
+
endTime,
|
|
490
|
+
timezone,
|
|
491
|
+
language: normalizeLanguage(slot.language),
|
|
492
|
+
showLabels: slot.showLabels !== false,
|
|
493
|
+
onExpire: expires,
|
|
494
|
+
...(expires === 'page' ? {
|
|
495
|
+
expirePage
|
|
496
|
+
} : {})
|
|
497
|
+
});
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (slot.type === 'choice') {
|
|
501
|
+
const options = normalizeChoiceOptions(slot.options);
|
|
502
|
+
const {
|
|
503
|
+
min,
|
|
504
|
+
max
|
|
505
|
+
} = normalizeChoiceBounds(slot, options.length);
|
|
506
|
+
out.push({
|
|
507
|
+
type: 'choice',
|
|
508
|
+
key: slot.key,
|
|
509
|
+
control: CHOICE_CONTROLS.includes(slot.control) ? slot.control : 'checkbox',
|
|
510
|
+
tagName: slotString(slot.tagName),
|
|
511
|
+
options,
|
|
512
|
+
min,
|
|
513
|
+
max,
|
|
514
|
+
submitLabel: slotStringOr(slot.submitLabel, WEB_POPUP_CHOICE_DEFAULTS.submitLabel),
|
|
515
|
+
submitVariant: BUTTON_VARIANTS.includes(slot.submitVariant) ? slot.submitVariant : 'primary',
|
|
516
|
+
successMessage: slotStringOr(slot.successMessage, WEB_POPUP_CHOICE_DEFAULTS.successMessage, MAX_SLOT_MESSAGE)
|
|
517
|
+
});
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (slot.type === 'rating') {
|
|
521
|
+
const labels = typeof slot.labels === 'object' && slot.labels !== null ? slot.labels : {};
|
|
522
|
+
out.push({
|
|
523
|
+
type: 'rating',
|
|
524
|
+
key: slot.key,
|
|
525
|
+
// A malformed scale renders as stars: five buttons read the same in
|
|
526
|
+
// every language, which an 0..10 row does not.
|
|
527
|
+
scale: RATING_SCALES.includes(slot.scale) ? slot.scale : 'stars',
|
|
528
|
+
labels: {
|
|
529
|
+
low: slotString(labels.low),
|
|
530
|
+
high: slotString(labels.high)
|
|
531
|
+
},
|
|
532
|
+
submitMode: RATING_SUBMIT_MODES.includes(slot.submitMode) ? slot.submitMode : 'instant',
|
|
533
|
+
submitLabel: slotStringOr(slot.submitLabel, WEB_POPUP_RATING_DEFAULTS.submitLabel),
|
|
534
|
+
eventName: slotString(slot.eventName),
|
|
535
|
+
successMessage: slotStringOr(slot.successMessage, WEB_POPUP_RATING_DEFAULTS.successMessage, MAX_SLOT_MESSAGE)
|
|
536
|
+
});
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (slot.type === 'glyph') {
|
|
540
|
+
// An unknown icon name has no geometry to draw — dropped, exactly like
|
|
541
|
+
// an unknown slot type.
|
|
542
|
+
if (!WEB_POPUP_GLYPH_NAMES.includes(slot.name)) continue;
|
|
543
|
+
out.push({
|
|
544
|
+
type: 'glyph',
|
|
545
|
+
key: slot.key,
|
|
546
|
+
name: slot.name,
|
|
547
|
+
size: WEB_POPUP_GLYPH_SIZES.includes(slot.size) ? slot.size : WEB_POPUP_GLYPH_SIZES[0],
|
|
548
|
+
color: typeof slot.color === 'string' && HEX_COLOR.test(slot.color) ? slot.color : ''
|
|
549
|
+
});
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (slot.type === 'email-subscription-form') {
|
|
553
|
+
out.push({
|
|
554
|
+
type: 'email-subscription-form',
|
|
555
|
+
key: slot.key,
|
|
556
|
+
formCode: typeof slot.formCode === 'string' ? slot.formCode : '',
|
|
557
|
+
fields: normalizeFormFields(slot.fields),
|
|
558
|
+
submitLabel: typeof slot.submitLabel === 'string' && slot.submitLabel ? slot.submitLabel : WEB_POPUP_FORM_DEFAULTS.submitLabel,
|
|
559
|
+
submitVariant: BUTTON_VARIANTS.includes(slot.submitVariant) ? slot.submitVariant : 'primary',
|
|
560
|
+
...(typeof slot.privacy === 'object' && slot.privacy !== null && typeof slot.privacy.checkboxLabel === 'string' ? {
|
|
561
|
+
privacy: {
|
|
562
|
+
checkboxLabel: slot.privacy.checkboxLabel,
|
|
563
|
+
url: typeof slot.privacy.url === 'string' ? slot.privacy.url : '',
|
|
564
|
+
linkLabel: typeof slot.privacy.linkLabel === 'string' ? slot.privacy.linkLabel : ''
|
|
565
|
+
}
|
|
566
|
+
} : {}),
|
|
567
|
+
successMessage: typeof slot.successMessage === 'string' && slot.successMessage ? slot.successMessage : WEB_POPUP_FORM_DEFAULTS.successMessage,
|
|
568
|
+
confirmEmailMessage: typeof slot.confirmEmailMessage === 'string' && slot.confirmEmailMessage ? slot.confirmEmailMessage : WEB_POPUP_FORM_DEFAULTS.confirmEmailMessage
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
// Unknown slot type → dropped.
|
|
572
|
+
}
|
|
573
|
+
return out;
|
|
574
|
+
}
|
|
575
|
+
// The zones come from API-writable json — keep only well-formed entries
|
|
576
|
+
// (capped, see above) so the renderer can trust the shape.
|
|
577
|
+
function normalizeContents(raw) {
|
|
578
|
+
if (!Array.isArray(raw)) return [];
|
|
579
|
+
const out = [];
|
|
580
|
+
for (const zone of raw) {
|
|
581
|
+
if (out.length >= MAX_ZONES) break;
|
|
582
|
+
if (typeof zone !== 'object' || zone === null) continue;
|
|
583
|
+
if (typeof zone.key !== 'string' || !zone.key) continue;
|
|
584
|
+
if (!Array.isArray(zone.slots)) continue;
|
|
585
|
+
out.push({
|
|
586
|
+
key: zone.key,
|
|
587
|
+
label: typeof zone.label === 'string' ? zone.label : '',
|
|
588
|
+
slots: normalizeSlots(zone.slots)
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Every subscription form code the popup's form slots reference (unique,
|
|
595
|
+
* empty codes skipped) — what a host records view stats for. Reads the RAW
|
|
596
|
+
* params (tolerant of malformed slots), so it works on parsed-but-unresolved
|
|
597
|
+
* json.
|
|
598
|
+
*/
|
|
599
|
+
export function collectWebPopupFormCodes(params) {
|
|
600
|
+
const out = new Set();
|
|
601
|
+
for (const page of params.pages) {
|
|
602
|
+
if (!Array.isArray(page?.contents)) continue;
|
|
603
|
+
for (const zone of page.contents) {
|
|
604
|
+
if (!Array.isArray(zone?.slots)) continue;
|
|
605
|
+
for (const slot of zone.slots) {
|
|
606
|
+
if (typeof slot !== 'object' || slot === null) continue;
|
|
607
|
+
if (slot.type !== 'email-subscription-form') continue;
|
|
608
|
+
if (typeof slot.formCode === 'string' && slot.formCode) out.add(slot.formCode);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return [...out];
|
|
613
|
+
}
|
|
614
|
+
// Has a 'hide-popup' countdown run out? The HOST asks before showing, at SHOW
|
|
615
|
+
// time; any page counts, and a malformed timer reads as 'not expired'.
|
|
616
|
+
export function webPopupExpired(params, nowMs) {
|
|
617
|
+
for (const page of params.pages) {
|
|
618
|
+
if (!Array.isArray(page?.contents)) continue;
|
|
619
|
+
for (const zone of page.contents) {
|
|
620
|
+
if (!Array.isArray(zone?.slots)) continue;
|
|
621
|
+
for (const slot of zone.slots) {
|
|
622
|
+
if (typeof slot !== 'object' || slot === null) continue;
|
|
623
|
+
if (slot.type !== 'timer' || slot.onExpire !== 'hide-popup') continue;
|
|
624
|
+
if (typeof slot.endTime !== 'string') continue;
|
|
625
|
+
const timezone = typeof slot.timezone === 'string' ? slot.timezone : '';
|
|
626
|
+
const deadline = webPopupWallClockMs(slot.endTime, timezone);
|
|
627
|
+
if (deadline !== null && deadline <= nowMs) return true;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* The popup box-size ceiling px, mirroring the layout tree's px clamp: the
|
|
635
|
+
* json is API-writable and the size lands as inline style on the customer's
|
|
636
|
+
* page. The editor clamps its Width/Height inputs to it too.
|
|
637
|
+
*/
|
|
638
|
+
export const WEB_POPUP_MAX_SIZE_PX = 2000;
|
|
639
|
+
function validSize(value) {
|
|
640
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.min(value, WEB_POPUP_MAX_SIZE_PX) : undefined;
|
|
641
|
+
}
|
|
642
|
+
export function resolveWebPopupPageSize(own, shared, axis) {
|
|
643
|
+
if (own === null) return undefined;
|
|
644
|
+
let resolved;
|
|
645
|
+
if (own === 'stretch') resolved = 'stretch';else resolved = validSize(own) ?? (shared === 'stretch' ? 'stretch' : validSize(shared));
|
|
646
|
+
return axis === 'width' && resolved === 'stretch' ? undefined : resolved;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* The renderable pages: each page carries its own embedded layout tree — no
|
|
650
|
+
* kind catalog at runtime. The page's named values are substituted into the
|
|
651
|
+
* tree first ($key tokens), then the result is validated/normalized
|
|
652
|
+
* (whitelisted props, unknown container types degrade to a Vertical stack) —
|
|
653
|
+
* so substituted values pass the same clamps as literals; pages whose layout
|
|
654
|
+
* isn't a tree at all are dropped. An empty result means the popup can't be
|
|
655
|
+
* rendered. width/height come out FINAL (page override ?? chrome, validated)
|
|
656
|
+
* — the renderer never falls back itself.
|
|
657
|
+
*/
|
|
658
|
+
export function resolveWebPopupPages(params) {
|
|
659
|
+
const out = [];
|
|
660
|
+
for (const page of params.pages) {
|
|
661
|
+
const values = normalizeWebPopupValues(page.values);
|
|
662
|
+
const layout = normalizeWebPopupLayout(substituteWebPopupLayoutValues(page.layout, webPopupValuesMap(values)));
|
|
663
|
+
if (!layout) continue;
|
|
664
|
+
const width = resolveWebPopupPageSize(page.width, params.width, 'width');
|
|
665
|
+
const height = resolveWebPopupPageSize(page.height, params.height, 'height');
|
|
666
|
+
out.push({
|
|
667
|
+
key: page.key,
|
|
668
|
+
layout,
|
|
669
|
+
images: normalizeImages(page.images),
|
|
670
|
+
contents: normalizeContents(page.contents),
|
|
671
|
+
...(width !== undefined ? {
|
|
672
|
+
width
|
|
673
|
+
} : {}),
|
|
674
|
+
...(height !== undefined ? {
|
|
675
|
+
height
|
|
676
|
+
} : {})
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
681
|
+
/** The page shown first: startPage when it exists, else the first page. */
|
|
682
|
+
export function startPageOf(params, pages) {
|
|
683
|
+
return pages.find(p => p.key === params.startPage) ?? pages[0];
|
|
684
|
+
}
|
|
685
|
+
export function placementMaxSize(placement) {
|
|
686
|
+
return `calc(100% - ${placement.offset * 2}px)`;
|
|
687
|
+
}
|