@perspective-ai/sdk 1.1.0 → 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.
@@ -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
+ });
@@ -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 */