@prosdevlab/experience-sdk-plugins 0.1.0 → 0.1.3
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/.turbo/turbo-build.log +17 -0
- package/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/dist/index.d.ts +213 -0
- package/dist/index.js +634 -0
- package/dist/index.js.map +1 -0
- package/package.json +18 -11
- package/src/banner/banner.test.ts +370 -10
- package/src/banner/banner.ts +246 -119
- package/src/types.ts +22 -0
- package/src/utils/sanitize.test.ts +412 -0
- package/src/utils/sanitize.ts +196 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import { storagePlugin } from '@lytics/sdk-kit-plugins';
|
|
2
|
+
|
|
3
|
+
// src/utils/sanitize.ts
|
|
4
|
+
var ALLOWED_TAGS = ["strong", "em", "a", "br", "span", "b", "i", "p"];
|
|
5
|
+
var ALLOWED_ATTRIBUTES = {
|
|
6
|
+
a: ["href", "class", "style", "title"],
|
|
7
|
+
span: ["class", "style"],
|
|
8
|
+
p: ["class", "style"]
|
|
9
|
+
// Other tags have no attributes allowed
|
|
10
|
+
};
|
|
11
|
+
function sanitizeHTML(html) {
|
|
12
|
+
if (!html || typeof html !== "string") {
|
|
13
|
+
return "";
|
|
14
|
+
}
|
|
15
|
+
const temp = document.createElement("div");
|
|
16
|
+
temp.innerHTML = html;
|
|
17
|
+
function sanitizeNode(node) {
|
|
18
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
19
|
+
return escapeHTML(node.textContent || "");
|
|
20
|
+
}
|
|
21
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
22
|
+
const element = node;
|
|
23
|
+
const tagName = element.tagName.toLowerCase();
|
|
24
|
+
if (!tagName || tagName.includes(" ")) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
if (!ALLOWED_TAGS.includes(tagName)) {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
const allowedAttrs = ALLOWED_ATTRIBUTES[tagName] || [];
|
|
31
|
+
const attrs = [];
|
|
32
|
+
for (const attr of allowedAttrs) {
|
|
33
|
+
const value = element.getAttribute(attr);
|
|
34
|
+
if (value !== null) {
|
|
35
|
+
if (attr === "href") {
|
|
36
|
+
const sanitizedHref = sanitizeURL(value);
|
|
37
|
+
if (sanitizedHref) {
|
|
38
|
+
attrs.push(`href="${escapeAttribute(sanitizedHref)}"`);
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
attrs.push(`${attr}="${escapeAttribute(value)}"`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const attrString = attrs.length > 0 ? " " + attrs.join(" ") : "";
|
|
46
|
+
let innerHTML = "";
|
|
47
|
+
for (const child of Array.from(element.childNodes)) {
|
|
48
|
+
innerHTML += sanitizeNode(child);
|
|
49
|
+
}
|
|
50
|
+
if (tagName === "br") {
|
|
51
|
+
return `<br${attrString} />`;
|
|
52
|
+
}
|
|
53
|
+
return `<${tagName}${attrString}>${innerHTML}</${tagName}>`;
|
|
54
|
+
}
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
let sanitized = "";
|
|
58
|
+
for (const child of Array.from(temp.childNodes)) {
|
|
59
|
+
sanitized += sanitizeNode(child);
|
|
60
|
+
}
|
|
61
|
+
return sanitized;
|
|
62
|
+
}
|
|
63
|
+
function escapeHTML(text) {
|
|
64
|
+
const div = document.createElement("div");
|
|
65
|
+
div.textContent = text;
|
|
66
|
+
return div.innerHTML;
|
|
67
|
+
}
|
|
68
|
+
function escapeAttribute(value) {
|
|
69
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
70
|
+
}
|
|
71
|
+
function sanitizeURL(url) {
|
|
72
|
+
if (!url || typeof url !== "string") {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
let decoded;
|
|
76
|
+
try {
|
|
77
|
+
decoded = decodeURIComponent(url);
|
|
78
|
+
} catch {
|
|
79
|
+
decoded = url;
|
|
80
|
+
}
|
|
81
|
+
const trimmed = decoded.trim().toLowerCase();
|
|
82
|
+
if (trimmed.startsWith("javascript:") || trimmed.startsWith("data:") || url.toLowerCase().trim().startsWith("javascript:") || url.toLowerCase().trim().startsWith("data:")) {
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("mailto:") || trimmed.startsWith("tel:") || trimmed.startsWith("/") || trimmed.startsWith("#") || trimmed.startsWith("?")) {
|
|
86
|
+
return url;
|
|
87
|
+
}
|
|
88
|
+
if (!trimmed.includes(":")) {
|
|
89
|
+
return url;
|
|
90
|
+
}
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/banner/banner.ts
|
|
95
|
+
var bannerPlugin = (plugin, instance, config) => {
|
|
96
|
+
plugin.ns("banner");
|
|
97
|
+
plugin.defaults({
|
|
98
|
+
banner: {
|
|
99
|
+
position: "top",
|
|
100
|
+
dismissable: true,
|
|
101
|
+
zIndex: 1e4
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
const activeBanners = /* @__PURE__ */ new Map();
|
|
105
|
+
function injectDefaultStyles() {
|
|
106
|
+
const styleId = "xp-banner-styles";
|
|
107
|
+
if (document.getElementById(styleId)) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const style = document.createElement("style");
|
|
111
|
+
style.id = styleId;
|
|
112
|
+
style.textContent = `
|
|
113
|
+
.xp-banner {
|
|
114
|
+
position: fixed;
|
|
115
|
+
left: 0;
|
|
116
|
+
right: 0;
|
|
117
|
+
width: 100%;
|
|
118
|
+
padding: 16px 20px;
|
|
119
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
120
|
+
font-size: 14px;
|
|
121
|
+
line-height: 1.5;
|
|
122
|
+
display: flex;
|
|
123
|
+
align-items: center;
|
|
124
|
+
justify-content: space-between;
|
|
125
|
+
box-sizing: border-box;
|
|
126
|
+
z-index: 10000;
|
|
127
|
+
background: #f9fafb;
|
|
128
|
+
color: #111827;
|
|
129
|
+
border-bottom: 1px solid #e5e7eb;
|
|
130
|
+
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.xp-banner--top {
|
|
134
|
+
top: 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.xp-banner--bottom {
|
|
138
|
+
bottom: 0;
|
|
139
|
+
border-bottom: none;
|
|
140
|
+
border-top: 1px solid #e5e7eb;
|
|
141
|
+
box-shadow: 0 -1px 3px 0 rgba(0, 0, 0, 0.05);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.xp-banner__container {
|
|
145
|
+
display: flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
justify-content: space-between;
|
|
148
|
+
gap: 20px;
|
|
149
|
+
width: 100%;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.xp-banner__content {
|
|
153
|
+
flex: 1;
|
|
154
|
+
min-width: 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.xp-banner__title {
|
|
158
|
+
font-weight: 600;
|
|
159
|
+
margin-bottom: 4px;
|
|
160
|
+
margin-top: 0;
|
|
161
|
+
font-size: 14px;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.xp-banner__message {
|
|
165
|
+
margin: 0;
|
|
166
|
+
font-size: 14px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.xp-banner__buttons {
|
|
170
|
+
display: flex;
|
|
171
|
+
align-items: center;
|
|
172
|
+
gap: 12px;
|
|
173
|
+
flex-wrap: wrap;
|
|
174
|
+
flex-shrink: 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.xp-banner__button {
|
|
178
|
+
padding: 8px 16px;
|
|
179
|
+
border: none;
|
|
180
|
+
border-radius: 6px;
|
|
181
|
+
font-size: 14px;
|
|
182
|
+
font-weight: 500;
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
transition: all 0.2s;
|
|
185
|
+
text-decoration: none;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.xp-banner__button--primary {
|
|
189
|
+
background: #2563eb;
|
|
190
|
+
color: #ffffff;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.xp-banner__button--primary:hover {
|
|
194
|
+
background: #1d4ed8;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.xp-banner__button--secondary {
|
|
198
|
+
background: #ffffff;
|
|
199
|
+
color: #374151;
|
|
200
|
+
border: 1px solid #d1d5db;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.xp-banner__button--secondary:hover {
|
|
204
|
+
background: #f9fafb;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.xp-banner__button--link {
|
|
208
|
+
background: transparent;
|
|
209
|
+
color: #2563eb;
|
|
210
|
+
padding: 4px 8px;
|
|
211
|
+
font-weight: 400;
|
|
212
|
+
text-decoration: underline;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.xp-banner__button--link:hover {
|
|
216
|
+
background: rgba(0, 0, 0, 0.05);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.xp-banner__close {
|
|
220
|
+
background: transparent;
|
|
221
|
+
border: none;
|
|
222
|
+
color: #6b7280;
|
|
223
|
+
font-size: 24px;
|
|
224
|
+
line-height: 1;
|
|
225
|
+
cursor: pointer;
|
|
226
|
+
padding: 0;
|
|
227
|
+
margin: 0;
|
|
228
|
+
opacity: 0.7;
|
|
229
|
+
transition: opacity 0.2s;
|
|
230
|
+
flex-shrink: 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.xp-banner__close:hover {
|
|
234
|
+
opacity: 1;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
@media (max-width: 640px) {
|
|
238
|
+
.xp-banner__container {
|
|
239
|
+
flex-direction: column;
|
|
240
|
+
align-items: stretch;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.xp-banner__buttons {
|
|
244
|
+
width: 100%;
|
|
245
|
+
flex-direction: column;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.xp-banner__button {
|
|
249
|
+
width: 100%;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* Dark mode support */
|
|
254
|
+
@media (prefers-color-scheme: dark) {
|
|
255
|
+
.xp-banner {
|
|
256
|
+
background: #1f2937;
|
|
257
|
+
color: #f3f4f6;
|
|
258
|
+
border-bottom-color: #374151;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.xp-banner--bottom {
|
|
262
|
+
border-top-color: #374151;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.xp-banner__button--primary {
|
|
266
|
+
background: #3b82f6;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.xp-banner__button--primary:hover {
|
|
270
|
+
background: #2563eb;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.xp-banner__button--secondary {
|
|
274
|
+
background: #374151;
|
|
275
|
+
color: #f3f4f6;
|
|
276
|
+
border-color: #4b5563;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.xp-banner__button--secondary:hover {
|
|
280
|
+
background: #4b5563;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.xp-banner__button--link {
|
|
284
|
+
color: #93c5fd;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.xp-banner__close {
|
|
288
|
+
color: #9ca3af;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
`;
|
|
292
|
+
document.head.appendChild(style);
|
|
293
|
+
}
|
|
294
|
+
function createBannerElement(experience) {
|
|
295
|
+
const content = experience.content;
|
|
296
|
+
const position = content.position ?? config.get("banner.position") ?? "top";
|
|
297
|
+
const dismissable = content.dismissable ?? config.get("banner.dismissable") ?? true;
|
|
298
|
+
const zIndex = config.get("banner.zIndex") ?? 1e4;
|
|
299
|
+
injectDefaultStyles();
|
|
300
|
+
const banner = document.createElement("div");
|
|
301
|
+
banner.setAttribute("data-experience-id", experience.id);
|
|
302
|
+
const baseClasses = ["xp-banner", `xp-banner--${position}`];
|
|
303
|
+
if (content.className) {
|
|
304
|
+
baseClasses.push(content.className);
|
|
305
|
+
}
|
|
306
|
+
banner.className = baseClasses.join(" ");
|
|
307
|
+
if (content.style) {
|
|
308
|
+
Object.assign(banner.style, content.style);
|
|
309
|
+
}
|
|
310
|
+
if (zIndex !== 1e4) {
|
|
311
|
+
banner.style.zIndex = String(zIndex);
|
|
312
|
+
}
|
|
313
|
+
const container = document.createElement("div");
|
|
314
|
+
container.className = "xp-banner__container";
|
|
315
|
+
banner.appendChild(container);
|
|
316
|
+
const contentDiv = document.createElement("div");
|
|
317
|
+
contentDiv.className = "xp-banner__content";
|
|
318
|
+
if (content.title) {
|
|
319
|
+
const title = document.createElement("h3");
|
|
320
|
+
title.className = "xp-banner__title";
|
|
321
|
+
title.innerHTML = sanitizeHTML(content.title);
|
|
322
|
+
contentDiv.appendChild(title);
|
|
323
|
+
}
|
|
324
|
+
const message = document.createElement("p");
|
|
325
|
+
message.className = "xp-banner__message";
|
|
326
|
+
message.innerHTML = sanitizeHTML(content.message);
|
|
327
|
+
contentDiv.appendChild(message);
|
|
328
|
+
container.appendChild(contentDiv);
|
|
329
|
+
banner.appendChild(contentDiv);
|
|
330
|
+
const buttonContainer = document.createElement("div");
|
|
331
|
+
buttonContainer.style.cssText = `
|
|
332
|
+
display: flex;
|
|
333
|
+
align-items: center;
|
|
334
|
+
gap: 12px;
|
|
335
|
+
flex-wrap: wrap;
|
|
336
|
+
`;
|
|
337
|
+
const buttonsDiv = document.createElement("div");
|
|
338
|
+
buttonsDiv.className = "xp-banner__buttons";
|
|
339
|
+
function createButton(buttonConfig) {
|
|
340
|
+
const button = document.createElement("button");
|
|
341
|
+
button.textContent = buttonConfig.text;
|
|
342
|
+
const variant = buttonConfig.variant || "primary";
|
|
343
|
+
const buttonClasses = ["xp-banner__button", `xp-banner__button--${variant}`];
|
|
344
|
+
if (buttonConfig.className) {
|
|
345
|
+
buttonClasses.push(buttonConfig.className);
|
|
346
|
+
}
|
|
347
|
+
button.className = buttonClasses.join(" ");
|
|
348
|
+
if (buttonConfig.style) {
|
|
349
|
+
Object.assign(button.style, buttonConfig.style);
|
|
350
|
+
}
|
|
351
|
+
button.addEventListener("click", () => {
|
|
352
|
+
instance.emit("experiences:action", {
|
|
353
|
+
experienceId: experience.id,
|
|
354
|
+
type: "banner",
|
|
355
|
+
action: buttonConfig.action,
|
|
356
|
+
url: buttonConfig.url,
|
|
357
|
+
metadata: buttonConfig.metadata,
|
|
358
|
+
variant,
|
|
359
|
+
timestamp: Date.now()
|
|
360
|
+
});
|
|
361
|
+
if (buttonConfig.url) {
|
|
362
|
+
window.location.href = buttonConfig.url;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
return button;
|
|
366
|
+
}
|
|
367
|
+
if (content.buttons && content.buttons.length > 0) {
|
|
368
|
+
content.buttons.forEach((buttonConfig) => {
|
|
369
|
+
const button = createButton(buttonConfig);
|
|
370
|
+
buttonsDiv.appendChild(button);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
if (dismissable) {
|
|
374
|
+
const closeButton = document.createElement("button");
|
|
375
|
+
closeButton.className = "xp-banner__close";
|
|
376
|
+
closeButton.innerHTML = "×";
|
|
377
|
+
closeButton.setAttribute("aria-label", "Close banner");
|
|
378
|
+
closeButton.addEventListener("click", () => {
|
|
379
|
+
remove(experience.id);
|
|
380
|
+
instance.emit("experiences:dismissed", {
|
|
381
|
+
experienceId: experience.id,
|
|
382
|
+
type: "banner"
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
buttonsDiv.appendChild(closeButton);
|
|
386
|
+
}
|
|
387
|
+
container.appendChild(buttonsDiv);
|
|
388
|
+
return banner;
|
|
389
|
+
}
|
|
390
|
+
function show(experience) {
|
|
391
|
+
if (activeBanners.has(experience.id)) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (typeof document === "undefined") {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const banner = createBannerElement(experience);
|
|
398
|
+
document.body.appendChild(banner);
|
|
399
|
+
activeBanners.set(experience.id, banner);
|
|
400
|
+
instance.emit("experiences:shown", {
|
|
401
|
+
experienceId: experience.id,
|
|
402
|
+
type: "banner",
|
|
403
|
+
timestamp: Date.now()
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
function remove(experienceId) {
|
|
407
|
+
if (experienceId) {
|
|
408
|
+
const banner = activeBanners.get(experienceId);
|
|
409
|
+
if (banner?.parentNode) {
|
|
410
|
+
banner.parentNode.removeChild(banner);
|
|
411
|
+
}
|
|
412
|
+
activeBanners.delete(experienceId);
|
|
413
|
+
} else {
|
|
414
|
+
for (const [id, banner] of activeBanners.entries()) {
|
|
415
|
+
if (banner?.parentNode) {
|
|
416
|
+
banner.parentNode.removeChild(banner);
|
|
417
|
+
}
|
|
418
|
+
activeBanners.delete(id);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function isShowing() {
|
|
423
|
+
return activeBanners.size > 0;
|
|
424
|
+
}
|
|
425
|
+
plugin.expose({
|
|
426
|
+
banner: {
|
|
427
|
+
show,
|
|
428
|
+
remove,
|
|
429
|
+
isShowing
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
instance.on("experiences:evaluated", (payload) => {
|
|
433
|
+
const items = Array.isArray(payload) ? payload : [payload];
|
|
434
|
+
for (const item of items) {
|
|
435
|
+
const typedItem = item;
|
|
436
|
+
const decision = typedItem.decision;
|
|
437
|
+
const experience = typedItem.experience;
|
|
438
|
+
if (experience?.type === "banner") {
|
|
439
|
+
if (decision?.show) {
|
|
440
|
+
show(experience);
|
|
441
|
+
} else if (experience.id && activeBanners.has(experience.id)) {
|
|
442
|
+
remove(experience.id);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
instance.on("sdk:destroy", () => {
|
|
448
|
+
remove();
|
|
449
|
+
});
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// src/debug/debug.ts
|
|
453
|
+
var debugPlugin = (plugin, instance, config) => {
|
|
454
|
+
plugin.ns("debug");
|
|
455
|
+
plugin.defaults({
|
|
456
|
+
debug: {
|
|
457
|
+
enabled: false,
|
|
458
|
+
console: false,
|
|
459
|
+
window: true
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
const isEnabled = () => config.get("debug.enabled") ?? false;
|
|
463
|
+
const shouldLogConsole = () => config.get("debug.console") ?? false;
|
|
464
|
+
const shouldEmitWindow = () => config.get("debug.window") ?? true;
|
|
465
|
+
const log = (message, data) => {
|
|
466
|
+
if (!isEnabled()) return;
|
|
467
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
468
|
+
const logData = {
|
|
469
|
+
timestamp,
|
|
470
|
+
message,
|
|
471
|
+
data
|
|
472
|
+
};
|
|
473
|
+
if (shouldLogConsole()) {
|
|
474
|
+
console.log(`[experiences] ${message}`, data || "");
|
|
475
|
+
}
|
|
476
|
+
if (shouldEmitWindow() && typeof window !== "undefined") {
|
|
477
|
+
const event = new CustomEvent("experience-sdk:debug", {
|
|
478
|
+
detail: logData
|
|
479
|
+
});
|
|
480
|
+
window.dispatchEvent(event);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
plugin.expose({
|
|
484
|
+
debug: {
|
|
485
|
+
log,
|
|
486
|
+
isEnabled
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
if (isEnabled()) {
|
|
490
|
+
instance.on("experiences:ready", () => {
|
|
491
|
+
if (!isEnabled()) return;
|
|
492
|
+
log("SDK initialized and ready");
|
|
493
|
+
});
|
|
494
|
+
instance.on("experiences:registered", (payload) => {
|
|
495
|
+
if (!isEnabled()) return;
|
|
496
|
+
log("Experience registered", payload);
|
|
497
|
+
});
|
|
498
|
+
instance.on("experiences:evaluated", (payload) => {
|
|
499
|
+
if (!isEnabled()) return;
|
|
500
|
+
log("Experience evaluated", payload);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
var frequencyPlugin = (plugin, instance, config) => {
|
|
505
|
+
plugin.ns("frequency");
|
|
506
|
+
plugin.defaults({
|
|
507
|
+
frequency: {
|
|
508
|
+
enabled: true,
|
|
509
|
+
namespace: "experiences:frequency"
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
const experienceFrequencyMap = /* @__PURE__ */ new Map();
|
|
513
|
+
if (!instance.storage) {
|
|
514
|
+
instance.use(storagePlugin);
|
|
515
|
+
}
|
|
516
|
+
const isEnabled = () => config.get("frequency.enabled") ?? true;
|
|
517
|
+
const getNamespace = () => config.get("frequency.namespace") ?? "experiences:frequency";
|
|
518
|
+
const getStorageBackend = (per) => {
|
|
519
|
+
return per === "session" ? sessionStorage : localStorage;
|
|
520
|
+
};
|
|
521
|
+
const getStorageKey = (experienceId) => {
|
|
522
|
+
return `${getNamespace()}:${experienceId}`;
|
|
523
|
+
};
|
|
524
|
+
const getImpressionData = (experienceId, per) => {
|
|
525
|
+
const storage = getStorageBackend(per);
|
|
526
|
+
const key = getStorageKey(experienceId);
|
|
527
|
+
const raw = storage.getItem(key);
|
|
528
|
+
if (!raw) {
|
|
529
|
+
return {
|
|
530
|
+
count: 0,
|
|
531
|
+
lastImpression: 0,
|
|
532
|
+
impressions: [],
|
|
533
|
+
per
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
return JSON.parse(raw);
|
|
538
|
+
} catch {
|
|
539
|
+
return {
|
|
540
|
+
count: 0,
|
|
541
|
+
lastImpression: 0,
|
|
542
|
+
impressions: [],
|
|
543
|
+
per
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
const saveImpressionData = (experienceId, data) => {
|
|
548
|
+
const per = data.per || "session";
|
|
549
|
+
const storage = getStorageBackend(per);
|
|
550
|
+
const key = getStorageKey(experienceId);
|
|
551
|
+
storage.setItem(key, JSON.stringify(data));
|
|
552
|
+
};
|
|
553
|
+
const getTimeWindow = (per) => {
|
|
554
|
+
switch (per) {
|
|
555
|
+
case "session":
|
|
556
|
+
return Number.POSITIVE_INFINITY;
|
|
557
|
+
// Session storage handles this
|
|
558
|
+
case "day":
|
|
559
|
+
return 24 * 60 * 60 * 1e3;
|
|
560
|
+
// 24 hours
|
|
561
|
+
case "week":
|
|
562
|
+
return 7 * 24 * 60 * 60 * 1e3;
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
const getImpressionCount = (experienceId, per = "session") => {
|
|
566
|
+
if (!isEnabled()) return 0;
|
|
567
|
+
const data = getImpressionData(experienceId, per);
|
|
568
|
+
return data.count;
|
|
569
|
+
};
|
|
570
|
+
const hasReachedCap = (experienceId, max, per) => {
|
|
571
|
+
if (!isEnabled()) return false;
|
|
572
|
+
const data = getImpressionData(experienceId, per);
|
|
573
|
+
const timeWindow = getTimeWindow(per);
|
|
574
|
+
const now = Date.now();
|
|
575
|
+
if (per === "session") {
|
|
576
|
+
return data.count >= max;
|
|
577
|
+
}
|
|
578
|
+
const recentImpressions = data.impressions.filter((timestamp) => now - timestamp < timeWindow);
|
|
579
|
+
return recentImpressions.length >= max;
|
|
580
|
+
};
|
|
581
|
+
const recordImpression = (experienceId, per = "session") => {
|
|
582
|
+
if (!isEnabled()) return;
|
|
583
|
+
const data = getImpressionData(experienceId, per);
|
|
584
|
+
const now = Date.now();
|
|
585
|
+
data.count += 1;
|
|
586
|
+
data.lastImpression = now;
|
|
587
|
+
data.impressions.push(now);
|
|
588
|
+
data.per = per;
|
|
589
|
+
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1e3;
|
|
590
|
+
data.impressions = data.impressions.filter((ts) => ts > sevenDaysAgo);
|
|
591
|
+
saveImpressionData(experienceId, data);
|
|
592
|
+
instance.emit("experiences:impression-recorded", {
|
|
593
|
+
experienceId,
|
|
594
|
+
count: data.count,
|
|
595
|
+
timestamp: now
|
|
596
|
+
});
|
|
597
|
+
};
|
|
598
|
+
plugin.expose({
|
|
599
|
+
frequency: {
|
|
600
|
+
getImpressionCount,
|
|
601
|
+
hasReachedCap,
|
|
602
|
+
recordImpression,
|
|
603
|
+
// Internal method to register experience frequency config
|
|
604
|
+
_registerExperience: (experienceId, per) => {
|
|
605
|
+
experienceFrequencyMap.set(experienceId, per);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
if (isEnabled()) {
|
|
610
|
+
instance.on("experiences:evaluated", (payload) => {
|
|
611
|
+
const items = Array.isArray(payload) ? payload : [payload];
|
|
612
|
+
for (const item of items) {
|
|
613
|
+
const decision = item.decision;
|
|
614
|
+
if (decision?.show && decision.experienceId) {
|
|
615
|
+
let per = experienceFrequencyMap.get(decision.experienceId) || "session";
|
|
616
|
+
if (!experienceFrequencyMap.has(decision.experienceId)) {
|
|
617
|
+
const freqStep = decision.trace.find(
|
|
618
|
+
(t) => t.step === "check-frequency-cap"
|
|
619
|
+
);
|
|
620
|
+
if (freqStep?.input && typeof freqStep.input === "object" && "per" in freqStep.input) {
|
|
621
|
+
per = freqStep.input.per;
|
|
622
|
+
experienceFrequencyMap.set(decision.experienceId, per);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
recordImpression(decision.experienceId, per);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
export { bannerPlugin, debugPlugin, frequencyPlugin };
|
|
633
|
+
//# sourceMappingURL=index.js.map
|
|
634
|
+
//# sourceMappingURL=index.js.map
|