@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/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@perspective-ai/sdk-react",
3
+ "version": "0.0.0-pr-21-20260224144030",
4
+ "description": "React components for Perspective AI embed SDK",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "import": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "require": {
14
+ "types": "./dist/index.d.cts",
15
+ "default": "./dist/index.cjs"
16
+ }
17
+ }
18
+ },
19
+ "main": "./dist/index.cjs",
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "files": [
23
+ "dist",
24
+ "src"
25
+ ],
26
+ "keywords": [
27
+ "perspective",
28
+ "perspective-ai",
29
+ "embed",
30
+ "sdk",
31
+ "react",
32
+ "ai",
33
+ "llm",
34
+ "conversational-ai",
35
+ "research",
36
+ "interview",
37
+ "voice-agent",
38
+ "widget"
39
+ ],
40
+ "author": "Perspective AI",
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/Perspective-AI/perspective.git",
45
+ "directory": "packages/sdk-react"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/Perspective-AI/perspective/issues"
49
+ },
50
+ "homepage": "https://getperspective.ai",
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "peerDependencies": {
55
+ "react": "^18.0.0 || ^19.0.0",
56
+ "react-dom": "^18.0.0 || ^19.0.0"
57
+ },
58
+ "dependencies": {
59
+ "@perspective-ai/sdk": "^0.0.0-pr-21-20260224144030"
60
+ },
61
+ "devDependencies": {
62
+ "@testing-library/dom": "^10.4.1",
63
+ "@testing-library/react": "^16.3.1",
64
+ "@types/react": "^19.2.3",
65
+ "@types/react-dom": "^19.2.3",
66
+ "react": "^18.3.1",
67
+ "react-dom": "^18.3.1",
68
+ "tsup": "^8.5.1",
69
+ "typescript": "^5.9.3"
70
+ },
71
+ "scripts": {
72
+ "build": "tsup",
73
+ "dev": "tsup --watch",
74
+ "typecheck": "tsc --noEmit",
75
+ "test": "vitest run"
76
+ }
77
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, cleanup } from "@testing-library/react";
3
+ import { FloatBubble } from "./FloatBubble";
4
+
5
+ const mockUnmount = vi.fn();
6
+ const mockOpen = vi.fn();
7
+ const mockClose = vi.fn();
8
+ const mockToggle = vi.fn();
9
+ const mockUpdate = vi.fn();
10
+
11
+ vi.mock("@perspective-ai/sdk", () => ({
12
+ createFloatBubble: vi.fn(() => ({
13
+ unmount: mockUnmount,
14
+ update: mockUpdate,
15
+ destroy: mockUnmount,
16
+ open: mockOpen,
17
+ close: mockClose,
18
+ toggle: mockToggle,
19
+ researchId: "test-research-id",
20
+ type: "float",
21
+ iframe: null,
22
+ container: null,
23
+ isOpen: false,
24
+ })),
25
+ }));
26
+
27
+ import { createFloatBubble } from "@perspective-ai/sdk";
28
+ const mockCreateFloatBubble = vi.mocked(createFloatBubble);
29
+
30
+ describe("FloatBubble", () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ });
34
+
35
+ afterEach(() => {
36
+ cleanup();
37
+ });
38
+
39
+ it("renders nothing (bubble is added to document.body)", () => {
40
+ const { container } = render(<FloatBubble researchId="test-research-id" />);
41
+
42
+ expect(container.innerHTML).toBe("");
43
+ });
44
+
45
+ it("calls createFloatBubble on mount", () => {
46
+ render(<FloatBubble researchId="test-research-id" />);
47
+
48
+ expect(mockCreateFloatBubble).toHaveBeenCalledTimes(1);
49
+ expect(mockCreateFloatBubble).toHaveBeenCalledWith(
50
+ expect.objectContaining({
51
+ researchId: "test-research-id",
52
+ })
53
+ );
54
+ });
55
+
56
+ it("calls unmount on cleanup", () => {
57
+ const { unmount } = render(<FloatBubble researchId="test-research-id" />);
58
+
59
+ expect(mockUnmount).not.toHaveBeenCalled();
60
+
61
+ unmount();
62
+
63
+ expect(mockUnmount).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it("passes config to createFloatBubble", () => {
67
+ const onReady = vi.fn();
68
+ const onSubmit = vi.fn();
69
+ const onClose = vi.fn();
70
+ const onError = vi.fn();
71
+
72
+ render(
73
+ <FloatBubble
74
+ researchId="test-research-id"
75
+ params={{ source: "test" }}
76
+ theme="dark"
77
+ host="https://custom.example.com"
78
+ onReady={onReady}
79
+ onSubmit={onSubmit}
80
+ onClose={onClose}
81
+ onError={onError}
82
+ />
83
+ );
84
+
85
+ expect(mockCreateFloatBubble).toHaveBeenCalledTimes(1);
86
+ const config = mockCreateFloatBubble.mock.calls[0]![0];
87
+ expect(config.researchId).toBe("test-research-id");
88
+ expect(config.params).toEqual({ source: "test" });
89
+ expect(config.theme).toBe("dark");
90
+ expect(config.host).toBe("https://custom.example.com");
91
+ });
92
+
93
+ it("re-creates float bubble when researchId changes", () => {
94
+ const { rerender } = render(<FloatBubble researchId="research-1" />);
95
+
96
+ expect(mockCreateFloatBubble).toHaveBeenCalledTimes(1);
97
+
98
+ rerender(<FloatBubble researchId="research-2" />);
99
+
100
+ expect(mockUnmount).toHaveBeenCalledTimes(1);
101
+ expect(mockCreateFloatBubble).toHaveBeenCalledTimes(2);
102
+ });
103
+
104
+ it("re-creates float bubble when theme changes", () => {
105
+ const { rerender } = render(
106
+ <FloatBubble researchId="test-research-id" theme="light" />
107
+ );
108
+
109
+ expect(mockCreateFloatBubble).toHaveBeenCalledTimes(1);
110
+
111
+ rerender(<FloatBubble researchId="test-research-id" theme="dark" />);
112
+
113
+ expect(mockUnmount).toHaveBeenCalledTimes(1);
114
+ expect(mockCreateFloatBubble).toHaveBeenCalledTimes(2);
115
+ });
116
+
117
+ it("passes brand colors to createFloatBubble", () => {
118
+ render(
119
+ <FloatBubble
120
+ researchId="test-research-id"
121
+ brand={{
122
+ light: { primary: "#ff0000", bg: "#ffffff" },
123
+ dark: { primary: "#0000ff", bg: "#000000" },
124
+ }}
125
+ />
126
+ );
127
+
128
+ const config = mockCreateFloatBubble.mock.calls[0]![0];
129
+ expect(config.brand).toEqual({
130
+ light: { primary: "#ff0000", bg: "#ffffff" },
131
+ dark: { primary: "#0000ff", bg: "#000000" },
132
+ });
133
+ });
134
+ });
@@ -0,0 +1,57 @@
1
+ import { useEffect, type RefObject } from "react";
2
+ import { type EmbedConfig, type FloatHandle } from "@perspective-ai/sdk";
3
+ import { useFloatBubble } from "./hooks/useFloatBubble";
4
+
5
+ export interface FloatBubbleProps extends Omit<EmbedConfig, "type"> {
6
+ /** Ref to access the handle for programmatic control */
7
+ embedRef?: RefObject<FloatHandle | null>;
8
+ }
9
+
10
+ /**
11
+ * Floating bubble widget that expands into a chat window.
12
+ * This is a convenience wrapper around useFloatBubble hook.
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * <FloatBubble researchId="abc" onSubmit={handleSubmit} />
17
+ * ```
18
+ */
19
+ export function FloatBubble({
20
+ researchId,
21
+ params,
22
+ brand,
23
+ theme,
24
+ host,
25
+ onReady,
26
+ onSubmit,
27
+ onNavigate,
28
+ onClose,
29
+ onError,
30
+ embedRef,
31
+ }: FloatBubbleProps) {
32
+ const { handle } = useFloatBubble({
33
+ researchId,
34
+ params,
35
+ brand,
36
+ theme,
37
+ host,
38
+ onReady,
39
+ onSubmit,
40
+ onNavigate,
41
+ onClose,
42
+ onError,
43
+ });
44
+
45
+ useEffect(() => {
46
+ if (embedRef) {
47
+ embedRef.current = handle;
48
+ }
49
+ return () => {
50
+ if (embedRef) {
51
+ embedRef.current = null;
52
+ }
53
+ };
54
+ }, [embedRef, handle]);
55
+
56
+ return null;
57
+ }
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, cleanup } from "@testing-library/react";
3
+ import { createRef } from "react";
4
+ import { Fullpage } from "./Fullpage";
5
+ import type { EmbedHandle } from "@perspective-ai/sdk";
6
+
7
+ const mockUnmount = vi.fn();
8
+ const mockUpdate = vi.fn();
9
+
10
+ vi.mock("@perspective-ai/sdk", () => ({
11
+ createFullpage: vi.fn(() => ({
12
+ unmount: mockUnmount,
13
+ update: mockUpdate,
14
+ destroy: mockUnmount,
15
+ researchId: "test-research-id",
16
+ type: "fullpage",
17
+ iframe: null,
18
+ container: null,
19
+ })),
20
+ }));
21
+
22
+ import { createFullpage } from "@perspective-ai/sdk";
23
+ const mockCreateFullpage = vi.mocked(createFullpage);
24
+
25
+ describe("Fullpage", () => {
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ });
29
+
30
+ afterEach(() => {
31
+ cleanup();
32
+ });
33
+
34
+ it("renders nothing (fullpage overlay is added to document.body)", () => {
35
+ const { container } = render(<Fullpage researchId="test-research-id" />);
36
+
37
+ expect(container.innerHTML).toBe("");
38
+ });
39
+
40
+ it("calls createFullpage on mount", () => {
41
+ render(<Fullpage researchId="test-research-id" />);
42
+
43
+ expect(mockCreateFullpage).toHaveBeenCalledTimes(1);
44
+ expect(mockCreateFullpage).toHaveBeenCalledWith(
45
+ expect.objectContaining({
46
+ researchId: "test-research-id",
47
+ })
48
+ );
49
+ });
50
+
51
+ it("calls unmount on cleanup", () => {
52
+ const { unmount } = render(<Fullpage researchId="test-research-id" />);
53
+
54
+ expect(mockUnmount).not.toHaveBeenCalled();
55
+
56
+ unmount();
57
+
58
+ expect(mockUnmount).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it("passes config to createFullpage", () => {
62
+ const onReady = vi.fn();
63
+ const onSubmit = vi.fn();
64
+ const onClose = vi.fn();
65
+ const onNavigate = vi.fn();
66
+ const onError = vi.fn();
67
+
68
+ render(
69
+ <Fullpage
70
+ researchId="test-research-id"
71
+ params={{ source: "test" }}
72
+ theme="dark"
73
+ host="https://custom.example.com"
74
+ onReady={onReady}
75
+ onSubmit={onSubmit}
76
+ onClose={onClose}
77
+ onNavigate={onNavigate}
78
+ onError={onError}
79
+ />
80
+ );
81
+
82
+ expect(mockCreateFullpage).toHaveBeenCalledTimes(1);
83
+ const config = mockCreateFullpage.mock.calls[0]![0];
84
+ expect(config.researchId).toBe("test-research-id");
85
+ expect(config.params).toEqual({ source: "test" });
86
+ expect(config.theme).toBe("dark");
87
+ expect(config.host).toBe("https://custom.example.com");
88
+ });
89
+
90
+ it("exposes handle via embedRef", () => {
91
+ const mockHandle: EmbedHandle = {
92
+ unmount: mockUnmount,
93
+ update: mockUpdate,
94
+ destroy: mockUnmount,
95
+ researchId: "test-research-id",
96
+ type: "fullpage",
97
+ iframe: null,
98
+ container: null,
99
+ };
100
+ mockCreateFullpage.mockReturnValueOnce(mockHandle);
101
+
102
+ const embedRef = createRef<EmbedHandle | null>();
103
+
104
+ render(<Fullpage researchId="test-research-id" embedRef={embedRef} />);
105
+
106
+ expect(embedRef.current).toBe(mockHandle);
107
+ });
108
+
109
+ it("clears embedRef on unmount", () => {
110
+ const embedRef = createRef<EmbedHandle | null>();
111
+
112
+ const { unmount } = render(
113
+ <Fullpage researchId="test-research-id" embedRef={embedRef} />
114
+ );
115
+
116
+ expect(embedRef.current).not.toBeNull();
117
+
118
+ unmount();
119
+
120
+ expect(embedRef.current).toBeNull();
121
+ });
122
+
123
+ it("re-creates fullpage when researchId changes", () => {
124
+ const { rerender } = render(<Fullpage researchId="research-1" />);
125
+
126
+ expect(mockCreateFullpage).toHaveBeenCalledTimes(1);
127
+
128
+ rerender(<Fullpage researchId="research-2" />);
129
+
130
+ expect(mockUnmount).toHaveBeenCalledTimes(1);
131
+ expect(mockCreateFullpage).toHaveBeenCalledTimes(2);
132
+ });
133
+
134
+ it("re-creates fullpage when theme changes", () => {
135
+ const { rerender } = render(
136
+ <Fullpage researchId="test-research-id" theme="light" />
137
+ );
138
+
139
+ expect(mockCreateFullpage).toHaveBeenCalledTimes(1);
140
+
141
+ rerender(<Fullpage researchId="test-research-id" theme="dark" />);
142
+
143
+ expect(mockUnmount).toHaveBeenCalledTimes(1);
144
+ expect(mockCreateFullpage).toHaveBeenCalledTimes(2);
145
+ });
146
+
147
+ it("passes brand colors to createFullpage", () => {
148
+ render(
149
+ <Fullpage
150
+ researchId="test-research-id"
151
+ brand={{
152
+ light: { primary: "#ff0000", bg: "#ffffff" },
153
+ dark: { primary: "#0000ff", bg: "#000000" },
154
+ }}
155
+ />
156
+ );
157
+
158
+ const config = mockCreateFullpage.mock.calls[0]![0];
159
+ expect(config.brand).toEqual({
160
+ light: { primary: "#ff0000", bg: "#ffffff" },
161
+ dark: { primary: "#0000ff", bg: "#000000" },
162
+ });
163
+ });
164
+ });
@@ -0,0 +1,83 @@
1
+ import { useRef, useEffect, type RefObject } from "react";
2
+ import {
3
+ createFullpage,
4
+ type EmbedConfig,
5
+ type EmbedHandle,
6
+ } from "@perspective-ai/sdk";
7
+ import { useStableCallback } from "./hooks/useStableCallback";
8
+
9
+ export interface FullpageProps extends Omit<EmbedConfig, "type"> {
10
+ /** Ref to access the embed handle for programmatic control */
11
+ embedRef?: RefObject<EmbedHandle | null>;
12
+ }
13
+
14
+ /**
15
+ * Full viewport embed component.
16
+ * Takes over the entire screen with the interview.
17
+ */
18
+ export function Fullpage({
19
+ researchId,
20
+ params,
21
+ brand,
22
+ theme,
23
+ host,
24
+ onReady,
25
+ onSubmit,
26
+ onNavigate,
27
+ onClose,
28
+ onError,
29
+ embedRef,
30
+ }: FullpageProps) {
31
+ const handleRef = useRef<EmbedHandle | null>(null);
32
+
33
+ // Stable callbacks
34
+ const stableOnReady = useStableCallback(onReady);
35
+ const stableOnSubmit = useStableCallback(onSubmit);
36
+ const stableOnNavigate = useStableCallback(onNavigate);
37
+ const stableOnClose = useStableCallback(onClose);
38
+ const stableOnError = useStableCallback(onError);
39
+
40
+ useEffect(() => {
41
+ const handle = createFullpage({
42
+ researchId,
43
+ params,
44
+ brand,
45
+ theme,
46
+ host,
47
+ onReady: stableOnReady,
48
+ onSubmit: stableOnSubmit,
49
+ onNavigate: stableOnNavigate,
50
+ onClose: stableOnClose,
51
+ onError: stableOnError,
52
+ });
53
+
54
+ handleRef.current = handle;
55
+
56
+ if (embedRef) {
57
+ embedRef.current = handle;
58
+ }
59
+
60
+ return () => {
61
+ handle.unmount();
62
+ handleRef.current = null;
63
+ if (embedRef) {
64
+ embedRef.current = null;
65
+ }
66
+ };
67
+ }, [
68
+ researchId,
69
+ params,
70
+ brand,
71
+ theme,
72
+ host,
73
+ stableOnReady,
74
+ stableOnSubmit,
75
+ stableOnNavigate,
76
+ stableOnClose,
77
+ stableOnError,
78
+ embedRef,
79
+ ]);
80
+
81
+ // This component doesn't render anything - the fullpage overlay is added to document.body
82
+ return null;
83
+ }