@perspective-ai/sdk 1.0.0-alpha.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/README.md +333 -0
- package/dist/browser.cjs +1939 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +213 -0
- package/dist/browser.d.ts +213 -0
- package/dist/browser.js +1900 -0
- package/dist/browser.js.map +1 -0
- package/dist/cdn/perspective.global.js +406 -0
- package/dist/cdn/perspective.global.js.map +1 -0
- package/dist/constants.cjs +142 -0
- package/dist/constants.cjs.map +1 -0
- package/dist/constants.d.cts +104 -0
- package/dist/constants.d.ts +104 -0
- package/dist/constants.js +127 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.cjs +1596 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +155 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +1579 -0
- package/dist/index.js.map +1 -0
- package/package.json +83 -0
- package/src/browser.test.ts +388 -0
- package/src/browser.ts +509 -0
- package/src/config.test.ts +81 -0
- package/src/config.ts +95 -0
- package/src/constants.ts +214 -0
- package/src/float.test.ts +332 -0
- package/src/float.ts +231 -0
- package/src/fullpage.test.ts +224 -0
- package/src/fullpage.ts +126 -0
- package/src/iframe.test.ts +1037 -0
- package/src/iframe.ts +421 -0
- package/src/index.ts +61 -0
- package/src/loading.ts +90 -0
- package/src/popup.test.ts +344 -0
- package/src/popup.ts +157 -0
- package/src/slider.test.ts +277 -0
- package/src/slider.ts +158 -0
- package/src/styles.ts +395 -0
- package/src/types.ts +148 -0
- package/src/utils.test.ts +162 -0
- package/src/utils.ts +86 -0
- package/src/widget.test.ts +375 -0
- package/src/widget.ts +195 -0
package/src/float.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating bubble embed - floating button that opens a chat window
|
|
3
|
+
* SSR-safe - returns no-op handle on server
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { EmbedConfig, FloatHandle, ThemeConfig } from "./types";
|
|
7
|
+
import { hasDom, getHost } from "./config";
|
|
8
|
+
import {
|
|
9
|
+
createIframe,
|
|
10
|
+
setupMessageListener,
|
|
11
|
+
registerIframe,
|
|
12
|
+
ensureGlobalListeners,
|
|
13
|
+
} from "./iframe";
|
|
14
|
+
import { createLoadingIndicator } from "./loading";
|
|
15
|
+
import { injectStyles, MIC_ICON, CLOSE_ICON } from "./styles";
|
|
16
|
+
import { cn, getThemeClass, resolveIsDark } from "./utils";
|
|
17
|
+
|
|
18
|
+
type FloatConfig = EmbedConfig & { _themeConfig?: ThemeConfig };
|
|
19
|
+
|
|
20
|
+
function createNoOpHandle(researchId: string): FloatHandle {
|
|
21
|
+
return {
|
|
22
|
+
unmount: () => {},
|
|
23
|
+
update: () => {},
|
|
24
|
+
destroy: () => {},
|
|
25
|
+
open: () => {},
|
|
26
|
+
close: () => {},
|
|
27
|
+
toggle: () => {},
|
|
28
|
+
isOpen: false,
|
|
29
|
+
researchId,
|
|
30
|
+
type: "float",
|
|
31
|
+
iframe: null,
|
|
32
|
+
container: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createFloatBubble(config: FloatConfig): FloatHandle {
|
|
37
|
+
const { researchId, _themeConfig, theme, brand } = config;
|
|
38
|
+
|
|
39
|
+
// SSR safety: return no-op handle
|
|
40
|
+
if (!hasDom()) {
|
|
41
|
+
return createNoOpHandle(researchId);
|
|
42
|
+
}
|
|
43
|
+
const host = getHost(config.host);
|
|
44
|
+
|
|
45
|
+
injectStyles();
|
|
46
|
+
ensureGlobalListeners();
|
|
47
|
+
|
|
48
|
+
// Create bubble button
|
|
49
|
+
const bubble = document.createElement("button");
|
|
50
|
+
bubble.className = cn(
|
|
51
|
+
"perspective-float-bubble perspective-embed-root",
|
|
52
|
+
getThemeClass(config.theme)
|
|
53
|
+
);
|
|
54
|
+
bubble.innerHTML = MIC_ICON;
|
|
55
|
+
bubble.setAttribute("aria-label", "Open chat");
|
|
56
|
+
bubble.setAttribute("data-perspective", "float-bubble");
|
|
57
|
+
|
|
58
|
+
// Apply theme color if available
|
|
59
|
+
if (_themeConfig || brand) {
|
|
60
|
+
const isDark = resolveIsDark(theme);
|
|
61
|
+
const bg = isDark
|
|
62
|
+
? (brand?.dark?.primary ?? _themeConfig?.darkPrimaryColor ?? "#a78bfa")
|
|
63
|
+
: (brand?.light?.primary ?? _themeConfig?.primaryColor ?? "#7c3aed");
|
|
64
|
+
bubble.style.setProperty("--perspective-float-bg", bg);
|
|
65
|
+
bubble.style.setProperty(
|
|
66
|
+
"--perspective-float-shadow",
|
|
67
|
+
`0 4px 12px ${bg}66`
|
|
68
|
+
);
|
|
69
|
+
bubble.style.setProperty(
|
|
70
|
+
"--perspective-float-shadow-hover",
|
|
71
|
+
`0 6px 16px ${bg}80`
|
|
72
|
+
);
|
|
73
|
+
bubble.style.backgroundColor = bg;
|
|
74
|
+
bubble.style.boxShadow = `0 4px 12px ${bg}66`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
document.body.appendChild(bubble);
|
|
78
|
+
|
|
79
|
+
let floatWindow: HTMLElement | null = null;
|
|
80
|
+
let iframe: HTMLIFrameElement | null = null;
|
|
81
|
+
let cleanup: (() => void) | null = null;
|
|
82
|
+
let unregisterIframe: (() => void) | null = null;
|
|
83
|
+
let isOpen = false;
|
|
84
|
+
|
|
85
|
+
// Mutable config reference for updates
|
|
86
|
+
let currentConfig = { ...config };
|
|
87
|
+
|
|
88
|
+
const openFloat = () => {
|
|
89
|
+
if (isOpen) return;
|
|
90
|
+
isOpen = true;
|
|
91
|
+
|
|
92
|
+
// Create float window
|
|
93
|
+
floatWindow = document.createElement("div");
|
|
94
|
+
floatWindow.className = cn(
|
|
95
|
+
"perspective-float-window perspective-embed-root",
|
|
96
|
+
getThemeClass(currentConfig.theme)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Create close button
|
|
100
|
+
const closeBtn = document.createElement("button");
|
|
101
|
+
closeBtn.className = "perspective-close";
|
|
102
|
+
closeBtn.innerHTML = CLOSE_ICON;
|
|
103
|
+
closeBtn.setAttribute("aria-label", "Close chat");
|
|
104
|
+
closeBtn.addEventListener("click", closeFloat);
|
|
105
|
+
|
|
106
|
+
// Create loading indicator with theme and brand colors
|
|
107
|
+
const loading = createLoadingIndicator({
|
|
108
|
+
theme: currentConfig.theme,
|
|
109
|
+
brand: currentConfig.brand,
|
|
110
|
+
});
|
|
111
|
+
loading.style.borderRadius = "16px";
|
|
112
|
+
|
|
113
|
+
// Create iframe (hidden initially)
|
|
114
|
+
iframe = createIframe(
|
|
115
|
+
researchId,
|
|
116
|
+
"float",
|
|
117
|
+
host,
|
|
118
|
+
currentConfig.params,
|
|
119
|
+
currentConfig.brand,
|
|
120
|
+
currentConfig.theme
|
|
121
|
+
);
|
|
122
|
+
iframe.style.opacity = "0";
|
|
123
|
+
iframe.style.transition = "opacity 0.3s ease";
|
|
124
|
+
|
|
125
|
+
floatWindow.appendChild(closeBtn);
|
|
126
|
+
floatWindow.appendChild(loading);
|
|
127
|
+
floatWindow.appendChild(iframe);
|
|
128
|
+
document.body.appendChild(floatWindow);
|
|
129
|
+
|
|
130
|
+
// Set up message listener with loading state handling
|
|
131
|
+
cleanup = setupMessageListener(
|
|
132
|
+
researchId,
|
|
133
|
+
{
|
|
134
|
+
get onReady() {
|
|
135
|
+
return () => {
|
|
136
|
+
loading.style.opacity = "0";
|
|
137
|
+
iframe!.style.opacity = "1";
|
|
138
|
+
setTimeout(() => loading.remove(), 300);
|
|
139
|
+
currentConfig.onReady?.();
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
get onSubmit() {
|
|
143
|
+
return currentConfig.onSubmit;
|
|
144
|
+
},
|
|
145
|
+
get onNavigate() {
|
|
146
|
+
return currentConfig.onNavigate;
|
|
147
|
+
},
|
|
148
|
+
get onClose() {
|
|
149
|
+
return closeFloat;
|
|
150
|
+
},
|
|
151
|
+
get onError() {
|
|
152
|
+
return currentConfig.onError;
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
iframe,
|
|
156
|
+
host,
|
|
157
|
+
{ skipResize: true }
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Register iframe for theme change notifications
|
|
161
|
+
if (iframe) {
|
|
162
|
+
unregisterIframe = registerIframe(iframe, host);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Update bubble icon to close
|
|
166
|
+
bubble.innerHTML = CLOSE_ICON;
|
|
167
|
+
bubble.setAttribute("aria-label", "Close chat");
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const closeFloat = () => {
|
|
171
|
+
if (!isOpen) return;
|
|
172
|
+
isOpen = false;
|
|
173
|
+
|
|
174
|
+
cleanup?.();
|
|
175
|
+
unregisterIframe?.();
|
|
176
|
+
floatWindow?.remove();
|
|
177
|
+
floatWindow = null;
|
|
178
|
+
iframe = null;
|
|
179
|
+
cleanup = null;
|
|
180
|
+
unregisterIframe = null;
|
|
181
|
+
|
|
182
|
+
// Restore bubble icon
|
|
183
|
+
bubble.innerHTML = MIC_ICON;
|
|
184
|
+
bubble.setAttribute("aria-label", "Open chat");
|
|
185
|
+
|
|
186
|
+
currentConfig.onClose?.();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Toggle on bubble click
|
|
190
|
+
bubble.addEventListener("click", () => {
|
|
191
|
+
if (isOpen) {
|
|
192
|
+
closeFloat();
|
|
193
|
+
} else {
|
|
194
|
+
openFloat();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const unmount = () => {
|
|
199
|
+
closeFloat();
|
|
200
|
+
bubble.remove();
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
unmount,
|
|
205
|
+
update: (options: Parameters<FloatHandle["update"]>[0]) => {
|
|
206
|
+
currentConfig = { ...currentConfig, ...options };
|
|
207
|
+
},
|
|
208
|
+
destroy: unmount,
|
|
209
|
+
open: openFloat,
|
|
210
|
+
close: closeFloat,
|
|
211
|
+
toggle: () => {
|
|
212
|
+
if (isOpen) {
|
|
213
|
+
closeFloat();
|
|
214
|
+
} else {
|
|
215
|
+
openFloat();
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
get isOpen() {
|
|
219
|
+
return isOpen;
|
|
220
|
+
},
|
|
221
|
+
researchId,
|
|
222
|
+
type: "float" as const,
|
|
223
|
+
get iframe() {
|
|
224
|
+
return iframe;
|
|
225
|
+
},
|
|
226
|
+
container: bubble,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @deprecated Use createFloatBubble instead */
|
|
231
|
+
export const createChatBubble = createFloatBubble;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { createFullpage } from "./fullpage";
|
|
3
|
+
import * as config from "./config";
|
|
4
|
+
|
|
5
|
+
describe("createFullpage", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
document.body.innerHTML = "";
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("creates fullpage container", () => {
|
|
16
|
+
const handle = createFullpage({
|
|
17
|
+
researchId: "test-research-id",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(handle.researchId).toBe("test-research-id");
|
|
21
|
+
expect(handle.type).toBe("fullpage");
|
|
22
|
+
expect(handle.iframe).toBeInstanceOf(HTMLIFrameElement);
|
|
23
|
+
expect(document.querySelector(".perspective-fullpage")).toBeTruthy();
|
|
24
|
+
|
|
25
|
+
handle.unmount();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("creates loading indicator", () => {
|
|
29
|
+
const handle = createFullpage({
|
|
30
|
+
researchId: "test-research-id",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const loading = document.querySelector(".perspective-loading");
|
|
34
|
+
expect(loading).toBeTruthy();
|
|
35
|
+
|
|
36
|
+
handle.unmount();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns no-op handle when no DOM", () => {
|
|
40
|
+
vi.spyOn(config, "hasDom").mockReturnValue(false);
|
|
41
|
+
|
|
42
|
+
const handle = createFullpage({
|
|
43
|
+
researchId: "test-research-id",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(handle.iframe).toBeNull();
|
|
47
|
+
expect(handle.container).toBeNull();
|
|
48
|
+
expect(handle.unmount).toBeInstanceOf(Function);
|
|
49
|
+
expect(() => handle.unmount()).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("unmount removes fullpage container", () => {
|
|
53
|
+
const handle = createFullpage({
|
|
54
|
+
researchId: "test-research-id",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(document.querySelector(".perspective-fullpage")).toBeTruthy();
|
|
58
|
+
|
|
59
|
+
handle.unmount();
|
|
60
|
+
|
|
61
|
+
expect(document.querySelector(".perspective-fullpage")).toBeFalsy();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("destroy is alias for unmount", () => {
|
|
65
|
+
const handle = createFullpage({
|
|
66
|
+
researchId: "test-research-id",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(document.querySelector(".perspective-fullpage")).toBeTruthy();
|
|
70
|
+
|
|
71
|
+
handle.destroy();
|
|
72
|
+
|
|
73
|
+
expect(document.querySelector(".perspective-fullpage")).toBeFalsy();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("calls onClose on unmount", () => {
|
|
77
|
+
const onClose = vi.fn();
|
|
78
|
+
const handle = createFullpage({
|
|
79
|
+
researchId: "test-research-id",
|
|
80
|
+
onClose,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
handle.unmount();
|
|
84
|
+
|
|
85
|
+
expect(onClose).toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("applies theme class", () => {
|
|
89
|
+
const handle = createFullpage({
|
|
90
|
+
researchId: "test-research-id",
|
|
91
|
+
theme: "dark",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const container = document.querySelector(".perspective-fullpage");
|
|
95
|
+
expect(container?.classList.contains("perspective-dark-theme")).toBe(true);
|
|
96
|
+
|
|
97
|
+
handle.unmount();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("uses custom host", () => {
|
|
101
|
+
const handle = createFullpage({
|
|
102
|
+
researchId: "test-research-id",
|
|
103
|
+
host: "https://custom.example.com",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const iframe = handle.iframe as HTMLIFrameElement;
|
|
107
|
+
expect(iframe.src).toContain("https://custom.example.com");
|
|
108
|
+
|
|
109
|
+
handle.unmount();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("passes params to iframe", () => {
|
|
113
|
+
const handle = createFullpage({
|
|
114
|
+
researchId: "test-research-id",
|
|
115
|
+
params: { source: "test", campaign: "demo" },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const iframe = handle.iframe as HTMLIFrameElement;
|
|
119
|
+
const url = new URL(iframe.src);
|
|
120
|
+
expect(url.searchParams.get("source")).toBe("test");
|
|
121
|
+
expect(url.searchParams.get("campaign")).toBe("demo");
|
|
122
|
+
|
|
123
|
+
handle.unmount();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("update modifies config", () => {
|
|
127
|
+
const onSubmit1 = vi.fn();
|
|
128
|
+
const onSubmit2 = vi.fn();
|
|
129
|
+
|
|
130
|
+
const handle = createFullpage({
|
|
131
|
+
researchId: "test-research-id",
|
|
132
|
+
onSubmit: onSubmit1,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(() => handle.update({ onSubmit: onSubmit2 })).not.toThrow();
|
|
136
|
+
|
|
137
|
+
handle.unmount();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("update() behavior", () => {
|
|
141
|
+
const host = "https://getperspective.ai";
|
|
142
|
+
const researchId = "test-research-id";
|
|
143
|
+
|
|
144
|
+
const sendMessage = (
|
|
145
|
+
iframe: HTMLIFrameElement,
|
|
146
|
+
type: string,
|
|
147
|
+
extra?: Record<string, unknown>
|
|
148
|
+
) => {
|
|
149
|
+
window.dispatchEvent(
|
|
150
|
+
new MessageEvent("message", {
|
|
151
|
+
data: { type, researchId, ...extra },
|
|
152
|
+
origin: host,
|
|
153
|
+
source: iframe.contentWindow,
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
it("update changes which callback is invoked", () => {
|
|
159
|
+
const onSubmit1 = vi.fn();
|
|
160
|
+
const onSubmit2 = vi.fn();
|
|
161
|
+
|
|
162
|
+
const handle = createFullpage({
|
|
163
|
+
researchId,
|
|
164
|
+
host,
|
|
165
|
+
onSubmit: onSubmit1,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
handle.update({ onSubmit: onSubmit2 });
|
|
169
|
+
|
|
170
|
+
sendMessage(handle.iframe!, "perspective:submit");
|
|
171
|
+
|
|
172
|
+
expect(onSubmit2).toHaveBeenCalledTimes(1);
|
|
173
|
+
expect(onSubmit1).not.toHaveBeenCalled();
|
|
174
|
+
|
|
175
|
+
handle.unmount();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("sequential updates only use latest callback", () => {
|
|
179
|
+
const fn1 = vi.fn();
|
|
180
|
+
const fn2 = vi.fn();
|
|
181
|
+
const fn3 = vi.fn();
|
|
182
|
+
|
|
183
|
+
const handle = createFullpage({
|
|
184
|
+
researchId,
|
|
185
|
+
host,
|
|
186
|
+
onSubmit: fn1,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
handle.update({ onSubmit: fn2 });
|
|
190
|
+
handle.update({ onSubmit: fn3 });
|
|
191
|
+
|
|
192
|
+
sendMessage(handle.iframe!, "perspective:submit");
|
|
193
|
+
|
|
194
|
+
expect(fn3).toHaveBeenCalledTimes(1);
|
|
195
|
+
expect(fn2).not.toHaveBeenCalled();
|
|
196
|
+
expect(fn1).not.toHaveBeenCalled();
|
|
197
|
+
|
|
198
|
+
handle.unmount();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("destroy prevents further callback invocations", () => {
|
|
202
|
+
const onSubmit = vi.fn();
|
|
203
|
+
const onClose = vi.fn();
|
|
204
|
+
|
|
205
|
+
const handle = createFullpage({
|
|
206
|
+
researchId,
|
|
207
|
+
host,
|
|
208
|
+
onSubmit,
|
|
209
|
+
onClose,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const iframe = handle.iframe!;
|
|
213
|
+
|
|
214
|
+
handle.unmount();
|
|
215
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
216
|
+
|
|
217
|
+
sendMessage(iframe, "perspective:submit");
|
|
218
|
+
sendMessage(iframe, "perspective:close");
|
|
219
|
+
|
|
220
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
221
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
package/src/fullpage.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fullpage embed - takes over entire viewport
|
|
3
|
+
* SSR-safe - returns no-op handle on server
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { EmbedConfig, EmbedHandle } from "./types";
|
|
7
|
+
import { hasDom, getHost } from "./config";
|
|
8
|
+
import {
|
|
9
|
+
createIframe,
|
|
10
|
+
setupMessageListener,
|
|
11
|
+
registerIframe,
|
|
12
|
+
ensureGlobalListeners,
|
|
13
|
+
} from "./iframe";
|
|
14
|
+
import { createLoadingIndicator } from "./loading";
|
|
15
|
+
import { injectStyles } from "./styles";
|
|
16
|
+
import { cn, getThemeClass } from "./utils";
|
|
17
|
+
|
|
18
|
+
function createNoOpHandle(researchId: string): EmbedHandle {
|
|
19
|
+
return {
|
|
20
|
+
unmount: () => {},
|
|
21
|
+
update: () => {},
|
|
22
|
+
destroy: () => {},
|
|
23
|
+
researchId,
|
|
24
|
+
type: "fullpage" as const,
|
|
25
|
+
iframe: null,
|
|
26
|
+
container: null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createFullpage(config: EmbedConfig): EmbedHandle {
|
|
31
|
+
const { researchId } = config;
|
|
32
|
+
|
|
33
|
+
// SSR safety: return no-op handle
|
|
34
|
+
if (!hasDom()) {
|
|
35
|
+
return createNoOpHandle(researchId);
|
|
36
|
+
}
|
|
37
|
+
const host = getHost(config.host);
|
|
38
|
+
|
|
39
|
+
injectStyles();
|
|
40
|
+
ensureGlobalListeners();
|
|
41
|
+
|
|
42
|
+
// Create fullpage container
|
|
43
|
+
const container = document.createElement("div");
|
|
44
|
+
container.className = cn(
|
|
45
|
+
"perspective-embed-root perspective-fullpage",
|
|
46
|
+
getThemeClass(config.theme)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Create loading indicator with theme and brand colors
|
|
50
|
+
const loading = createLoadingIndicator({
|
|
51
|
+
theme: config.theme,
|
|
52
|
+
brand: config.brand,
|
|
53
|
+
});
|
|
54
|
+
container.appendChild(loading);
|
|
55
|
+
|
|
56
|
+
// Create iframe (hidden initially)
|
|
57
|
+
const iframe = createIframe(
|
|
58
|
+
researchId,
|
|
59
|
+
"fullpage",
|
|
60
|
+
host,
|
|
61
|
+
config.params,
|
|
62
|
+
config.brand,
|
|
63
|
+
config.theme
|
|
64
|
+
);
|
|
65
|
+
iframe.style.opacity = "0";
|
|
66
|
+
iframe.style.transition = "opacity 0.3s ease";
|
|
67
|
+
|
|
68
|
+
container.appendChild(iframe);
|
|
69
|
+
document.body.appendChild(container);
|
|
70
|
+
|
|
71
|
+
// Mutable config reference for updates
|
|
72
|
+
let currentConfig = { ...config };
|
|
73
|
+
let messageCleanup: (() => void) | null = null;
|
|
74
|
+
|
|
75
|
+
// Register iframe for theme change notifications
|
|
76
|
+
const unregisterIframe = registerIframe(iframe, host);
|
|
77
|
+
|
|
78
|
+
const unmount = () => {
|
|
79
|
+
messageCleanup?.();
|
|
80
|
+
unregisterIframe();
|
|
81
|
+
container.remove();
|
|
82
|
+
currentConfig.onClose?.();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Set up message listener
|
|
86
|
+
messageCleanup = setupMessageListener(
|
|
87
|
+
researchId,
|
|
88
|
+
{
|
|
89
|
+
get onReady() {
|
|
90
|
+
return () => {
|
|
91
|
+
loading.style.opacity = "0";
|
|
92
|
+
iframe.style.opacity = "1";
|
|
93
|
+
setTimeout(() => loading.remove(), 300);
|
|
94
|
+
currentConfig.onReady?.();
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
get onSubmit() {
|
|
98
|
+
return currentConfig.onSubmit;
|
|
99
|
+
},
|
|
100
|
+
get onNavigate() {
|
|
101
|
+
return currentConfig.onNavigate;
|
|
102
|
+
},
|
|
103
|
+
get onClose() {
|
|
104
|
+
return unmount;
|
|
105
|
+
},
|
|
106
|
+
get onError() {
|
|
107
|
+
return currentConfig.onError;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
iframe,
|
|
111
|
+
host,
|
|
112
|
+
{ skipResize: true }
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
unmount,
|
|
117
|
+
update: (options) => {
|
|
118
|
+
currentConfig = { ...currentConfig, ...options };
|
|
119
|
+
},
|
|
120
|
+
destroy: unmount,
|
|
121
|
+
researchId,
|
|
122
|
+
type: "fullpage" as const,
|
|
123
|
+
iframe,
|
|
124
|
+
container,
|
|
125
|
+
};
|
|
126
|
+
}
|