@obsrviq/widgets 0.3.0
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/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.js +465 -0
- package/dist/index.js.map +1 -0
- package/dist/obsrviq-replay.global.js +3821 -0
- package/dist/obsrviq-replay.global.js.map +1 -0
- package/package.json +48 -0
- package/src/ObsrviqCopilot.tsx +95 -0
- package/src/ObsrviqInsights.tsx +115 -0
- package/src/ObsrviqRecommendations.tsx +75 -0
- package/src/ObsrviqReplay.tsx +237 -0
- package/src/ObsrviqScore.tsx +78 -0
- package/src/client.ts +64 -0
- package/src/common.tsx +39 -0
- package/src/embed-css.ts +3 -0
- package/src/global.ts +71 -0
- package/src/index.ts +12 -0
- package/styles.css +264 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Obsrviq
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @obsrviq/widgets
|
|
2
|
+
|
|
3
|
+
Embeddable, white-label React components. Drop them into your product; they inherit
|
|
4
|
+
your theme via CSS variables and show no Obsrviq branding.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install @obsrviq/widgets
|
|
8
|
+
```
|
|
9
|
+
```tsx
|
|
10
|
+
import {
|
|
11
|
+
ObsrviqReplay, ObsrviqInsights, ObsrviqRecommendations, ObsrviqScore, ObsrviqCopilot,
|
|
12
|
+
} from '@obsrviq/widgets';
|
|
13
|
+
import '@obsrviq/widgets/styles.css'; // one import pulls in all tokens + styles
|
|
14
|
+
|
|
15
|
+
const api = { apiBaseUrl: 'https://api.yourapp.com', apiKey: 'sk_live_…' };
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
| Component | Props (beyond `api` + `theme`) |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `<ObsrviqReplay/>` | `sessionId`, `startAtMs?`, `height?`, `onEnded?` |
|
|
21
|
+
| `<ObsrviqInsights/>` | `siteId`, `max?`, `onApply?`, `onDismiss?` |
|
|
22
|
+
| `<ObsrviqRecommendations/>` | `siteId`, `onAction?` |
|
|
23
|
+
| `<ObsrviqScore/>` | `siteId`, `type` (`performance`/`accessibility`/`ai-visibility`), `size?` |
|
|
24
|
+
| `<ObsrviqCopilot/>` | `siteId`, `placeholder?` |
|
|
25
|
+
|
|
26
|
+
Each renders a skeleton within ~1s, is keyboard- and screen-reader-accessible,
|
|
27
|
+
inherits the host theme, and degrades to a friendly empty state with no data.
|
|
28
|
+
|
|
29
|
+
## Theming
|
|
30
|
+
|
|
31
|
+
Override any `--lum-*` token globally, or per-widget via the `theme` prop:
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
<ObsrviqInsights siteId="…" {...api} theme={{ '--lum-accent': '#e11d48', '--lum-radius': '14px' }} />
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> **Browser key exposure:** widgets given a raw `sk_` key expose it client-side.
|
|
38
|
+
> For customer-facing embeds, proxy through your backend or use a short-lived JWT.
|
|
39
|
+
|
|
40
|
+
Full guide: [docs/EMBEDDING.md](../../docs/EMBEDDING.md).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { InsightCard, ScoreType } from '@obsrviq/types';
|
|
3
|
+
|
|
4
|
+
/** Light/dark color scheme, or an object of `--lum-*` overrides to white-label. */
|
|
5
|
+
type WidgetTheme = 'light' | 'dark' | Record<string, string>;
|
|
6
|
+
interface BaseWidgetProps {
|
|
7
|
+
apiBaseUrl: string;
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
token?: string;
|
|
10
|
+
/** `'light'` or `'dark'` to pick a color scheme (default follows the page —
|
|
11
|
+
* dark for the standalone embed), or an object of `--lum-*` overrides. */
|
|
12
|
+
theme?: WidgetTheme;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ClipBounds = {
|
|
16
|
+
fromEvent?: string;
|
|
17
|
+
toEvent?: string;
|
|
18
|
+
fromMs?: number;
|
|
19
|
+
toMs?: number;
|
|
20
|
+
fromUtc?: number;
|
|
21
|
+
toUtc?: number;
|
|
22
|
+
loop?: boolean;
|
|
23
|
+
};
|
|
24
|
+
/** What to render when the recording can't be shown (not found / load error):
|
|
25
|
+
* 'message' (default) shows a small note; 'hide' renders nothing so the embed
|
|
26
|
+
* collapses on the host page (pair with `onError` to hide your own wrapper). */
|
|
27
|
+
type UnavailableFallback = 'message' | 'hide';
|
|
28
|
+
interface ObsrviqReplayProps extends BaseWidgetProps {
|
|
29
|
+
/** The session to play. Optional if `userId` is given — then the session is
|
|
30
|
+
* resolved from the user + clip bounds (UTC window / event names). */
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
/** Resolve the session from this user instead of a session id. */
|
|
33
|
+
userId?: string;
|
|
34
|
+
/** Optional site scope for `userId` resolution; omit to search the whole tenant. */
|
|
35
|
+
siteId?: string;
|
|
36
|
+
startAtMs?: number;
|
|
37
|
+
/** Play only a segment: bound by two custom-event names, our-clock ms, or UTC. */
|
|
38
|
+
clip?: ClipBounds;
|
|
39
|
+
/** Focus mode the player opens in ('stage' = Watch). Overrides the site default. */
|
|
40
|
+
defaultMode?: 'balanced' | 'stage' | 'debug';
|
|
41
|
+
/** Restrict the focus-mode toggle to this allowlist; a single entry hides it. */
|
|
42
|
+
allowedModes?: Array<'balanced' | 'stage' | 'debug'>;
|
|
43
|
+
/** Playback speed the player opens at (clamped to the nearest offered speed). */
|
|
44
|
+
defaultSpeed?: number;
|
|
45
|
+
/** Show the fullscreen control + `F` shortcut. Default true. */
|
|
46
|
+
allowFullscreen?: boolean;
|
|
47
|
+
/** Base for "Copy link" deep-links (this embed's URL has no session id). A
|
|
48
|
+
* `:id` template, or a base we append `/sessions/:id` to. Omit → console host. */
|
|
49
|
+
shareUrl?: string;
|
|
50
|
+
onEnded?: () => void;
|
|
51
|
+
/** Called once when the recording can't be shown — `reason` is 'not_found'
|
|
52
|
+
* (no such session / no match for the user) or 'error' (load/network/auth).
|
|
53
|
+
* Use it to hide your own container, log, retry, etc. */
|
|
54
|
+
onError?: (info: {
|
|
55
|
+
reason: 'not_found' | 'error';
|
|
56
|
+
message: string;
|
|
57
|
+
}) => void;
|
|
58
|
+
/** What to render when unavailable. Default 'message'. Use 'hide' to collapse. */
|
|
59
|
+
fallback?: UnavailableFallback;
|
|
60
|
+
height?: number | string;
|
|
61
|
+
}
|
|
62
|
+
/** The replay player itself, embeddable and white-label (§12). */
|
|
63
|
+
declare function ObsrviqReplay({ sessionId, userId, siteId, apiBaseUrl, apiKey, token, theme, startAtMs, clip, defaultMode, allowedModes, defaultSpeed, allowFullscreen, shareUrl, onEnded, onError, fallback, height, }: ObsrviqReplayProps): react.JSX.Element | null;
|
|
64
|
+
|
|
65
|
+
interface ObsrviqInsightsProps extends BaseWidgetProps {
|
|
66
|
+
siteId: string;
|
|
67
|
+
max?: number;
|
|
68
|
+
onApply?: (card: InsightCard) => void;
|
|
69
|
+
onDismiss?: (card: InsightCard) => void;
|
|
70
|
+
}
|
|
71
|
+
declare function ObsrviqInsights(props: ObsrviqInsightsProps): react.JSX.Element;
|
|
72
|
+
|
|
73
|
+
interface ObsrviqRecommendationsProps extends BaseWidgetProps {
|
|
74
|
+
siteId: string;
|
|
75
|
+
onAction?: (rec: InsightCard) => void;
|
|
76
|
+
}
|
|
77
|
+
/** A prioritized recommendation queue (impact × confidence / effort, §14). */
|
|
78
|
+
declare function ObsrviqRecommendations({ siteId, apiBaseUrl, apiKey, token, theme, onAction }: ObsrviqRecommendationsProps): react.JSX.Element;
|
|
79
|
+
|
|
80
|
+
interface ObsrviqScoreProps extends BaseWidgetProps {
|
|
81
|
+
siteId: string;
|
|
82
|
+
type: ScoreType;
|
|
83
|
+
size?: number;
|
|
84
|
+
}
|
|
85
|
+
declare function ObsrviqScore({ siteId, type, apiBaseUrl, apiKey, token, theme, size }: ObsrviqScoreProps): react.JSX.Element;
|
|
86
|
+
|
|
87
|
+
interface ObsrviqCopilotProps extends BaseWidgetProps {
|
|
88
|
+
siteId: string;
|
|
89
|
+
placeholder?: string;
|
|
90
|
+
}
|
|
91
|
+
/** Embeddable copilot — NL Q&A grounded in the tenant's data (§14). */
|
|
92
|
+
declare function ObsrviqCopilot({ siteId, apiBaseUrl, apiKey, token, theme, placeholder }: ObsrviqCopilotProps): react.JSX.Element;
|
|
93
|
+
|
|
94
|
+
export { type BaseWidgetProps, ObsrviqCopilot, type ObsrviqCopilotProps, ObsrviqInsights, type ObsrviqInsightsProps, ObsrviqRecommendations, type ObsrviqRecommendationsProps, ObsrviqReplay, type ObsrviqReplayProps, ObsrviqScore, type ObsrviqScoreProps };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// src/ObsrviqReplay.tsx
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { ReplayPlayer } from "@obsrviq/player";
|
|
4
|
+
|
|
5
|
+
// src/common.tsx
|
|
6
|
+
import { jsx } from "react/jsx-runtime";
|
|
7
|
+
function ThemeScope({
|
|
8
|
+
theme,
|
|
9
|
+
className,
|
|
10
|
+
children
|
|
11
|
+
}) {
|
|
12
|
+
const scheme = typeof theme === "string" ? theme : void 0;
|
|
13
|
+
const overrides = theme && typeof theme === "object" ? theme : void 0;
|
|
14
|
+
return /* @__PURE__ */ jsx(
|
|
15
|
+
"div",
|
|
16
|
+
{
|
|
17
|
+
className: `lum-root lum lum-widget ${className ?? ""}`,
|
|
18
|
+
"data-lum-theme": scheme,
|
|
19
|
+
style: overrides,
|
|
20
|
+
children
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/ObsrviqReplay.tsx
|
|
26
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
27
|
+
function authHeaders(token, apiKey) {
|
|
28
|
+
if (token) return { authorization: `Bearer ${token}` };
|
|
29
|
+
if (apiKey) return { authorization: `Bearer ${apiKey}`, "x-obsrviq-key": apiKey };
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
function useResolvedSession(enabled, opts) {
|
|
33
|
+
const { apiBaseUrl, token, apiKey, userId, siteId, clip } = opts;
|
|
34
|
+
const [state, setState] = useState({
|
|
35
|
+
sessionId: null,
|
|
36
|
+
loading: enabled,
|
|
37
|
+
error: null,
|
|
38
|
+
notFound: false
|
|
39
|
+
});
|
|
40
|
+
const sig = JSON.stringify([userId, siteId, clip?.fromUtc, clip?.toUtc, clip?.fromEvent, clip?.toEvent]);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!enabled || !userId) return;
|
|
43
|
+
const ctrl = new AbortController();
|
|
44
|
+
setState({ sessionId: null, loading: true, error: null, notFound: false });
|
|
45
|
+
const q = new URLSearchParams({ userId });
|
|
46
|
+
if (siteId) q.set("siteId", siteId);
|
|
47
|
+
if (clip?.fromUtc != null && clip?.toUtc != null) {
|
|
48
|
+
q.set("fromUtc", String(clip.fromUtc));
|
|
49
|
+
q.set("toUtc", String(clip.toUtc));
|
|
50
|
+
} else if (clip?.fromEvent) {
|
|
51
|
+
q.set("fromEvent", clip.fromEvent);
|
|
52
|
+
if (clip.toEvent) q.set("toEvent", clip.toEvent);
|
|
53
|
+
}
|
|
54
|
+
const base = apiBaseUrl.replace(/\/$/, "");
|
|
55
|
+
fetch(`${base}/v1/sessions/locate?${q.toString()}`, {
|
|
56
|
+
headers: authHeaders(token, apiKey),
|
|
57
|
+
signal: ctrl.signal
|
|
58
|
+
}).then(async (r) => {
|
|
59
|
+
if (!r.ok) {
|
|
60
|
+
const notFound = r.status === 404;
|
|
61
|
+
throw Object.assign(
|
|
62
|
+
new Error(notFound ? "No matching recording for that user." : `Lookup failed (${r.status}).`),
|
|
63
|
+
{ notFound }
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return r.json();
|
|
67
|
+
}).then((j) => {
|
|
68
|
+
if (!ctrl.signal.aborted) setState({ sessionId: j.sessionId, loading: false, error: null, notFound: false });
|
|
69
|
+
}).catch((e) => {
|
|
70
|
+
if (ctrl.signal.aborted) return;
|
|
71
|
+
const notFound = !!e?.notFound;
|
|
72
|
+
setState({
|
|
73
|
+
sessionId: null,
|
|
74
|
+
loading: false,
|
|
75
|
+
error: e instanceof Error ? e.message : "Lookup failed.",
|
|
76
|
+
notFound
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
return () => ctrl.abort();
|
|
80
|
+
}, [enabled, sig, apiBaseUrl]);
|
|
81
|
+
return state;
|
|
82
|
+
}
|
|
83
|
+
var msgStyle = {
|
|
84
|
+
display: "flex",
|
|
85
|
+
alignItems: "center",
|
|
86
|
+
justifyContent: "center",
|
|
87
|
+
height: "100%",
|
|
88
|
+
padding: 16,
|
|
89
|
+
textAlign: "center",
|
|
90
|
+
color: "var(--lum-muted)",
|
|
91
|
+
fontSize: 14
|
|
92
|
+
};
|
|
93
|
+
function ObsrviqReplay({
|
|
94
|
+
sessionId,
|
|
95
|
+
userId,
|
|
96
|
+
siteId,
|
|
97
|
+
apiBaseUrl,
|
|
98
|
+
apiKey,
|
|
99
|
+
token,
|
|
100
|
+
theme,
|
|
101
|
+
startAtMs,
|
|
102
|
+
clip,
|
|
103
|
+
defaultMode,
|
|
104
|
+
allowedModes,
|
|
105
|
+
defaultSpeed,
|
|
106
|
+
allowFullscreen,
|
|
107
|
+
shareUrl,
|
|
108
|
+
onEnded,
|
|
109
|
+
onError,
|
|
110
|
+
fallback = "message",
|
|
111
|
+
height = 560
|
|
112
|
+
}) {
|
|
113
|
+
const needResolve = !sessionId && !!userId;
|
|
114
|
+
const resolved = useResolvedSession(needResolve, { apiBaseUrl, token, apiKey, userId, siteId, clip });
|
|
115
|
+
const [playerError, setPlayerError] = useState(null);
|
|
116
|
+
const effectiveId = sessionId ?? resolved.sessionId;
|
|
117
|
+
useEffect(() => setPlayerError(null), [effectiveId]);
|
|
118
|
+
const reportedRef = useRef(null);
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
let info = null;
|
|
121
|
+
if (!sessionId && !userId) info = { reason: "error", message: "Provide a sessionId or a userId." };
|
|
122
|
+
else if (resolved.error) info = { reason: resolved.notFound ? "not_found" : "error", message: resolved.error };
|
|
123
|
+
else if (playerError) info = playerError;
|
|
124
|
+
if (info) {
|
|
125
|
+
if (reportedRef.current !== info.message) {
|
|
126
|
+
reportedRef.current = info.message;
|
|
127
|
+
onError?.(info);
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
reportedRef.current = null;
|
|
131
|
+
}
|
|
132
|
+
}, [sessionId, userId, resolved.error, resolved.notFound, playerError]);
|
|
133
|
+
if (!effectiveId) {
|
|
134
|
+
if (fallback === "hide") return null;
|
|
135
|
+
const msg = !sessionId && !userId ? "Provide a sessionId or a userId to play a recording." : needResolve && resolved.loading ? "Finding the recording\u2026" : resolved.error ?? "Recording unavailable.";
|
|
136
|
+
return /* @__PURE__ */ jsx2(ThemeScope, { theme, className: "lum-widget--replay", children: /* @__PURE__ */ jsx2("div", { style: { height }, children: /* @__PURE__ */ jsx2("div", { style: msgStyle, children: msg }) }) });
|
|
137
|
+
}
|
|
138
|
+
if (playerError && fallback === "hide") return null;
|
|
139
|
+
return /* @__PURE__ */ jsx2(ThemeScope, { theme, className: "lum-widget--replay", children: /* @__PURE__ */ jsx2("div", { style: { height }, children: /* @__PURE__ */ jsx2(
|
|
140
|
+
ReplayPlayer,
|
|
141
|
+
{
|
|
142
|
+
sessionId: effectiveId,
|
|
143
|
+
apiBaseUrl,
|
|
144
|
+
apiKey,
|
|
145
|
+
token,
|
|
146
|
+
startAtMs,
|
|
147
|
+
clip,
|
|
148
|
+
defaultMode,
|
|
149
|
+
allowedModes,
|
|
150
|
+
defaultSpeed,
|
|
151
|
+
allowFullscreen,
|
|
152
|
+
shareUrl,
|
|
153
|
+
onEnded,
|
|
154
|
+
onError: (info) => setPlayerError(info),
|
|
155
|
+
embedded: true
|
|
156
|
+
}
|
|
157
|
+
) }) });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/ObsrviqInsights.tsx
|
|
161
|
+
import { useEffect as useEffect2, useState as useState2 } from "react";
|
|
162
|
+
import { Badge, Button, EmptyState, ErrorState, Skeleton, ToastProvider, useToast } from "@obsrviq/ui";
|
|
163
|
+
|
|
164
|
+
// src/client.ts
|
|
165
|
+
function headers(cfg) {
|
|
166
|
+
const key = cfg.token || cfg.apiKey;
|
|
167
|
+
return key ? { authorization: `Bearer ${key}` } : {};
|
|
168
|
+
}
|
|
169
|
+
async function apiGet(cfg, path) {
|
|
170
|
+
const res = await fetch(`${cfg.apiBaseUrl.replace(/\/$/, "")}${path}`, { headers: headers(cfg) });
|
|
171
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
172
|
+
return await res.json();
|
|
173
|
+
}
|
|
174
|
+
async function apiPost(cfg, path, body) {
|
|
175
|
+
const res = await fetch(`${cfg.apiBaseUrl.replace(/\/$/, "")}${path}`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { ...headers(cfg), "content-type": "application/json" },
|
|
178
|
+
body: body ? JSON.stringify(body) : void 0
|
|
179
|
+
});
|
|
180
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
181
|
+
return await res.json();
|
|
182
|
+
}
|
|
183
|
+
async function* copilotStream(cfg, siteId, question) {
|
|
184
|
+
const res = await fetch(`${cfg.apiBaseUrl.replace(/\/$/, "")}/v1/copilot/ask`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { ...headers(cfg), "content-type": "application/json" },
|
|
187
|
+
body: JSON.stringify({ siteId, question })
|
|
188
|
+
});
|
|
189
|
+
if (!res.ok || !res.body) throw new Error(`copilot ${res.status}`);
|
|
190
|
+
const reader = res.body.getReader();
|
|
191
|
+
const decoder = new TextDecoder();
|
|
192
|
+
let buffer = "";
|
|
193
|
+
for (; ; ) {
|
|
194
|
+
const { done, value } = await reader.read();
|
|
195
|
+
if (done) break;
|
|
196
|
+
buffer += decoder.decode(value, { stream: true });
|
|
197
|
+
const lines = buffer.split("\n");
|
|
198
|
+
buffer = lines.pop() ?? "";
|
|
199
|
+
for (const line of lines) {
|
|
200
|
+
const t = line.trim();
|
|
201
|
+
if (!t.startsWith("data:")) continue;
|
|
202
|
+
const data = t.slice(5).trim();
|
|
203
|
+
if (data === "[DONE]") return;
|
|
204
|
+
try {
|
|
205
|
+
yield JSON.parse(data);
|
|
206
|
+
} catch {
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/ObsrviqInsights.tsx
|
|
213
|
+
import { Fragment, jsx as jsx3, jsxs } from "react/jsx-runtime";
|
|
214
|
+
var SEVERITY_TONE = {
|
|
215
|
+
info: "info",
|
|
216
|
+
low: "info",
|
|
217
|
+
medium: "warn",
|
|
218
|
+
high: "error"
|
|
219
|
+
};
|
|
220
|
+
function ObsrviqInsights(props) {
|
|
221
|
+
return /* @__PURE__ */ jsx3(ThemeScope, { theme: props.theme, className: "lum-widget--insights", children: /* @__PURE__ */ jsx3(ToastProvider, { children: /* @__PURE__ */ jsx3(InsightsInner, { ...props }) }) });
|
|
222
|
+
}
|
|
223
|
+
function InsightsInner({ siteId, apiBaseUrl, apiKey, token, max = 5, onApply, onDismiss }) {
|
|
224
|
+
const cfg = { apiBaseUrl, apiKey, token };
|
|
225
|
+
const [cards, setCards] = useState2(null);
|
|
226
|
+
const [error, setError] = useState2(null);
|
|
227
|
+
const toast = useToast();
|
|
228
|
+
useEffect2(() => {
|
|
229
|
+
let alive = true;
|
|
230
|
+
apiGet(cfg, `/v1/insights?siteId=${siteId}`).then((r) => alive && setCards(r.items)).catch((e) => alive && setError(e.message));
|
|
231
|
+
return () => {
|
|
232
|
+
alive = false;
|
|
233
|
+
};
|
|
234
|
+
}, [siteId]);
|
|
235
|
+
const act = async (card, verb) => {
|
|
236
|
+
setCards((cs) => (cs ?? []).map((c) => c.id === card.id ? { ...c, status: verb === "apply" ? "applied" : "dismissed" } : c));
|
|
237
|
+
try {
|
|
238
|
+
await apiPost(cfg, `/v1/insights/${card.id}/${verb}`);
|
|
239
|
+
toast(verb === "apply" ? "Applied" : "Dismissed", verb === "apply" ? "ok" : "default");
|
|
240
|
+
(verb === "apply" ? onApply : onDismiss)?.(card);
|
|
241
|
+
} catch {
|
|
242
|
+
toast("Could not update \u2014 try again", "error");
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
if (error)
|
|
246
|
+
return /* @__PURE__ */ jsx3(ErrorState, { title: "We couldn't load insights.", children: error });
|
|
247
|
+
if (!cards)
|
|
248
|
+
return /* @__PURE__ */ jsx3("div", { className: "lum-w-cards", children: Array.from({ length: 3 }).map((_, i) => /* @__PURE__ */ jsxs("div", { className: "lum-w-card", children: [
|
|
249
|
+
/* @__PURE__ */ jsx3(Skeleton, { width: "60%", height: 16 }),
|
|
250
|
+
/* @__PURE__ */ jsx3(Skeleton, { width: "100%", height: 40 })
|
|
251
|
+
] }, i)) });
|
|
252
|
+
const visible = cards.filter((c) => c.status !== "dismissed").slice(0, max);
|
|
253
|
+
if (visible.length === 0)
|
|
254
|
+
return /* @__PURE__ */ jsx3(EmptyState, { title: "No insights yet", children: "As soon as visitors arrive, recommendations will appear here." });
|
|
255
|
+
return /* @__PURE__ */ jsx3("div", { className: "lum-w-cards", children: visible.map((c) => /* @__PURE__ */ jsxs("article", { className: "lum-w-card", children: [
|
|
256
|
+
/* @__PURE__ */ jsxs("header", { className: "lum-w-card__head", children: [
|
|
257
|
+
/* @__PURE__ */ jsx3(Badge, { tone: SEVERITY_TONE[c.severity], children: c.severity }),
|
|
258
|
+
/* @__PURE__ */ jsx3("strong", { children: c.title })
|
|
259
|
+
] }),
|
|
260
|
+
/* @__PURE__ */ jsx3("p", { className: "lum-w-card__what", children: c.what }),
|
|
261
|
+
/* @__PURE__ */ jsx3("p", { className: "lum-w-card__why lum-muted", children: c.why }),
|
|
262
|
+
/* @__PURE__ */ jsx3("p", { className: "lum-w-card__rec", children: c.recommendation }),
|
|
263
|
+
/* @__PURE__ */ jsxs("div", { className: "lum-w-card__meta", children: [
|
|
264
|
+
c.expectedImpact && /* @__PURE__ */ jsxs(Badge, { tone: "ok", children: [
|
|
265
|
+
c.expectedImpact.metric,
|
|
266
|
+
" ",
|
|
267
|
+
c.expectedImpact.lift
|
|
268
|
+
] }),
|
|
269
|
+
/* @__PURE__ */ jsxs(Badge, { children: [
|
|
270
|
+
"effort: ",
|
|
271
|
+
c.effort
|
|
272
|
+
] }),
|
|
273
|
+
c.evidence.length > 0 && /* @__PURE__ */ jsxs("span", { className: "lum-muted", children: [
|
|
274
|
+
c.evidence.length,
|
|
275
|
+
" sessions"
|
|
276
|
+
] })
|
|
277
|
+
] }),
|
|
278
|
+
/* @__PURE__ */ jsx3("footer", { className: "lum-w-card__actions", children: c.status === "applied" ? /* @__PURE__ */ jsx3(Badge, { tone: "ok", children: "Applied" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
279
|
+
/* @__PURE__ */ jsx3(Button, { variant: "primary", size: "sm", onClick: () => act(c, "apply"), children: "Apply" }),
|
|
280
|
+
/* @__PURE__ */ jsx3(Button, { variant: "ghost", size: "sm", onClick: () => act(c, "dismiss"), children: "Dismiss" })
|
|
281
|
+
] }) })
|
|
282
|
+
] }, c.id)) });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// src/ObsrviqRecommendations.tsx
|
|
286
|
+
import { useEffect as useEffect3, useState as useState3 } from "react";
|
|
287
|
+
import { Badge as Badge2, Button as Button2, EmptyState as EmptyState2, ErrorState as ErrorState2, Skeleton as Skeleton2 } from "@obsrviq/ui";
|
|
288
|
+
import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
289
|
+
function ObsrviqRecommendations({ siteId, apiBaseUrl, apiKey, token, theme, onAction }) {
|
|
290
|
+
const cfg = { apiBaseUrl, apiKey, token };
|
|
291
|
+
const [items, setItems] = useState3(null);
|
|
292
|
+
const [error, setError] = useState3(null);
|
|
293
|
+
useEffect3(() => {
|
|
294
|
+
let alive = true;
|
|
295
|
+
apiGet(cfg, `/v1/insights?siteId=${siteId}`).then((r) => alive && setItems(r.items)).catch((e) => alive && setError(e.message));
|
|
296
|
+
return () => {
|
|
297
|
+
alive = false;
|
|
298
|
+
};
|
|
299
|
+
}, [siteId]);
|
|
300
|
+
const run = async (rec) => {
|
|
301
|
+
setItems((xs) => (xs ?? []).filter((x) => x.id !== rec.id));
|
|
302
|
+
try {
|
|
303
|
+
await apiPost(cfg, `/v1/insights/${rec.id}/apply`);
|
|
304
|
+
} catch {
|
|
305
|
+
}
|
|
306
|
+
onAction?.(rec);
|
|
307
|
+
};
|
|
308
|
+
return /* @__PURE__ */ jsx4(ThemeScope, { theme, className: "lum-widget--recs", children: error ? /* @__PURE__ */ jsx4(ErrorState2, { title: "We couldn't load recommendations.", children: error }) : !items ? /* @__PURE__ */ jsx4("div", { className: "lum-w-queue", children: Array.from({ length: 3 }).map((_, i) => /* @__PURE__ */ jsx4(Skeleton2, { height: 48 }, i)) }) : items.filter((i) => i.status === "new" || i.status === "viewed").length === 0 ? /* @__PURE__ */ jsx4(EmptyState2, { title: "You're all caught up", children: "No open recommendations right now." }) : /* @__PURE__ */ jsx4("ol", { className: "lum-w-queue", children: items.filter((i) => i.status === "new" || i.status === "viewed").map((rec, i) => /* @__PURE__ */ jsxs2("li", { className: "lum-w-queue__item", children: [
|
|
309
|
+
/* @__PURE__ */ jsx4("span", { className: "lum-w-queue__rank lum-mono", children: i + 1 }),
|
|
310
|
+
/* @__PURE__ */ jsxs2("div", { className: "lum-w-queue__body", children: [
|
|
311
|
+
/* @__PURE__ */ jsx4("div", { className: "lum-w-queue__title", children: rec.title }),
|
|
312
|
+
/* @__PURE__ */ jsxs2("div", { className: "lum-w-queue__meta", children: [
|
|
313
|
+
/* @__PURE__ */ jsx4(Badge2, { tone: rec.severity === "high" ? "error" : rec.severity === "medium" ? "warn" : "info", children: rec.severity }),
|
|
314
|
+
rec.expectedImpact && /* @__PURE__ */ jsx4(Badge2, { tone: "ok", children: rec.expectedImpact.lift }),
|
|
315
|
+
/* @__PURE__ */ jsxs2(Badge2, { children: [
|
|
316
|
+
"effort: ",
|
|
317
|
+
rec.effort
|
|
318
|
+
] })
|
|
319
|
+
] })
|
|
320
|
+
] }),
|
|
321
|
+
/* @__PURE__ */ jsx4(Button2, { size: "sm", variant: "primary", onClick: () => run(rec), children: rec.action.type === "one_click_apply" ? "Apply" : rec.action.type === "open_experiment" ? "Experiment" : "Review" })
|
|
322
|
+
] }, rec.id)) }) });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/ObsrviqScore.tsx
|
|
326
|
+
import { useEffect as useEffect4, useState as useState4 } from "react";
|
|
327
|
+
import { Skeleton as Skeleton3 } from "@obsrviq/ui";
|
|
328
|
+
import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
329
|
+
var LABEL = {
|
|
330
|
+
accessibility: "Accessibility",
|
|
331
|
+
performance: "Performance",
|
|
332
|
+
"ai-visibility": "AI Visibility"
|
|
333
|
+
};
|
|
334
|
+
var RATING_COLOR = {
|
|
335
|
+
good: "var(--lum-ok)",
|
|
336
|
+
"needs-improvement": "var(--lum-warn)",
|
|
337
|
+
poor: "var(--lum-error)"
|
|
338
|
+
};
|
|
339
|
+
function ObsrviqScore({ siteId, type, apiBaseUrl, apiKey, token, theme, size = 120 }) {
|
|
340
|
+
const cfg = { apiBaseUrl, apiKey, token };
|
|
341
|
+
const [score, setScore] = useState4(null);
|
|
342
|
+
useEffect4(() => {
|
|
343
|
+
let alive = true;
|
|
344
|
+
apiGet(cfg, `/v1/scores?siteId=${siteId}&type=${type}`).then((s) => alive && setScore(s)).catch(() => alive && setScore(null));
|
|
345
|
+
return () => {
|
|
346
|
+
alive = false;
|
|
347
|
+
};
|
|
348
|
+
}, [siteId, type]);
|
|
349
|
+
const r = size / 2 - 8;
|
|
350
|
+
const c = 2 * Math.PI * r;
|
|
351
|
+
const value = score?.value ?? 0;
|
|
352
|
+
const color = score ? RATING_COLOR[score.rating] : "var(--lum-muted)";
|
|
353
|
+
return /* @__PURE__ */ jsx5(ThemeScope, { theme, className: "lum-widget--score", children: /* @__PURE__ */ jsxs3("div", { className: "lum-w-score", style: { width: size }, children: [
|
|
354
|
+
!score ? /* @__PURE__ */ jsx5(Skeleton3, { width: size, height: size, radius: size / 2 }) : /* @__PURE__ */ jsxs3("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, role: "img", "aria-label": `${LABEL[type]} score ${value}`, children: [
|
|
355
|
+
/* @__PURE__ */ jsx5("circle", { cx: size / 2, cy: size / 2, r, fill: "none", stroke: "var(--lum-viz-grid)", strokeWidth: 9 }),
|
|
356
|
+
/* @__PURE__ */ jsx5(
|
|
357
|
+
"circle",
|
|
358
|
+
{
|
|
359
|
+
className: "lum-w-score__arc",
|
|
360
|
+
cx: size / 2,
|
|
361
|
+
cy: size / 2,
|
|
362
|
+
r,
|
|
363
|
+
fill: "none",
|
|
364
|
+
stroke: color,
|
|
365
|
+
strokeWidth: 9,
|
|
366
|
+
strokeLinecap: "round",
|
|
367
|
+
strokeDasharray: c,
|
|
368
|
+
strokeDashoffset: c - value / 100 * c,
|
|
369
|
+
transform: `rotate(-90 ${size / 2} ${size / 2})`,
|
|
370
|
+
style: { ["--lum-arc-c"]: c }
|
|
371
|
+
}
|
|
372
|
+
),
|
|
373
|
+
/* @__PURE__ */ jsx5("text", { x: "50%", y: "48%", textAnchor: "middle", dominantBaseline: "middle", fontSize: size * 0.26, fill: "var(--lum-text)", fontWeight: "700", fontFamily: "var(--lum-font-display)", letterSpacing: "-0.02em", children: value }),
|
|
374
|
+
/* @__PURE__ */ jsx5("text", { x: "50%", y: "68%", textAnchor: "middle", fill: "var(--lum-muted)", fontSize: size * 0.1, children: "/ 100" })
|
|
375
|
+
] }),
|
|
376
|
+
/* @__PURE__ */ jsx5("div", { className: "lum-w-score__label", children: LABEL[type] })
|
|
377
|
+
] }) });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/ObsrviqCopilot.tsx
|
|
381
|
+
import { useRef as useRef2, useState as useState5 } from "react";
|
|
382
|
+
import { Send } from "lucide-react";
|
|
383
|
+
import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
384
|
+
function ObsrviqCopilot({ siteId, apiBaseUrl, apiKey, token, theme, placeholder = "Ask about your visitors\u2026" }) {
|
|
385
|
+
const cfg = { apiBaseUrl, apiKey, token };
|
|
386
|
+
const [input, setInput] = useState5("");
|
|
387
|
+
const [turns, setTurns] = useState5([]);
|
|
388
|
+
const [busy, setBusy] = useState5(false);
|
|
389
|
+
const scrollRef = useRef2(null);
|
|
390
|
+
const ask = async () => {
|
|
391
|
+
const q = input.trim();
|
|
392
|
+
if (!q || busy) return;
|
|
393
|
+
setInput("");
|
|
394
|
+
setBusy(true);
|
|
395
|
+
const idx = turns.length;
|
|
396
|
+
setTurns((t) => [...t, { q, a: "", evidence: [], streaming: true }]);
|
|
397
|
+
try {
|
|
398
|
+
for await (const chunk of copilotStream(cfg, siteId, q)) {
|
|
399
|
+
if (chunk.kind === "token") {
|
|
400
|
+
setTurns((t) => t.map((x, i) => i === idx ? { ...x, a: x.a + chunk.text } : x));
|
|
401
|
+
} else if (chunk.kind === "evidence") {
|
|
402
|
+
setTurns((t) => t.map((x, i) => i === idx ? { ...x, evidence: chunk.evidence.map((e) => e.ref) } : x));
|
|
403
|
+
} else if (chunk.kind === "error") {
|
|
404
|
+
setTurns((t) => t.map((x, i) => i === idx ? { ...x, a: x.a + `
|
|
405
|
+
[error: ${chunk.message}]` } : x));
|
|
406
|
+
}
|
|
407
|
+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
|
408
|
+
}
|
|
409
|
+
} catch (e) {
|
|
410
|
+
setTurns((t) => t.map((x, i) => i === idx ? { ...x, a: x.a + `
|
|
411
|
+
[connection error]` } : x));
|
|
412
|
+
} finally {
|
|
413
|
+
setTurns((t) => t.map((x, i) => i === idx ? { ...x, streaming: false } : x));
|
|
414
|
+
setBusy(false);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
return /* @__PURE__ */ jsx6(ThemeScope, { theme, className: "lum-widget--copilot", children: /* @__PURE__ */ jsxs4("div", { className: "lum-w-copilot", children: [
|
|
418
|
+
/* @__PURE__ */ jsxs4("div", { className: "lum-w-copilot__log", ref: scrollRef, children: [
|
|
419
|
+
turns.length === 0 && /* @__PURE__ */ jsx6("div", { className: "lum-w-copilot__hint lum-muted", children: "Try \u201CWhere are users struggling?\u201D or \u201CWhat's causing errors?\u201D" }),
|
|
420
|
+
turns.map((t, i) => /* @__PURE__ */ jsxs4("div", { className: "lum-w-copilot__turn", children: [
|
|
421
|
+
/* @__PURE__ */ jsx6("div", { className: "lum-w-copilot__q", children: t.q }),
|
|
422
|
+
/* @__PURE__ */ jsxs4("div", { className: "lum-w-copilot__a", children: [
|
|
423
|
+
t.a,
|
|
424
|
+
t.streaming && /* @__PURE__ */ jsx6("span", { className: "lum-w-copilot__caret" }),
|
|
425
|
+
t.evidence.length > 0 && /* @__PURE__ */ jsxs4("div", { className: "lum-w-copilot__ev lum-muted", children: [
|
|
426
|
+
"Evidence: ",
|
|
427
|
+
t.evidence.length,
|
|
428
|
+
" session(s)"
|
|
429
|
+
] })
|
|
430
|
+
] })
|
|
431
|
+
] }, i))
|
|
432
|
+
] }),
|
|
433
|
+
/* @__PURE__ */ jsxs4(
|
|
434
|
+
"form",
|
|
435
|
+
{
|
|
436
|
+
className: "lum-w-copilot__bar",
|
|
437
|
+
onSubmit: (e) => {
|
|
438
|
+
e.preventDefault();
|
|
439
|
+
void ask();
|
|
440
|
+
},
|
|
441
|
+
children: [
|
|
442
|
+
/* @__PURE__ */ jsx6(
|
|
443
|
+
"input",
|
|
444
|
+
{
|
|
445
|
+
className: "lum-pl-search",
|
|
446
|
+
value: input,
|
|
447
|
+
onChange: (e) => setInput(e.target.value),
|
|
448
|
+
placeholder,
|
|
449
|
+
"aria-label": "Ask the copilot"
|
|
450
|
+
}
|
|
451
|
+
),
|
|
452
|
+
/* @__PURE__ */ jsx6("button", { className: "lum-btn lum-btn--primary lum-btn--sm", type: "submit", disabled: busy, "aria-label": "Send", children: /* @__PURE__ */ jsx6(Send, { size: 15 }) })
|
|
453
|
+
]
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
] }) });
|
|
457
|
+
}
|
|
458
|
+
export {
|
|
459
|
+
ObsrviqCopilot,
|
|
460
|
+
ObsrviqInsights,
|
|
461
|
+
ObsrviqRecommendations,
|
|
462
|
+
ObsrviqReplay,
|
|
463
|
+
ObsrviqScore
|
|
464
|
+
};
|
|
465
|
+
//# sourceMappingURL=index.js.map
|