@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.
@@ -0,0 +1,1394 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useColorScheme } from './colorScheme';
4
+ import { webPopupButtonPillStyle, webPopupFormInputStyle, webPopupRatingScores, webPopupTextColor, webPopupSlotGap, webPopupSlotRole, WEB_POPUP_BG_SECTION_FALLBACK, WEB_POPUP_BUTTON_SLOT_PADDING, WEB_POPUP_FONT_STACK, WEB_POPUP_KEYFRAMES, WEB_POPUP_PAGE_TRANSITION_MS, WEB_POPUP_RETRY_ERROR, WEB_POPUP_TEXT_SLOT_PADDING, WEB_POPUP_TEXT_VARIANT_STYLES } from './constants';
5
+ import { WEB_POPUP_GLYPH_PATHS, WEB_POPUP_THUMB_UP_PATH } from './glyphs';
6
+ import { placementMaxSize, resolveWebPopupPages, safeUrl, sanitizeWebPopupHtml, startPageOf, webPopupLinkTargetAttrs } from './helpers';
7
+ import { webPopupBackgroundImageStyle, webPopupContainerStyle, webPopupScrimGradient } from './layout';
8
+ import { resolveWebPopupBoxChrome, resolveWebPopupColors, webPopupColorAlpha, webPopupDarkEnabled, webPopupImageSrc } from './palette';
9
+ import { webPopupTimerLabels, webPopupTimerRemaining } from './timer';
10
+ // Renders a popup from its model by interpreting each page's embedded layout
11
+ // tree — the same node vocabulary the editor's canvas builds from: container
12
+ // nodes become inline-styled grid divs, `Content` leaves render a named
13
+ // zone's slot stack, `Image` leaves an image param. 'live' mode adds the
14
+ // viewport chrome (overlay, placement, animation, close), 'preview' renders
15
+ // just the popup box inline. Only one page renders at a time; buttons with a
16
+ // `page` action switch pages. All styling is inline (shadow-DOM friendly, no
17
+ // CSS files).
18
+ const ANIMATION_STYLE = {
19
+ none: undefined,
20
+ fade: 'pw-vpopup-fade 0.35s ease-out backwards',
21
+ slide: 'pw-vpopup-slide 0.35s ease-out backwards'
22
+ };
23
+ function TextSlotView({
24
+ slot,
25
+ align,
26
+ inverse,
27
+ colors
28
+ }) {
29
+ const style = {
30
+ ...WEB_POPUP_TEXT_VARIANT_STYLES[slot.variant],
31
+ textAlign: slot.align ?? align
32
+ };
33
+ const color = webPopupTextColor(slot.variant, colors, inverse);
34
+ if (color) style.color = color;
35
+ // The html comes from the editor's rich-text slots, but the json is
36
+ // API-writable — sanitize on render (inline tags only, safe link hrefs).
37
+ return _jsx("div", {
38
+ style: style,
39
+ dangerouslySetInnerHTML: {
40
+ __html: sanitizeWebPopupHtml(slot.html)
41
+ }
42
+ });
43
+ }
44
+ function ButtonSlotView({
45
+ slot,
46
+ colors,
47
+ onRequestClose,
48
+ onPageChange,
49
+ onRequestSubscribe
50
+ }) {
51
+ const [askPhase, setAskPhase] = useState('idle');
52
+ const pill = webPopupButtonPillStyle(slot.variant, colors);
53
+ const wrapper = {};
54
+ if (slot.action.type === 'close') {
55
+ return _jsx("div", {
56
+ style: wrapper,
57
+ children: _jsx("button", {
58
+ type: "button",
59
+ style: pill,
60
+ onClick: onRequestClose,
61
+ children: slot.label
62
+ })
63
+ });
64
+ }
65
+ if (slot.action.type === 'page') {
66
+ const target = slot.action.page;
67
+ return _jsx("div", {
68
+ style: wrapper,
69
+ children: _jsx("button", {
70
+ type: "button",
71
+ style: pill,
72
+ onClick: () => onPageChange(target),
73
+ children: slot.label
74
+ })
75
+ });
76
+ }
77
+ if (slot.action.type === 'subscribe') {
78
+ // 'asking' guards the double click (one prompt, one registerDevice);
79
+ // 'denied' stays disabled because the browser grants no second prompt.
80
+ const spent = askPhase !== 'idle';
81
+ const ask = () => {
82
+ if (!onRequestSubscribe || spent) return;
83
+ setAskPhase('asking');
84
+ onRequestSubscribe().then(result => {
85
+ // Granted: the CTA is spent, so the popup goes away — that IS the
86
+ // feedback. Dismissed ('default') or failed: the pill comes back.
87
+ if (result === 'granted') {
88
+ onRequestClose?.();
89
+ return;
90
+ }
91
+ setAskPhase(result === 'denied' ? 'denied' : 'idle');
92
+ }).catch(() => setAskPhase('idle'));
93
+ };
94
+ return _jsx("div", {
95
+ style: wrapper,
96
+ children: _jsx("button", {
97
+ type: "button",
98
+ disabled: spent,
99
+ style: {
100
+ ...pill,
101
+ ...(spent ? {
102
+ opacity: 0.7
103
+ } : {})
104
+ },
105
+ onClick: ask,
106
+ children: slot.label
107
+ })
108
+ });
109
+ }
110
+ const href = safeUrl(slot.action.url);
111
+ // No (safe) URL yet: an inert pill — not an '#' anchor opening a blank tab.
112
+ if (!href) {
113
+ return _jsx("div", {
114
+ style: wrapper,
115
+ children: _jsx("button", {
116
+ type: "button",
117
+ style: pill,
118
+ children: slot.label
119
+ })
120
+ });
121
+ }
122
+ return _jsx("div", {
123
+ style: wrapper,
124
+ children: _jsx("a", {
125
+ href: href,
126
+ ...webPopupLinkTargetAttrs(slot.action.target),
127
+ style: pill,
128
+ children: slot.label
129
+ })
130
+ });
131
+ }
132
+ // The copy a submitted slot leaves behind, in the zone's own body style.
133
+ function SlotMessageView({
134
+ message,
135
+ align,
136
+ inverse,
137
+ colors
138
+ }) {
139
+ const color = webPopupTextColor('body', colors, inverse);
140
+ return _jsx("div", {
141
+ style: {
142
+ ...WEB_POPUP_TEXT_VARIANT_STYLES.body,
143
+ padding: WEB_POPUP_TEXT_SLOT_PADDING,
144
+ textAlign: align,
145
+ ...(color ? {
146
+ color
147
+ } : {})
148
+ },
149
+ children: message
150
+ });
151
+ }
152
+ const FORM_EMAIL_PATTERN = /^\S+@\S+\.\S+$/;
153
+ const FORM_INPUT_TYPES = {
154
+ email: 'email',
155
+ phone: 'tel'
156
+ };
157
+ const FORM_AUTOCOMPLETE = {
158
+ email: 'email',
159
+ firstName: 'given-name',
160
+ lastName: 'family-name',
161
+ phone: 'tel'
162
+ };
163
+ // The privacy-check error renders under the checkbox row; a field key can
164
+ // never collide with it (normalized keys are non-empty slot-local ids).
165
+ const PRIVACY_ERROR_KEY = '__privacy';
166
+ /**
167
+ * An email-subscription form slot: the fields, the consent row and the
168
+ * submit pill, all popup-styled. Local state machine: idle → submitting →
169
+ * success/confirm (the form swaps to the slot's message) or error (the form
170
+ * stays, a generic retryable note appears). Without an onSubmitForm handler
171
+ * or a bound formCode the form is inert — inputs type, submit does nothing —
172
+ * which is exactly what previews and the editor canvas want.
173
+ */
174
+ function FormSlotView({
175
+ slot,
176
+ align,
177
+ inverse,
178
+ colors,
179
+ onSubmitForm
180
+ }) {
181
+ const [values, setValues] = useState({});
182
+ const [policyAccepted, setPolicyAccepted] = useState(false);
183
+ const [focusedKey, setFocusedKey] = useState(null);
184
+ const [errors, setErrors] = useState({});
185
+ const [phase, setPhase] = useState('idle');
186
+ const captionColor = webPopupTextColor('caption', colors, inverse);
187
+ const captionStyle = {
188
+ ...WEB_POPUP_TEXT_VARIANT_STYLES.caption,
189
+ ...(captionColor ? {
190
+ color: captionColor
191
+ } : {})
192
+ };
193
+ const errorStyle = {
194
+ fontSize: 12,
195
+ lineHeight: 1.4,
196
+ color: colors.error,
197
+ paddingTop: 2
198
+ };
199
+ if (phase === 'success' || phase === 'confirm') {
200
+ return _jsx(SlotMessageView, {
201
+ message: phase === 'confirm' ? slot.confirmEmailMessage : slot.successMessage,
202
+ align: align,
203
+ inverse: inverse,
204
+ colors: colors
205
+ });
206
+ }
207
+ const setFieldValue = (key, value) => {
208
+ setValues(prev => ({
209
+ ...prev,
210
+ [key]: value
211
+ }));
212
+ setErrors(prev => prev[key] ? Object.fromEntries(Object.entries(prev).filter(([k]) => k !== key)) : prev);
213
+ };
214
+ const submit = () => {
215
+ if (!onSubmitForm || !slot.formCode || phase === 'submitting') return;
216
+ const found = {};
217
+ for (const field of slot.fields) {
218
+ const value = (values[field.key] ?? '').trim();
219
+ if (field.required && !value) found[field.key] = 'Required';else if (field.kind === 'email' && value && !FORM_EMAIL_PATTERN.test(value)) {
220
+ found[field.key] = 'Enter a valid email';
221
+ }
222
+ }
223
+ if (slot.privacy && !policyAccepted) found[PRIVACY_ERROR_KEY] = 'Please accept the privacy policy';
224
+ setErrors(found);
225
+ if (Object.keys(found).length > 0) return;
226
+ const payload = {
227
+ email: '',
228
+ policyAccepted: slot.privacy ? policyAccepted : true
229
+ };
230
+ const customFields = {};
231
+ for (const field of slot.fields) {
232
+ const value = (values[field.key] ?? '').trim();
233
+ if (!value) continue;
234
+ if (field.kind === 'email') payload.email = payload.email || value;else if (field.kind === 'firstName') payload.firstName = value;else if (field.kind === 'lastName') payload.lastName = value;else if (field.kind === 'phone') payload.phone = value;else customFields[field.tag || field.key] = value;
235
+ }
236
+ if (Object.keys(customFields).length > 0) payload.customFields = customFields;
237
+ setPhase('submitting');
238
+ // Promise.resolve wrapper: a host callback that throws SYNCHRONOUSLY
239
+ // (before returning its promise) must land in the error phase too — a
240
+ // bare .then(ok, fail) would let the throw escape and strand the form
241
+ // in 'submitting' with no way back.
242
+ Promise.resolve().then(() => onSubmitForm(slot.formCode, payload)).then(result => setPhase(result === 'confirm-email' ? 'confirm' : 'success'), () => setPhase('error'));
243
+ };
244
+ const submitting = phase === 'submitting';
245
+ const privacyHref = slot.privacy ? safeUrl(slot.privacy.url) : undefined;
246
+ const pill = {
247
+ ...webPopupButtonPillStyle(slot.submitVariant, colors),
248
+ ...(submitting ? {
249
+ opacity: 0.6,
250
+ cursor: 'default'
251
+ } : {})
252
+ };
253
+ return _jsxs("form", {
254
+ noValidate: true,
255
+ style: {
256
+ margin: 0,
257
+ textAlign: 'left'
258
+ },
259
+ onSubmit: event => {
260
+ event.preventDefault();
261
+ submit();
262
+ },
263
+ children: [slot.fields.map(field => {
264
+ const error = errors[field.key];
265
+ let borderColor;
266
+ if (error) borderColor = colors.error;else if (focusedKey === field.key) borderColor = colors.accent;
267
+ return _jsxs("div", {
268
+ style: {
269
+ padding: WEB_POPUP_TEXT_SLOT_PADDING
270
+ },
271
+ children: [field.label && _jsx("div", {
272
+ style: {
273
+ ...captionStyle,
274
+ paddingBottom: 4
275
+ },
276
+ children: field.label
277
+ }), _jsx("input", {
278
+ type: FORM_INPUT_TYPES[field.kind] ?? 'text',
279
+ autoComplete: FORM_AUTOCOMPLETE[field.kind],
280
+ placeholder: field.placeholder || undefined,
281
+ value: values[field.key] ?? '',
282
+ disabled: submitting,
283
+ style: webPopupFormInputStyle(colors, borderColor),
284
+ onFocus: () => setFocusedKey(field.key),
285
+ onBlur: () => setFocusedKey(key => key === field.key ? null : key),
286
+ onChange: event => setFieldValue(field.key, event.currentTarget.value)
287
+ }), error && _jsx("div", {
288
+ style: errorStyle,
289
+ children: error
290
+ })]
291
+ }, field.key);
292
+ }), slot.privacy && _jsxs("div", {
293
+ style: {
294
+ padding: WEB_POPUP_TEXT_SLOT_PADDING
295
+ },
296
+ children: [_jsxs("label", {
297
+ style: {
298
+ ...captionStyle,
299
+ display: 'flex',
300
+ alignItems: 'flex-start',
301
+ gap: 8,
302
+ cursor: 'pointer'
303
+ },
304
+ children: [_jsx("input", {
305
+ type: "checkbox",
306
+ checked: policyAccepted,
307
+ disabled: submitting,
308
+ style: {
309
+ margin: '2px 0 0',
310
+ flexShrink: 0
311
+ },
312
+ onChange: event => {
313
+ setPolicyAccepted(event.currentTarget.checked);
314
+ setErrors(prev => prev[PRIVACY_ERROR_KEY] ? Object.fromEntries(Object.entries(prev).filter(([k]) => k !== PRIVACY_ERROR_KEY)) : prev);
315
+ }
316
+ }), _jsxs("span", {
317
+ children: [slot.privacy.checkboxLabel, privacyHref && _jsxs(_Fragment, {
318
+ children: [' ', _jsx("a", {
319
+ href: privacyHref,
320
+ ...webPopupLinkTargetAttrs(undefined),
321
+ style: {
322
+ color: colors.accent
323
+ },
324
+ children: slot.privacy.linkLabel || slot.privacy.url
325
+ })]
326
+ })]
327
+ })]
328
+ }), errors[PRIVACY_ERROR_KEY] && _jsx("div", {
329
+ style: errorStyle,
330
+ children: errors[PRIVACY_ERROR_KEY]
331
+ })]
332
+ }), _jsx("div", {
333
+ style: {
334
+ padding: WEB_POPUP_BUTTON_SLOT_PADDING
335
+ },
336
+ children: _jsx("button", {
337
+ type: "submit",
338
+ disabled: submitting,
339
+ style: pill,
340
+ children: slot.submitLabel
341
+ })
342
+ }), phase === 'error' && _jsx("div", {
343
+ style: {
344
+ ...errorStyle,
345
+ textAlign: align
346
+ },
347
+ children: WEB_POPUP_RETRY_ERROR
348
+ })]
349
+ });
350
+ }
351
+ // The phase-2 slots. The two that submit follow the form slot's rules exactly:
352
+ // inert without a callback, idle → submitting → success/error, then its copy.
353
+ // One cell of the countdown: the digits over their unit label.
354
+ function TimerCell({
355
+ value,
356
+ label,
357
+ digitStyle,
358
+ labelStyle
359
+ }) {
360
+ return _jsxs("div", {
361
+ style: {
362
+ minWidth: 44
363
+ },
364
+ children: [_jsx("div", {
365
+ style: digitStyle,
366
+ children: String(value).padStart(2, '0')
367
+ }), label ? _jsx("div", {
368
+ style: labelStyle,
369
+ children: label
370
+ }) : null]
371
+ });
372
+ }
373
+ function TimerSlotView({
374
+ slot,
375
+ align,
376
+ inverse,
377
+ colors,
378
+ mode,
379
+ onRequestClose,
380
+ onPageChange
381
+ }) {
382
+ const [nowMs, setNowMs] = useState(() => Date.now());
383
+ const remaining = webPopupTimerRemaining(slot.endTime, slot.timezone, nowMs);
384
+ const expired = remaining.total === 0;
385
+ // 'preview' draws a still frame: a gallery is many popups at once, and every
386
+ // one of them would tick. A finished countdown stops on its own.
387
+ useEffect(() => {
388
+ if (mode !== 'live' || expired) return undefined;
389
+ const id = setInterval(() => setNowMs(Date.now()), 1000);
390
+ return () => clearInterval(id);
391
+ }, [mode, expired, slot.endTime, slot.timezone]);
392
+ // Past the deadline, the slot does what it was told. 'hide-popup' gets here
393
+ // only for a popup left open ACROSS its deadline (the host gates the rest).
394
+ useEffect(() => {
395
+ if (mode !== 'live' || !expired) return;
396
+ if (slot.onExpire === 'hide-popup') onRequestClose?.();else if (slot.onExpire === 'page' && slot.expirePage) onPageChange(slot.expirePage);
397
+ }, [mode, expired, slot.onExpire, slot.expirePage, onRequestClose, onPageChange]);
398
+ const digitColor = webPopupTextColor('h2', colors, inverse);
399
+ const labelColor = webPopupTextColor('caption', colors, inverse);
400
+ const digitStyle = {
401
+ fontSize: 28,
402
+ fontWeight: 700,
403
+ lineHeight: 1.2,
404
+ // Tabular figures: without them every second reflows the row.
405
+ fontVariantNumeric: 'tabular-nums',
406
+ ...(digitColor ? {
407
+ color: digitColor
408
+ } : {})
409
+ };
410
+ const labelStyle = {
411
+ ...WEB_POPUP_TEXT_VARIANT_STYLES.caption,
412
+ ...(labelColor ? {
413
+ color: labelColor
414
+ } : {})
415
+ };
416
+ const labels = webPopupTimerLabels(slot.language);
417
+ const cells = [{
418
+ value: remaining.days,
419
+ label: labels.days
420
+ }, {
421
+ value: remaining.hours,
422
+ label: labels.hours
423
+ }, {
424
+ value: remaining.minutes,
425
+ label: labels.minutes
426
+ }, {
427
+ value: remaining.seconds,
428
+ label: labels.seconds
429
+ }];
430
+ return _jsx("div", {
431
+ style: {
432
+ padding: WEB_POPUP_TEXT_SLOT_PADDING,
433
+ textAlign: align
434
+ },
435
+ children: _jsx("div", {
436
+ style: {
437
+ display: 'inline-flex',
438
+ gap: 12,
439
+ textAlign: 'center'
440
+ },
441
+ children: cells.map(cell => _jsx(TimerCell, {
442
+ value: cell.value,
443
+ label: slot.showLabels ? cell.label : '',
444
+ digitStyle: digitStyle,
445
+ labelStyle: labelStyle
446
+ }, cell.label || cell.value))
447
+ })
448
+ });
449
+ }
450
+ function ChoiceSlotView({
451
+ slot,
452
+ align,
453
+ inverse,
454
+ colors,
455
+ onSubmitChoice
456
+ }) {
457
+ const [picked, setPicked] = useState([]);
458
+ const [phase, setPhase] = useState('idle');
459
+ const [notice, setNotice] = useState('');
460
+ if (phase === 'success') {
461
+ return _jsx(SlotMessageView, {
462
+ message: slot.successMessage,
463
+ align: align,
464
+ inverse: inverse,
465
+ colors: colors
466
+ });
467
+ }
468
+ const toggle = value => {
469
+ setNotice('');
470
+ setPicked(prev => {
471
+ if (prev.includes(value)) return prev.filter(entry => entry !== value);
472
+ // At the ceiling the extra tap is refused with a reason rather than
473
+ // silently dropping the oldest pick.
474
+ if (slot.max > 0 && prev.length >= slot.max) {
475
+ setNotice(`Pick up to ${slot.max}`);
476
+ return prev;
477
+ }
478
+ return [...prev, value];
479
+ });
480
+ };
481
+ const submit = () => {
482
+ if (!onSubmitChoice || !slot.tagName || phase === 'submitting') return;
483
+ // An empty submit would CLEAR the tag — never what a "save" button means.
484
+ const floor = Math.max(slot.min, 1);
485
+ if (picked.length < floor) {
486
+ setNotice(floor === 1 ? 'Pick at least one' : `Pick at least ${floor}`);
487
+ return;
488
+ }
489
+ const values = slot.options.filter(option => picked.includes(option.value)).map(option => option.tagValue || option.value);
490
+ setNotice('');
491
+ setPhase('submitting');
492
+ // Promise.resolve wrapper: a host callback that throws SYNCHRONOUSLY must
493
+ // land in the error phase too, not escape and strand the slot.
494
+ Promise.resolve().then(() => onSubmitChoice({
495
+ slotKey: slot.key,
496
+ tagName: slot.tagName,
497
+ values
498
+ })).then(() => setPhase('success'), () => setPhase('error'));
499
+ };
500
+ const submitting = phase === 'submitting';
501
+ const textColor = webPopupTextColor('body', colors, inverse);
502
+ const optionStyle = {
503
+ ...WEB_POPUP_TEXT_VARIANT_STYLES.body,
504
+ ...(textColor ? {
505
+ color: textColor
506
+ } : {})
507
+ };
508
+ const chip = selected => ({
509
+ padding: '6px 14px',
510
+ borderRadius: 999,
511
+ border: `1px solid ${colors.accent}`,
512
+ backgroundColor: selected ? colors.accent : 'transparent',
513
+ color: selected ? colors.onAccent : colors.accent,
514
+ fontFamily: 'inherit',
515
+ fontSize: 14,
516
+ lineHeight: 1.4,
517
+ cursor: 'pointer'
518
+ });
519
+ return _jsxs("div", {
520
+ style: {
521
+ padding: WEB_POPUP_TEXT_SLOT_PADDING
522
+ },
523
+ children: [slot.control === 'chips' ? _jsx("div", {
524
+ style: {
525
+ display: 'flex',
526
+ flexWrap: 'wrap',
527
+ gap: 8,
528
+ justifyContent: align === 'center' ? 'center' : 'flex-start'
529
+ },
530
+ children: slot.options.map(option => _jsx("button", {
531
+ type: "button",
532
+ "aria-pressed": picked.includes(option.value),
533
+ style: chip(picked.includes(option.value)),
534
+ onClick: () => toggle(option.value),
535
+ children: option.label
536
+ }, option.value))
537
+ }) : _jsx("div", {
538
+ style: {
539
+ display: 'grid',
540
+ gap: 6
541
+ },
542
+ children: slot.options.map(option => _jsxs("label", {
543
+ style: {
544
+ ...optionStyle,
545
+ display: 'flex',
546
+ alignItems: 'center',
547
+ gap: 8,
548
+ cursor: 'pointer'
549
+ },
550
+ children: [_jsx("input", {
551
+ type: "checkbox",
552
+ checked: picked.includes(option.value),
553
+ onChange: () => toggle(option.value),
554
+ style: {
555
+ accentColor: colors.accent,
556
+ width: 16,
557
+ height: 16,
558
+ margin: 0
559
+ }
560
+ }), option.label]
561
+ }, option.value))
562
+ }), notice ? _jsx("div", {
563
+ style: {
564
+ ...WEB_POPUP_TEXT_VARIANT_STYLES.caption,
565
+ color: colors.error,
566
+ paddingTop: 4
567
+ },
568
+ children: notice
569
+ }) : null, phase === 'error' ? _jsx("div", {
570
+ style: {
571
+ ...WEB_POPUP_TEXT_VARIANT_STYLES.caption,
572
+ color: colors.error,
573
+ paddingTop: 4
574
+ },
575
+ children: WEB_POPUP_RETRY_ERROR
576
+ }) : null, _jsx("div", {
577
+ style: {
578
+ padding: WEB_POPUP_BUTTON_SLOT_PADDING
579
+ },
580
+ children: _jsx("button", {
581
+ type: "button",
582
+ style: {
583
+ ...webPopupButtonPillStyle(slot.submitVariant, colors),
584
+ ...(submitting ? {
585
+ opacity: 0.7
586
+ } : {})
587
+ },
588
+ onClick: submit,
589
+ children: slot.submitLabel
590
+ })
591
+ })]
592
+ });
593
+ }
594
+ // One rating button: a number for nps, a star for stars, a thumb for thumbs.
595
+ function RatingButton({
596
+ score,
597
+ scale,
598
+ selected,
599
+ colors,
600
+ onPick
601
+ }) {
602
+ const base = {
603
+ display: 'inline-flex',
604
+ alignItems: 'center',
605
+ justifyContent: 'center',
606
+ padding: 0,
607
+ border: `1px solid ${colors.accent}`,
608
+ backgroundColor: selected ? colors.accent : 'transparent',
609
+ color: selected ? colors.onAccent : colors.accent,
610
+ fontFamily: 'inherit',
611
+ fontSize: 14,
612
+ lineHeight: 1,
613
+ cursor: 'pointer'
614
+ };
615
+ if (scale === 'nps') {
616
+ return _jsx("button", {
617
+ type: "button",
618
+ "aria-label": `Rate ${score}`,
619
+ "aria-pressed": selected,
620
+ style: {
621
+ ...base,
622
+ width: 30,
623
+ height: 32,
624
+ borderRadius: 6
625
+ },
626
+ onClick: onPick,
627
+ children: score
628
+ });
629
+ }
630
+ const thumbs = scale === 'thumbs';
631
+ // Icon-only buttons need a name of their own; the nps row says it in text.
632
+ let label = `Rate ${score}`;
633
+ if (thumbs) label = score === 1 ? 'Thumbs up' : 'Thumbs down';
634
+ return _jsx("button", {
635
+ type: "button",
636
+ "aria-label": label,
637
+ "aria-pressed": selected,
638
+ style: {
639
+ ...base,
640
+ width: thumbs ? 44 : 34,
641
+ height: thumbs ? 40 : 34,
642
+ borderRadius: thumbs ? 8 : 6
643
+ },
644
+ onClick: onPick,
645
+ children: _jsx("svg", {
646
+ width: thumbs ? 22 : 20,
647
+ height: thumbs ? 22 : 20,
648
+ viewBox: "0 0 24 24",
649
+ fill: "none",
650
+ stroke: "currentColor",
651
+ strokeWidth: 2,
652
+ strokeLinecap: "round",
653
+ strokeLinejoin: "round",
654
+ "aria-hidden": "true",
655
+ children: _jsx("path", {
656
+ d: thumbs ? WEB_POPUP_THUMB_UP_PATH : WEB_POPUP_GLYPH_PATHS.star,
657
+ ...(thumbs && score === 0 ? {
658
+ transform: 'rotate(180 12 12)'
659
+ } : {})
660
+ })
661
+ })
662
+ });
663
+ }
664
+ function RatingSlotView({
665
+ slot,
666
+ align,
667
+ inverse,
668
+ colors,
669
+ onSubmitRating
670
+ }) {
671
+ const [score, setScore] = useState(null);
672
+ const [phase, setPhase] = useState('idle');
673
+ if (phase === 'success') {
674
+ return _jsx(SlotMessageView, {
675
+ message: slot.successMessage,
676
+ align: align,
677
+ inverse: inverse,
678
+ colors: colors
679
+ });
680
+ }
681
+ const send = value => {
682
+ if (!onSubmitRating || !slot.eventName || phase === 'submitting') return;
683
+ setPhase('submitting');
684
+ // See ChoiceSlotView: a synchronous throw must land in 'error' too.
685
+ Promise.resolve().then(() => onSubmitRating({
686
+ slotKey: slot.key,
687
+ eventName: slot.eventName,
688
+ score: value,
689
+ scale: slot.scale
690
+ })).then(() => setPhase('success'), () => setPhase('error'));
691
+ };
692
+ const pick = value => {
693
+ // Same guard as `send`: otherwise a tap during an in-flight submit moves the
694
+ // highlight while the host keeps the score that was actually sent.
695
+ if (phase === 'submitting') return;
696
+ setScore(value);
697
+ if (slot.submitMode === 'instant') send(value);
698
+ };
699
+ const scores = webPopupRatingScores(slot.scale);
700
+ const labelColor = webPopupTextColor('caption', colors, inverse);
701
+ const edgeLabelStyle = {
702
+ ...WEB_POPUP_TEXT_VARIANT_STYLES.caption,
703
+ ...(labelColor ? {
704
+ color: labelColor
705
+ } : {})
706
+ };
707
+ // A star scale fills every button up to the pick — the row IS the score.
708
+ const isSelected = value => slot.scale === 'stars' ? score !== null && value <= score : score === value;
709
+ return _jsxs("div", {
710
+ style: {
711
+ padding: WEB_POPUP_TEXT_SLOT_PADDING
712
+ },
713
+ children: [_jsx("div", {
714
+ style: {
715
+ display: 'flex',
716
+ flexWrap: 'wrap',
717
+ gap: 6,
718
+ justifyContent: align === 'center' ? 'center' : 'flex-start'
719
+ },
720
+ children: scores.map(value => _jsx(RatingButton, {
721
+ score: value,
722
+ scale: slot.scale,
723
+ selected: isSelected(value),
724
+ colors: colors,
725
+ onPick: () => pick(value)
726
+ }, value))
727
+ }), slot.labels.low || slot.labels.high ? _jsxs("div", {
728
+ style: {
729
+ display: 'flex',
730
+ justifyContent: 'space-between',
731
+ gap: 8,
732
+ paddingTop: 4
733
+ },
734
+ children: [_jsx("span", {
735
+ style: edgeLabelStyle,
736
+ children: slot.labels.low
737
+ }), _jsx("span", {
738
+ style: edgeLabelStyle,
739
+ children: slot.labels.high
740
+ })]
741
+ }) : null, phase === 'error' ? _jsx("div", {
742
+ style: {
743
+ ...WEB_POPUP_TEXT_VARIANT_STYLES.caption,
744
+ color: colors.error,
745
+ paddingTop: 4
746
+ },
747
+ children: WEB_POPUP_RETRY_ERROR
748
+ }) : null, slot.submitMode === 'button' ? _jsx("div", {
749
+ style: {
750
+ padding: WEB_POPUP_BUTTON_SLOT_PADDING
751
+ },
752
+ children: _jsx("button", {
753
+ type: "button",
754
+ style: {
755
+ ...webPopupButtonPillStyle('primary', colors),
756
+ ...(phase === 'submitting' ? {
757
+ opacity: 0.7
758
+ } : {})
759
+ },
760
+ onClick: () => score === null ? undefined : send(score),
761
+ children: slot.submitLabel
762
+ })
763
+ }) : null]
764
+ });
765
+ }
766
+ function GlyphSlotView({
767
+ slot,
768
+ align,
769
+ inverse,
770
+ colors
771
+ }) {
772
+ // No authored color: the accent, or the inverse copy color over a dark image
773
+ // (where the accent can be the one thing that doesn't read).
774
+ const color = slot.color || (inverse ? colors.textInverse : colors.accent);
775
+ return _jsx("div", {
776
+ style: {
777
+ padding: WEB_POPUP_TEXT_SLOT_PADDING,
778
+ textAlign: align,
779
+ lineHeight: 0
780
+ },
781
+ children: _jsx("svg", {
782
+ width: slot.size,
783
+ height: slot.size,
784
+ viewBox: "0 0 24 24",
785
+ fill: "none",
786
+ stroke: color,
787
+ strokeWidth: 2,
788
+ strokeLinecap: "round",
789
+ strokeLinejoin: "round",
790
+ "aria-hidden": "true",
791
+ style: {
792
+ display: 'inline-block'
793
+ },
794
+ children: _jsx("path", {
795
+ d: WEB_POPUP_GLYPH_PATHS[slot.name]
796
+ })
797
+ })
798
+ });
799
+ }
800
+ // A switch, not a chain of `&&`: the compiler is what notices a slot type with
801
+ // no view. Exported so the editor canvas draws these slots with it, not a copy.
802
+ export function WebPopupSlotView({
803
+ slot,
804
+ align,
805
+ inverse,
806
+ colors,
807
+ mode = 'live',
808
+ onRequestClose,
809
+ onPageChange,
810
+ onSubmitForm,
811
+ onSubmitChoice,
812
+ onSubmitRating,
813
+ onRequestSubscribe
814
+ }) {
815
+ // A page jump nobody handles is a no-op, not a crash — the editor canvas
816
+ // and the previews mount this without any navigation of their own.
817
+ const pageChange = onPageChange ?? (() => {});
818
+ switch (slot.type) {
819
+ case 'text':
820
+ return _jsx(TextSlotView, {
821
+ slot: slot,
822
+ align: align,
823
+ inverse: inverse,
824
+ colors: colors
825
+ });
826
+ case 'button':
827
+ return _jsx(ButtonSlotView, {
828
+ slot: slot,
829
+ colors: colors,
830
+ onRequestClose: onRequestClose,
831
+ onPageChange: pageChange,
832
+ onRequestSubscribe: onRequestSubscribe
833
+ });
834
+ case 'email-subscription-form':
835
+ return _jsx(FormSlotView, {
836
+ slot: slot,
837
+ align: align,
838
+ inverse: inverse,
839
+ colors: colors,
840
+ onSubmitForm: onSubmitForm
841
+ });
842
+ case 'timer':
843
+ return _jsx(TimerSlotView, {
844
+ slot: slot,
845
+ align: align,
846
+ inverse: inverse,
847
+ colors: colors,
848
+ mode: mode,
849
+ onRequestClose: onRequestClose,
850
+ onPageChange: pageChange
851
+ });
852
+ case 'choice':
853
+ return _jsx(ChoiceSlotView, {
854
+ slot: slot,
855
+ align: align,
856
+ inverse: inverse,
857
+ colors: colors,
858
+ onSubmitChoice: onSubmitChoice
859
+ });
860
+ case 'rating':
861
+ return _jsx(RatingSlotView, {
862
+ slot: slot,
863
+ align: align,
864
+ inverse: inverse,
865
+ colors: colors,
866
+ onSubmitRating: onSubmitRating
867
+ });
868
+ case 'glyph':
869
+ return _jsx(GlyphSlotView, {
870
+ slot: slot,
871
+ align: align,
872
+ inverse: inverse,
873
+ colors: colors
874
+ });
875
+ }
876
+ }
877
+ // A Content leaf: the named zone's slots (texts, buttons and forms in order)
878
+ // as a grid stack. An unknown zone key renders as an empty spot, not a crash.
879
+ function ContentNodeView({
880
+ node,
881
+ page,
882
+ colors,
883
+ mode,
884
+ leaf,
885
+ onRequestClose,
886
+ onPageChange,
887
+ onSubmitForm,
888
+ onSubmitChoice,
889
+ onSubmitRating,
890
+ onRequestSubscribe
891
+ }) {
892
+ const zone = page.contents.find(z => z.key === node.props.content);
893
+ if (!zone) return null;
894
+ if (leaf?.renderZone) {
895
+ return _jsx("div", {
896
+ style: {
897
+ minWidth: 0
898
+ },
899
+ children: leaf.renderZone(zone, node)
900
+ });
901
+ }
902
+ const align = node.props.align ?? 'left';
903
+ const inverse = node.props.tone === 'inverse';
904
+ return _jsx("div", {
905
+ style: {
906
+ minWidth: 0
907
+ },
908
+ children: zone.slots.map((slot, index) => {
909
+ const previous = zone.slots[index - 1];
910
+ const marginTop = previous ? webPopupSlotGap(webPopupSlotRole(previous), webPopupSlotRole(slot)) : 0;
911
+ return _jsx("div", {
912
+ style: marginTop ? {
913
+ marginTop
914
+ } : undefined,
915
+ children: _jsx(WebPopupSlotView, {
916
+ slot: slot,
917
+ align: align,
918
+ inverse: inverse,
919
+ colors: colors,
920
+ mode: mode,
921
+ onRequestClose: onRequestClose,
922
+ onPageChange: onPageChange,
923
+ onSubmitForm: onSubmitForm,
924
+ onSubmitChoice: onSubmitChoice,
925
+ onSubmitRating: onSubmitRating,
926
+ onRequestSubscribe: onRequestSubscribe
927
+ })
928
+ }, slot.key);
929
+ })
930
+ });
931
+ }
932
+ // The playable url of a media param, or undefined when it has no video.
933
+ function mediaVideoSrc(image) {
934
+ return image?.video ? safeUrl(image.video.src) : undefined;
935
+ }
936
+ // A media param's video: autoplay only muted, playsInline to keep iOS out of
937
+ // fullscreen, and in 'preview' it holds on the poster instead of streaming.
938
+ function MediaVideo({
939
+ image,
940
+ poster,
941
+ src,
942
+ style,
943
+ mode
944
+ }) {
945
+ return _jsx("video", {
946
+ src: src,
947
+ poster: poster || undefined,
948
+ style: style,
949
+ autoPlay: mode === 'live',
950
+ muted: true,
951
+ playsInline: true,
952
+ preload: "metadata",
953
+ loop: image.video?.loop !== false,
954
+ controls: image.video?.controls === true
955
+ });
956
+ }
957
+ function ImageNodeView({
958
+ node,
959
+ page,
960
+ leaf,
961
+ mode,
962
+ scheme
963
+ }) {
964
+ const image = page.images.find(i => i.key === node.props.image);
965
+ if (!image) return null;
966
+ // The override sees srcless params too — an editor wants a placeholder
967
+ // where the runtime shows nothing.
968
+ if (leaf?.renderImage) return _jsx(_Fragment, {
969
+ children: leaf.renderImage(image, node)
970
+ });
971
+ const video = mediaVideoSrc(image);
972
+ const src = webPopupImageSrc(image, scheme);
973
+ if (!src && !video) return null;
974
+ const style = {
975
+ display: 'block',
976
+ width: '100%',
977
+ minWidth: 0
978
+ };
979
+ if (image.fit) {
980
+ style.height = '100%';
981
+ style.objectFit = image.fit;
982
+ } else {
983
+ style.height = 'auto';
984
+ }
985
+ if (image.blur) style.filter = `blur(${image.blur}px)`;
986
+ if (video) {
987
+ return _jsx(MediaVideo, {
988
+ image: image,
989
+ poster: src,
990
+ src: video,
991
+ style: style,
992
+ mode: mode
993
+ });
994
+ }
995
+ return _jsx("img", {
996
+ src: src,
997
+ alt: "",
998
+ style: style
999
+ });
1000
+ }
1001
+ // Both layered backgrounds (blur, video) share this shell: the color/poster
1002
+ // paints on an outer grid box, the content rides an inner layer covering it.
1003
+ function LayeredBackgroundBox({
1004
+ style,
1005
+ outer,
1006
+ inner,
1007
+ layer,
1008
+ children
1009
+ }) {
1010
+ const {
1011
+ backgroundColor,
1012
+ ...rest
1013
+ } = style;
1014
+ return _jsxs("div", {
1015
+ style: {
1016
+ display: 'grid',
1017
+ minWidth: 0,
1018
+ backgroundColor,
1019
+ ...outer
1020
+ },
1021
+ children: [layer, _jsx("div", {
1022
+ style: {
1023
+ ...rest,
1024
+ ...inner
1025
+ },
1026
+ children: children
1027
+ })]
1028
+ });
1029
+ }
1030
+ // The tree interpreter: containers → grid divs, leaves → zone/image views.
1031
+ function LayoutNodeView({
1032
+ node,
1033
+ page,
1034
+ colors,
1035
+ mode,
1036
+ scheme,
1037
+ leaf,
1038
+ onRequestClose,
1039
+ onPageChange,
1040
+ onSubmitForm,
1041
+ onSubmitChoice,
1042
+ onSubmitRating,
1043
+ onRequestSubscribe
1044
+ }) {
1045
+ if (node.type === 'Content') {
1046
+ return _jsx(ContentNodeView, {
1047
+ node: node,
1048
+ page: page,
1049
+ colors: colors,
1050
+ mode: mode,
1051
+ scheme: scheme,
1052
+ leaf: leaf,
1053
+ onRequestClose: onRequestClose,
1054
+ onPageChange: onPageChange,
1055
+ onSubmitForm: onSubmitForm,
1056
+ onSubmitChoice: onSubmitChoice,
1057
+ onSubmitRating: onSubmitRating,
1058
+ onRequestSubscribe: onRequestSubscribe
1059
+ });
1060
+ }
1061
+ if (node.type === 'Image') {
1062
+ return _jsx(ImageNodeView, {
1063
+ node: node,
1064
+ page: page,
1065
+ leaf: leaf,
1066
+ mode: mode,
1067
+ scheme: scheme
1068
+ });
1069
+ }
1070
+ const bgKey = node.props?.backgroundImage;
1071
+ const background = bgKey ? page.images.find(i => i.key === bgKey) : undefined;
1072
+ const blur = background?.blur ?? 0;
1073
+ const dim = background?.dim ?? 0;
1074
+ const bgVideo = mediaVideoSrc(background);
1075
+ const bgSrc = background ? webPopupImageSrc(background, scheme) : undefined;
1076
+ const style = webPopupContainerStyle(node, blur > 0 || bgVideo ? undefined : bgSrc || undefined, dim);
1077
+ if (bgKey) style.backgroundColor = WEB_POPUP_BG_SECTION_FALLBACK;
1078
+ const children = node.children.map((child, index) => _jsx(LayoutNodeView
1079
+ // Layout nodes are static per page render — positional keys are fine.
1080
+ , {
1081
+ node: child,
1082
+ page: page,
1083
+ colors: colors,
1084
+ mode: mode,
1085
+ scheme: scheme,
1086
+ leaf: leaf,
1087
+ onRequestClose: onRequestClose,
1088
+ onPageChange: onPageChange,
1089
+ onSubmitForm: onSubmitForm,
1090
+ onSubmitChoice: onSubmitChoice,
1091
+ onSubmitRating: onSubmitRating,
1092
+ onRequestSubscribe: onRequestSubscribe
1093
+ }, index));
1094
+ if (bgVideo && background) {
1095
+ // A background video can't ride the css background property, so it paints
1096
+ // as its own layer; the scrim moves onto the content layer above it.
1097
+ const scrim = webPopupScrimGradient(dim);
1098
+ return _jsx(LayeredBackgroundBox, {
1099
+ style: style,
1100
+ outer: {
1101
+ position: 'relative',
1102
+ overflow: 'hidden',
1103
+ ...(bgSrc ? webPopupBackgroundImageStyle(bgSrc) : {})
1104
+ },
1105
+ inner: {
1106
+ position: 'relative',
1107
+ ...(scrim ? {
1108
+ backgroundImage: scrim
1109
+ } : {})
1110
+ },
1111
+ layer: _jsx(MediaVideo, {
1112
+ image: background,
1113
+ poster: bgSrc ?? '',
1114
+ src: bgVideo,
1115
+ mode: mode,
1116
+ style: {
1117
+ position: 'absolute',
1118
+ inset: 0,
1119
+ width: '100%',
1120
+ height: '100%',
1121
+ objectFit: 'cover',
1122
+ ...(blur > 0 ? {
1123
+ filter: `blur(${blur}px)`
1124
+ } : {})
1125
+ }
1126
+ }),
1127
+ children: children
1128
+ });
1129
+ }
1130
+ if (blur > 0) {
1131
+ // backdrop-filter blurs what's painted BEHIND its element, never the
1132
+ // element's own background — so the image paints on an outer box and the
1133
+ // laid-out content rides an inner blurring layer covering it edge to
1134
+ // edge (grid/padding move onto that layer). The outer box is a grid so
1135
+ // a stretched height (a 'stretch' page's root) passes through to the
1136
+ // layer actually carrying alignContent.
1137
+ return _jsx(LayeredBackgroundBox, {
1138
+ style: style,
1139
+ outer: bgSrc ? webPopupBackgroundImageStyle(bgSrc, dim) : undefined,
1140
+ inner: {
1141
+ backdropFilter: `blur(${blur}px)`,
1142
+ WebkitBackdropFilter: `blur(${blur}px)`
1143
+ },
1144
+ children: children
1145
+ });
1146
+ }
1147
+ return _jsx("div", {
1148
+ style: style,
1149
+ children: children
1150
+ });
1151
+ }
1152
+ // The popup box: the shared chrome (background, radius, shadow) around one
1153
+ // resolved page's layout tree. Exported for the editor — its canvas is this
1154
+ // very interpreter with `leafRenderers` swapping the Content/Image leaves
1155
+ // for editable views (the container interpretation stays this component's,
1156
+ // which is what keeps the canvas true to the runtime); the viewport chrome
1157
+ // (placement, overlay, enter animation) is the caller's business there.
1158
+ // After a page switch (`animatePage`) the incoming content fades in — keyed
1159
+ // by page so the animation restarts per switch — and an explicit px height
1160
+ // glides via the transition (auto heights just snap).
1161
+ export function WebPopupBox({
1162
+ page,
1163
+ params,
1164
+ mode = 'live',
1165
+ colorScheme = 'light',
1166
+ leafRenderers,
1167
+ animatePage = false,
1168
+ onRequestClose,
1169
+ onPageChange,
1170
+ onSubmitForm,
1171
+ onSubmitChoice,
1172
+ onSubmitRating,
1173
+ onRequestSubscribe
1174
+ }) {
1175
+ const colors = resolveWebPopupColors(params, colorScheme);
1176
+ const box = resolveWebPopupBoxChrome(params, colorScheme);
1177
+ const style = {
1178
+ position: 'relative',
1179
+ boxSizing: 'border-box',
1180
+ overflow: 'hidden',
1181
+ backgroundColor: colors.background,
1182
+ colorScheme: colors.scheme,
1183
+ fontFamily: WEB_POPUP_FONT_STACK,
1184
+ color: colors.text,
1185
+ borderRadius: params.borderRadius > 0 ? params.borderRadius : undefined,
1186
+ boxShadow: box.boxShadow && box.boxShadow !== 'none' ? box.boxShadow : undefined,
1187
+ transition: `height ${WEB_POPUP_PAGE_TRANSITION_MS}ms ease`
1188
+ };
1189
+ if (page.height) {
1190
+ // 'stretch' fills whatever box the caller gives (the fixed container pins
1191
+ // both vertical edges in live mode); a px height is the box's own.
1192
+ style.height = page.height === 'stretch' ? '100%' : page.height;
1193
+ style.overflowY = 'auto';
1194
+ }
1195
+ // A stretch page's tree gets the full box height to lay out in: the
1196
+ // min-height'd single-track grid stretches the root container, so its
1197
+ // alignContent can place the content vertically (a px height keeps the
1198
+ // pre-stretch behavior — content flows from the top).
1199
+ const pageStyle = {
1200
+ ...(animatePage ? {
1201
+ animation: `pw-vpopup-page ${WEB_POPUP_PAGE_TRANSITION_MS}ms ease-out backwards`
1202
+ } : {}),
1203
+ ...(page.height === 'stretch' ? {
1204
+ display: 'grid',
1205
+ minHeight: '100%'
1206
+ } : {})
1207
+ };
1208
+ return _jsx("div", {
1209
+ style: style,
1210
+ children: _jsx("div", {
1211
+ style: pageStyle,
1212
+ children: _jsx(LayoutNodeView, {
1213
+ node: page.layout,
1214
+ page: page,
1215
+ colors: colors,
1216
+ mode: mode,
1217
+ scheme: colors.scheme,
1218
+ leaf: leafRenderers,
1219
+ onRequestClose: onRequestClose,
1220
+ onPageChange: onPageChange ?? (() => {}),
1221
+ onSubmitForm: onSubmitForm,
1222
+ onSubmitChoice: onSubmitChoice,
1223
+ onSubmitRating: onSubmitRating,
1224
+ onRequestSubscribe: onRequestSubscribe
1225
+ })
1226
+ }, page.key)
1227
+ });
1228
+ }
1229
+ function CloseButton({
1230
+ colors,
1231
+ onRequestClose
1232
+ }) {
1233
+ return _jsx("button", {
1234
+ type: "button",
1235
+ "aria-label": "Close",
1236
+ onClick: onRequestClose,
1237
+ style: {
1238
+ position: 'absolute',
1239
+ top: 10,
1240
+ right: 10,
1241
+ width: 24,
1242
+ height: 24,
1243
+ padding: 0,
1244
+ border: 'none',
1245
+ borderRadius: '50%',
1246
+ background: webPopupColorAlpha(colors.surface, 0.92),
1247
+ boxShadow: '0 1px 3px rgba(16, 24, 40, 0.25)',
1248
+ color: colors.chromeIcon,
1249
+ fontSize: 13,
1250
+ lineHeight: 1,
1251
+ cursor: 'pointer'
1252
+ },
1253
+ children: "\u2715"
1254
+ });
1255
+ }
1256
+ // Fixed viewport placement from the placement param: anchored edges get the
1257
+ // offset, unanchored axes center via translate. No width = stretch between
1258
+ // the side gutters; height 'stretch' = pinned to both vertical edges the same
1259
+ // way. The size is the ACTIVE page's (resolved page ?? chrome), so the box
1260
+ // re-anchors itself when a page switch changes it.
1261
+ function fixedContainerStyle(params, width, height) {
1262
+ const {
1263
+ placement
1264
+ } = params;
1265
+ const style = {
1266
+ position: 'fixed',
1267
+ boxSizing: 'border-box',
1268
+ maxWidth: placementMaxSize(placement),
1269
+ maxHeight: placementMaxSize(placement),
1270
+ // The widget host is pointer-transparent (a corner banner must not block
1271
+ // the page); the popup itself takes clicks back.
1272
+ pointerEvents: 'auto'
1273
+ };
1274
+ if (width) {
1275
+ style.width = width;
1276
+ const h = placement.h;
1277
+ if (h === 'left') style.left = placement.offset;else if (h === 'right') style.right = placement.offset;else style.left = '50%';
1278
+ } else {
1279
+ // Stretch: pin both side edges regardless of the horizontal anchor.
1280
+ style.left = placement.offset;
1281
+ style.right = placement.offset;
1282
+ }
1283
+ if (height === 'stretch') {
1284
+ // Stretch: pin both vertical edges; the v anchor has nothing to place.
1285
+ style.top = placement.offset;
1286
+ style.bottom = placement.offset;
1287
+ } else if (placement.v === 'top') style.top = placement.offset;else if (placement.v === 'bottom') style.bottom = placement.offset;else style.top = '50%';
1288
+ const tx = width && placement.h === 'center' ? '-50%' : '0';
1289
+ const ty = placement.v === 'middle' && height !== 'stretch' ? '-50%' : '0';
1290
+ if (tx !== '0' || ty !== '0') style.transform = `translate(${tx}, ${ty})`;
1291
+ const anim = ANIMATION_STYLE[params.animation];
1292
+ if (anim) style.animation = anim;
1293
+ // A page switch that changes the (explicit) width glides instead of
1294
+ // jumping; the anchored edges keep the box pinned while it resizes.
1295
+ style.transition = `width ${WEB_POPUP_PAGE_TRANSITION_MS}ms ease`;
1296
+ return style;
1297
+ }
1298
+ export function WebPopupReader({
1299
+ params,
1300
+ mode = 'live',
1301
+ colorScheme = 'auto',
1302
+ onRequestClose,
1303
+ onSubmitForm,
1304
+ onSubmitChoice,
1305
+ onSubmitRating,
1306
+ onRequestSubscribe,
1307
+ leafRenderers
1308
+ }) {
1309
+ // null = never navigated: show the start page. Guarded against keys that
1310
+ // don't exist (anymore) — an edited/older document falls back gracefully.
1311
+ const [pageKey, setPageKey] = useState(null);
1312
+ // Only the active page's images are in the DOM — the rest would start
1313
+ // downloading on the page switch, leaving the previous picture on screen
1314
+ // until the new one arrives. Warm the browser cache for every page up front.
1315
+ useEffect(() => {
1316
+ resolveWebPopupPages(params).forEach(p => p.images.forEach(({
1317
+ src,
1318
+ srcDark
1319
+ }) => {
1320
+ [src, srcDark].forEach(url => {
1321
+ if (url) new Image().src = url;
1322
+ });
1323
+ }));
1324
+ }, [params]);
1325
+ const scheme = useColorScheme(colorScheme, webPopupDarkEnabled(params));
1326
+ const pages = resolveWebPopupPages(params);
1327
+ if (pages.length === 0) return null;
1328
+ const page = pages.find(p => p.key === pageKey) ?? startPageOf(params, pages);
1329
+ // null = the initial page: entry is the chrome's business (the enter
1330
+ // animation in live mode); the page fade only plays on actual switches.
1331
+ const animatePage = pageKey !== null;
1332
+ if (mode === 'preview') {
1333
+ return _jsxs("div", {
1334
+ style: {
1335
+ position: 'relative',
1336
+ width: page.width ?? '100%',
1337
+ transition: `width ${WEB_POPUP_PAGE_TRANSITION_MS}ms ease`
1338
+ },
1339
+ children: [_jsx("style", {
1340
+ children: WEB_POPUP_KEYFRAMES
1341
+ }), _jsx(WebPopupBox, {
1342
+ page: page,
1343
+ params: params,
1344
+ mode: mode,
1345
+ colorScheme: scheme,
1346
+ leafRenderers: leafRenderers,
1347
+ animatePage: animatePage,
1348
+ onRequestClose: onRequestClose,
1349
+ onPageChange: setPageKey,
1350
+ onSubmitForm: onSubmitForm,
1351
+ onSubmitChoice: onSubmitChoice,
1352
+ onSubmitRating: onSubmitRating,
1353
+ onRequestSubscribe: onRequestSubscribe
1354
+ })]
1355
+ });
1356
+ }
1357
+ const {
1358
+ overlay
1359
+ } = resolveWebPopupBoxChrome(params, scheme);
1360
+ return _jsxs(_Fragment, {
1361
+ children: [_jsx("style", {
1362
+ children: WEB_POPUP_KEYFRAMES
1363
+ }), overlay && _jsx("div", {
1364
+ style: {
1365
+ position: 'fixed',
1366
+ inset: 0,
1367
+ backgroundColor: overlay.color,
1368
+ opacity: overlay.opacity,
1369
+ animation: 'pw-vpopup-overlay 0.2s ease-out backwards',
1370
+ pointerEvents: 'auto'
1371
+ },
1372
+ onClick: onRequestClose
1373
+ }), _jsxs("div", {
1374
+ style: fixedContainerStyle(params, page.width, page.height),
1375
+ children: [_jsx(WebPopupBox, {
1376
+ page: page,
1377
+ params: params,
1378
+ mode: mode,
1379
+ colorScheme: scheme,
1380
+ leafRenderers: leafRenderers,
1381
+ animatePage: animatePage,
1382
+ onRequestClose: onRequestClose,
1383
+ onPageChange: setPageKey,
1384
+ onSubmitForm: onSubmitForm,
1385
+ onSubmitChoice: onSubmitChoice,
1386
+ onSubmitRating: onSubmitRating,
1387
+ onRequestSubscribe: onRequestSubscribe
1388
+ }), _jsx(CloseButton, {
1389
+ colors: resolveWebPopupColors(params, scheme),
1390
+ onRequestClose: onRequestClose
1391
+ })]
1392
+ })]
1393
+ });
1394
+ }