@perspective-ai/sdk-react 0.0.0-pr-21-20260224144030
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/README.md +339 -0
- package/dist/index.cjs +607 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +175 -0
- package/dist/index.d.ts +175 -0
- package/dist/index.js +597 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
- package/src/FloatBubble.test.tsx +134 -0
- package/src/FloatBubble.tsx +57 -0
- package/src/Fullpage.test.tsx +164 -0
- package/src/Fullpage.tsx +83 -0
- package/src/Widget.test.tsx +308 -0
- package/src/Widget.tsx +100 -0
- package/src/hooks/useAutoOpen.test.ts +168 -0
- package/src/hooks/useAutoOpen.ts +63 -0
- package/src/hooks/useFloatBubble.test.ts +91 -0
- package/src/hooks/useFloatBubble.ts +180 -0
- package/src/hooks/usePopup.test.ts +219 -0
- package/src/hooks/usePopup.ts +190 -0
- package/src/hooks/useSlider.test.ts +150 -0
- package/src/hooks/useSlider.ts +181 -0
- package/src/hooks/useStableCallback.test.ts +97 -0
- package/src/hooks/useStableCallback.ts +24 -0
- package/src/hooks/useThemeSync.test.ts +127 -0
- package/src/hooks/useThemeSync.ts +36 -0
- package/src/index.ts +57 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { render, screen, cleanup } from "@testing-library/react";
|
|
3
|
+
import { createRef, StrictMode } from "react";
|
|
4
|
+
import { Widget } from "./Widget";
|
|
5
|
+
import type { EmbedHandle } from "@perspective-ai/sdk";
|
|
6
|
+
|
|
7
|
+
// Mock the core embed package
|
|
8
|
+
vi.mock("@perspective-ai/sdk", () => ({
|
|
9
|
+
createWidget: vi.fn(() => ({
|
|
10
|
+
unmount: vi.fn(),
|
|
11
|
+
update: vi.fn(),
|
|
12
|
+
destroy: vi.fn(),
|
|
13
|
+
researchId: "test-research-id",
|
|
14
|
+
type: "widget",
|
|
15
|
+
iframe: null,
|
|
16
|
+
container: null,
|
|
17
|
+
})),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { createWidget } from "@perspective-ai/sdk";
|
|
21
|
+
const mockCreateWidget = vi.mocked(createWidget);
|
|
22
|
+
|
|
23
|
+
describe("Widget", () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
cleanup();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders a div container", () => {
|
|
33
|
+
render(<Widget researchId="test-research-id" />);
|
|
34
|
+
|
|
35
|
+
const container = screen.getByTestId("perspective-widget");
|
|
36
|
+
expect(container).toBeDefined();
|
|
37
|
+
expect(container.tagName).toBe("DIV");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("has default minHeight of 500px", () => {
|
|
41
|
+
render(<Widget researchId="test-research-id" />);
|
|
42
|
+
|
|
43
|
+
const container = screen.getByTestId("perspective-widget");
|
|
44
|
+
expect(container.style.minHeight).toBe("500px");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("accepts custom className", () => {
|
|
48
|
+
render(<Widget researchId="test-research-id" className="custom-class" />);
|
|
49
|
+
|
|
50
|
+
const container = screen.getByTestId("perspective-widget");
|
|
51
|
+
expect(container.classList.contains("custom-class")).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("accepts custom style", () => {
|
|
55
|
+
render(
|
|
56
|
+
<Widget
|
|
57
|
+
researchId="test-research-id"
|
|
58
|
+
style={{ backgroundColor: "red", minHeight: 600 }}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const container = screen.getByTestId("perspective-widget");
|
|
63
|
+
expect(container.style.backgroundColor).toBe("red");
|
|
64
|
+
expect(container.style.minHeight).toBe("600px"); // Custom overrides default
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("calls createWidget with correct config", () => {
|
|
68
|
+
const onReady = vi.fn();
|
|
69
|
+
const onSubmit = vi.fn();
|
|
70
|
+
|
|
71
|
+
render(
|
|
72
|
+
<Widget
|
|
73
|
+
researchId="test-research-id"
|
|
74
|
+
params={{ source: "test" }}
|
|
75
|
+
theme="dark"
|
|
76
|
+
host="https://custom.example.com"
|
|
77
|
+
onReady={onReady}
|
|
78
|
+
onSubmit={onSubmit}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(mockCreateWidget).toHaveBeenCalledTimes(1);
|
|
83
|
+
const [container, config] = mockCreateWidget.mock.calls[0]!;
|
|
84
|
+
expect(container).toBeInstanceOf(HTMLDivElement);
|
|
85
|
+
expect(config.researchId).toBe("test-research-id");
|
|
86
|
+
expect(config.params).toEqual({ source: "test" });
|
|
87
|
+
expect(config.theme).toBe("dark");
|
|
88
|
+
expect(config.host).toBe("https://custom.example.com");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("calls unmount on cleanup", () => {
|
|
92
|
+
const mockUnmount = vi.fn();
|
|
93
|
+
mockCreateWidget.mockReturnValueOnce({
|
|
94
|
+
unmount: mockUnmount,
|
|
95
|
+
update: vi.fn(),
|
|
96
|
+
destroy: vi.fn(),
|
|
97
|
+
researchId: "test-research-id",
|
|
98
|
+
type: "widget",
|
|
99
|
+
iframe: null,
|
|
100
|
+
container: null,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const { unmount } = render(<Widget researchId="test-research-id" />);
|
|
104
|
+
|
|
105
|
+
expect(mockUnmount).not.toHaveBeenCalled();
|
|
106
|
+
|
|
107
|
+
unmount();
|
|
108
|
+
|
|
109
|
+
expect(mockUnmount).toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("exposes handle via embedRef", () => {
|
|
113
|
+
const mockHandle: EmbedHandle = {
|
|
114
|
+
unmount: vi.fn(),
|
|
115
|
+
update: vi.fn(),
|
|
116
|
+
destroy: vi.fn(),
|
|
117
|
+
researchId: "test-research-id",
|
|
118
|
+
type: "widget",
|
|
119
|
+
iframe: null,
|
|
120
|
+
container: null,
|
|
121
|
+
};
|
|
122
|
+
mockCreateWidget.mockReturnValueOnce(mockHandle);
|
|
123
|
+
|
|
124
|
+
const embedRef = createRef<EmbedHandle | null>();
|
|
125
|
+
|
|
126
|
+
render(<Widget researchId="test-research-id" embedRef={embedRef} />);
|
|
127
|
+
|
|
128
|
+
expect(embedRef.current).toBe(mockHandle);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("clears embedRef on unmount", () => {
|
|
132
|
+
const embedRef = createRef<EmbedHandle | null>();
|
|
133
|
+
|
|
134
|
+
const { unmount } = render(
|
|
135
|
+
<Widget researchId="test-research-id" embedRef={embedRef} />
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(embedRef.current).not.toBeNull();
|
|
139
|
+
|
|
140
|
+
unmount();
|
|
141
|
+
|
|
142
|
+
expect(embedRef.current).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("re-creates widget when researchId changes", () => {
|
|
146
|
+
const { rerender } = render(<Widget researchId="research-1" />);
|
|
147
|
+
|
|
148
|
+
expect(mockCreateWidget).toHaveBeenCalledTimes(1);
|
|
149
|
+
|
|
150
|
+
rerender(<Widget researchId="research-2" />);
|
|
151
|
+
|
|
152
|
+
expect(mockCreateWidget).toHaveBeenCalledTimes(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("passes additional div props", () => {
|
|
156
|
+
render(
|
|
157
|
+
<Widget
|
|
158
|
+
researchId="test-research-id"
|
|
159
|
+
aria-label="Interview widget"
|
|
160
|
+
role="region"
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const container = screen.getByTestId("perspective-widget");
|
|
165
|
+
expect(container.getAttribute("aria-label")).toBe("Interview widget");
|
|
166
|
+
expect(container.getAttribute("role")).toBe("region");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("StrictMode behavior", () => {
|
|
170
|
+
it("creates only one iframe in StrictMode (mock inserts real DOM)", () => {
|
|
171
|
+
const mockUnmount = vi.fn();
|
|
172
|
+
mockCreateWidget.mockImplementation((containerEl: HTMLElement | null) => {
|
|
173
|
+
if (!containerEl) {
|
|
174
|
+
return {
|
|
175
|
+
unmount: vi.fn(),
|
|
176
|
+
update: vi.fn(),
|
|
177
|
+
destroy: vi.fn(),
|
|
178
|
+
researchId: "test-research-id",
|
|
179
|
+
type: "widget" as const,
|
|
180
|
+
iframe: null,
|
|
181
|
+
container: null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const iframe = document.createElement("iframe");
|
|
185
|
+
iframe.setAttribute("data-perspective", "true");
|
|
186
|
+
containerEl.appendChild(iframe);
|
|
187
|
+
return {
|
|
188
|
+
unmount: () => {
|
|
189
|
+
mockUnmount();
|
|
190
|
+
iframe.remove();
|
|
191
|
+
},
|
|
192
|
+
update: vi.fn(),
|
|
193
|
+
destroy: vi.fn(),
|
|
194
|
+
researchId: "test-research-id",
|
|
195
|
+
type: "widget" as const,
|
|
196
|
+
iframe,
|
|
197
|
+
container: containerEl,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
render(
|
|
202
|
+
<StrictMode>
|
|
203
|
+
<Widget researchId="test-research-id" />
|
|
204
|
+
</StrictMode>
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const container = screen.getByTestId("perspective-widget");
|
|
208
|
+
const iframes = container.querySelectorAll("iframe[data-perspective]");
|
|
209
|
+
expect(iframes.length).toBe(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("properly cleans up in StrictMode double-mount cycle", () => {
|
|
213
|
+
const mockUnmount = vi.fn();
|
|
214
|
+
mockCreateWidget.mockImplementation((containerEl: HTMLElement | null) => {
|
|
215
|
+
if (!containerEl) {
|
|
216
|
+
return {
|
|
217
|
+
unmount: vi.fn(),
|
|
218
|
+
update: vi.fn(),
|
|
219
|
+
destroy: vi.fn(),
|
|
220
|
+
researchId: "test-research-id",
|
|
221
|
+
type: "widget" as const,
|
|
222
|
+
iframe: null,
|
|
223
|
+
container: null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const iframe = document.createElement("iframe");
|
|
227
|
+
iframe.setAttribute("data-perspective", "true");
|
|
228
|
+
containerEl.appendChild(iframe);
|
|
229
|
+
return {
|
|
230
|
+
unmount: () => {
|
|
231
|
+
mockUnmount();
|
|
232
|
+
iframe.remove();
|
|
233
|
+
},
|
|
234
|
+
update: vi.fn(),
|
|
235
|
+
destroy: vi.fn(),
|
|
236
|
+
researchId: "test-research-id",
|
|
237
|
+
type: "widget" as const,
|
|
238
|
+
iframe,
|
|
239
|
+
container: containerEl,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const { unmount } = render(
|
|
244
|
+
<StrictMode>
|
|
245
|
+
<Widget researchId="test-research-id" />
|
|
246
|
+
</StrictMode>
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
unmount();
|
|
250
|
+
|
|
251
|
+
expect(mockUnmount).toHaveBeenCalled();
|
|
252
|
+
const container = screen.queryByTestId("perspective-widget");
|
|
253
|
+
if (container) {
|
|
254
|
+
const iframes = container.querySelectorAll("iframe[data-perspective]");
|
|
255
|
+
expect(iframes.length).toBe(0);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("StrictMode double-mount calls createWidget twice but cleanup prevents duplicates", () => {
|
|
260
|
+
const createCalls: number[] = [];
|
|
261
|
+
const unmountCalls: number[] = [];
|
|
262
|
+
let callCount = 0;
|
|
263
|
+
|
|
264
|
+
mockCreateWidget.mockImplementation((containerEl: HTMLElement | null) => {
|
|
265
|
+
if (!containerEl) {
|
|
266
|
+
return {
|
|
267
|
+
unmount: vi.fn(),
|
|
268
|
+
update: vi.fn(),
|
|
269
|
+
destroy: vi.fn(),
|
|
270
|
+
researchId: "test-research-id",
|
|
271
|
+
type: "widget" as const,
|
|
272
|
+
iframe: null,
|
|
273
|
+
container: null,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const thisCall = ++callCount;
|
|
277
|
+
createCalls.push(thisCall);
|
|
278
|
+
const iframe = document.createElement("iframe");
|
|
279
|
+
iframe.setAttribute("data-perspective", "true");
|
|
280
|
+
iframe.setAttribute("data-call", String(thisCall));
|
|
281
|
+
containerEl.appendChild(iframe);
|
|
282
|
+
return {
|
|
283
|
+
unmount: () => {
|
|
284
|
+
unmountCalls.push(thisCall);
|
|
285
|
+
iframe.remove();
|
|
286
|
+
},
|
|
287
|
+
update: vi.fn(),
|
|
288
|
+
destroy: vi.fn(),
|
|
289
|
+
researchId: "test-research-id",
|
|
290
|
+
type: "widget" as const,
|
|
291
|
+
iframe,
|
|
292
|
+
container: containerEl,
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
render(
|
|
297
|
+
<StrictMode>
|
|
298
|
+
<Widget researchId="test-research-id" />
|
|
299
|
+
</StrictMode>
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const container = screen.getByTestId("perspective-widget");
|
|
303
|
+
const iframes = container.querySelectorAll("iframe[data-perspective]");
|
|
304
|
+
expect(iframes.length).toBe(1);
|
|
305
|
+
expect(createCalls.length).toBeGreaterThanOrEqual(1);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
package/src/Widget.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useRef, useEffect, type HTMLAttributes, type RefObject } from "react";
|
|
2
|
+
import {
|
|
3
|
+
createWidget,
|
|
4
|
+
type EmbedConfig,
|
|
5
|
+
type EmbedHandle,
|
|
6
|
+
} from "@perspective-ai/sdk";
|
|
7
|
+
import { useStableCallback } from "./hooks/useStableCallback";
|
|
8
|
+
|
|
9
|
+
export interface WidgetProps
|
|
10
|
+
extends
|
|
11
|
+
Omit<EmbedConfig, "type">,
|
|
12
|
+
Omit<HTMLAttributes<HTMLDivElement>, "onError" | "onSubmit"> {
|
|
13
|
+
/** Ref to access the embed handle for programmatic control */
|
|
14
|
+
embedRef?: RefObject<EmbedHandle | null>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Inline widget embed component.
|
|
19
|
+
* Renders the interview directly in a container.
|
|
20
|
+
*/
|
|
21
|
+
export function Widget({
|
|
22
|
+
researchId,
|
|
23
|
+
params,
|
|
24
|
+
brand,
|
|
25
|
+
theme,
|
|
26
|
+
host,
|
|
27
|
+
onReady,
|
|
28
|
+
onSubmit,
|
|
29
|
+
onNavigate,
|
|
30
|
+
onClose,
|
|
31
|
+
onError,
|
|
32
|
+
embedRef,
|
|
33
|
+
className,
|
|
34
|
+
style,
|
|
35
|
+
...divProps
|
|
36
|
+
}: WidgetProps) {
|
|
37
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
38
|
+
const handleRef = useRef<EmbedHandle | null>(null);
|
|
39
|
+
|
|
40
|
+
// Stable callbacks to avoid re-mounting on callback changes
|
|
41
|
+
const stableOnReady = useStableCallback(onReady);
|
|
42
|
+
const stableOnSubmit = useStableCallback(onSubmit);
|
|
43
|
+
const stableOnNavigate = useStableCallback(onNavigate);
|
|
44
|
+
const stableOnClose = useStableCallback(onClose);
|
|
45
|
+
const stableOnError = useStableCallback(onError);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const container = containerRef.current;
|
|
49
|
+
if (!container) return;
|
|
50
|
+
|
|
51
|
+
const handle = createWidget(container, {
|
|
52
|
+
researchId,
|
|
53
|
+
params,
|
|
54
|
+
brand,
|
|
55
|
+
theme,
|
|
56
|
+
host,
|
|
57
|
+
onReady: stableOnReady,
|
|
58
|
+
onSubmit: stableOnSubmit,
|
|
59
|
+
onNavigate: stableOnNavigate,
|
|
60
|
+
onClose: stableOnClose,
|
|
61
|
+
onError: stableOnError,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
handleRef.current = handle;
|
|
65
|
+
|
|
66
|
+
if (embedRef) {
|
|
67
|
+
embedRef.current = handle;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return () => {
|
|
71
|
+
handle.unmount();
|
|
72
|
+
handleRef.current = null;
|
|
73
|
+
if (embedRef) {
|
|
74
|
+
embedRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}, [
|
|
78
|
+
researchId,
|
|
79
|
+
params,
|
|
80
|
+
brand,
|
|
81
|
+
theme,
|
|
82
|
+
host,
|
|
83
|
+
stableOnReady,
|
|
84
|
+
stableOnSubmit,
|
|
85
|
+
stableOnNavigate,
|
|
86
|
+
stableOnClose,
|
|
87
|
+
stableOnError,
|
|
88
|
+
embedRef,
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
ref={containerRef}
|
|
94
|
+
className={className}
|
|
95
|
+
style={{ minHeight: 500, ...style }}
|
|
96
|
+
data-testid="perspective-widget"
|
|
97
|
+
{...divProps}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { renderHook, cleanup, act } from "@testing-library/react";
|
|
3
|
+
import { useAutoOpen } from "./useAutoOpen";
|
|
4
|
+
|
|
5
|
+
vi.mock("@perspective-ai/sdk", () => ({
|
|
6
|
+
openPopup: vi.fn(() => ({
|
|
7
|
+
unmount: vi.fn(),
|
|
8
|
+
update: vi.fn(),
|
|
9
|
+
destroy: vi.fn(),
|
|
10
|
+
researchId: "test-id",
|
|
11
|
+
type: "popup",
|
|
12
|
+
iframe: null,
|
|
13
|
+
container: null,
|
|
14
|
+
})),
|
|
15
|
+
setupTrigger: vi.fn(() => vi.fn()),
|
|
16
|
+
shouldShow: vi.fn(() => true),
|
|
17
|
+
markShown: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// useStableCallback just passes through in tests
|
|
21
|
+
vi.mock("./useStableCallback", () => ({
|
|
22
|
+
useStableCallback: (cb: unknown) => cb,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
openPopup,
|
|
27
|
+
setupTrigger,
|
|
28
|
+
shouldShow,
|
|
29
|
+
markShown,
|
|
30
|
+
} from "@perspective-ai/sdk";
|
|
31
|
+
|
|
32
|
+
const mockOpenPopup = vi.mocked(openPopup);
|
|
33
|
+
const mockSetupTrigger = vi.mocked(setupTrigger);
|
|
34
|
+
const mockShouldShow = vi.mocked(shouldShow);
|
|
35
|
+
const mockMarkShown = vi.mocked(markShown);
|
|
36
|
+
|
|
37
|
+
describe("useAutoOpen", () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
// Restore defaults after clearAllMocks resets implementations
|
|
41
|
+
mockShouldShow.mockReturnValue(true);
|
|
42
|
+
mockSetupTrigger.mockReturnValue(vi.fn());
|
|
43
|
+
vi.useFakeTimers();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.useRealTimers();
|
|
48
|
+
cleanup();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("calls setupTrigger on mount when shouldShow returns true", () => {
|
|
52
|
+
renderHook(() =>
|
|
53
|
+
useAutoOpen({
|
|
54
|
+
researchId: "test-id",
|
|
55
|
+
trigger: { type: "timeout", delay: 5000 },
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(mockShouldShow).toHaveBeenCalledWith("test-id", "session");
|
|
60
|
+
expect(mockSetupTrigger).toHaveBeenCalledWith(
|
|
61
|
+
{ type: "timeout", delay: 5000 },
|
|
62
|
+
expect.any(Function)
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("does not call setupTrigger when shouldShow returns false", () => {
|
|
67
|
+
mockShouldShow.mockReturnValue(false);
|
|
68
|
+
|
|
69
|
+
renderHook(() =>
|
|
70
|
+
useAutoOpen({
|
|
71
|
+
researchId: "test-id",
|
|
72
|
+
trigger: { type: "timeout", delay: 5000 },
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(mockSetupTrigger).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("calls markShown and openPopup when trigger fires", () => {
|
|
80
|
+
let triggerCallback: () => void;
|
|
81
|
+
mockSetupTrigger.mockImplementation((_config, cb) => {
|
|
82
|
+
triggerCallback = cb;
|
|
83
|
+
return vi.fn();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const { result } = renderHook(() =>
|
|
87
|
+
useAutoOpen({
|
|
88
|
+
researchId: "test-id",
|
|
89
|
+
trigger: { type: "timeout", delay: 5000 },
|
|
90
|
+
showOnce: "visitor",
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(result.current.triggered).toBe(false);
|
|
95
|
+
|
|
96
|
+
act(() => triggerCallback!());
|
|
97
|
+
|
|
98
|
+
expect(mockMarkShown).toHaveBeenCalledWith("test-id", "visitor");
|
|
99
|
+
expect(mockOpenPopup).toHaveBeenCalledWith(
|
|
100
|
+
expect.objectContaining({ researchId: "test-id" })
|
|
101
|
+
);
|
|
102
|
+
expect(result.current.triggered).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("only fires once even if trigger callback called multiple times", () => {
|
|
106
|
+
let triggerCallback: () => void;
|
|
107
|
+
mockSetupTrigger.mockImplementation((_config, cb) => {
|
|
108
|
+
triggerCallback = cb;
|
|
109
|
+
return vi.fn();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const { result } = renderHook(() =>
|
|
113
|
+
useAutoOpen({
|
|
114
|
+
researchId: "test-id",
|
|
115
|
+
trigger: { type: "timeout", delay: 5000 },
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
act(() => triggerCallback!());
|
|
120
|
+
act(() => triggerCallback!());
|
|
121
|
+
|
|
122
|
+
expect(mockOpenPopup).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(result.current.triggered).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("defaults showOnce to session", () => {
|
|
127
|
+
renderHook(() =>
|
|
128
|
+
useAutoOpen({
|
|
129
|
+
researchId: "test-id",
|
|
130
|
+
trigger: { type: "exit-intent" },
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(mockShouldShow).toHaveBeenCalledWith("test-id", "session");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("calls cleanup on unmount", () => {
|
|
138
|
+
const mockCleanup = vi.fn();
|
|
139
|
+
mockSetupTrigger.mockReturnValue(mockCleanup);
|
|
140
|
+
|
|
141
|
+
const { unmount } = renderHook(() =>
|
|
142
|
+
useAutoOpen({
|
|
143
|
+
researchId: "test-id",
|
|
144
|
+
trigger: { type: "timeout", delay: 5000 },
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
unmount();
|
|
149
|
+
|
|
150
|
+
expect(mockCleanup).toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("cancel() stops the trigger", () => {
|
|
154
|
+
const mockCleanup = vi.fn();
|
|
155
|
+
mockSetupTrigger.mockReturnValue(mockCleanup);
|
|
156
|
+
|
|
157
|
+
const { result } = renderHook(() =>
|
|
158
|
+
useAutoOpen({
|
|
159
|
+
researchId: "test-id",
|
|
160
|
+
trigger: { type: "timeout", delay: 5000 },
|
|
161
|
+
})
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
result.current.cancel();
|
|
165
|
+
|
|
166
|
+
expect(mockCleanup).toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
openPopup,
|
|
4
|
+
setupTrigger,
|
|
5
|
+
shouldShow,
|
|
6
|
+
markShown,
|
|
7
|
+
} from "@perspective-ai/sdk";
|
|
8
|
+
import type { EmbedConfig, TriggerConfig, ShowOnce } from "@perspective-ai/sdk";
|
|
9
|
+
import { useStableCallback } from "./useStableCallback";
|
|
10
|
+
|
|
11
|
+
export interface UseAutoOpenOptions extends Omit<
|
|
12
|
+
EmbedConfig,
|
|
13
|
+
"type" | "autoOpen"
|
|
14
|
+
> {
|
|
15
|
+
trigger: TriggerConfig;
|
|
16
|
+
showOnce?: ShowOnce; // default: "session"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseAutoOpenReturn {
|
|
20
|
+
/** Cancel the pending trigger */
|
|
21
|
+
cancel: () => void;
|
|
22
|
+
/** Whether the trigger has fired */
|
|
23
|
+
triggered: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useAutoOpen(options: UseAutoOpenOptions): UseAutoOpenReturn {
|
|
27
|
+
const { trigger, showOnce = "session", researchId, ...embedConfig } = options;
|
|
28
|
+
const cleanupRef = useRef<(() => void) | null>(null);
|
|
29
|
+
const [triggered, setTriggered] = useState(false);
|
|
30
|
+
const triggerDelay = trigger.type === "timeout" ? trigger.delay : undefined;
|
|
31
|
+
|
|
32
|
+
// Fix #5: useStableCallback so the trigger always calls with latest config
|
|
33
|
+
const stableOnTrigger = useStableCallback(() => {
|
|
34
|
+
markShown(researchId, showOnce);
|
|
35
|
+
openPopup({ researchId, ...embedConfig });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!shouldShow(researchId, showOnce)) return;
|
|
40
|
+
|
|
41
|
+
cleanupRef.current = setupTrigger(trigger, () => {
|
|
42
|
+
setTriggered((prev) => {
|
|
43
|
+
if (prev) return prev; // already fired
|
|
44
|
+
stableOnTrigger();
|
|
45
|
+
return true;
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return () => {
|
|
50
|
+
cleanupRef.current?.();
|
|
51
|
+
cleanupRef.current = null;
|
|
52
|
+
};
|
|
53
|
+
// Primitive deps only — avoids re-triggering on object identity changes
|
|
54
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
55
|
+
}, [researchId, trigger.type, triggerDelay, showOnce]);
|
|
56
|
+
|
|
57
|
+
const cancel = useCallback(() => {
|
|
58
|
+
cleanupRef.current?.();
|
|
59
|
+
cleanupRef.current = null;
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
return { cancel, triggered };
|
|
63
|
+
}
|