@prosdevlab/experience-sdk-plugins 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +120 -0
- package/README.md +141 -79
- package/dist/index.d.ts +206 -35
- package/dist/index.js +1229 -75
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/banner/banner.ts +63 -62
- package/src/exit-intent/exit-intent.ts +2 -3
- package/src/index.ts +2 -0
- package/src/inline/index.ts +3 -0
- package/src/inline/inline.test.ts +620 -0
- package/src/inline/inline.ts +269 -0
- package/src/inline/insertion.ts +66 -0
- package/src/inline/types.ts +52 -0
- package/src/integration.test.ts +356 -297
- package/src/modal/form-rendering.ts +262 -0
- package/src/modal/form-styles.ts +212 -0
- package/src/modal/form-validation.test.ts +413 -0
- package/src/modal/form-validation.ts +126 -0
- package/src/modal/index.ts +3 -0
- package/src/modal/modal-styles.ts +204 -0
- package/src/modal/modal.browser.test.ts +164 -0
- package/src/modal/modal.test.ts +1294 -0
- package/src/modal/modal.ts +685 -0
- package/src/modal/types.ts +114 -0
- package/src/scroll-depth/scroll-depth.test.ts +35 -0
- package/src/scroll-depth/scroll-depth.ts +2 -4
- package/src/time-delay/time-delay.test.ts +2 -2
- package/src/time-delay/time-delay.ts +2 -3
- package/src/types.ts +20 -36
- package/src/utils/sanitize.ts +4 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { SDK } from '@lytics/sdk-kit';
|
|
2
|
+
import type { StoragePlugin } from '@lytics/sdk-kit-plugins';
|
|
3
|
+
import { storagePlugin } from '@lytics/sdk-kit-plugins';
|
|
4
|
+
import { sanitizeHTML } from '../utils/sanitize';
|
|
5
|
+
import { insertContent, removeContent } from './insertion';
|
|
6
|
+
import type { InlinePlugin } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SDK instance with storage plugin
|
|
10
|
+
*/
|
|
11
|
+
type SDKWithStorage = SDK & { storage?: StoragePlugin };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Inline Plugin
|
|
15
|
+
*
|
|
16
|
+
* Embeds experiences directly within page content using DOM selectors.
|
|
17
|
+
* Supports multiple insertion positions and dismissal with persistence.
|
|
18
|
+
*/
|
|
19
|
+
export const inlinePlugin = (plugin: any, instance: SDK, config: any): void => {
|
|
20
|
+
plugin.ns('experiences.inline');
|
|
21
|
+
|
|
22
|
+
plugin.defaults({
|
|
23
|
+
inline: {
|
|
24
|
+
retry: false,
|
|
25
|
+
retryTimeout: 5000,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Auto-load storage plugin if not present
|
|
30
|
+
if (!(instance as SDKWithStorage).storage) {
|
|
31
|
+
instance.use(storagePlugin);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Cast instance to include storage
|
|
35
|
+
const sdkInstance = instance as SDKWithStorage;
|
|
36
|
+
|
|
37
|
+
// Inject CSS variables
|
|
38
|
+
if (typeof document !== 'undefined') {
|
|
39
|
+
const styleId = 'xp-inline-styles';
|
|
40
|
+
if (!document.getElementById(styleId)) {
|
|
41
|
+
const style = document.createElement('style');
|
|
42
|
+
style.id = styleId;
|
|
43
|
+
style.textContent = getInlineStyles();
|
|
44
|
+
document.head.appendChild(style);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const activeInlines = new Map<string, HTMLElement>();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Show an inline experience
|
|
52
|
+
*/
|
|
53
|
+
const show = (experience: any): void => {
|
|
54
|
+
const { id, content } = experience;
|
|
55
|
+
|
|
56
|
+
// Check if already showing (prevent duplicates)
|
|
57
|
+
if (activeInlines.has(id)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check if dismissed and persisted
|
|
62
|
+
if (content.persist && content.dismissable && sdkInstance.storage) {
|
|
63
|
+
const dismissed = sdkInstance.storage.get(`xp-inline-dismissed-${id}`);
|
|
64
|
+
if (dismissed) {
|
|
65
|
+
instance.emit('experiences:inline:dismissed', {
|
|
66
|
+
experienceId: id,
|
|
67
|
+
reason: 'previously-dismissed',
|
|
68
|
+
timestamp: Date.now(),
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Try to insert content
|
|
75
|
+
const element = insertContent(
|
|
76
|
+
content.selector,
|
|
77
|
+
sanitizeHTML(content.message),
|
|
78
|
+
content.position || 'replace',
|
|
79
|
+
id
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!element) {
|
|
83
|
+
// Selector not found
|
|
84
|
+
instance.emit('experiences:inline:error', {
|
|
85
|
+
experienceId: id,
|
|
86
|
+
error: 'selector-not-found',
|
|
87
|
+
selector: content.selector,
|
|
88
|
+
timestamp: Date.now(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Retry logic (if enabled)
|
|
92
|
+
const retryEnabled = config.get('inline.retry') ?? false;
|
|
93
|
+
const retryTimeout = config.get('inline.retryTimeout') ?? 5000;
|
|
94
|
+
|
|
95
|
+
if (retryEnabled) {
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
show(experience);
|
|
98
|
+
}, retryTimeout);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Store reference
|
|
105
|
+
activeInlines.set(id, element);
|
|
106
|
+
|
|
107
|
+
// Apply custom styles
|
|
108
|
+
if (content.className) {
|
|
109
|
+
element.classList.add(content.className);
|
|
110
|
+
}
|
|
111
|
+
if (content.style) {
|
|
112
|
+
Object.assign(element.style, content.style);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Add dismissal button if needed
|
|
116
|
+
if (content.dismissable) {
|
|
117
|
+
const closeBtn = document.createElement('button');
|
|
118
|
+
closeBtn.className = 'xp-inline__close';
|
|
119
|
+
closeBtn.setAttribute('aria-label', 'Close');
|
|
120
|
+
closeBtn.textContent = '×';
|
|
121
|
+
closeBtn.onclick = () => {
|
|
122
|
+
remove(id);
|
|
123
|
+
if (content.persist && sdkInstance.storage) {
|
|
124
|
+
sdkInstance.storage.set(`xp-inline-dismissed-${id}`, true);
|
|
125
|
+
}
|
|
126
|
+
instance.emit('experiences:dismissed', {
|
|
127
|
+
experienceId: id,
|
|
128
|
+
timestamp: Date.now(),
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
element.prepend(closeBtn);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Emit shown event
|
|
135
|
+
instance.emit('experiences:shown', {
|
|
136
|
+
experienceId: id,
|
|
137
|
+
type: 'inline',
|
|
138
|
+
selector: content.selector,
|
|
139
|
+
position: content.position || 'replace',
|
|
140
|
+
timestamp: Date.now(),
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Remove an inline experience
|
|
146
|
+
*/
|
|
147
|
+
const remove = (experienceId: string): void => {
|
|
148
|
+
const element = activeInlines.get(experienceId);
|
|
149
|
+
if (!element) return;
|
|
150
|
+
|
|
151
|
+
removeContent(experienceId);
|
|
152
|
+
activeInlines.delete(experienceId);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check if an inline experience is showing
|
|
157
|
+
*/
|
|
158
|
+
const isShowing = (experienceId?: string): boolean => {
|
|
159
|
+
if (experienceId) {
|
|
160
|
+
return activeInlines.has(experienceId);
|
|
161
|
+
}
|
|
162
|
+
return activeInlines.size > 0;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Expose public API
|
|
166
|
+
plugin.expose({
|
|
167
|
+
inline: {
|
|
168
|
+
show,
|
|
169
|
+
remove,
|
|
170
|
+
isShowing,
|
|
171
|
+
} as InlinePlugin,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Auto-show on evaluation
|
|
175
|
+
instance.on('experiences:evaluated', (data: any) => {
|
|
176
|
+
if (data.decision?.show && data.experience?.type === 'inline') {
|
|
177
|
+
show(data.experience);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Cleanup on destroy
|
|
182
|
+
instance.on('sdk:destroy', () => {
|
|
183
|
+
for (const id of Array.from(activeInlines.keys())) {
|
|
184
|
+
remove(id);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get CSS styles for inline experiences
|
|
191
|
+
*/
|
|
192
|
+
function getInlineStyles(): string {
|
|
193
|
+
return `
|
|
194
|
+
:root {
|
|
195
|
+
--xp-inline-close-size: 24px;
|
|
196
|
+
--xp-inline-close-color: #666;
|
|
197
|
+
--xp-inline-close-hover-color: #111;
|
|
198
|
+
--xp-inline-close-bg: transparent;
|
|
199
|
+
--xp-inline-close-hover-bg: rgba(0, 0, 0, 0.05);
|
|
200
|
+
--xp-inline-close-border-radius: 4px;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
@media (prefers-color-scheme: dark) {
|
|
204
|
+
:root {
|
|
205
|
+
--xp-inline-close-color: #9ca3af;
|
|
206
|
+
--xp-inline-close-hover-color: #f9fafb;
|
|
207
|
+
--xp-inline-close-hover-bg: rgba(255, 255, 255, 0.1);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.xp-inline {
|
|
212
|
+
position: relative;
|
|
213
|
+
animation: xp-inline-enter 0.4s ease-out forwards;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
@keyframes xp-inline-enter {
|
|
217
|
+
from {
|
|
218
|
+
opacity: 0;
|
|
219
|
+
transform: translateY(-8px);
|
|
220
|
+
}
|
|
221
|
+
to {
|
|
222
|
+
opacity: 1;
|
|
223
|
+
transform: translateY(0);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* Respect user's motion preferences */
|
|
228
|
+
@media (prefers-reduced-motion: reduce) {
|
|
229
|
+
.xp-inline {
|
|
230
|
+
animation: xp-inline-enter-reduced 0.2s ease-out forwards;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@keyframes xp-inline-enter-reduced {
|
|
234
|
+
from {
|
|
235
|
+
opacity: 0;
|
|
236
|
+
}
|
|
237
|
+
to {
|
|
238
|
+
opacity: 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.xp-inline__close {
|
|
244
|
+
position: absolute;
|
|
245
|
+
top: 8px;
|
|
246
|
+
right: 8px;
|
|
247
|
+
width: var(--xp-inline-close-size);
|
|
248
|
+
height: var(--xp-inline-close-size);
|
|
249
|
+
padding: 0;
|
|
250
|
+
border: none;
|
|
251
|
+
background: var(--xp-inline-close-bg);
|
|
252
|
+
color: var(--xp-inline-close-color);
|
|
253
|
+
font-size: 20px;
|
|
254
|
+
line-height: 1;
|
|
255
|
+
cursor: pointer;
|
|
256
|
+
border-radius: var(--xp-inline-close-border-radius);
|
|
257
|
+
transition: all 0.2s ease;
|
|
258
|
+
display: flex;
|
|
259
|
+
align-items: center;
|
|
260
|
+
justify-content: center;
|
|
261
|
+
z-index: 1;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.xp-inline__close:hover {
|
|
265
|
+
background: var(--xp-inline-close-hover-bg);
|
|
266
|
+
color: var(--xp-inline-close-hover-color);
|
|
267
|
+
}
|
|
268
|
+
`;
|
|
269
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { InsertionPosition } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Insert content into a target element using specified position
|
|
5
|
+
*
|
|
6
|
+
* @param selector - CSS selector for target element
|
|
7
|
+
* @param content - HTML content to insert
|
|
8
|
+
* @param position - Where to insert the content
|
|
9
|
+
* @param experienceId - Unique identifier for the experience
|
|
10
|
+
* @returns The created wrapper element, or null if target not found
|
|
11
|
+
*/
|
|
12
|
+
export function insertContent(
|
|
13
|
+
selector: string,
|
|
14
|
+
content: string,
|
|
15
|
+
position: InsertionPosition,
|
|
16
|
+
experienceId: string
|
|
17
|
+
): HTMLElement | null {
|
|
18
|
+
const target = document.querySelector(selector);
|
|
19
|
+
|
|
20
|
+
if (!target) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const wrapper = document.createElement('div');
|
|
25
|
+
wrapper.className = 'xp-inline';
|
|
26
|
+
wrapper.setAttribute('data-xp-id', experienceId);
|
|
27
|
+
wrapper.innerHTML = content;
|
|
28
|
+
|
|
29
|
+
switch (position) {
|
|
30
|
+
case 'replace':
|
|
31
|
+
target.innerHTML = '';
|
|
32
|
+
target.appendChild(wrapper);
|
|
33
|
+
break;
|
|
34
|
+
case 'append':
|
|
35
|
+
target.appendChild(wrapper);
|
|
36
|
+
break;
|
|
37
|
+
case 'prepend':
|
|
38
|
+
target.insertBefore(wrapper, target.firstChild);
|
|
39
|
+
break;
|
|
40
|
+
case 'before':
|
|
41
|
+
target.parentElement?.insertBefore(wrapper, target);
|
|
42
|
+
break;
|
|
43
|
+
case 'after':
|
|
44
|
+
target.parentElement?.insertBefore(wrapper, target.nextSibling);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return wrapper;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Remove inline content by experience ID
|
|
53
|
+
*
|
|
54
|
+
* @param experienceId - Unique identifier for the experience
|
|
55
|
+
* @returns True if element was found and removed, false otherwise
|
|
56
|
+
*/
|
|
57
|
+
export function removeContent(experienceId: string): boolean {
|
|
58
|
+
const element = document.querySelector(`[data-xp-id="${experienceId}"]`);
|
|
59
|
+
|
|
60
|
+
if (!element) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
element.remove();
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PluginFunction } from '@lytics/sdk-kit';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inline plugin configuration
|
|
5
|
+
*/
|
|
6
|
+
export interface InlinePluginConfig {
|
|
7
|
+
inline?: {
|
|
8
|
+
/** Retry selector lookup if not found (default: false) */
|
|
9
|
+
retry?: boolean;
|
|
10
|
+
/** Retry timeout in ms (default: 5000) */
|
|
11
|
+
retryTimeout?: number;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Inline content configuration
|
|
17
|
+
*/
|
|
18
|
+
export interface InlineContent {
|
|
19
|
+
/** CSS selector for target element */
|
|
20
|
+
selector: string;
|
|
21
|
+
/** Where to insert content (default: 'replace') */
|
|
22
|
+
position?: 'replace' | 'append' | 'prepend' | 'before' | 'after';
|
|
23
|
+
/** HTML content to insert */
|
|
24
|
+
message: string;
|
|
25
|
+
/** Show close button (default: false) */
|
|
26
|
+
dismissable?: boolean;
|
|
27
|
+
/** Remember dismissal in localStorage (default: false) */
|
|
28
|
+
persist?: boolean;
|
|
29
|
+
/** Custom CSS class */
|
|
30
|
+
className?: string;
|
|
31
|
+
/** Inline styles */
|
|
32
|
+
style?: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Inline plugin API
|
|
37
|
+
*/
|
|
38
|
+
export interface InlinePlugin {
|
|
39
|
+
/** Show an inline experience */
|
|
40
|
+
show(experience: any): void;
|
|
41
|
+
/** Remove a specific inline experience */
|
|
42
|
+
remove(experienceId: string): void;
|
|
43
|
+
/** Check if an inline experience is showing */
|
|
44
|
+
isShowing(experienceId?: string): boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Insertion position for inline content
|
|
49
|
+
*/
|
|
50
|
+
export type InsertionPosition = 'replace' | 'append' | 'prepend' | 'before' | 'after';
|
|
51
|
+
|
|
52
|
+
export type { PluginFunction };
|