@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 +6 -2
- package/subscription-widget/SubscriptionWidgetReader.d.ts +23 -0
- package/subscription-widget/SubscriptionWidgetReader.js +307 -0
- package/subscription-widget/constants.d.ts +19 -0
- package/subscription-widget/constants.js +61 -0
- package/subscription-widget/helpers.d.ts +16 -0
- package/subscription-widget/helpers.js +110 -0
- package/subscription-widget/index.d.ts +4 -0
- package/subscription-widget/index.js +3 -0
- package/subscription-widget/types.d.ts +90 -0
- package/subscription-widget/types.js +1 -0
- package/web-popups/WebPopupReader.js +1 -21
- package/web-popups/colorScheme.d.ts +2 -0
- package/web-popups/colorScheme.js +22 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pushwoosh/websdk-common",
|
|
3
|
-
"version": "6.
|
|
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,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
|
+
}
|