@perspective-ai/sdk 1.1.0 → 1.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/browser.cjs +105 -6
- package/dist/browser.cjs.map +1 -1
- package/dist/browser.d.cts +13 -0
- package/dist/browser.d.ts +13 -0
- package/dist/browser.js +105 -6
- package/dist/browser.js.map +1 -1
- package/dist/cdn/perspective.global.js +17 -17
- package/dist/cdn/perspective.global.js.map +1 -1
- package/dist/constants.cjs +6 -3
- package/dist/constants.cjs.map +1 -1
- package/dist/constants.d.cts +4 -2
- package/dist/constants.d.ts +4 -2
- package/dist/constants.js +6 -3
- package/dist/constants.js.map +1 -1
- package/dist/index.cjs +81 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +60 -1
- package/dist/index.d.ts +60 -1
- package/dist/index.js +77 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/browser.test.ts +44 -1
- package/src/browser.ts +48 -3
- package/src/constants.ts +6 -2
- package/src/iframe.ts +8 -0
- package/src/index.ts +13 -0
- package/src/triggers.test.ts +272 -0
- package/src/triggers.ts +127 -0
- package/src/types.ts +19 -0
package/src/browser.ts
CHANGED
|
@@ -24,6 +24,13 @@ import type {
|
|
|
24
24
|
ThemeConfig,
|
|
25
25
|
} from "./types";
|
|
26
26
|
import { DATA_ATTRS, THEME_VALUES } from "./constants";
|
|
27
|
+
import {
|
|
28
|
+
parseTriggerAttr,
|
|
29
|
+
parseShowOnceAttr,
|
|
30
|
+
setupTrigger,
|
|
31
|
+
shouldShow,
|
|
32
|
+
markShown,
|
|
33
|
+
} from "./triggers";
|
|
27
34
|
import { createWidget } from "./widget";
|
|
28
35
|
import { openPopup } from "./popup";
|
|
29
36
|
import { openSlider } from "./slider";
|
|
@@ -35,6 +42,9 @@ import { resolveIsDark } from "./utils";
|
|
|
35
42
|
// Track all active instances
|
|
36
43
|
const instances: Map<string, EmbedHandle | FloatHandle> = new Map();
|
|
37
44
|
|
|
45
|
+
// Track auto-open trigger cleanups (keyed by researchId)
|
|
46
|
+
const triggerCleanups: Map<string, () => void> = new Map();
|
|
47
|
+
|
|
38
48
|
// Theme config cache
|
|
39
49
|
const configCache: Map<string, ThemeConfig> = new Map();
|
|
40
50
|
|
|
@@ -317,8 +327,16 @@ function destroy(researchId: string): void {
|
|
|
317
327
|
function destroyAll(): void {
|
|
318
328
|
instances.forEach((instance) => instance.unmount());
|
|
319
329
|
instances.clear();
|
|
330
|
+
triggerCleanups.forEach((cleanup) => cleanup());
|
|
331
|
+
triggerCleanups.clear();
|
|
320
332
|
styledButtons.clear();
|
|
321
333
|
teardownButtonThemeListener();
|
|
334
|
+
// Reset initialized flags so autoInit can re-process elements cleanly
|
|
335
|
+
if (hasDom()) {
|
|
336
|
+
document
|
|
337
|
+
.querySelectorAll<HTMLElement>("[data-perspective-initialized]")
|
|
338
|
+
.forEach((el) => el.removeAttribute("data-perspective-initialized"));
|
|
339
|
+
}
|
|
322
340
|
}
|
|
323
341
|
|
|
324
342
|
/**
|
|
@@ -361,9 +379,36 @@ function autoInit(): void {
|
|
|
361
379
|
el.setAttribute("data-perspective-initialized", "true");
|
|
362
380
|
|
|
363
381
|
const researchId = el.getAttribute(DATA_ATTRS.popup);
|
|
364
|
-
if (researchId)
|
|
365
|
-
|
|
366
|
-
|
|
382
|
+
if (!researchId) return;
|
|
383
|
+
|
|
384
|
+
const params = parseParamsAttr(el);
|
|
385
|
+
const brandConfig = extractBrandConfig(el);
|
|
386
|
+
const autoOpenAttr = el.getAttribute(DATA_ATTRS.autoOpen);
|
|
387
|
+
|
|
388
|
+
if (autoOpenAttr) {
|
|
389
|
+
// Auto-open mode: trigger-based, no button styling
|
|
390
|
+
try {
|
|
391
|
+
const trigger = parseTriggerAttr(autoOpenAttr);
|
|
392
|
+
const showOnce = parseShowOnceAttr(
|
|
393
|
+
el.getAttribute(DATA_ATTRS.showOnce)
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
if (shouldShow(researchId, showOnce)) {
|
|
397
|
+
// Clean up any existing trigger for this researchId
|
|
398
|
+
triggerCleanups.get(researchId)?.();
|
|
399
|
+
|
|
400
|
+
const cleanup = setupTrigger(trigger, () => {
|
|
401
|
+
triggerCleanups.delete(researchId);
|
|
402
|
+
markShown(researchId, showOnce);
|
|
403
|
+
init({ researchId, type: "popup", params, ...brandConfig });
|
|
404
|
+
});
|
|
405
|
+
triggerCleanups.set(researchId, cleanup);
|
|
406
|
+
}
|
|
407
|
+
} catch (e) {
|
|
408
|
+
console.warn("[Perspective]", (e as Error).message);
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
// Click-to-open mode: styled button
|
|
367
412
|
styleButton(el, DEFAULT_THEME, brandConfig);
|
|
368
413
|
el.addEventListener("click", (e) => {
|
|
369
414
|
e.preventDefault();
|
package/src/constants.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// SDK Version & Features
|
|
9
9
|
// ============================================================================
|
|
10
10
|
|
|
11
|
-
/** SDK version for handshake protocol */
|
|
12
|
-
|
|
11
|
+
/** SDK version for handshake protocol — replaced at build time by tsup define */
|
|
12
|
+
declare const PKG_VERSION: string;
|
|
13
|
+
export const SDK_VERSION = PKG_VERSION;
|
|
13
14
|
|
|
14
15
|
/** Feature flags as bitset for version negotiation */
|
|
15
16
|
export const FEATURES = {
|
|
@@ -84,6 +85,8 @@ export const DATA_ATTRS = {
|
|
|
84
85
|
brandDark: "data-perspective-brand-dark",
|
|
85
86
|
theme: "data-perspective-theme",
|
|
86
87
|
noStyle: "data-perspective-no-style",
|
|
88
|
+
autoOpen: "data-perspective-auto-open",
|
|
89
|
+
showOnce: "data-perspective-show-once",
|
|
87
90
|
} as const;
|
|
88
91
|
|
|
89
92
|
export type DataAttr = (typeof DATA_ATTRS)[keyof typeof DATA_ATTRS];
|
|
@@ -167,4 +170,5 @@ export type ModeValue = (typeof MODE_VALUES)[keyof typeof MODE_VALUES];
|
|
|
167
170
|
|
|
168
171
|
export const STORAGE_KEYS = {
|
|
169
172
|
anonId: "perspective-anon-id",
|
|
173
|
+
triggerShown: "perspective-trigger-shown",
|
|
170
174
|
} as const;
|
package/src/iframe.ts
CHANGED
|
@@ -27,6 +27,14 @@ import { normalizeHex } from "./utils";
|
|
|
27
27
|
/** Validate redirect URL - allow https, http localhost, and relative URLs */
|
|
28
28
|
function isAllowedRedirectUrl(url: string): boolean {
|
|
29
29
|
if (!url || typeof url !== "string") return false;
|
|
30
|
+
// Relative URLs (path, query, hash) are always safe — same origin by definition
|
|
31
|
+
// Exclude protocol-relative URLs (// prefix) which resolve to an external origin
|
|
32
|
+
if (
|
|
33
|
+
(url.startsWith("/") && !url.startsWith("//")) ||
|
|
34
|
+
url.startsWith("?") ||
|
|
35
|
+
url.startsWith("#")
|
|
36
|
+
)
|
|
37
|
+
return true;
|
|
30
38
|
try {
|
|
31
39
|
const parsed = new URL(url, window.location.origin);
|
|
32
40
|
const protocol = parsed.protocol.toLowerCase();
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,15 @@ export { openSlider } from "./slider";
|
|
|
29
29
|
export { createFloatBubble, createChatBubble } from "./float";
|
|
30
30
|
export { createFullpage } from "./fullpage";
|
|
31
31
|
|
|
32
|
+
// Auto-open triggers
|
|
33
|
+
export {
|
|
34
|
+
setupTrigger,
|
|
35
|
+
parseTriggerAttr,
|
|
36
|
+
parseShowOnceAttr,
|
|
37
|
+
shouldShow,
|
|
38
|
+
markShown,
|
|
39
|
+
} from "./triggers";
|
|
40
|
+
|
|
32
41
|
// Configuration
|
|
33
42
|
export { configure, getConfig } from "./config";
|
|
34
43
|
|
|
@@ -44,6 +53,10 @@ export type {
|
|
|
44
53
|
EmbedType,
|
|
45
54
|
ThemeConfig,
|
|
46
55
|
SDKConfig,
|
|
56
|
+
TriggerType,
|
|
57
|
+
TriggerConfig,
|
|
58
|
+
ShowOnce,
|
|
59
|
+
AutoOpenConfig,
|
|
47
60
|
} from "./types";
|
|
48
61
|
|
|
49
62
|
// Re-export commonly used constants and types
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseTriggerAttr,
|
|
4
|
+
parseShowOnceAttr,
|
|
5
|
+
setupTrigger,
|
|
6
|
+
shouldShow,
|
|
7
|
+
markShown,
|
|
8
|
+
} from "./triggers";
|
|
9
|
+
|
|
10
|
+
describe("triggers", () => {
|
|
11
|
+
describe("parseTriggerAttr", () => {
|
|
12
|
+
it("parses timeout with delay", () => {
|
|
13
|
+
expect(parseTriggerAttr("timeout:5000")).toEqual({
|
|
14
|
+
type: "timeout",
|
|
15
|
+
delay: 5000,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("parses timeout with zero delay", () => {
|
|
20
|
+
expect(parseTriggerAttr("timeout:0")).toEqual({
|
|
21
|
+
type: "timeout",
|
|
22
|
+
delay: 0,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("parses bare timeout as 5000ms default", () => {
|
|
27
|
+
expect(parseTriggerAttr("timeout")).toEqual({
|
|
28
|
+
type: "timeout",
|
|
29
|
+
delay: 5000,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("parses exit-intent", () => {
|
|
34
|
+
expect(parseTriggerAttr("exit-intent")).toEqual({
|
|
35
|
+
type: "exit-intent",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("trims whitespace", () => {
|
|
40
|
+
expect(parseTriggerAttr(" timeout:3000 ")).toEqual({
|
|
41
|
+
type: "timeout",
|
|
42
|
+
delay: 3000,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("throws on invalid timeout delay", () => {
|
|
47
|
+
expect(() => parseTriggerAttr("timeout:abc")).toThrow(
|
|
48
|
+
"Invalid timeout delay"
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("throws on negative timeout delay", () => {
|
|
53
|
+
expect(() => parseTriggerAttr("timeout:-1")).toThrow(
|
|
54
|
+
"Invalid timeout delay"
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("throws on unknown trigger type", () => {
|
|
59
|
+
expect(() => parseTriggerAttr("scroll:50")).toThrow(
|
|
60
|
+
"Unknown trigger type"
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("setupTrigger", () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
vi.useFakeTimers();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
vi.useRealTimers();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("calls callback after timeout delay", () => {
|
|
75
|
+
const callback = vi.fn();
|
|
76
|
+
setupTrigger({ type: "timeout", delay: 3000 }, callback);
|
|
77
|
+
|
|
78
|
+
expect(callback).not.toHaveBeenCalled();
|
|
79
|
+
vi.advanceTimersByTime(2999);
|
|
80
|
+
expect(callback).not.toHaveBeenCalled();
|
|
81
|
+
vi.advanceTimersByTime(1);
|
|
82
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns cleanup that clears timeout", () => {
|
|
86
|
+
const callback = vi.fn();
|
|
87
|
+
const cleanup = setupTrigger({ type: "timeout", delay: 3000 }, callback);
|
|
88
|
+
|
|
89
|
+
cleanup();
|
|
90
|
+
vi.advanceTimersByTime(5000);
|
|
91
|
+
expect(callback).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("sets up exit-intent on mouseleave", () => {
|
|
95
|
+
const callback = vi.fn();
|
|
96
|
+
const addSpy = vi.spyOn(document, "addEventListener");
|
|
97
|
+
|
|
98
|
+
setupTrigger({ type: "exit-intent" }, callback);
|
|
99
|
+
|
|
100
|
+
expect(addSpy).toHaveBeenCalledWith("mouseleave", expect.any(Function));
|
|
101
|
+
addSpy.mockRestore();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("fires exit-intent callback when clientY <= 0", () => {
|
|
105
|
+
const callback = vi.fn();
|
|
106
|
+
setupTrigger({ type: "exit-intent" }, callback);
|
|
107
|
+
|
|
108
|
+
// Simulate mouse leaving the document upward
|
|
109
|
+
const event = new MouseEvent("mouseleave", { clientY: -1 });
|
|
110
|
+
document.dispatchEvent(event);
|
|
111
|
+
|
|
112
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("does not fire exit-intent when clientY > 0", () => {
|
|
116
|
+
const callback = vi.fn();
|
|
117
|
+
setupTrigger({ type: "exit-intent" }, callback);
|
|
118
|
+
|
|
119
|
+
const event = new MouseEvent("mouseleave", { clientY: 100 });
|
|
120
|
+
document.dispatchEvent(event);
|
|
121
|
+
|
|
122
|
+
expect(callback).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("removes exit-intent listener after firing", () => {
|
|
126
|
+
const callback = vi.fn();
|
|
127
|
+
setupTrigger({ type: "exit-intent" }, callback);
|
|
128
|
+
|
|
129
|
+
const event1 = new MouseEvent("mouseleave", { clientY: -1 });
|
|
130
|
+
document.dispatchEvent(event1);
|
|
131
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
132
|
+
|
|
133
|
+
// Second event should not fire
|
|
134
|
+
const event2 = new MouseEvent("mouseleave", { clientY: -1 });
|
|
135
|
+
document.dispatchEvent(event2);
|
|
136
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns cleanup that removes exit-intent listener", () => {
|
|
140
|
+
const callback = vi.fn();
|
|
141
|
+
const removeSpy = vi.spyOn(document, "removeEventListener");
|
|
142
|
+
|
|
143
|
+
const cleanup = setupTrigger({ type: "exit-intent" }, callback);
|
|
144
|
+
cleanup();
|
|
145
|
+
|
|
146
|
+
expect(removeSpy).toHaveBeenCalledWith(
|
|
147
|
+
"mouseleave",
|
|
148
|
+
expect.any(Function)
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Event should not fire after cleanup
|
|
152
|
+
const event = new MouseEvent("mouseleave", { clientY: -1 });
|
|
153
|
+
document.dispatchEvent(event);
|
|
154
|
+
expect(callback).not.toHaveBeenCalled();
|
|
155
|
+
|
|
156
|
+
removeSpy.mockRestore();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("parseShowOnceAttr", () => {
|
|
161
|
+
it('returns "session" for null', () => {
|
|
162
|
+
expect(parseShowOnceAttr(null)).toBe("session");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('returns "session" for empty string', () => {
|
|
166
|
+
expect(parseShowOnceAttr("")).toBe("session");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('returns "session" for "session"', () => {
|
|
170
|
+
expect(parseShowOnceAttr("session")).toBe("session");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('returns "visitor" for "visitor"', () => {
|
|
174
|
+
expect(parseShowOnceAttr("visitor")).toBe("visitor");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns false for "false"', () => {
|
|
178
|
+
expect(parseShowOnceAttr("false")).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('defaults unknown values to "session"', () => {
|
|
182
|
+
expect(parseShowOnceAttr("always")).toBe("session");
|
|
183
|
+
expect(parseShowOnceAttr("never")).toBe("session");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("trims whitespace", () => {
|
|
187
|
+
expect(parseShowOnceAttr(" visitor ")).toBe("visitor");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("shouldShow / markShown", () => {
|
|
192
|
+
beforeEach(() => {
|
|
193
|
+
sessionStorage.clear();
|
|
194
|
+
localStorage.clear();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("returns true when no prior shown (session)", () => {
|
|
198
|
+
expect(shouldShow("test-id", "session")).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns true when no prior shown (visitor)", () => {
|
|
202
|
+
expect(shouldShow("test-id", "visitor")).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("returns true when showOnce is false", () => {
|
|
206
|
+
expect(shouldShow("test-id", false)).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("returns false after markShown (session)", () => {
|
|
210
|
+
markShown("test-id", "session");
|
|
211
|
+
expect(shouldShow("test-id", "session")).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("returns false after markShown (visitor)", () => {
|
|
215
|
+
markShown("test-id", "visitor");
|
|
216
|
+
expect(shouldShow("test-id", "visitor")).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("always returns true when showOnce is false, even after markShown", () => {
|
|
220
|
+
markShown("test-id", false);
|
|
221
|
+
expect(shouldShow("test-id", false)).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("uses sessionStorage for session mode", () => {
|
|
225
|
+
markShown("test-id", "session");
|
|
226
|
+
expect(sessionStorage.getItem("perspective-trigger-shown:test-id")).toBe(
|
|
227
|
+
"1"
|
|
228
|
+
);
|
|
229
|
+
expect(
|
|
230
|
+
localStorage.getItem("perspective-trigger-shown:test-id")
|
|
231
|
+
).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("uses localStorage for visitor mode", () => {
|
|
235
|
+
markShown("test-id", "visitor");
|
|
236
|
+
expect(localStorage.getItem("perspective-trigger-shown:test-id")).toBe(
|
|
237
|
+
"1"
|
|
238
|
+
);
|
|
239
|
+
expect(
|
|
240
|
+
sessionStorage.getItem("perspective-trigger-shown:test-id")
|
|
241
|
+
).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("deduplicates per researchId", () => {
|
|
245
|
+
markShown("id-1", "session");
|
|
246
|
+
expect(shouldShow("id-1", "session")).toBe(false);
|
|
247
|
+
expect(shouldShow("id-2", "session")).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("shouldShow returns true when storage throws", () => {
|
|
251
|
+
const spy = vi
|
|
252
|
+
.spyOn(Storage.prototype, "getItem")
|
|
253
|
+
.mockImplementation(() => {
|
|
254
|
+
throw new Error("Storage disabled");
|
|
255
|
+
});
|
|
256
|
+
expect(shouldShow("test-id", "session")).toBe(true);
|
|
257
|
+
expect(shouldShow("test-id", "visitor")).toBe(true);
|
|
258
|
+
spy.mockRestore();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("markShown does not throw when storage throws", () => {
|
|
262
|
+
const spy = vi
|
|
263
|
+
.spyOn(Storage.prototype, "setItem")
|
|
264
|
+
.mockImplementation(() => {
|
|
265
|
+
throw new Error("Storage disabled");
|
|
266
|
+
});
|
|
267
|
+
expect(() => markShown("test-id", "session")).not.toThrow();
|
|
268
|
+
expect(() => markShown("test-id", "visitor")).not.toThrow();
|
|
269
|
+
spy.mockRestore();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
package/src/triggers.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-open trigger system for popup embeds.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Timeout: Open after a delay (ms)
|
|
6
|
+
* - Exit intent: Open when user moves cursor above viewport
|
|
7
|
+
*
|
|
8
|
+
* Show-once dedup:
|
|
9
|
+
* - "session" -> sessionStorage
|
|
10
|
+
* - "visitor" -> localStorage
|
|
11
|
+
* - false -> always show
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { STORAGE_KEYS } from "./constants";
|
|
15
|
+
import type { TriggerConfig, ShowOnce } from "./types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a trigger attribute value into a TriggerConfig.
|
|
19
|
+
*
|
|
20
|
+
* Formats:
|
|
21
|
+
* - "timeout:5000" -> { type: "timeout", delay: 5000 }
|
|
22
|
+
* - "exit-intent" -> { type: "exit-intent" }
|
|
23
|
+
*/
|
|
24
|
+
export function parseTriggerAttr(value: string): TriggerConfig {
|
|
25
|
+
const trimmed = value.trim();
|
|
26
|
+
|
|
27
|
+
if (trimmed.startsWith("timeout:")) {
|
|
28
|
+
const delay = parseInt(trimmed.slice("timeout:".length), 10);
|
|
29
|
+
if (isNaN(delay) || delay < 0) {
|
|
30
|
+
throw new Error(`Invalid timeout delay: "${value}"`);
|
|
31
|
+
}
|
|
32
|
+
return { type: "timeout", delay };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (trimmed === "timeout") {
|
|
36
|
+
return { type: "timeout", delay: 5000 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (trimmed === "exit-intent") {
|
|
40
|
+
return { type: "exit-intent" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Unknown trigger type: "${value}". Expected "timeout:<ms>" or "exit-intent".`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Set up a trigger that calls `callback` when fired.
|
|
50
|
+
* Returns a cleanup function to teardown the trigger.
|
|
51
|
+
*/
|
|
52
|
+
export function setupTrigger(
|
|
53
|
+
config: TriggerConfig,
|
|
54
|
+
callback: () => void
|
|
55
|
+
): () => void {
|
|
56
|
+
if (config.type === "timeout") {
|
|
57
|
+
const timer = setTimeout(callback, config.delay);
|
|
58
|
+
return () => clearTimeout(timer);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (config.type === "exit-intent") {
|
|
62
|
+
const handler = (e: MouseEvent) => {
|
|
63
|
+
if (e.clientY <= 0) {
|
|
64
|
+
callback();
|
|
65
|
+
document.removeEventListener("mouseleave", handler);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
document.addEventListener("mouseleave", handler);
|
|
69
|
+
return () => document.removeEventListener("mouseleave", handler);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Exhaustive check
|
|
73
|
+
const _exhaustive: never = config;
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Unknown trigger type: ${(_exhaustive as TriggerConfig).type}`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function storageKey(researchId: string): string {
|
|
80
|
+
return `${STORAGE_KEYS.triggerShown}:${researchId}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse a show-once attribute value into a ShowOnce.
|
|
85
|
+
*
|
|
86
|
+
* Formats:
|
|
87
|
+
* - "session" -> "session"
|
|
88
|
+
* - "visitor" -> "visitor"
|
|
89
|
+
* - "false" -> false
|
|
90
|
+
* - anything else -> defaults to "session"
|
|
91
|
+
*/
|
|
92
|
+
export function parseShowOnceAttr(value: string | null): ShowOnce {
|
|
93
|
+
if (!value) return "session";
|
|
94
|
+
const trimmed = value.trim();
|
|
95
|
+
if (trimmed === "visitor") return "visitor";
|
|
96
|
+
if (trimmed === "false") return false;
|
|
97
|
+
return "session";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if the popup should be shown based on show-once dedup.
|
|
102
|
+
*/
|
|
103
|
+
export function shouldShow(researchId: string, showOnce: ShowOnce): boolean {
|
|
104
|
+
if (showOnce === false) return true;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const storage = showOnce === "visitor" ? localStorage : sessionStorage;
|
|
108
|
+
return storage.getItem(storageKey(researchId)) === null;
|
|
109
|
+
} catch {
|
|
110
|
+
// Storage unavailable (private browsing, etc.) — show anyway
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Mark the popup as shown for dedup purposes.
|
|
117
|
+
*/
|
|
118
|
+
export function markShown(researchId: string, showOnce: ShowOnce): void {
|
|
119
|
+
if (showOnce === false) return;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const storage = showOnce === "visitor" ? localStorage : sessionStorage;
|
|
123
|
+
storage.setItem(storageKey(researchId), "1");
|
|
124
|
+
} catch {
|
|
125
|
+
// Storage unavailable — ignore
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -23,6 +23,23 @@ export type EmbedType =
|
|
|
23
23
|
| "fullpage"
|
|
24
24
|
| "chat";
|
|
25
25
|
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Auto-open Trigger Types
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
export type TriggerConfig =
|
|
31
|
+
| { type: "timeout"; delay: number }
|
|
32
|
+
| { type: "exit-intent" };
|
|
33
|
+
|
|
34
|
+
export type TriggerType = TriggerConfig["type"];
|
|
35
|
+
|
|
36
|
+
export type ShowOnce = "session" | "visitor" | false;
|
|
37
|
+
|
|
38
|
+
export interface AutoOpenConfig {
|
|
39
|
+
trigger: TriggerConfig;
|
|
40
|
+
showOnce?: ShowOnce; // default: "session"
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
/** Brand colors that can be passed via embed code */
|
|
27
44
|
export interface BrandColors {
|
|
28
45
|
/** Primary accent color (buttons, links, focus states) */
|
|
@@ -51,6 +68,8 @@ export interface EmbedConfig {
|
|
|
51
68
|
theme?: ThemeValue;
|
|
52
69
|
/** Override the default host (defaults to https://getperspective.ai) */
|
|
53
70
|
host?: string;
|
|
71
|
+
/** Auto-open trigger configuration (popup only) */
|
|
72
|
+
autoOpen?: AutoOpenConfig;
|
|
54
73
|
/** Callback when embed is ready */
|
|
55
74
|
onReady?: () => void;
|
|
56
75
|
/** Callback when interview is submitted/completed */
|