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