@pushwoosh/websdk-common 0.0.0 → 6.16.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.
@@ -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
+ }