@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/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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 = "&times;";
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