@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.
@@ -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 };