@perspective-ai/sdk 1.0.1 → 1.1.2
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 +107 -31
- 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 +107 -31
- 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 +8 -23
- package/dist/constants.cjs.map +1 -1
- package/dist/constants.d.cts +5 -4
- package/dist/constants.d.ts +5 -4
- package/dist/constants.js +9 -23
- package/dist/constants.js.map +1 -1
- package/dist/index.cjs +86 -28
- 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 +82 -29
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/browser.ts +42 -3
- package/src/constants.ts +7 -28
- package/src/iframe.test.ts +115 -0
- package/src/iframe.ts +8 -9
- 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/constants.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// ============================================================================
|
|
10
10
|
|
|
11
11
|
/** SDK version for handshake protocol */
|
|
12
|
-
export const SDK_VERSION = "1.
|
|
12
|
+
export const SDK_VERSION = "1.1.2";
|
|
13
13
|
|
|
14
14
|
/** Feature flags as bitset for version negotiation */
|
|
15
15
|
export const FEATURES = {
|
|
@@ -60,36 +60,12 @@ export const BRAND_KEYS = {
|
|
|
60
60
|
export type BrandKey = (typeof BRAND_KEYS)[keyof typeof BRAND_KEYS];
|
|
61
61
|
|
|
62
62
|
// ============================================================================
|
|
63
|
-
//
|
|
64
|
-
// ============================================================================
|
|
65
|
-
|
|
66
|
-
export const UTM_PARAMS = [
|
|
67
|
-
"utm_source",
|
|
68
|
-
"utm_medium",
|
|
69
|
-
"utm_campaign",
|
|
70
|
-
"utm_term",
|
|
71
|
-
"utm_content",
|
|
72
|
-
] as const;
|
|
73
|
-
|
|
74
|
-
export type UtmParam = (typeof UTM_PARAMS)[number];
|
|
75
|
-
|
|
76
|
-
// ============================================================================
|
|
77
|
-
// Reserved Parameters (cannot be overridden via custom params)
|
|
63
|
+
// Reserved Parameters (cannot be overridden via custom params or parent URL)
|
|
78
64
|
// ============================================================================
|
|
79
65
|
|
|
80
66
|
export const RESERVED_PARAMS: Set<string> = new Set([
|
|
81
|
-
PARAM_KEYS
|
|
82
|
-
|
|
83
|
-
PARAM_KEYS.theme,
|
|
84
|
-
BRAND_KEYS.primary,
|
|
85
|
-
BRAND_KEYS.secondary,
|
|
86
|
-
BRAND_KEYS.bg,
|
|
87
|
-
BRAND_KEYS.text,
|
|
88
|
-
BRAND_KEYS.darkPrimary,
|
|
89
|
-
BRAND_KEYS.darkSecondary,
|
|
90
|
-
BRAND_KEYS.darkBg,
|
|
91
|
-
BRAND_KEYS.darkText,
|
|
92
|
-
...UTM_PARAMS,
|
|
67
|
+
...Object.values(PARAM_KEYS),
|
|
68
|
+
...Object.values(BRAND_KEYS),
|
|
93
69
|
]);
|
|
94
70
|
|
|
95
71
|
// ============================================================================
|
|
@@ -108,6 +84,8 @@ export const DATA_ATTRS = {
|
|
|
108
84
|
brandDark: "data-perspective-brand-dark",
|
|
109
85
|
theme: "data-perspective-theme",
|
|
110
86
|
noStyle: "data-perspective-no-style",
|
|
87
|
+
autoOpen: "data-perspective-auto-open",
|
|
88
|
+
showOnce: "data-perspective-show-once",
|
|
111
89
|
} as const;
|
|
112
90
|
|
|
113
91
|
export type DataAttr = (typeof DATA_ATTRS)[keyof typeof DATA_ATTRS];
|
|
@@ -191,4 +169,5 @@ export type ModeValue = (typeof MODE_VALUES)[keyof typeof MODE_VALUES];
|
|
|
191
169
|
|
|
192
170
|
export const STORAGE_KEYS = {
|
|
193
171
|
anonId: "perspective-anon-id",
|
|
172
|
+
triggerShown: "perspective-trigger-shown",
|
|
194
173
|
} as const;
|
package/src/iframe.test.ts
CHANGED
|
@@ -62,6 +62,121 @@ describe("createIframe", () => {
|
|
|
62
62
|
expect(src.searchParams.get(PARAM_KEYS.theme)).toBe("dark");
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
it("forwards parent page search params to iframe", () => {
|
|
66
|
+
// Simulate parent page URL with search params
|
|
67
|
+
const originalLocation = window.location;
|
|
68
|
+
Object.defineProperty(window, "location", {
|
|
69
|
+
value: { ...originalLocation, search: "?ref=pricing-enterprise&foo=bar" },
|
|
70
|
+
writable: true,
|
|
71
|
+
configurable: true,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const iframe = createIframe(
|
|
75
|
+
"test-research-id",
|
|
76
|
+
"widget",
|
|
77
|
+
"https://getperspective.ai"
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const src = new URL(iframe.src);
|
|
81
|
+
expect(src.searchParams.get("ref")).toBe("pricing-enterprise");
|
|
82
|
+
expect(src.searchParams.get("foo")).toBe("bar");
|
|
83
|
+
|
|
84
|
+
Object.defineProperty(window, "location", {
|
|
85
|
+
value: originalLocation,
|
|
86
|
+
writable: true,
|
|
87
|
+
configurable: true,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("does not forward reserved params from parent URL", () => {
|
|
92
|
+
const originalLocation = window.location;
|
|
93
|
+
Object.defineProperty(window, "location", {
|
|
94
|
+
value: {
|
|
95
|
+
...originalLocation,
|
|
96
|
+
search: "?embed=false&theme=dark&ref=test",
|
|
97
|
+
},
|
|
98
|
+
writable: true,
|
|
99
|
+
configurable: true,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const iframe = createIframe(
|
|
103
|
+
"test-research-id",
|
|
104
|
+
"widget",
|
|
105
|
+
"https://getperspective.ai"
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const src = new URL(iframe.src);
|
|
109
|
+
// Reserved params should be set by SDK, not from parent URL
|
|
110
|
+
expect(src.searchParams.get(PARAM_KEYS.embed)).toBe("true");
|
|
111
|
+
// Non-reserved params should be forwarded
|
|
112
|
+
expect(src.searchParams.get("ref")).toBe("test");
|
|
113
|
+
|
|
114
|
+
Object.defineProperty(window, "location", {
|
|
115
|
+
value: originalLocation,
|
|
116
|
+
writable: true,
|
|
117
|
+
configurable: true,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("custom params override parent URL params", () => {
|
|
122
|
+
const originalLocation = window.location;
|
|
123
|
+
Object.defineProperty(window, "location", {
|
|
124
|
+
value: { ...originalLocation, search: "?ref=from-url&source=parent" },
|
|
125
|
+
writable: true,
|
|
126
|
+
configurable: true,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const iframe = createIframe(
|
|
130
|
+
"test-research-id",
|
|
131
|
+
"widget",
|
|
132
|
+
"https://getperspective.ai",
|
|
133
|
+
{ ref: "from-custom", extra: "value" }
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const src = new URL(iframe.src);
|
|
137
|
+
// Custom params should override parent URL params
|
|
138
|
+
expect(src.searchParams.get("ref")).toBe("from-custom");
|
|
139
|
+
// Parent-only params should still be forwarded
|
|
140
|
+
expect(src.searchParams.get("source")).toBe("parent");
|
|
141
|
+
// Custom-only params should be present
|
|
142
|
+
expect(src.searchParams.get("extra")).toBe("value");
|
|
143
|
+
|
|
144
|
+
Object.defineProperty(window, "location", {
|
|
145
|
+
value: originalLocation,
|
|
146
|
+
writable: true,
|
|
147
|
+
configurable: true,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("forwards UTM params from parent URL", () => {
|
|
152
|
+
const originalLocation = window.location;
|
|
153
|
+
Object.defineProperty(window, "location", {
|
|
154
|
+
value: {
|
|
155
|
+
...originalLocation,
|
|
156
|
+
search: "?utm_source=google&utm_campaign=summer&ref=pricing",
|
|
157
|
+
},
|
|
158
|
+
writable: true,
|
|
159
|
+
configurable: true,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const iframe = createIframe(
|
|
163
|
+
"test-research-id",
|
|
164
|
+
"widget",
|
|
165
|
+
"https://getperspective.ai"
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const src = new URL(iframe.src);
|
|
169
|
+
expect(src.searchParams.get("utm_source")).toBe("google");
|
|
170
|
+
expect(src.searchParams.get("utm_campaign")).toBe("summer");
|
|
171
|
+
expect(src.searchParams.get("ref")).toBe("pricing");
|
|
172
|
+
|
|
173
|
+
Object.defineProperty(window, "location", {
|
|
174
|
+
value: originalLocation,
|
|
175
|
+
writable: true,
|
|
176
|
+
configurable: true,
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
65
180
|
it("includes brand colors", () => {
|
|
66
181
|
const iframe = createIframe(
|
|
67
182
|
"test-research-id",
|
package/src/iframe.ts
CHANGED
|
@@ -11,7 +11,6 @@ import type {
|
|
|
11
11
|
} from "./types";
|
|
12
12
|
import { hasDom } from "./config";
|
|
13
13
|
import {
|
|
14
|
-
UTM_PARAMS,
|
|
15
14
|
RESERVED_PARAMS,
|
|
16
15
|
PARAM_KEYS,
|
|
17
16
|
BRAND_KEYS,
|
|
@@ -63,16 +62,15 @@ function getOrCreateAnonId(): string {
|
|
|
63
62
|
}
|
|
64
63
|
}
|
|
65
64
|
|
|
66
|
-
/** Collect
|
|
67
|
-
function
|
|
65
|
+
/** Collect all search params from the parent page URL (excluding reserved SDK params) */
|
|
66
|
+
function getParentSearchParams(): Record<string, string> {
|
|
68
67
|
if (!hasDom()) return {};
|
|
69
68
|
|
|
70
69
|
const params: Record<string, string> = {};
|
|
71
70
|
const searchParams = new URLSearchParams(window.location.search);
|
|
72
71
|
|
|
73
|
-
for (const key of
|
|
74
|
-
|
|
75
|
-
if (value) {
|
|
72
|
+
for (const [key, value] of searchParams.entries()) {
|
|
73
|
+
if (!RESERVED_PARAMS.has(key)) {
|
|
76
74
|
params[key] = value;
|
|
77
75
|
}
|
|
78
76
|
}
|
|
@@ -111,9 +109,10 @@ function buildIframeUrl(
|
|
|
111
109
|
url.searchParams.set(PARAM_KEYS.theme, themeOverride || THEME_VALUES.light);
|
|
112
110
|
}
|
|
113
111
|
|
|
114
|
-
// Auto-forward
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
// Auto-forward all parent page search params (e.g. ?ref=pricing-enterprise)
|
|
113
|
+
// These are added first so that explicit customParams can override them
|
|
114
|
+
const parentParams = getParentSearchParams();
|
|
115
|
+
for (const [key, value] of Object.entries(parentParams)) {
|
|
117
116
|
url.searchParams.set(key, value);
|
|
118
117
|
}
|
|
119
118
|
|
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 */
|