@pushwoosh/websdk-common 6.16.0 → 6.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pushwoosh/websdk-common",
3
- "version": "6.16.0",
4
- "description": "Code shared by the Pushwoosh WebSDK and the Control Panel, one entry point per module (web-popups: web popup model and renderer)",
3
+ "version": "6.17.1",
4
+ "description": "Code shared by the Pushwoosh WebSDK and the Control Panel, one entry point per module (web-popups: web popup model and renderer; subscription-widget: push subscription widget model and renderer)",
5
5
  "main": "index.js",
6
6
  "module": "index.js",
7
7
  "types": "index.d.ts",
@@ -14,6 +14,10 @@
14
14
  "types": "./web-popups/index.d.ts",
15
15
  "default": "./web-popups/index.js"
16
16
  },
17
+ "./subscription-widget": {
18
+ "types": "./subscription-widget/index.d.ts",
19
+ "default": "./subscription-widget/index.js"
20
+ },
17
21
  "./package.json": "./package.json"
18
22
  },
19
23
  "sideEffects": false,
@@ -0,0 +1,23 @@
1
+ import { type SubscriptionWidgetConfig, type SubscriptionWidgetPromptVariant, type SubscriptionWidgetReaderProps } from './types';
2
+ import { type WebPopupResolvedColors } from '../web-popups/types';
3
+ /** The prompt box without placement, for CP forms that show it beside their fields. */
4
+ export declare function SubscriptionPromptBox({ config, variant, colors, busy, onAccept, onDecline }: {
5
+ config: SubscriptionWidgetConfig;
6
+ variant: SubscriptionWidgetPromptVariant;
7
+ colors: WebPopupResolvedColors;
8
+ busy?: boolean;
9
+ onAccept?: () => void;
10
+ onDecline?: () => void;
11
+ }): import("react").JSX.Element;
12
+ /** The launcher bell without placement. */
13
+ export declare function SubscriptionLauncherButton({ config, blocked, colors, onClick }: {
14
+ config: SubscriptionWidgetConfig;
15
+ blocked: boolean;
16
+ colors: WebPopupResolvedColors;
17
+ onClick?: () => void;
18
+ }): import("react").JSX.Element;
19
+ /**
20
+ * The launcher, or while `open` the prompt in its place; 'live' pins it to the viewport, 'preview' to the
21
+ * nearest positioned parent with no enter animation. The host owns `open` and every side effect.
22
+ */
23
+ export declare function SubscriptionWidgetReader({ config, permission, open, mode, colorScheme, busy, onAccept, onDecline, onLauncherClick, }: SubscriptionWidgetReaderProps): import("react").JSX.Element | null;
@@ -0,0 +1,307 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { SUBSCRIPTION_WIDGET_KEYFRAMES } from './constants';
3
+ import { subscriptionWidgetLauncherVisible, subscriptionWidgetPromptVariant } from './helpers';
4
+ import { useColorScheme } from '../web-popups/colorScheme';
5
+ import { webPopupButtonPillStyle } from '../web-popups/constants';
6
+ import { WEB_POPUP_GLYPH_PATHS } from '../web-popups/glyphs';
7
+ import { resolveWebPopupColors, webPopupDarkEnabled } from '../web-popups/palette';
8
+ const CARD_WIDTH = 360;
9
+ const MODAL_WIDTH = 420;
10
+ const EDGE_GAP = 16;
11
+ const BOX_SHADOW = '0 12px 32px rgba(16, 24, 40, 0.18)';
12
+ const BELL_SLASH_PATH = 'M4 4l16 16';
13
+ function cornerStyle(corner, offset) {
14
+ const [v, h] = corner.split('-');
15
+ return {
16
+ [v]: offset,
17
+ [h]: offset
18
+ };
19
+ }
20
+ function enterAnimation(fromTop, animate) {
21
+ return animate ? `${fromTop ? 'pw-subw-drop' : 'pw-subw-rise'} 0.3s ease-out backwards` : undefined;
22
+ }
23
+ function promptFrameStyle(config, position, animate) {
24
+ const {
25
+ layout,
26
+ position: at
27
+ } = config.prompt;
28
+ const base = {
29
+ position,
30
+ zIndex: config.zIndex,
31
+ boxSizing: 'border-box',
32
+ pointerEvents: 'auto'
33
+ };
34
+ if (layout === 'bar') {
35
+ return {
36
+ ...base,
37
+ left: 0,
38
+ right: 0,
39
+ [at]: 0,
40
+ animation: enterAnimation(at === 'top', animate)
41
+ };
42
+ }
43
+ if (layout === 'modal') {
44
+ return {
45
+ ...base,
46
+ top: '50%',
47
+ left: '50%',
48
+ width: `min(${MODAL_WIDTH}px, calc(100% - ${EDGE_GAP * 2}px))`,
49
+ transform: 'translate(-50%, -50%)',
50
+ animation: animate ? 'pw-subw-fade 0.25s ease-out backwards' : undefined
51
+ };
52
+ }
53
+ const width = `min(${CARD_WIDTH}px, calc(100% - ${EDGE_GAP * 2}px))`;
54
+ const animation = enterAnimation(at.startsWith('top'), animate);
55
+ if (at === 'top' || at === 'bottom') {
56
+ return {
57
+ ...base,
58
+ [at]: EDGE_GAP,
59
+ left: '50%',
60
+ width,
61
+ transform: 'translateX(-50%)',
62
+ animation
63
+ };
64
+ }
65
+ return {
66
+ ...base,
67
+ ...cornerStyle(at, EDGE_GAP),
68
+ width,
69
+ animation
70
+ };
71
+ }
72
+ function Button({
73
+ label,
74
+ primary,
75
+ colors,
76
+ disabled,
77
+ onClick
78
+ }) {
79
+ return _jsx("button", {
80
+ type: "button",
81
+ disabled: disabled,
82
+ onClick: onClick,
83
+ style: {
84
+ ...webPopupButtonPillStyle(primary ? 'primary' : 'secondary', colors),
85
+ display: 'inline-block',
86
+ width: 'auto',
87
+ padding: '8px 18px',
88
+ whiteSpace: 'nowrap',
89
+ opacity: disabled ? 0.6 : 1,
90
+ cursor: disabled ? 'default' : 'pointer'
91
+ },
92
+ children: label
93
+ });
94
+ }
95
+ /** The prompt box without placement, for CP forms that show it beside their fields. */
96
+ export function SubscriptionPromptBox({
97
+ config,
98
+ variant,
99
+ colors,
100
+ busy,
101
+ onAccept,
102
+ onDecline
103
+ }) {
104
+ const {
105
+ prompt,
106
+ texts
107
+ } = config;
108
+ const bar = prompt.layout === 'bar';
109
+ const blocked = variant === 'blocked';
110
+ return _jsxs("div", {
111
+ role: "dialog",
112
+ "aria-label": blocked ? texts.blockedTitle : texts.title,
113
+ style: {
114
+ display: 'flex',
115
+ flexDirection: bar ? 'row' : 'column',
116
+ alignItems: bar ? 'center' : 'stretch',
117
+ flexWrap: bar ? 'wrap' : 'nowrap',
118
+ gap: 16,
119
+ padding: bar ? '12px 20px' : 20,
120
+ boxSizing: 'border-box',
121
+ width: '100%',
122
+ borderRadius: bar ? 0 : prompt.borderRadius,
123
+ boxShadow: BOX_SHADOW,
124
+ backgroundColor: colors.background,
125
+ color: colors.text,
126
+ fontFamily: config.fontFamily,
127
+ colorScheme: colors.scheme
128
+ },
129
+ children: [_jsxs("div", {
130
+ style: {
131
+ display: 'flex',
132
+ alignItems: 'flex-start',
133
+ gap: 12,
134
+ flex: bar ? '1 1 240px' : undefined
135
+ },
136
+ children: [prompt.iconUrl && _jsx("img", {
137
+ src: prompt.iconUrl,
138
+ alt: "",
139
+ width: 48,
140
+ height: 48,
141
+ style: {
142
+ flex: 'none',
143
+ borderRadius: 8,
144
+ objectFit: 'cover'
145
+ }
146
+ }), _jsxs("div", {
147
+ style: {
148
+ display: 'flex',
149
+ flexDirection: 'column',
150
+ gap: 4,
151
+ minWidth: 0
152
+ },
153
+ children: [_jsx("div", {
154
+ style: {
155
+ fontSize: 16,
156
+ fontWeight: 600,
157
+ lineHeight: 1.4
158
+ },
159
+ children: blocked ? texts.blockedTitle : texts.title
160
+ }), _jsx("div", {
161
+ style: {
162
+ fontSize: 14,
163
+ lineHeight: 1.5,
164
+ color: colors.textSecondary
165
+ },
166
+ children: blocked ? texts.blockedMessage : texts.message
167
+ })]
168
+ })]
169
+ }), _jsx("div", {
170
+ style: {
171
+ display: 'flex',
172
+ justifyContent: 'flex-end',
173
+ gap: 8,
174
+ flex: 'none'
175
+ },
176
+ children: blocked ? _jsx(Button, {
177
+ label: texts.closeText,
178
+ primary: true,
179
+ colors: colors,
180
+ onClick: onDecline
181
+ }) : _jsxs(_Fragment, {
182
+ children: [_jsx(Button, {
183
+ label: texts.declineText,
184
+ primary: false,
185
+ colors: colors,
186
+ onClick: onDecline
187
+ }), _jsx(Button, {
188
+ label: texts.acceptText,
189
+ primary: true,
190
+ colors: colors,
191
+ disabled: busy,
192
+ onClick: onAccept
193
+ })]
194
+ })
195
+ })]
196
+ });
197
+ }
198
+ /** The launcher bell without placement. */
199
+ export function SubscriptionLauncherButton({
200
+ config,
201
+ blocked,
202
+ colors,
203
+ onClick
204
+ }) {
205
+ const {
206
+ size
207
+ } = config.launcher;
208
+ const label = blocked ? config.texts.launcherBlockedLabel : config.texts.launcherLabel;
209
+ const iconSize = Math.round(size / 2);
210
+ return _jsx("button", {
211
+ type: "button",
212
+ "aria-label": label,
213
+ title: label,
214
+ onClick: onClick,
215
+ style: {
216
+ display: 'flex',
217
+ alignItems: 'center',
218
+ justifyContent: 'center',
219
+ width: size,
220
+ height: size,
221
+ padding: 0,
222
+ border: 'none',
223
+ borderRadius: '50%',
224
+ backgroundColor: colors.accent,
225
+ color: colors.onAccent,
226
+ boxShadow: BOX_SHADOW,
227
+ cursor: 'pointer',
228
+ opacity: blocked ? 0.7 : 1
229
+ },
230
+ children: _jsxs("svg", {
231
+ width: iconSize,
232
+ height: iconSize,
233
+ viewBox: "0 0 24 24",
234
+ fill: "none",
235
+ stroke: "currentColor",
236
+ strokeWidth: 2,
237
+ strokeLinecap: "round",
238
+ strokeLinejoin: "round",
239
+ "aria-hidden": "true",
240
+ children: [_jsx("path", {
241
+ d: WEB_POPUP_GLYPH_PATHS.bell
242
+ }), blocked && _jsx("path", {
243
+ d: BELL_SLASH_PATH
244
+ })]
245
+ })
246
+ });
247
+ }
248
+ /**
249
+ * The launcher, or while `open` the prompt in its place; 'live' pins it to the viewport, 'preview' to the
250
+ * nearest positioned parent with no enter animation. The host owns `open` and every side effect.
251
+ */
252
+ export function SubscriptionWidgetReader({
253
+ config,
254
+ permission,
255
+ open,
256
+ mode = 'live',
257
+ colorScheme = 'auto',
258
+ busy,
259
+ onAccept,
260
+ onDecline,
261
+ onLauncherClick
262
+ }) {
263
+ const scheme = useColorScheme(colorScheme, webPopupDarkEnabled(config));
264
+ const colors = resolveWebPopupColors(config, scheme);
265
+ const live = mode === 'live';
266
+ const position = live ? 'fixed' : 'absolute';
267
+ const launcher = !open && subscriptionWidgetLauncherVisible(config, permission);
268
+ if (!config.enable || !open && !launcher) return null;
269
+ return _jsxs(_Fragment, {
270
+ children: [live && _jsx("style", {
271
+ children: SUBSCRIPTION_WIDGET_KEYFRAMES
272
+ }), launcher && _jsx("div", {
273
+ style: {
274
+ position,
275
+ zIndex: config.zIndex,
276
+ pointerEvents: 'auto',
277
+ ...cornerStyle(config.launcher.position, config.launcher.offset)
278
+ },
279
+ children: _jsx(SubscriptionLauncherButton, {
280
+ config: config,
281
+ blocked: permission === 'denied',
282
+ colors: colors,
283
+ onClick: onLauncherClick
284
+ })
285
+ }), open && config.prompt.overlay && _jsx("div", {
286
+ style: {
287
+ position,
288
+ inset: 0,
289
+ zIndex: config.zIndex,
290
+ backgroundColor: 'rgba(16, 24, 40, 0.5)',
291
+ animation: live ? 'pw-subw-fade 0.2s ease-out backwards' : undefined,
292
+ pointerEvents: 'auto'
293
+ },
294
+ onClick: onDecline
295
+ }), open && _jsx("div", {
296
+ style: promptFrameStyle(config, position, live),
297
+ children: _jsx(SubscriptionPromptBox, {
298
+ config: config,
299
+ variant: subscriptionWidgetPromptVariant(permission),
300
+ colors: colors,
301
+ busy: busy,
302
+ onAccept: onAccept,
303
+ onDecline: onDecline
304
+ })
305
+ })]
306
+ });
307
+ }
@@ -0,0 +1,19 @@
1
+ import { type SubscriptionWidgetConfig, type SubscriptionWidgetCorner, type SubscriptionWidgetLayout, type SubscriptionWidgetPosition, type SubscriptionWidgetTrigger } from './types';
2
+ /** Positions each layout accepts; the first one is the layout's default. */
3
+ export declare const SUBSCRIPTION_WIDGET_POSITIONS: Record<SubscriptionWidgetLayout, SubscriptionWidgetPosition[]>;
4
+ export declare const SUBSCRIPTION_WIDGET_LAYOUTS: SubscriptionWidgetLayout[];
5
+ export declare const SUBSCRIPTION_WIDGET_CORNERS: SubscriptionWidgetCorner[];
6
+ export declare const SUBSCRIPTION_WIDGET_TRIGGERS: SubscriptionWidgetTrigger[];
7
+ /** Inclusive bounds every numeric field is clamped to. */
8
+ export declare const SUBSCRIPTION_WIDGET_LIMITS: {
9
+ readonly delay: readonly [0, 3600];
10
+ readonly cappingCount: readonly [0, 100];
11
+ readonly cappingInterval: readonly [0, 8760];
12
+ readonly borderRadius: readonly [0, 32];
13
+ readonly offset: readonly [0, 100];
14
+ readonly size: readonly [32, 80];
15
+ readonly zIndex: readonly [0, 2147483647];
16
+ };
17
+ export declare const SUBSCRIPTION_WIDGET_TEXT_MAX_LENGTH = 300;
18
+ export declare const SUBSCRIPTION_WIDGET_DEFAULT_CONFIG: SubscriptionWidgetConfig;
19
+ export declare const SUBSCRIPTION_WIDGET_KEYFRAMES = "\n@keyframes pw-subw-fade { from { opacity: 0; } }\n@keyframes pw-subw-drop { from { opacity: 0; translate: 0 -16px; } }\n@keyframes pw-subw-rise { from { opacity: 0; translate: 0 16px; } }\n";
@@ -0,0 +1,61 @@
1
+ import { WEB_POPUP_DEFAULT_COLORS, WEB_POPUP_FONT_STACK } from '../web-popups/constants';
2
+ /** Positions each layout accepts; the first one is the layout's default. */
3
+ export const SUBSCRIPTION_WIDGET_POSITIONS = {
4
+ card: ['top', 'bottom', 'top-left', 'top-right', 'bottom-left', 'bottom-right'],
5
+ bar: ['top', 'bottom'],
6
+ modal: ['center']
7
+ };
8
+ export const SUBSCRIPTION_WIDGET_LAYOUTS = Object.keys(SUBSCRIPTION_WIDGET_POSITIONS);
9
+ export const SUBSCRIPTION_WIDGET_CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
10
+ export const SUBSCRIPTION_WIDGET_TRIGGERS = ['auto', 'launcher', 'manual'];
11
+ /** Inclusive bounds every numeric field is clamped to. */
12
+ export const SUBSCRIPTION_WIDGET_LIMITS = {
13
+ delay: [0, 3600],
14
+ cappingCount: [0, 100],
15
+ cappingInterval: [0, 8760],
16
+ borderRadius: [0, 32],
17
+ offset: [0, 100],
18
+ size: [32, 80],
19
+ zIndex: [0, 2147483647]
20
+ };
21
+ export const SUBSCRIPTION_WIDGET_TEXT_MAX_LENGTH = 300;
22
+ export const SUBSCRIPTION_WIDGET_DEFAULT_CONFIG = {
23
+ enable: false,
24
+ prompt: {
25
+ layout: 'card',
26
+ position: 'top',
27
+ borderRadius: 12,
28
+ overlay: false
29
+ },
30
+ launcher: {
31
+ enabled: false,
32
+ position: 'bottom-right',
33
+ offset: 20,
34
+ size: 48
35
+ },
36
+ display: {
37
+ trigger: 'auto',
38
+ delay: 5,
39
+ cappingCount: 3,
40
+ cappingInterval: 4
41
+ },
42
+ texts: {
43
+ title: 'Stay in the loop',
44
+ message: 'Allow notifications to get our news and special offers first.',
45
+ acceptText: 'Allow',
46
+ declineText: 'Not now',
47
+ closeText: 'Got it',
48
+ blockedTitle: 'Notifications are blocked',
49
+ blockedMessage: 'To get our notifications, allow them for this site in your browser settings.',
50
+ launcherLabel: 'Subscribe to notifications',
51
+ launcherBlockedLabel: 'Notifications are blocked'
52
+ },
53
+ colors: WEB_POPUP_DEFAULT_COLORS,
54
+ fontFamily: WEB_POPUP_FONT_STACK,
55
+ zIndex: 2147483000
56
+ };
57
+ export const SUBSCRIPTION_WIDGET_KEYFRAMES = `
58
+ @keyframes pw-subw-fade { from { opacity: 0; } }
59
+ @keyframes pw-subw-drop { from { opacity: 0; translate: 0 -16px; } }
60
+ @keyframes pw-subw-rise { from { opacity: 0; translate: 0 16px; } }
61
+ `;
@@ -0,0 +1,16 @@
1
+ import { type SubscriptionWidgetConfig, type SubscriptionWidgetHistory, type SubscriptionWidgetPermission, type SubscriptionWidgetPromptVariant } from './types';
2
+ /**
3
+ * The widget configuration from API-writable json: enums whitelisted, numbers
4
+ * clamped, strings trimmed and capped, urls sanitized, defaults for the rest.
5
+ */
6
+ export declare function normalizeSubscriptionWidgetConfig(raw: unknown): SubscriptionWidgetConfig;
7
+ /** True when the visitor can still act on the ask: not subscribed and push is supported. */
8
+ export declare function subscriptionWidgetActionable(permission: SubscriptionWidgetPermission): boolean;
9
+ /** Which prompt a visitor gets for their permission state. */
10
+ export declare function subscriptionWidgetPromptVariant(permission: SubscriptionWidgetPermission): SubscriptionWidgetPromptVariant;
11
+ /** Whether the launcher is on screen. */
12
+ export declare function subscriptionWidgetLauncherVisible(config: SubscriptionWidgetConfig, permission: SubscriptionWidgetPermission): boolean;
13
+ /** Whether the prompt may open by itself now; the host still waits `display.delay` before showing it. */
14
+ export declare function subscriptionWidgetCanAutoShow(config: SubscriptionWidgetConfig, permission: SubscriptionWidgetPermission, history: SubscriptionWidgetHistory, nowMs: number): boolean;
15
+ /** The history after one more auto show at `nowMs`. */
16
+ export declare function nextSubscriptionWidgetHistory(history: SubscriptionWidgetHistory, nowMs: number): SubscriptionWidgetHistory;
@@ -0,0 +1,110 @@
1
+ import { SUBSCRIPTION_WIDGET_CORNERS, SUBSCRIPTION_WIDGET_DEFAULT_CONFIG, SUBSCRIPTION_WIDGET_LAYOUTS, SUBSCRIPTION_WIDGET_LIMITS, SUBSCRIPTION_WIDGET_POSITIONS, SUBSCRIPTION_WIDGET_TEXT_MAX_LENGTH, SUBSCRIPTION_WIDGET_TRIGGERS } from './constants';
2
+ import { normalizeWebPopupColors, normalizeWebPopupDark, safeUrl } from '../web-popups/helpers';
3
+ const HOUR_MS = 3600 * 1000;
4
+ function record(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
6
+ }
7
+ function oneOf(value, allowed, fallback) {
8
+ return allowed.includes(value) ? value : fallback;
9
+ }
10
+ function clamped(value, [min, max], fallback) {
11
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
12
+ return Math.max(min, Math.min(max, Math.round(value)));
13
+ }
14
+ function text(value, fallback) {
15
+ if (typeof value !== 'string') return fallback;
16
+ const trimmed = value.trim();
17
+ return trimmed ? trimmed.slice(0, SUBSCRIPTION_WIDGET_TEXT_MAX_LENGTH) : fallback;
18
+ }
19
+ function imageUrl(value) {
20
+ const url = safeUrl(typeof value === 'string' ? value : undefined);
21
+ return url && /^(https?:\/\/|\/)/i.test(url) ? url : undefined;
22
+ }
23
+ function normalizeTexts(raw) {
24
+ const defaults = SUBSCRIPTION_WIDGET_DEFAULT_CONFIG.texts;
25
+ const keys = Object.keys(defaults);
26
+ return Object.fromEntries(keys.map(key => [key, text(raw[key], defaults[key])]));
27
+ }
28
+ /**
29
+ * The widget configuration from API-writable json: enums whitelisted, numbers
30
+ * clamped, strings trimmed and capped, urls sanitized, defaults for the rest.
31
+ */
32
+ export function normalizeSubscriptionWidgetConfig(raw) {
33
+ const defaults = SUBSCRIPTION_WIDGET_DEFAULT_CONFIG;
34
+ const config = record(raw);
35
+ const prompt = record(config.prompt);
36
+ const launcher = record(config.launcher);
37
+ const display = record(config.display);
38
+ const limits = SUBSCRIPTION_WIDGET_LIMITS;
39
+ const layout = oneOf(prompt.layout, SUBSCRIPTION_WIDGET_LAYOUTS, defaults.prompt.layout);
40
+ const positions = SUBSCRIPTION_WIDGET_POSITIONS[layout];
41
+ const trigger = oneOf(display.trigger, SUBSCRIPTION_WIDGET_TRIGGERS, defaults.display.trigger);
42
+ const iconUrl = imageUrl(prompt.iconUrl);
43
+ const colors = normalizeWebPopupColors({
44
+ colors: config.colors
45
+ });
46
+ const dark = normalizeWebPopupDark({
47
+ dark: config.dark
48
+ }, colors);
49
+ return {
50
+ enable: config.enable === true,
51
+ prompt: {
52
+ layout,
53
+ position: oneOf(prompt.position, positions, positions[0]),
54
+ ...(iconUrl ? {
55
+ iconUrl
56
+ } : {}),
57
+ borderRadius: clamped(prompt.borderRadius, limits.borderRadius, defaults.prompt.borderRadius),
58
+ overlay: layout === 'modal' || prompt.overlay === true
59
+ },
60
+ launcher: {
61
+ enabled: trigger === 'launcher' || launcher.enabled === true,
62
+ position: oneOf(launcher.position, SUBSCRIPTION_WIDGET_CORNERS, defaults.launcher.position),
63
+ offset: clamped(launcher.offset, limits.offset, defaults.launcher.offset),
64
+ size: clamped(launcher.size, limits.size, defaults.launcher.size)
65
+ },
66
+ display: {
67
+ trigger,
68
+ delay: clamped(display.delay, limits.delay, defaults.display.delay),
69
+ cappingCount: clamped(display.cappingCount, limits.cappingCount, defaults.display.cappingCount),
70
+ cappingInterval: clamped(display.cappingInterval, limits.cappingInterval, defaults.display.cappingInterval)
71
+ },
72
+ texts: normalizeTexts(record(config.texts)),
73
+ colors,
74
+ ...(dark ? {
75
+ dark
76
+ } : {}),
77
+ fontFamily: text(config.fontFamily, defaults.fontFamily),
78
+ zIndex: clamped(config.zIndex, limits.zIndex, defaults.zIndex)
79
+ };
80
+ }
81
+ /** True when the visitor can still act on the ask: not subscribed and push is supported. */
82
+ export function subscriptionWidgetActionable(permission) {
83
+ return permission === 'default' || permission === 'denied';
84
+ }
85
+ /** Which prompt a visitor gets for their permission state. */
86
+ export function subscriptionWidgetPromptVariant(permission) {
87
+ return permission === 'denied' ? 'blocked' : 'ask';
88
+ }
89
+ /** Whether the launcher is on screen. */
90
+ export function subscriptionWidgetLauncherVisible(config, permission) {
91
+ return config.enable && config.launcher.enabled && subscriptionWidgetActionable(permission);
92
+ }
93
+ /** Whether the prompt may open by itself now; the host still waits `display.delay` before showing it. */
94
+ export function subscriptionWidgetCanAutoShow(config, permission, history, nowMs) {
95
+ const {
96
+ trigger,
97
+ cappingCount,
98
+ cappingInterval
99
+ } = config.display;
100
+ if (!config.enable || trigger !== 'auto' || permission !== 'default') return false;
101
+ if (cappingCount > 0 && history.shownCount >= cappingCount) return false;
102
+ return history.lastShownAt === null || nowMs - history.lastShownAt >= cappingInterval * HOUR_MS;
103
+ }
104
+ /** The history after one more auto show at `nowMs`. */
105
+ export function nextSubscriptionWidgetHistory(history, nowMs) {
106
+ return {
107
+ shownCount: history.shownCount + 1,
108
+ lastShownAt: nowMs
109
+ };
110
+ }
@@ -0,0 +1,4 @@
1
+ export { SubscriptionWidgetReader, SubscriptionPromptBox, SubscriptionLauncherButton } from './SubscriptionWidgetReader';
2
+ export { normalizeSubscriptionWidgetConfig, subscriptionWidgetActionable, subscriptionWidgetPromptVariant, subscriptionWidgetLauncherVisible, subscriptionWidgetCanAutoShow, nextSubscriptionWidgetHistory, } from './helpers';
3
+ export { SUBSCRIPTION_WIDGET_DEFAULT_CONFIG, SUBSCRIPTION_WIDGET_POSITIONS, SUBSCRIPTION_WIDGET_LAYOUTS, SUBSCRIPTION_WIDGET_CORNERS, SUBSCRIPTION_WIDGET_TRIGGERS, SUBSCRIPTION_WIDGET_LIMITS, SUBSCRIPTION_WIDGET_TEXT_MAX_LENGTH, } from './constants';
4
+ export type { SubscriptionWidgetConfig, SubscriptionWidgetCorner, SubscriptionWidgetDisplay, SubscriptionWidgetHistory, SubscriptionWidgetLauncher, SubscriptionWidgetLayout, SubscriptionWidgetPermission, SubscriptionWidgetPosition, SubscriptionWidgetPrompt, SubscriptionWidgetPromptVariant, SubscriptionWidgetReaderMode, SubscriptionWidgetReaderProps, SubscriptionWidgetTexts, SubscriptionWidgetTrigger, } from './types';
@@ -0,0 +1,3 @@
1
+ export { SubscriptionWidgetReader, SubscriptionPromptBox, SubscriptionLauncherButton } from './SubscriptionWidgetReader';
2
+ export { normalizeSubscriptionWidgetConfig, subscriptionWidgetActionable, subscriptionWidgetPromptVariant, subscriptionWidgetLauncherVisible, subscriptionWidgetCanAutoShow, nextSubscriptionWidgetHistory } from './helpers';
3
+ export { SUBSCRIPTION_WIDGET_DEFAULT_CONFIG, SUBSCRIPTION_WIDGET_POSITIONS, SUBSCRIPTION_WIDGET_LAYOUTS, SUBSCRIPTION_WIDGET_CORNERS, SUBSCRIPTION_WIDGET_TRIGGERS, SUBSCRIPTION_WIDGET_LIMITS, SUBSCRIPTION_WIDGET_TEXT_MAX_LENGTH } from './constants';
@@ -0,0 +1,90 @@
1
+ import { type WebPopupColors, type WebPopupColorSchemePref, type WebPopupDark } from '../web-popups/types';
2
+ /** How the ask is drawn: a corner/edge card, a full-width bar, or a centered modal. */
3
+ export type SubscriptionWidgetLayout = 'card' | 'bar' | 'modal';
4
+ /** Screen corner a launcher or a card sits in. */
5
+ export type SubscriptionWidgetCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
6
+ /** Where the prompt sits; each layout accepts a subset (see SUBSCRIPTION_WIDGET_POSITIONS). */
7
+ export type SubscriptionWidgetPosition = SubscriptionWidgetCorner | 'top' | 'bottom' | 'center';
8
+ /** What opens the prompt: a timer on page load, a launcher click only, or the host API only. */
9
+ export type SubscriptionWidgetTrigger = 'auto' | 'launcher' | 'manual';
10
+ /** Browser notification permission as the host sees it; 'granted' = subscribed. */
11
+ export type SubscriptionWidgetPermission = 'default' | 'granted' | 'denied' | 'unsupported';
12
+ /** 'ask' invites to subscribe, 'blocked' explains how to unblock notifications in the browser. */
13
+ export type SubscriptionWidgetPromptVariant = 'ask' | 'blocked';
14
+ /** Every visitor-facing string of the widget. */
15
+ export type SubscriptionWidgetTexts = {
16
+ title: string;
17
+ message: string;
18
+ acceptText: string;
19
+ declineText: string;
20
+ /** Dismisses the 'blocked' prompt. */
21
+ closeText: string;
22
+ blockedTitle: string;
23
+ blockedMessage: string;
24
+ /** Launcher tooltip while the visitor can still subscribe. */
25
+ launcherLabel: string;
26
+ /** Launcher tooltip while notifications are blocked. */
27
+ launcherBlockedLabel: string;
28
+ };
29
+ /** The ask itself. */
30
+ export type SubscriptionWidgetPrompt = {
31
+ layout: SubscriptionWidgetLayout;
32
+ position: SubscriptionWidgetPosition;
33
+ /** Image drawn beside the copy; absent = no image. */
34
+ iconUrl?: string;
35
+ borderRadius: number;
36
+ /** Page dim behind the prompt; a modal always has one. */
37
+ overlay: boolean;
38
+ };
39
+ /** The floating bell that reopens the prompt. */
40
+ export type SubscriptionWidgetLauncher = {
41
+ enabled: boolean;
42
+ position: SubscriptionWidgetCorner;
43
+ /** Distance from the screen edges, px. */
44
+ offset: number;
45
+ /** Button diameter, px. */
46
+ size: number;
47
+ };
48
+ /** When the prompt shows by itself. */
49
+ export type SubscriptionWidgetDisplay = {
50
+ trigger: SubscriptionWidgetTrigger;
51
+ /** Seconds after page load before an auto show. */
52
+ delay: number;
53
+ /** Auto shows per visitor; 0 = unlimited. */
54
+ cappingCount: number;
55
+ /** Hours between two auto shows. */
56
+ cappingInterval: number;
57
+ };
58
+ /** The whole widget configuration as stored in the WebSDK config (`subscriptionWidget`). */
59
+ export type SubscriptionWidgetConfig = {
60
+ enable: boolean;
61
+ prompt: SubscriptionWidgetPrompt;
62
+ launcher: SubscriptionWidgetLauncher;
63
+ display: SubscriptionWidgetDisplay;
64
+ texts: SubscriptionWidgetTexts;
65
+ colors: WebPopupColors;
66
+ dark?: WebPopupDark;
67
+ fontFamily: string;
68
+ zIndex: number;
69
+ };
70
+ /** What the host remembers about auto shows for one visitor. */
71
+ export type SubscriptionWidgetHistory = {
72
+ shownCount: number;
73
+ /** Epoch ms of the last auto show; null = never shown. */
74
+ lastShownAt: number | null;
75
+ };
76
+ /** 'live' draws fixed to the viewport; 'preview' draws the same inside the nearest positioned parent. */
77
+ export type SubscriptionWidgetReaderMode = 'live' | 'preview';
78
+ export type SubscriptionWidgetReaderProps = {
79
+ config: SubscriptionWidgetConfig;
80
+ permission: SubscriptionWidgetPermission;
81
+ /** Whether the prompt is on screen; the host owns this state. */
82
+ open: boolean;
83
+ mode?: SubscriptionWidgetReaderMode;
84
+ colorScheme?: WebPopupColorSchemePref;
85
+ /** Disables the accept button while the browser permission dialog is up. */
86
+ busy?: boolean;
87
+ onAccept?: () => void;
88
+ onDecline?: () => void;
89
+ onLauncherClick?: () => void;
90
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
+ import { useColorScheme } from './colorScheme';
3
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';
4
5
  import { WEB_POPUP_GLYPH_PATHS, WEB_POPUP_THUMB_UP_PATH } from './glyphs';
5
6
  import { placementMaxSize, resolveWebPopupPages, safeUrl, sanitizeWebPopupHtml, startPageOf, webPopupLinkTargetAttrs } from './helpers';
@@ -1294,27 +1295,6 @@ function fixedContainerStyle(params, width, height) {
1294
1295
  style.transition = `width ${WEB_POPUP_PAGE_TRANSITION_MS}ms ease`;
1295
1296
  return style;
1296
1297
  }
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
1298
  export function WebPopupReader({
1319
1299
  params,
1320
1300
  mode = 'live',
@@ -0,0 +1,2 @@
1
+ import { type WebPopupColorScheme, type WebPopupColorSchemePref } from './types';
2
+ export declare function useColorScheme(pref: WebPopupColorSchemePref, watch: boolean): WebPopupColorScheme;
@@ -0,0 +1,22 @@
1
+ import { useEffect, useState } from 'react';
2
+ const DARK_MEDIA_QUERY = '(prefers-color-scheme: dark)';
3
+ function prefersDark() {
4
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(DARK_MEDIA_QUERY).matches;
5
+ }
6
+ // 'auto' follows the visitor's OS setting and keeps following it: a popup can
7
+ // be on screen when the theme flips, and a stale palette is the visible half.
8
+ export function useColorScheme(pref, watch) {
9
+ const [dark, setDark] = useState(() => pref === 'auto' && watch && prefersDark());
10
+ useEffect(() => {
11
+ if (pref !== 'auto' || !watch || typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
12
+ return undefined;
13
+ }
14
+ const media = window.matchMedia(DARK_MEDIA_QUERY);
15
+ const onChange = event => setDark(event.matches);
16
+ setDark(media.matches);
17
+ media.addEventListener('change', onChange);
18
+ return () => media.removeEventListener('change', onChange);
19
+ }, [pref, watch]);
20
+ if (pref !== 'auto') return pref;
21
+ return dark ? 'dark' : 'light';
22
+ }