@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/browser.ts
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Perspective Embed SDK - Browser Entry
|
|
3
|
+
*
|
|
4
|
+
* CDN/Script Tag Entry Point - auto-init, attaches to window.Perspective
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* <script src="https://getperspective.ai/v1/perspective.js"></script>
|
|
8
|
+
*
|
|
9
|
+
* <!-- Auto-init with data attributes -->
|
|
10
|
+
* <div data-perspective-widget="research_xxx"></div>
|
|
11
|
+
* <button data-perspective-popup="research_xxx">Open Survey</button>
|
|
12
|
+
*
|
|
13
|
+
* <!-- Or programmatic -->
|
|
14
|
+
* <script>
|
|
15
|
+
* Perspective.openPopup({ researchId: 'xxx' });
|
|
16
|
+
* </script>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
BrandColors,
|
|
21
|
+
EmbedConfig,
|
|
22
|
+
EmbedHandle,
|
|
23
|
+
FloatHandle,
|
|
24
|
+
ThemeConfig,
|
|
25
|
+
} from "./types";
|
|
26
|
+
import { DATA_ATTRS, THEME_VALUES } from "./constants";
|
|
27
|
+
import { createWidget } from "./widget";
|
|
28
|
+
import { openPopup } from "./popup";
|
|
29
|
+
import { openSlider } from "./slider";
|
|
30
|
+
import { createFloatBubble, createChatBubble } from "./float";
|
|
31
|
+
import { createFullpage } from "./fullpage";
|
|
32
|
+
import { configure, getConfig, hasDom, getHost } from "./config";
|
|
33
|
+
import { resolveIsDark } from "./utils";
|
|
34
|
+
|
|
35
|
+
// Track all active instances
|
|
36
|
+
const instances: Map<string, EmbedHandle | FloatHandle> = new Map();
|
|
37
|
+
|
|
38
|
+
// Theme config cache
|
|
39
|
+
const configCache: Map<string, ThemeConfig> = new Map();
|
|
40
|
+
|
|
41
|
+
type ButtonStyleConfig = {
|
|
42
|
+
themeConfig: ThemeConfig;
|
|
43
|
+
theme?: EmbedConfig["theme"];
|
|
44
|
+
brand?: EmbedConfig["brand"];
|
|
45
|
+
};
|
|
46
|
+
const styledButtons = new Map<HTMLElement, ButtonStyleConfig>();
|
|
47
|
+
let buttonThemeMediaQuery: MediaQueryList | null = null;
|
|
48
|
+
|
|
49
|
+
const DEFAULT_THEME: ThemeConfig = {
|
|
50
|
+
primaryColor: "#7c3aed",
|
|
51
|
+
textColor: "#ffffff",
|
|
52
|
+
darkPrimaryColor: "#a78bfa",
|
|
53
|
+
darkTextColor: "#ffffff",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fetch theme config from API (cached)
|
|
58
|
+
*/
|
|
59
|
+
async function fetchConfig(researchId: string): Promise<ThemeConfig> {
|
|
60
|
+
if (configCache.has(researchId)) {
|
|
61
|
+
return configCache.get(researchId)!;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const host = getHost();
|
|
66
|
+
const res = await fetch(`${host}/api/v1/embed/config/${researchId}`);
|
|
67
|
+
if (!res.ok) return DEFAULT_THEME;
|
|
68
|
+
const config = await res.json();
|
|
69
|
+
configCache.set(researchId, config);
|
|
70
|
+
return config;
|
|
71
|
+
} catch {
|
|
72
|
+
return DEFAULT_THEME;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Apply theme styles to a button element
|
|
78
|
+
*/
|
|
79
|
+
function styleButton(
|
|
80
|
+
el: HTMLElement,
|
|
81
|
+
themeConfig: ThemeConfig,
|
|
82
|
+
options?: { theme?: EmbedConfig["theme"]; brand?: EmbedConfig["brand"] }
|
|
83
|
+
): void {
|
|
84
|
+
if (el.hasAttribute(DATA_ATTRS.noStyle)) return;
|
|
85
|
+
|
|
86
|
+
styledButtons.set(el, {
|
|
87
|
+
themeConfig,
|
|
88
|
+
theme: options?.theme,
|
|
89
|
+
brand: options?.brand,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
updateButtonTheme(el, {
|
|
93
|
+
themeConfig,
|
|
94
|
+
theme: options?.theme,
|
|
95
|
+
brand: options?.brand,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Update button styles based on theme
|
|
101
|
+
*/
|
|
102
|
+
function updateButtonTheme(el: HTMLElement, config: ButtonStyleConfig): void {
|
|
103
|
+
const { themeConfig, theme, brand } = config;
|
|
104
|
+
const isDark = resolveIsDark(theme);
|
|
105
|
+
|
|
106
|
+
const bg = isDark
|
|
107
|
+
? (brand?.dark?.primary ?? themeConfig.darkPrimaryColor)
|
|
108
|
+
: (brand?.light?.primary ?? themeConfig.primaryColor);
|
|
109
|
+
const text = isDark
|
|
110
|
+
? (brand?.dark?.text ?? themeConfig.darkTextColor)
|
|
111
|
+
: (brand?.light?.text ?? themeConfig.textColor);
|
|
112
|
+
|
|
113
|
+
el.style.backgroundColor = bg;
|
|
114
|
+
el.style.color = text;
|
|
115
|
+
el.style.padding = "10px 20px";
|
|
116
|
+
el.style.border = "none";
|
|
117
|
+
el.style.borderRadius = "8px";
|
|
118
|
+
el.style.fontWeight = "500";
|
|
119
|
+
el.style.cursor = "pointer";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Update all styled buttons when theme changes
|
|
124
|
+
*/
|
|
125
|
+
function updateAllButtonThemes(): void {
|
|
126
|
+
styledButtons.forEach((config, el) => {
|
|
127
|
+
if (document.contains(el)) {
|
|
128
|
+
updateButtonTheme(el, config);
|
|
129
|
+
} else {
|
|
130
|
+
styledButtons.delete(el);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let buttonThemeListener: ((e: MediaQueryListEvent) => void) | null = null;
|
|
136
|
+
|
|
137
|
+
function setupButtonThemeListener(): void {
|
|
138
|
+
if (buttonThemeListener || !hasDom()) return;
|
|
139
|
+
|
|
140
|
+
buttonThemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
141
|
+
buttonThemeListener = () => updateAllButtonThemes();
|
|
142
|
+
buttonThemeMediaQuery.addEventListener("change", buttonThemeListener);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function teardownButtonThemeListener(): void {
|
|
146
|
+
if (buttonThemeListener && buttonThemeMediaQuery) {
|
|
147
|
+
buttonThemeMediaQuery.removeEventListener("change", buttonThemeListener);
|
|
148
|
+
buttonThemeListener = null;
|
|
149
|
+
buttonThemeMediaQuery = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Parse params from data attribute (format: "key1=value1,key2=value2")
|
|
155
|
+
*/
|
|
156
|
+
function parseParamsAttr(el: HTMLElement): Record<string, string> | undefined {
|
|
157
|
+
const paramsStr = el.getAttribute(DATA_ATTRS.params);
|
|
158
|
+
if (!paramsStr) return undefined;
|
|
159
|
+
|
|
160
|
+
const params: Record<string, string> = {};
|
|
161
|
+
for (const pair of paramsStr.split(",")) {
|
|
162
|
+
const [key, ...valueParts] = pair.trim().split("=");
|
|
163
|
+
if (key) {
|
|
164
|
+
params[key.trim()] = valueParts.join("=").trim();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return Object.keys(params).length > 0 ? params : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Parse brand colors from data attribute (format: "primary=#xxx,bg=#yyy")
|
|
172
|
+
*/
|
|
173
|
+
function parseBrandAttr(attrValue: string | null): BrandColors | undefined {
|
|
174
|
+
if (!attrValue) return undefined;
|
|
175
|
+
|
|
176
|
+
const colors: BrandColors = {};
|
|
177
|
+
for (const pair of attrValue.split(",")) {
|
|
178
|
+
const [key, ...valueParts] = pair.trim().split("=");
|
|
179
|
+
if (key && valueParts.length > 0) {
|
|
180
|
+
const value = valueParts.join("=").trim();
|
|
181
|
+
if (value) {
|
|
182
|
+
const k = key.trim() as keyof BrandColors;
|
|
183
|
+
if (
|
|
184
|
+
k === "primary" ||
|
|
185
|
+
k === "secondary" ||
|
|
186
|
+
k === "bg" ||
|
|
187
|
+
k === "text"
|
|
188
|
+
) {
|
|
189
|
+
colors[k] = value;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return Object.keys(colors).length > 0 ? colors : undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Extract brand config and theme from element attributes
|
|
199
|
+
*/
|
|
200
|
+
function extractBrandConfig(
|
|
201
|
+
el: HTMLElement
|
|
202
|
+
): Pick<EmbedConfig, "brand" | "theme"> {
|
|
203
|
+
const light = parseBrandAttr(el.getAttribute(DATA_ATTRS.brand));
|
|
204
|
+
const dark = parseBrandAttr(el.getAttribute(DATA_ATTRS.brandDark));
|
|
205
|
+
const themeAttr = el.getAttribute(DATA_ATTRS.theme);
|
|
206
|
+
|
|
207
|
+
const config: Pick<EmbedConfig, "brand" | "theme"> = {};
|
|
208
|
+
|
|
209
|
+
if (light || dark) {
|
|
210
|
+
config.brand = {};
|
|
211
|
+
if (light) config.brand.light = light;
|
|
212
|
+
if (dark) config.brand.dark = dark;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (
|
|
216
|
+
themeAttr === THEME_VALUES.dark ||
|
|
217
|
+
themeAttr === THEME_VALUES.light ||
|
|
218
|
+
themeAttr === THEME_VALUES.system
|
|
219
|
+
) {
|
|
220
|
+
config.theme = themeAttr;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return config;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Initialize an embed programmatically
|
|
228
|
+
*/
|
|
229
|
+
function init(config: EmbedConfig): EmbedHandle | FloatHandle {
|
|
230
|
+
const { researchId } = config;
|
|
231
|
+
// Normalize legacy "chat" type to "float"
|
|
232
|
+
const type = config.type === "chat" ? "float" : (config.type ?? "widget");
|
|
233
|
+
|
|
234
|
+
// Destroy existing instance for this research
|
|
235
|
+
if (instances.has(researchId)) {
|
|
236
|
+
instances.get(researchId)!.unmount();
|
|
237
|
+
instances.delete(researchId);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let instance: EmbedHandle | FloatHandle;
|
|
241
|
+
|
|
242
|
+
switch (type) {
|
|
243
|
+
case "popup":
|
|
244
|
+
instance = openPopup(config);
|
|
245
|
+
break;
|
|
246
|
+
case "slider":
|
|
247
|
+
instance = openSlider(config);
|
|
248
|
+
break;
|
|
249
|
+
case "float":
|
|
250
|
+
instance = createFloatBubble(config);
|
|
251
|
+
break;
|
|
252
|
+
case "fullpage":
|
|
253
|
+
instance = createFullpage(config);
|
|
254
|
+
break;
|
|
255
|
+
default:
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Unknown embed type "${type}". Valid types: popup, slider, float, fullpage (use init()), or widget (use mount()).`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
instances.set(researchId, instance);
|
|
262
|
+
return instance;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Mount a widget into a container element
|
|
267
|
+
*/
|
|
268
|
+
function mount(
|
|
269
|
+
container: HTMLElement | string,
|
|
270
|
+
config: EmbedConfig
|
|
271
|
+
): EmbedHandle {
|
|
272
|
+
const { researchId } = config;
|
|
273
|
+
const type = config.type === "chat" ? "float" : (config.type ?? "widget");
|
|
274
|
+
|
|
275
|
+
const el =
|
|
276
|
+
typeof container === "string"
|
|
277
|
+
? document.querySelector<HTMLElement>(container)
|
|
278
|
+
: container;
|
|
279
|
+
|
|
280
|
+
if (!el) {
|
|
281
|
+
throw new Error(`Container not found: ${container}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Destroy existing instance
|
|
285
|
+
if (instances.has(researchId)) {
|
|
286
|
+
instances.get(researchId)!.unmount();
|
|
287
|
+
instances.delete(researchId);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let instance: EmbedHandle;
|
|
291
|
+
|
|
292
|
+
switch (type) {
|
|
293
|
+
case "widget":
|
|
294
|
+
instance = createWidget(el, config);
|
|
295
|
+
break;
|
|
296
|
+
default:
|
|
297
|
+
// For popup/slider/float, just use init - container not used
|
|
298
|
+
instance = init({ ...config, type }) as EmbedHandle;
|
|
299
|
+
return instance;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
instances.set(researchId, instance);
|
|
303
|
+
return instance;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Destroy an embed instance
|
|
308
|
+
*/
|
|
309
|
+
function destroy(researchId: string): void {
|
|
310
|
+
const instance = instances.get(researchId);
|
|
311
|
+
if (instance) {
|
|
312
|
+
instance.unmount();
|
|
313
|
+
instances.delete(researchId);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function destroyAll(): void {
|
|
318
|
+
instances.forEach((instance) => instance.unmount());
|
|
319
|
+
instances.clear();
|
|
320
|
+
styledButtons.clear();
|
|
321
|
+
teardownButtonThemeListener();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Auto-initialize embeds from data attributes
|
|
326
|
+
*/
|
|
327
|
+
function autoInit(): void {
|
|
328
|
+
if (!hasDom()) return;
|
|
329
|
+
|
|
330
|
+
setupButtonThemeListener();
|
|
331
|
+
|
|
332
|
+
// Widget embeds
|
|
333
|
+
document
|
|
334
|
+
.querySelectorAll<HTMLElement>(`[${DATA_ATTRS.widget}]`)
|
|
335
|
+
.forEach((el) => {
|
|
336
|
+
const researchId = el.getAttribute(DATA_ATTRS.widget);
|
|
337
|
+
if (researchId && !instances.has(researchId)) {
|
|
338
|
+
const params = parseParamsAttr(el);
|
|
339
|
+
const brandConfig = extractBrandConfig(el);
|
|
340
|
+
mount(el, { researchId, type: "widget", params, ...brandConfig });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Fullpage embeds
|
|
345
|
+
document
|
|
346
|
+
.querySelectorAll<HTMLElement>(`[${DATA_ATTRS.fullpage}]`)
|
|
347
|
+
.forEach((el) => {
|
|
348
|
+
const researchId = el.getAttribute(DATA_ATTRS.fullpage);
|
|
349
|
+
if (researchId && !instances.has(researchId)) {
|
|
350
|
+
const params = parseParamsAttr(el);
|
|
351
|
+
const brandConfig = extractBrandConfig(el);
|
|
352
|
+
init({ researchId, type: "fullpage", params, ...brandConfig });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Popup triggers
|
|
357
|
+
document
|
|
358
|
+
.querySelectorAll<HTMLElement>(`[${DATA_ATTRS.popup}]`)
|
|
359
|
+
.forEach((el) => {
|
|
360
|
+
if (el.hasAttribute("data-perspective-initialized")) return;
|
|
361
|
+
el.setAttribute("data-perspective-initialized", "true");
|
|
362
|
+
|
|
363
|
+
const researchId = el.getAttribute(DATA_ATTRS.popup);
|
|
364
|
+
if (researchId) {
|
|
365
|
+
const params = parseParamsAttr(el);
|
|
366
|
+
const brandConfig = extractBrandConfig(el);
|
|
367
|
+
styleButton(el, DEFAULT_THEME, brandConfig);
|
|
368
|
+
el.addEventListener("click", (e) => {
|
|
369
|
+
e.preventDefault();
|
|
370
|
+
init({ researchId, type: "popup", params, ...brandConfig });
|
|
371
|
+
});
|
|
372
|
+
fetchConfig(researchId).then((config) => {
|
|
373
|
+
styleButton(el, config, brandConfig);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Slider triggers
|
|
379
|
+
document
|
|
380
|
+
.querySelectorAll<HTMLElement>(`[${DATA_ATTRS.slider}]`)
|
|
381
|
+
.forEach((el) => {
|
|
382
|
+
if (el.hasAttribute("data-perspective-initialized")) return;
|
|
383
|
+
el.setAttribute("data-perspective-initialized", "true");
|
|
384
|
+
|
|
385
|
+
const researchId = el.getAttribute(DATA_ATTRS.slider);
|
|
386
|
+
if (researchId) {
|
|
387
|
+
const params = parseParamsAttr(el);
|
|
388
|
+
const brandConfig = extractBrandConfig(el);
|
|
389
|
+
styleButton(el, DEFAULT_THEME, brandConfig);
|
|
390
|
+
el.addEventListener("click", (e) => {
|
|
391
|
+
e.preventDefault();
|
|
392
|
+
init({ researchId, type: "slider", params, ...brandConfig });
|
|
393
|
+
});
|
|
394
|
+
fetchConfig(researchId).then((config) => {
|
|
395
|
+
styleButton(el, config, brandConfig);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Float bubble - supports both data-perspective-float and data-perspective-chat (legacy)
|
|
401
|
+
const floatSelector = `[${DATA_ATTRS.float}], [${DATA_ATTRS.chat}]`;
|
|
402
|
+
const floatEl = document.querySelector<HTMLElement>(floatSelector);
|
|
403
|
+
if (floatEl) {
|
|
404
|
+
const researchId =
|
|
405
|
+
floatEl.getAttribute(DATA_ATTRS.float) ||
|
|
406
|
+
floatEl.getAttribute(DATA_ATTRS.chat);
|
|
407
|
+
if (researchId && !instances.has(researchId)) {
|
|
408
|
+
const params = parseParamsAttr(floatEl);
|
|
409
|
+
const brandConfig = extractBrandConfig(floatEl);
|
|
410
|
+
init({
|
|
411
|
+
researchId,
|
|
412
|
+
type: "float",
|
|
413
|
+
params,
|
|
414
|
+
...brandConfig,
|
|
415
|
+
_themeConfig: DEFAULT_THEME,
|
|
416
|
+
} as EmbedConfig & { _themeConfig: ThemeConfig });
|
|
417
|
+
|
|
418
|
+
fetchConfig(researchId).then((config) => {
|
|
419
|
+
// Update bubble color with fetched theme
|
|
420
|
+
const bubble = document.querySelector<HTMLElement>(
|
|
421
|
+
'[data-perspective="float-bubble"]'
|
|
422
|
+
);
|
|
423
|
+
if (bubble && !floatEl.hasAttribute(DATA_ATTRS.noStyle)) {
|
|
424
|
+
const isDark = resolveIsDark(brandConfig.theme);
|
|
425
|
+
const bg = isDark
|
|
426
|
+
? (brandConfig.brand?.dark?.primary ?? config.darkPrimaryColor)
|
|
427
|
+
: (brandConfig.brand?.light?.primary ?? config.primaryColor);
|
|
428
|
+
bubble.style.setProperty("--perspective-float-bg", bg);
|
|
429
|
+
bubble.style.setProperty(
|
|
430
|
+
"--perspective-float-shadow",
|
|
431
|
+
`0 4px 12px ${bg}66`
|
|
432
|
+
);
|
|
433
|
+
bubble.style.setProperty(
|
|
434
|
+
"--perspective-float-shadow-hover",
|
|
435
|
+
`0 6px 16px ${bg}80`
|
|
436
|
+
);
|
|
437
|
+
bubble.style.backgroundColor = bg;
|
|
438
|
+
bubble.style.boxShadow = `0 4px 12px ${bg}66`;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Build the public API
|
|
446
|
+
const Perspective = {
|
|
447
|
+
// Configuration
|
|
448
|
+
configure,
|
|
449
|
+
getConfig,
|
|
450
|
+
|
|
451
|
+
// Instance management
|
|
452
|
+
init,
|
|
453
|
+
mount,
|
|
454
|
+
destroy,
|
|
455
|
+
destroyAll,
|
|
456
|
+
autoInit,
|
|
457
|
+
|
|
458
|
+
// Direct creation functions (primary API)
|
|
459
|
+
createWidget,
|
|
460
|
+
openPopup,
|
|
461
|
+
openSlider,
|
|
462
|
+
createFloatBubble,
|
|
463
|
+
createFullpage,
|
|
464
|
+
|
|
465
|
+
// Legacy alias
|
|
466
|
+
createChatBubble,
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
declare global {
|
|
470
|
+
interface Window {
|
|
471
|
+
__PERSPECTIVE_SDK_INITIALIZED__?: boolean;
|
|
472
|
+
Perspective?: typeof Perspective;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Prevent duplicate initialization when script is loaded multiple times
|
|
477
|
+
// (e.g., SPAs, tag managers, hot-reload, or accidental double-include).
|
|
478
|
+
// Without this guard, each script evaluation would register new listeners
|
|
479
|
+
// and create isolated module state, causing memory leaks and duplicate handlers.
|
|
480
|
+
if (hasDom() && !window.__PERSPECTIVE_SDK_INITIALIZED__) {
|
|
481
|
+
window.__PERSPECTIVE_SDK_INITIALIZED__ = true;
|
|
482
|
+
|
|
483
|
+
if (document.readyState === "loading") {
|
|
484
|
+
document.addEventListener("DOMContentLoaded", autoInit, { once: true });
|
|
485
|
+
} else {
|
|
486
|
+
autoInit();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
window.Perspective = Perspective;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Export for module usage
|
|
493
|
+
export {
|
|
494
|
+
configure,
|
|
495
|
+
getConfig,
|
|
496
|
+
init,
|
|
497
|
+
mount,
|
|
498
|
+
destroy,
|
|
499
|
+
destroyAll,
|
|
500
|
+
autoInit,
|
|
501
|
+
createWidget,
|
|
502
|
+
openPopup,
|
|
503
|
+
openSlider,
|
|
504
|
+
createFloatBubble,
|
|
505
|
+
createChatBubble,
|
|
506
|
+
createFullpage,
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
export default Perspective;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
describe("config", () => {
|
|
4
|
+
let configure: typeof import("./config").configure;
|
|
5
|
+
let getConfig: typeof import("./config").getConfig;
|
|
6
|
+
let hasDom: typeof import("./config").hasDom;
|
|
7
|
+
let getHost: typeof import("./config").getHost;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
// Reset module state between tests to avoid state leakage
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
const configModule = await import("./config");
|
|
13
|
+
configure = configModule.configure;
|
|
14
|
+
getConfig = configModule.getConfig;
|
|
15
|
+
hasDom = configModule.hasDom;
|
|
16
|
+
getHost = configModule.getHost;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.restoreAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("configure", () => {
|
|
24
|
+
it("sets global config", () => {
|
|
25
|
+
configure({ host: "https://example.com" });
|
|
26
|
+
expect(getConfig()).toEqual({ host: "https://example.com" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("merges with existing config", () => {
|
|
30
|
+
configure({ host: "https://example.com" });
|
|
31
|
+
configure({ host: "https://other.com" });
|
|
32
|
+
expect(getConfig()).toEqual({ host: "https://other.com" });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("getConfig", () => {
|
|
37
|
+
it("returns a copy of config", () => {
|
|
38
|
+
configure({ host: "https://example.com" });
|
|
39
|
+
const config1 = getConfig();
|
|
40
|
+
const config2 = getConfig();
|
|
41
|
+
expect(config1).toEqual(config2);
|
|
42
|
+
expect(config1).not.toBe(config2); // Different object references
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("hasDom", () => {
|
|
47
|
+
it("returns true when window and document exist", () => {
|
|
48
|
+
expect(hasDom()).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("getHost", () => {
|
|
53
|
+
it("returns instance host if provided", () => {
|
|
54
|
+
expect(getHost("https://instance.example.com")).toBe(
|
|
55
|
+
"https://instance.example.com"
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("normalizes URL to origin", () => {
|
|
60
|
+
expect(getHost("https://example.com/some/path?query=1")).toBe(
|
|
61
|
+
"https://example.com"
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns global config host if no instance host", () => {
|
|
66
|
+
configure({ host: "https://global.example.com" });
|
|
67
|
+
expect(getHost()).toBe("https://global.example.com");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("instance host takes precedence over global", () => {
|
|
71
|
+
configure({ host: "https://global.example.com" });
|
|
72
|
+
expect(getHost("https://instance.example.com")).toBe(
|
|
73
|
+
"https://instance.example.com"
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns default host when no config", () => {
|
|
78
|
+
expect(getHost()).toBe("https://getperspective.ai");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embed SDK configuration
|
|
3
|
+
* SSR-safe - DOM access is guarded and lazy
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SDKConfig } from "./types";
|
|
7
|
+
|
|
8
|
+
/** Default production host */
|
|
9
|
+
const DEFAULT_HOST = "https://getperspective.ai";
|
|
10
|
+
|
|
11
|
+
/** Global SDK configuration - can be set before creating embeds */
|
|
12
|
+
let globalConfig: SDKConfig = {};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Configure the SDK globally
|
|
16
|
+
* Call this before creating any embeds if you need to override defaults
|
|
17
|
+
*/
|
|
18
|
+
export function configure(config: SDKConfig): void {
|
|
19
|
+
globalConfig = { ...globalConfig, ...config };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get the current SDK configuration
|
|
24
|
+
*/
|
|
25
|
+
export function getConfig(): SDKConfig {
|
|
26
|
+
return { ...globalConfig };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if DOM is available (SSR safety)
|
|
31
|
+
*/
|
|
32
|
+
export function hasDom(): boolean {
|
|
33
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeToOrigin(host: string): string {
|
|
37
|
+
try {
|
|
38
|
+
return new URL(host).origin;
|
|
39
|
+
} catch {
|
|
40
|
+
return host;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getHost(instanceHost?: string): string {
|
|
45
|
+
// Instance-level override
|
|
46
|
+
if (instanceHost) {
|
|
47
|
+
return normalizeToOrigin(instanceHost);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Global config override
|
|
51
|
+
if (globalConfig.host) {
|
|
52
|
+
return normalizeToOrigin(globalConfig.host);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Try to infer from script src (only in browser, only at load time)
|
|
56
|
+
if (hasDom()) {
|
|
57
|
+
const scriptHost = getScriptHost();
|
|
58
|
+
if (scriptHost) {
|
|
59
|
+
return scriptHost;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return DEFAULT_HOST;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Capture script src at load time - document.currentScript only available during initial execution
|
|
67
|
+
let capturedScriptHost: string | null = null;
|
|
68
|
+
|
|
69
|
+
function getScriptHost(): string | null {
|
|
70
|
+
if (capturedScriptHost !== null) {
|
|
71
|
+
return capturedScriptHost;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!hasDom()) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const currentScript = document.currentScript as HTMLScriptElement | null;
|
|
79
|
+
if (currentScript?.src) {
|
|
80
|
+
try {
|
|
81
|
+
capturedScriptHost = new URL(currentScript.src).origin;
|
|
82
|
+
return capturedScriptHost;
|
|
83
|
+
} catch {
|
|
84
|
+
// Invalid URL, ignore
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
capturedScriptHost = ""; // Mark as attempted
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Capture script host immediately when module loads (in browser)
|
|
93
|
+
if (hasDom()) {
|
|
94
|
+
getScriptHost();
|
|
95
|
+
}
|