@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/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
|
+
}
|