@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/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
- const params = parseParamsAttr(el);
366
- const brandConfig = extractBrandConfig(el);
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
- export const SDK_VERSION = "1.1.0";
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
+ });
@@ -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 */