@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/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@obsrviq/widgets",
3
+ "version": "0.3.0",
4
+ "license": "MIT",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "description": "Obsrviq embeddable white-label widgets — replay, insights, recommendations, scores, copilot (§12).",
9
+ "type": "module",
10
+ "main": "./dist/index.js",
11
+ "module": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ },
18
+ "./styles.css": "./styles.css"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src",
23
+ "styles.css"
24
+ ],
25
+ "peerDependencies": {
26
+ "react": "^18.0.0",
27
+ "react-dom": "^18.0.0"
28
+ },
29
+ "dependencies": {
30
+ "lucide-react": "^0.468.0",
31
+ "@obsrviq/player": "0.3.0",
32
+ "@obsrviq/ui": "0.3.0",
33
+ "@obsrviq/types": "0.3.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/react": "^18.3.17",
37
+ "@types/react-dom": "^18.3.5",
38
+ "react": "^18.3.1",
39
+ "react-dom": "^18.3.1",
40
+ "tsup": "^8.3.5",
41
+ "typescript": "^5.7.2"
42
+ },
43
+ "scripts": {
44
+ "build": "node scripts/build-embed-css.mjs && tsup",
45
+ "dev": "tsup --watch",
46
+ "typecheck": "tsc --noEmit"
47
+ }
48
+ }
@@ -0,0 +1,95 @@
1
+ import { useRef, useState } from 'react';
2
+ import { Send } from 'lucide-react';
3
+ import { ThemeScope, type BaseWidgetProps } from './common.js';
4
+ import { copilotStream } from './client.js';
5
+
6
+ export interface ObsrviqCopilotProps extends BaseWidgetProps {
7
+ siteId: string;
8
+ placeholder?: string;
9
+ }
10
+
11
+ interface Turn {
12
+ q: string;
13
+ a: string;
14
+ evidence: string[];
15
+ streaming: boolean;
16
+ }
17
+
18
+ /** Embeddable copilot — NL Q&A grounded in the tenant's data (§14). */
19
+ export function ObsrviqCopilot({ siteId, apiBaseUrl, apiKey, token, theme, placeholder = 'Ask about your visitors…' }: ObsrviqCopilotProps) {
20
+ const cfg = { apiBaseUrl, apiKey, token };
21
+ const [input, setInput] = useState('');
22
+ const [turns, setTurns] = useState<Turn[]>([]);
23
+ const [busy, setBusy] = useState(false);
24
+ const scrollRef = useRef<HTMLDivElement>(null);
25
+
26
+ const ask = async () => {
27
+ const q = input.trim();
28
+ if (!q || busy) return;
29
+ setInput('');
30
+ setBusy(true);
31
+ const idx = turns.length;
32
+ setTurns((t) => [...t, { q, a: '', evidence: [], streaming: true }]);
33
+ try {
34
+ for await (const chunk of copilotStream(cfg, siteId, q)) {
35
+ if (chunk.kind === 'token') {
36
+ setTurns((t) => t.map((x, i) => (i === idx ? { ...x, a: x.a + chunk.text } : x)));
37
+ } else if (chunk.kind === 'evidence') {
38
+ setTurns((t) => t.map((x, i) => (i === idx ? { ...x, evidence: chunk.evidence.map((e) => e.ref) } : x)));
39
+ } else if (chunk.kind === 'error') {
40
+ setTurns((t) => t.map((x, i) => (i === idx ? { ...x, a: x.a + `\n[error: ${chunk.message}]` } : x)));
41
+ }
42
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
43
+ }
44
+ } catch (e) {
45
+ setTurns((t) => t.map((x, i) => (i === idx ? { ...x, a: x.a + `\n[connection error]` } : x)));
46
+ } finally {
47
+ setTurns((t) => t.map((x, i) => (i === idx ? { ...x, streaming: false } : x)));
48
+ setBusy(false);
49
+ }
50
+ };
51
+
52
+ return (
53
+ <ThemeScope theme={theme} className="lum-widget--copilot">
54
+ <div className="lum-w-copilot">
55
+ <div className="lum-w-copilot__log" ref={scrollRef}>
56
+ {turns.length === 0 && (
57
+ <div className="lum-w-copilot__hint lum-muted">
58
+ Try “Where are users struggling?” or “What's causing errors?”
59
+ </div>
60
+ )}
61
+ {turns.map((t, i) => (
62
+ <div key={i} className="lum-w-copilot__turn">
63
+ <div className="lum-w-copilot__q">{t.q}</div>
64
+ <div className="lum-w-copilot__a">
65
+ {t.a}
66
+ {t.streaming && <span className="lum-w-copilot__caret" />}
67
+ {t.evidence.length > 0 && (
68
+ <div className="lum-w-copilot__ev lum-muted">Evidence: {t.evidence.length} session(s)</div>
69
+ )}
70
+ </div>
71
+ </div>
72
+ ))}
73
+ </div>
74
+ <form
75
+ className="lum-w-copilot__bar"
76
+ onSubmit={(e) => {
77
+ e.preventDefault();
78
+ void ask();
79
+ }}
80
+ >
81
+ <input
82
+ className="lum-pl-search"
83
+ value={input}
84
+ onChange={(e) => setInput(e.target.value)}
85
+ placeholder={placeholder}
86
+ aria-label="Ask the copilot"
87
+ />
88
+ <button className="lum-btn lum-btn--primary lum-btn--sm" type="submit" disabled={busy} aria-label="Send">
89
+ <Send size={15} />
90
+ </button>
91
+ </form>
92
+ </div>
93
+ </ThemeScope>
94
+ );
95
+ }
@@ -0,0 +1,115 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { InsightCard } from '@obsrviq/types';
3
+ import { Badge, Button, EmptyState, ErrorState, Skeleton, ToastProvider, useToast } from '@obsrviq/ui';
4
+ import { ThemeScope, type BaseWidgetProps } from './common.js';
5
+ import { apiGet, apiPost } from './client.js';
6
+
7
+ export interface ObsrviqInsightsProps extends BaseWidgetProps {
8
+ siteId: string;
9
+ max?: number;
10
+ onApply?: (card: InsightCard) => void;
11
+ onDismiss?: (card: InsightCard) => void;
12
+ }
13
+
14
+ const SEVERITY_TONE: Record<InsightCard['severity'], 'info' | 'warn' | 'error'> = {
15
+ info: 'info',
16
+ low: 'info',
17
+ medium: 'warn',
18
+ high: 'error',
19
+ };
20
+
21
+ export function ObsrviqInsights(props: ObsrviqInsightsProps) {
22
+ return (
23
+ <ThemeScope theme={props.theme} className="lum-widget--insights">
24
+ <ToastProvider>
25
+ <InsightsInner {...props} />
26
+ </ToastProvider>
27
+ </ThemeScope>
28
+ );
29
+ }
30
+
31
+ function InsightsInner({ siteId, apiBaseUrl, apiKey, token, max = 5, onApply, onDismiss }: ObsrviqInsightsProps) {
32
+ const cfg = { apiBaseUrl, apiKey, token };
33
+ const [cards, setCards] = useState<InsightCard[] | null>(null);
34
+ const [error, setError] = useState<string | null>(null);
35
+ const toast = useToast();
36
+
37
+ useEffect(() => {
38
+ let alive = true;
39
+ apiGet<{ items: InsightCard[] }>(cfg, `/v1/insights?siteId=${siteId}`)
40
+ .then((r) => alive && setCards(r.items))
41
+ .catch((e) => alive && setError(e.message));
42
+ return () => {
43
+ alive = false;
44
+ };
45
+ // eslint-disable-next-line react-hooks/exhaustive-deps
46
+ }, [siteId]);
47
+
48
+ const act = async (card: InsightCard, verb: 'apply' | 'dismiss') => {
49
+ setCards((cs) => (cs ?? []).map((c) => (c.id === card.id ? { ...c, status: verb === 'apply' ? 'applied' : 'dismissed' } : c)));
50
+ try {
51
+ await apiPost(cfg, `/v1/insights/${card.id}/${verb}`);
52
+ toast(verb === 'apply' ? 'Applied' : 'Dismissed', verb === 'apply' ? 'ok' : 'default');
53
+ (verb === 'apply' ? onApply : onDismiss)?.(card);
54
+ } catch {
55
+ toast('Could not update — try again', 'error');
56
+ }
57
+ };
58
+
59
+ if (error)
60
+ return <ErrorState title="We couldn't load insights.">{error}</ErrorState>;
61
+ if (!cards)
62
+ return (
63
+ <div className="lum-w-cards">
64
+ {Array.from({ length: 3 }).map((_, i) => (
65
+ <div className="lum-w-card" key={i}>
66
+ <Skeleton width="60%" height={16} />
67
+ <Skeleton width="100%" height={40} />
68
+ </div>
69
+ ))}
70
+ </div>
71
+ );
72
+
73
+ const visible = cards.filter((c) => c.status !== 'dismissed').slice(0, max);
74
+ if (visible.length === 0)
75
+ return <EmptyState title="No insights yet">As soon as visitors arrive, recommendations will appear here.</EmptyState>;
76
+
77
+ return (
78
+ <div className="lum-w-cards">
79
+ {visible.map((c) => (
80
+ <article className="lum-w-card" key={c.id}>
81
+ <header className="lum-w-card__head">
82
+ <Badge tone={SEVERITY_TONE[c.severity]}>{c.severity}</Badge>
83
+ <strong>{c.title}</strong>
84
+ </header>
85
+ <p className="lum-w-card__what">{c.what}</p>
86
+ <p className="lum-w-card__why lum-muted">{c.why}</p>
87
+ <p className="lum-w-card__rec">{c.recommendation}</p>
88
+ <div className="lum-w-card__meta">
89
+ {c.expectedImpact && (
90
+ <Badge tone="ok">
91
+ {c.expectedImpact.metric} {c.expectedImpact.lift}
92
+ </Badge>
93
+ )}
94
+ <Badge>effort: {c.effort}</Badge>
95
+ {c.evidence.length > 0 && <span className="lum-muted">{c.evidence.length} sessions</span>}
96
+ </div>
97
+ <footer className="lum-w-card__actions">
98
+ {c.status === 'applied' ? (
99
+ <Badge tone="ok">Applied</Badge>
100
+ ) : (
101
+ <>
102
+ <Button variant="primary" size="sm" onClick={() => act(c, 'apply')}>
103
+ Apply
104
+ </Button>
105
+ <Button variant="ghost" size="sm" onClick={() => act(c, 'dismiss')}>
106
+ Dismiss
107
+ </Button>
108
+ </>
109
+ )}
110
+ </footer>
111
+ </article>
112
+ ))}
113
+ </div>
114
+ );
115
+ }
@@ -0,0 +1,75 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { InsightCard } from '@obsrviq/types';
3
+ import { Badge, Button, EmptyState, ErrorState, Skeleton } from '@obsrviq/ui';
4
+ import { ThemeScope, type BaseWidgetProps } from './common.js';
5
+ import { apiGet, apiPost } from './client.js';
6
+
7
+ export interface ObsrviqRecommendationsProps extends BaseWidgetProps {
8
+ siteId: string;
9
+ onAction?: (rec: InsightCard) => void;
10
+ }
11
+
12
+ /** A prioritized recommendation queue (impact × confidence / effort, §14). */
13
+ export function ObsrviqRecommendations({ siteId, apiBaseUrl, apiKey, token, theme, onAction }: ObsrviqRecommendationsProps) {
14
+ const cfg = { apiBaseUrl, apiKey, token };
15
+ const [items, setItems] = useState<InsightCard[] | null>(null);
16
+ const [error, setError] = useState<string | null>(null);
17
+
18
+ useEffect(() => {
19
+ let alive = true;
20
+ apiGet<{ items: InsightCard[] }>(cfg, `/v1/insights?siteId=${siteId}`)
21
+ .then((r) => alive && setItems(r.items))
22
+ .catch((e) => alive && setError(e.message));
23
+ return () => {
24
+ alive = false;
25
+ };
26
+ // eslint-disable-next-line react-hooks/exhaustive-deps
27
+ }, [siteId]);
28
+
29
+ const run = async (rec: InsightCard) => {
30
+ setItems((xs) => (xs ?? []).filter((x) => x.id !== rec.id));
31
+ try {
32
+ await apiPost(cfg, `/v1/insights/${rec.id}/apply`);
33
+ } catch {
34
+ /* keep optimistic */
35
+ }
36
+ onAction?.(rec);
37
+ };
38
+
39
+ return (
40
+ <ThemeScope theme={theme} className="lum-widget--recs">
41
+ {error ? (
42
+ <ErrorState title="We couldn't load recommendations.">{error}</ErrorState>
43
+ ) : !items ? (
44
+ <div className="lum-w-queue">
45
+ {Array.from({ length: 3 }).map((_, i) => (
46
+ <Skeleton key={i} height={48} />
47
+ ))}
48
+ </div>
49
+ ) : items.filter((i) => i.status === 'new' || i.status === 'viewed').length === 0 ? (
50
+ <EmptyState title="You're all caught up">No open recommendations right now.</EmptyState>
51
+ ) : (
52
+ <ol className="lum-w-queue">
53
+ {items
54
+ .filter((i) => i.status === 'new' || i.status === 'viewed')
55
+ .map((rec, i) => (
56
+ <li className="lum-w-queue__item" key={rec.id}>
57
+ <span className="lum-w-queue__rank lum-mono">{i + 1}</span>
58
+ <div className="lum-w-queue__body">
59
+ <div className="lum-w-queue__title">{rec.title}</div>
60
+ <div className="lum-w-queue__meta">
61
+ <Badge tone={rec.severity === 'high' ? 'error' : rec.severity === 'medium' ? 'warn' : 'info'}>{rec.severity}</Badge>
62
+ {rec.expectedImpact && <Badge tone="ok">{rec.expectedImpact.lift}</Badge>}
63
+ <Badge>effort: {rec.effort}</Badge>
64
+ </div>
65
+ </div>
66
+ <Button size="sm" variant="primary" onClick={() => run(rec)}>
67
+ {rec.action.type === 'one_click_apply' ? 'Apply' : rec.action.type === 'open_experiment' ? 'Experiment' : 'Review'}
68
+ </Button>
69
+ </li>
70
+ ))}
71
+ </ol>
72
+ )}
73
+ </ThemeScope>
74
+ );
75
+ }
@@ -0,0 +1,237 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { ReplayPlayer } from '@obsrviq/player';
3
+ import { ThemeScope, type BaseWidgetProps } from './common.js';
4
+
5
+ type ClipBounds = {
6
+ fromEvent?: string;
7
+ toEvent?: string;
8
+ fromMs?: number;
9
+ toMs?: number;
10
+ fromUtc?: number;
11
+ toUtc?: number;
12
+ loop?: boolean;
13
+ };
14
+
15
+ /** What to render when the recording can't be shown (not found / load error):
16
+ * 'message' (default) shows a small note; 'hide' renders nothing so the embed
17
+ * collapses on the host page (pair with `onError` to hide your own wrapper). */
18
+ export type UnavailableFallback = 'message' | 'hide';
19
+
20
+ export interface ObsrviqReplayProps extends BaseWidgetProps {
21
+ /** The session to play. Optional if `userId` is given — then the session is
22
+ * resolved from the user + clip bounds (UTC window / event names). */
23
+ sessionId?: string;
24
+ /** Resolve the session from this user instead of a session id. */
25
+ userId?: string;
26
+ /** Optional site scope for `userId` resolution; omit to search the whole tenant. */
27
+ siteId?: string;
28
+ startAtMs?: number;
29
+ /** Play only a segment: bound by two custom-event names, our-clock ms, or UTC. */
30
+ clip?: ClipBounds;
31
+ /** Focus mode the player opens in ('stage' = Watch). Overrides the site default. */
32
+ defaultMode?: 'balanced' | 'stage' | 'debug';
33
+ /** Restrict the focus-mode toggle to this allowlist; a single entry hides it. */
34
+ allowedModes?: Array<'balanced' | 'stage' | 'debug'>;
35
+ /** Playback speed the player opens at (clamped to the nearest offered speed). */
36
+ defaultSpeed?: number;
37
+ /** Show the fullscreen control + `F` shortcut. Default true. */
38
+ allowFullscreen?: boolean;
39
+ /** Base for "Copy link" deep-links (this embed's URL has no session id). A
40
+ * `:id` template, or a base we append `/sessions/:id` to. Omit → console host. */
41
+ shareUrl?: string;
42
+ onEnded?: () => void;
43
+ /** Called once when the recording can't be shown — `reason` is 'not_found'
44
+ * (no such session / no match for the user) or 'error' (load/network/auth).
45
+ * Use it to hide your own container, log, retry, etc. */
46
+ onError?: (info: { reason: 'not_found' | 'error'; message: string }) => void;
47
+ /** What to render when unavailable. Default 'message'. Use 'hide' to collapse. */
48
+ fallback?: UnavailableFallback;
49
+ height?: number | string;
50
+ }
51
+
52
+ function authHeaders(token?: string, apiKey?: string): Record<string, string> {
53
+ if (token) return { authorization: `Bearer ${token}` };
54
+ if (apiKey) return { authorization: `Bearer ${apiKey}`, 'x-obsrviq-key': apiKey };
55
+ return {};
56
+ }
57
+
58
+ interface Resolved {
59
+ sessionId: string | null;
60
+ loading: boolean;
61
+ error: string | null;
62
+ notFound: boolean;
63
+ }
64
+
65
+ /** Resolve a session id from a user (+ clip bounds) via /v1/sessions/locate. */
66
+ function useResolvedSession(
67
+ enabled: boolean,
68
+ opts: {
69
+ apiBaseUrl: string;
70
+ token?: string;
71
+ apiKey?: string;
72
+ userId?: string;
73
+ siteId?: string;
74
+ clip?: ClipBounds;
75
+ },
76
+ ): Resolved {
77
+ const { apiBaseUrl, token, apiKey, userId, siteId, clip } = opts;
78
+ const [state, setState] = useState<Resolved>({
79
+ sessionId: null,
80
+ loading: enabled,
81
+ error: null,
82
+ notFound: false,
83
+ });
84
+ // Stable signature of just the locate inputs (so we don't refetch every render).
85
+ const sig = JSON.stringify([userId, siteId, clip?.fromUtc, clip?.toUtc, clip?.fromEvent, clip?.toEvent]);
86
+
87
+ useEffect(() => {
88
+ if (!enabled || !userId) return;
89
+ const ctrl = new AbortController();
90
+ setState({ sessionId: null, loading: true, error: null, notFound: false });
91
+ const q = new URLSearchParams({ userId });
92
+ if (siteId) q.set('siteId', siteId);
93
+ if (clip?.fromUtc != null && clip?.toUtc != null) {
94
+ q.set('fromUtc', String(clip.fromUtc));
95
+ q.set('toUtc', String(clip.toUtc));
96
+ } else if (clip?.fromEvent) {
97
+ q.set('fromEvent', clip.fromEvent);
98
+ if (clip.toEvent) q.set('toEvent', clip.toEvent);
99
+ }
100
+ const base = apiBaseUrl.replace(/\/$/, '');
101
+ fetch(`${base}/v1/sessions/locate?${q.toString()}`, {
102
+ headers: authHeaders(token, apiKey),
103
+ signal: ctrl.signal,
104
+ })
105
+ .then(async (r) => {
106
+ if (!r.ok) {
107
+ const notFound = r.status === 404;
108
+ throw Object.assign(
109
+ new Error(notFound ? 'No matching recording for that user.' : `Lookup failed (${r.status}).`),
110
+ { notFound },
111
+ );
112
+ }
113
+ return r.json() as Promise<{ sessionId: string }>;
114
+ })
115
+ .then((j) => {
116
+ if (!ctrl.signal.aborted) setState({ sessionId: j.sessionId, loading: false, error: null, notFound: false });
117
+ })
118
+ .catch((e: unknown) => {
119
+ if (ctrl.signal.aborted) return;
120
+ const notFound = !!(e as { notFound?: boolean })?.notFound;
121
+ setState({
122
+ sessionId: null,
123
+ loading: false,
124
+ error: e instanceof Error ? e.message : 'Lookup failed.',
125
+ notFound,
126
+ });
127
+ });
128
+ return () => ctrl.abort();
129
+ // eslint-disable-next-line react-hooks/exhaustive-deps
130
+ }, [enabled, sig, apiBaseUrl]);
131
+
132
+ return state;
133
+ }
134
+
135
+ const msgStyle = {
136
+ display: 'flex',
137
+ alignItems: 'center',
138
+ justifyContent: 'center',
139
+ height: '100%',
140
+ padding: 16,
141
+ textAlign: 'center',
142
+ color: 'var(--lum-muted)',
143
+ fontSize: 14,
144
+ } as const;
145
+
146
+ /** The replay player itself, embeddable and white-label (§12). */
147
+ export function ObsrviqReplay({
148
+ sessionId,
149
+ userId,
150
+ siteId,
151
+ apiBaseUrl,
152
+ apiKey,
153
+ token,
154
+ theme,
155
+ startAtMs,
156
+ clip,
157
+ defaultMode,
158
+ allowedModes,
159
+ defaultSpeed,
160
+ allowFullscreen,
161
+ shareUrl,
162
+ onEnded,
163
+ onError,
164
+ fallback = 'message',
165
+ height = 560,
166
+ }: ObsrviqReplayProps) {
167
+ const needResolve = !sessionId && !!userId;
168
+ const resolved = useResolvedSession(needResolve, { apiBaseUrl, token, apiKey, userId, siteId, clip });
169
+ const [playerError, setPlayerError] = useState<{ reason: 'not_found' | 'error'; message: string } | null>(null);
170
+ const effectiveId = sessionId ?? resolved.sessionId;
171
+
172
+ // A fresh session id clears any prior player error.
173
+ useEffect(() => setPlayerError(null), [effectiveId]);
174
+
175
+ // Notify the host once per distinct failure — independent of how we render it.
176
+ const reportedRef = useRef<string | null>(null);
177
+ useEffect(() => {
178
+ let info: { reason: 'not_found' | 'error'; message: string } | null = null;
179
+ if (!sessionId && !userId) info = { reason: 'error', message: 'Provide a sessionId or a userId.' };
180
+ else if (resolved.error) info = { reason: resolved.notFound ? 'not_found' : 'error', message: resolved.error };
181
+ else if (playerError) info = playerError;
182
+ if (info) {
183
+ if (reportedRef.current !== info.message) {
184
+ reportedRef.current = info.message;
185
+ onError?.(info);
186
+ }
187
+ } else {
188
+ reportedRef.current = null;
189
+ }
190
+ // eslint-disable-next-line react-hooks/exhaustive-deps
191
+ }, [sessionId, userId, resolved.error, resolved.notFound, playerError]);
192
+
193
+ // No session to hand the player (bad config, still resolving, or no match).
194
+ if (!effectiveId) {
195
+ if (fallback === 'hide') return null;
196
+ const msg =
197
+ !sessionId && !userId
198
+ ? 'Provide a sessionId or a userId to play a recording.'
199
+ : needResolve && resolved.loading
200
+ ? 'Finding the recording…'
201
+ : (resolved.error ?? 'Recording unavailable.');
202
+ return (
203
+ <ThemeScope theme={theme} className="lum-widget--replay">
204
+ <div style={{ height }}>
205
+ <div style={msgStyle}>{msg}</div>
206
+ </div>
207
+ </ThemeScope>
208
+ );
209
+ }
210
+
211
+ // We have a session. If it fails to load and we're hiding, collapse the embed;
212
+ // otherwise keep the player mounted so it shows its own (retry-capable) error UI.
213
+ if (playerError && fallback === 'hide') return null;
214
+
215
+ return (
216
+ <ThemeScope theme={theme} className="lum-widget--replay">
217
+ <div style={{ height }}>
218
+ <ReplayPlayer
219
+ sessionId={effectiveId}
220
+ apiBaseUrl={apiBaseUrl}
221
+ apiKey={apiKey}
222
+ token={token}
223
+ startAtMs={startAtMs}
224
+ clip={clip}
225
+ defaultMode={defaultMode}
226
+ allowedModes={allowedModes}
227
+ defaultSpeed={defaultSpeed}
228
+ allowFullscreen={allowFullscreen}
229
+ shareUrl={shareUrl}
230
+ onEnded={onEnded}
231
+ onError={(info) => setPlayerError(info)}
232
+ embedded
233
+ />
234
+ </div>
235
+ </ThemeScope>
236
+ );
237
+ }
@@ -0,0 +1,78 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { Score, ScoreType } from '@obsrviq/types';
3
+ import { Skeleton } from '@obsrviq/ui';
4
+ import { ThemeScope, type BaseWidgetProps } from './common.js';
5
+ import { apiGet } from './client.js';
6
+
7
+ export interface ObsrviqScoreProps extends BaseWidgetProps {
8
+ siteId: string;
9
+ type: ScoreType;
10
+ size?: number;
11
+ }
12
+
13
+ const LABEL: Record<ScoreType, string> = {
14
+ accessibility: 'Accessibility',
15
+ performance: 'Performance',
16
+ 'ai-visibility': 'AI Visibility',
17
+ };
18
+ const RATING_COLOR: Record<Score['rating'], string> = {
19
+ good: 'var(--lum-ok)',
20
+ 'needs-improvement': 'var(--lum-warn)',
21
+ poor: 'var(--lum-error)',
22
+ };
23
+
24
+ export function ObsrviqScore({ siteId, type, apiBaseUrl, apiKey, token, theme, size = 120 }: ObsrviqScoreProps) {
25
+ const cfg = { apiBaseUrl, apiKey, token };
26
+ const [score, setScore] = useState<Score | null>(null);
27
+
28
+ useEffect(() => {
29
+ let alive = true;
30
+ apiGet<Score>(cfg, `/v1/scores?siteId=${siteId}&type=${type}`)
31
+ .then((s) => alive && setScore(s))
32
+ .catch(() => alive && setScore(null));
33
+ return () => {
34
+ alive = false;
35
+ };
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [siteId, type]);
38
+
39
+ const r = size / 2 - 8;
40
+ const c = 2 * Math.PI * r;
41
+ const value = score?.value ?? 0;
42
+ const color = score ? RATING_COLOR[score.rating] : 'var(--lum-muted)';
43
+
44
+ return (
45
+ <ThemeScope theme={theme} className="lum-widget--score">
46
+ <div className="lum-w-score" style={{ width: size }}>
47
+ {!score ? (
48
+ <Skeleton width={size} height={size} radius={size / 2} />
49
+ ) : (
50
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label={`${LABEL[type]} score ${value}`}>
51
+ <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--lum-viz-grid)" strokeWidth={9} />
52
+ <circle
53
+ className="lum-w-score__arc"
54
+ cx={size / 2}
55
+ cy={size / 2}
56
+ r={r}
57
+ fill="none"
58
+ stroke={color}
59
+ strokeWidth={9}
60
+ strokeLinecap="round"
61
+ strokeDasharray={c}
62
+ strokeDashoffset={c - (value / 100) * c}
63
+ transform={`rotate(-90 ${size / 2} ${size / 2})`}
64
+ style={{ ['--lum-arc-c' as string]: c }}
65
+ />
66
+ <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">
67
+ {value}
68
+ </text>
69
+ <text x="50%" y="68%" textAnchor="middle" fill="var(--lum-muted)" fontSize={size * 0.1}>
70
+ / 100
71
+ </text>
72
+ </svg>
73
+ )}
74
+ <div className="lum-w-score__label">{LABEL[type]}</div>
75
+ </div>
76
+ </ThemeScope>
77
+ );
78
+ }