@usero/sdk 0.3.0 → 0.3.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/dist/plugins/session-replay.cjs +1 -3
- package/dist/plugins/session-replay.cjs.map +1 -1
- package/dist/plugins/session-replay.js +1 -3
- package/dist/plugins/session-replay.js.map +1 -1
- package/dist/plugins/user-test.cjs +528 -0
- package/dist/plugins/user-test.cjs.map +1 -0
- package/dist/plugins/user-test.d.cts +61 -0
- package/dist/plugins/user-test.d.ts +61 -0
- package/dist/plugins/user-test.js +525 -0
- package/dist/plugins/user-test.js.map +1 -0
- package/dist/rrweb-IQA3KVSA.js +17065 -0
- package/dist/rrweb-IQA3KVSA.js.map +1 -0
- package/dist/rrweb-SDFFFL7O.cjs +17077 -0
- package/dist/rrweb-SDFFFL7O.cjs.map +1 -0
- package/package.json +7 -2
- package/dist/all-M6KEAHE5.cjs +0 -9110
- package/dist/all-M6KEAHE5.cjs.map +0 -1
- package/dist/all-T4CCPHSL.js +0 -9095
- package/dist/all-T4CCPHSL.js.map +0 -1
- package/dist/chunk-5BLDMQED.cjs +0 -18
- package/dist/chunk-5BLDMQED.cjs.map +0 -1
- package/dist/chunk-NSBPE2FW.js +0 -15
- package/dist/chunk-NSBPE2FW.js.map +0 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
type FeedbackRating = 1 | 2 | 3 | 4;
|
|
2
|
+
interface ScreenshotData {
|
|
3
|
+
fileName: string;
|
|
4
|
+
url: string;
|
|
5
|
+
fileSize: number;
|
|
6
|
+
width?: number;
|
|
7
|
+
height?: number;
|
|
8
|
+
mimeType: string;
|
|
9
|
+
}
|
|
10
|
+
interface FeedbackSubmission {
|
|
11
|
+
clientId: string;
|
|
12
|
+
rating?: FeedbackRating;
|
|
13
|
+
comment?: string;
|
|
14
|
+
userEmail?: string;
|
|
15
|
+
pageUrl: string;
|
|
16
|
+
pageTitle: string;
|
|
17
|
+
referrer?: string;
|
|
18
|
+
environment?: string;
|
|
19
|
+
screenshots?: ScreenshotData[];
|
|
20
|
+
metadata?: Record<string, unknown>;
|
|
21
|
+
replayEvents?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PluginLogger {
|
|
25
|
+
debug: (...args: unknown[]) => void;
|
|
26
|
+
info: (...args: unknown[]) => void;
|
|
27
|
+
warn: (...args: unknown[]) => void;
|
|
28
|
+
error: (...args: unknown[]) => void;
|
|
29
|
+
}
|
|
30
|
+
interface PluginContext {
|
|
31
|
+
clientId: string;
|
|
32
|
+
baseUrl: string;
|
|
33
|
+
getStore: <T>() => T | undefined;
|
|
34
|
+
setStore: <T>(value: T) => void;
|
|
35
|
+
logger: PluginLogger;
|
|
36
|
+
}
|
|
37
|
+
interface UseroPlugin {
|
|
38
|
+
name: string;
|
|
39
|
+
onInit?: (ctx: PluginContext) => void | Promise<void>;
|
|
40
|
+
onFeedbackSubmit?: (ctx: PluginContext, submission: FeedbackSubmission) => Promise<Partial<FeedbackSubmission> | undefined> | Partial<FeedbackSubmission> | undefined;
|
|
41
|
+
onDestroy?: (ctx: PluginContext) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface UserTestOptions {
|
|
45
|
+
queryParam?: string;
|
|
46
|
+
chunkSeconds?: number;
|
|
47
|
+
apiUrl?: string;
|
|
48
|
+
testerName?: string;
|
|
49
|
+
hideIndicator?: boolean;
|
|
50
|
+
}
|
|
51
|
+
declare function getTestSlug(queryParam: string): string | null;
|
|
52
|
+
declare function isMediaRecorderSupported(): boolean;
|
|
53
|
+
declare function pickMimeType(): string | undefined;
|
|
54
|
+
declare function userTest(options?: UserTestOptions): UseroPlugin;
|
|
55
|
+
declare const __test__: {
|
|
56
|
+
getTestSlug: typeof getTestSlug;
|
|
57
|
+
pickMimeType: typeof pickMimeType;
|
|
58
|
+
isMediaRecorderSupported: typeof isMediaRecorderSupported;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export { type UserTestOptions, __test__, userTest };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
type FeedbackRating = 1 | 2 | 3 | 4;
|
|
2
|
+
interface ScreenshotData {
|
|
3
|
+
fileName: string;
|
|
4
|
+
url: string;
|
|
5
|
+
fileSize: number;
|
|
6
|
+
width?: number;
|
|
7
|
+
height?: number;
|
|
8
|
+
mimeType: string;
|
|
9
|
+
}
|
|
10
|
+
interface FeedbackSubmission {
|
|
11
|
+
clientId: string;
|
|
12
|
+
rating?: FeedbackRating;
|
|
13
|
+
comment?: string;
|
|
14
|
+
userEmail?: string;
|
|
15
|
+
pageUrl: string;
|
|
16
|
+
pageTitle: string;
|
|
17
|
+
referrer?: string;
|
|
18
|
+
environment?: string;
|
|
19
|
+
screenshots?: ScreenshotData[];
|
|
20
|
+
metadata?: Record<string, unknown>;
|
|
21
|
+
replayEvents?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PluginLogger {
|
|
25
|
+
debug: (...args: unknown[]) => void;
|
|
26
|
+
info: (...args: unknown[]) => void;
|
|
27
|
+
warn: (...args: unknown[]) => void;
|
|
28
|
+
error: (...args: unknown[]) => void;
|
|
29
|
+
}
|
|
30
|
+
interface PluginContext {
|
|
31
|
+
clientId: string;
|
|
32
|
+
baseUrl: string;
|
|
33
|
+
getStore: <T>() => T | undefined;
|
|
34
|
+
setStore: <T>(value: T) => void;
|
|
35
|
+
logger: PluginLogger;
|
|
36
|
+
}
|
|
37
|
+
interface UseroPlugin {
|
|
38
|
+
name: string;
|
|
39
|
+
onInit?: (ctx: PluginContext) => void | Promise<void>;
|
|
40
|
+
onFeedbackSubmit?: (ctx: PluginContext, submission: FeedbackSubmission) => Promise<Partial<FeedbackSubmission> | undefined> | Partial<FeedbackSubmission> | undefined;
|
|
41
|
+
onDestroy?: (ctx: PluginContext) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface UserTestOptions {
|
|
45
|
+
queryParam?: string;
|
|
46
|
+
chunkSeconds?: number;
|
|
47
|
+
apiUrl?: string;
|
|
48
|
+
testerName?: string;
|
|
49
|
+
hideIndicator?: boolean;
|
|
50
|
+
}
|
|
51
|
+
declare function getTestSlug(queryParam: string): string | null;
|
|
52
|
+
declare function isMediaRecorderSupported(): boolean;
|
|
53
|
+
declare function pickMimeType(): string | undefined;
|
|
54
|
+
declare function userTest(options?: UserTestOptions): UseroPlugin;
|
|
55
|
+
declare const __test__: {
|
|
56
|
+
getTestSlug: typeof getTestSlug;
|
|
57
|
+
pickMimeType: typeof pickMimeType;
|
|
58
|
+
isMediaRecorderSupported: typeof isMediaRecorderSupported;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export { type UserTestOptions, __test__, userTest };
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var DEFAULT_API_URL = "https://usero.io";
|
|
3
|
+
|
|
4
|
+
// src/plugins/user-test.ts
|
|
5
|
+
var DEFAULT_OPTIONS = {
|
|
6
|
+
queryParam: "usero_test",
|
|
7
|
+
chunkSeconds: 30,
|
|
8
|
+
apiUrl: DEFAULT_API_URL,
|
|
9
|
+
testerName: "",
|
|
10
|
+
hideIndicator: false
|
|
11
|
+
};
|
|
12
|
+
var TESTER_NAME_STORAGE_KEY = "usero:user-test:tester-name";
|
|
13
|
+
var IDB_NAME = "usero-user-test";
|
|
14
|
+
var IDB_STORE = "pending-chunks";
|
|
15
|
+
function readTesterName(override) {
|
|
16
|
+
if (override) return override;
|
|
17
|
+
try {
|
|
18
|
+
const stored = window.localStorage?.getItem(TESTER_NAME_STORAGE_KEY);
|
|
19
|
+
if (stored && stored.trim()) return stored.trim().slice(0, 120);
|
|
20
|
+
} catch {
|
|
21
|
+
}
|
|
22
|
+
return void 0;
|
|
23
|
+
}
|
|
24
|
+
function getTestSlug(queryParam) {
|
|
25
|
+
if (typeof window === "undefined" || typeof window.location === "undefined") return null;
|
|
26
|
+
try {
|
|
27
|
+
const params = new URLSearchParams(window.location.search);
|
|
28
|
+
const slug = params.get(queryParam);
|
|
29
|
+
if (!slug) return null;
|
|
30
|
+
const cleaned = slug.trim().slice(0, 64);
|
|
31
|
+
if (!/^[a-z0-9-]+$/i.test(cleaned)) return null;
|
|
32
|
+
return cleaned;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isMediaRecorderSupported() {
|
|
38
|
+
return typeof window !== "undefined" && typeof window.MediaRecorder !== "undefined" && typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia;
|
|
39
|
+
}
|
|
40
|
+
function pickMimeType() {
|
|
41
|
+
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4"];
|
|
42
|
+
for (const candidate of candidates) {
|
|
43
|
+
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported?.(candidate)) {
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return void 0;
|
|
48
|
+
}
|
|
49
|
+
function idbOpen() {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
if (typeof indexedDB === "undefined") {
|
|
52
|
+
resolve(null);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const req = indexedDB.open(IDB_NAME, 1);
|
|
57
|
+
req.onupgradeneeded = () => {
|
|
58
|
+
const db = req.result;
|
|
59
|
+
if (!db.objectStoreNames.contains(IDB_STORE)) {
|
|
60
|
+
db.createObjectStore(IDB_STORE, { keyPath: "id" });
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
req.onsuccess = () => resolve(req.result);
|
|
64
|
+
req.onerror = () => resolve(null);
|
|
65
|
+
} catch {
|
|
66
|
+
resolve(null);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async function idbStashChunk(chunk) {
|
|
71
|
+
const db = await idbOpen();
|
|
72
|
+
if (!db) return;
|
|
73
|
+
await new Promise((resolve) => {
|
|
74
|
+
try {
|
|
75
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
76
|
+
tx.objectStore(IDB_STORE).put(chunk);
|
|
77
|
+
tx.oncomplete = () => resolve();
|
|
78
|
+
tx.onerror = () => resolve();
|
|
79
|
+
tx.onabort = () => resolve();
|
|
80
|
+
} catch {
|
|
81
|
+
resolve();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
db.close();
|
|
85
|
+
}
|
|
86
|
+
async function idbDeleteChunk(id) {
|
|
87
|
+
const db = await idbOpen();
|
|
88
|
+
if (!db) return;
|
|
89
|
+
await new Promise((resolve) => {
|
|
90
|
+
try {
|
|
91
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
92
|
+
tx.objectStore(IDB_STORE).delete(id);
|
|
93
|
+
tx.oncomplete = () => resolve();
|
|
94
|
+
tx.onerror = () => resolve();
|
|
95
|
+
tx.onabort = () => resolve();
|
|
96
|
+
} catch {
|
|
97
|
+
resolve();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
db.close();
|
|
101
|
+
}
|
|
102
|
+
async function idbListChunks(sessionId) {
|
|
103
|
+
const db = await idbOpen();
|
|
104
|
+
if (!db) return [];
|
|
105
|
+
const items = await new Promise((resolve) => {
|
|
106
|
+
try {
|
|
107
|
+
const tx = db.transaction(IDB_STORE, "readonly");
|
|
108
|
+
const req = tx.objectStore(IDB_STORE).getAll();
|
|
109
|
+
req.onsuccess = () => {
|
|
110
|
+
const all = req.result ?? [];
|
|
111
|
+
resolve(all.filter((c) => c.sessionId === sessionId));
|
|
112
|
+
};
|
|
113
|
+
req.onerror = () => resolve([]);
|
|
114
|
+
} catch {
|
|
115
|
+
resolve([]);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
db.close();
|
|
119
|
+
return items;
|
|
120
|
+
}
|
|
121
|
+
async function uploadChunkWithRetry(apiUrl, sessionId, index, blob, logger, maxAttempts = 5) {
|
|
122
|
+
const url = `${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/chunk?index=${index}`;
|
|
123
|
+
let attempt = 0;
|
|
124
|
+
while (attempt < maxAttempts) {
|
|
125
|
+
try {
|
|
126
|
+
const res = await fetch(url, {
|
|
127
|
+
method: "PUT",
|
|
128
|
+
body: blob,
|
|
129
|
+
headers: { "Content-Type": blob.type || "audio/webm" },
|
|
130
|
+
keepalive: blob.size <= 60 * 1024
|
|
131
|
+
// browsers cap keepalive bodies
|
|
132
|
+
});
|
|
133
|
+
if (res.ok) return true;
|
|
134
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
|
|
135
|
+
logger.error(`chunk ${index} rejected with ${res.status}`);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logger.warn(`chunk ${index} upload attempt ${attempt + 1} failed`, err);
|
|
140
|
+
}
|
|
141
|
+
attempt += 1;
|
|
142
|
+
const backoff = Math.min(15e3, 500 * 2 ** attempt) + Math.floor(Math.random() * 250);
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
function buildIndicator(host, store, onFinish) {
|
|
148
|
+
const root = host.attachShadow({ mode: "closed" });
|
|
149
|
+
const style = document.createElement("style");
|
|
150
|
+
style.textContent = `
|
|
151
|
+
:host { all: initial; }
|
|
152
|
+
.bar {
|
|
153
|
+
position: fixed;
|
|
154
|
+
bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
|
|
155
|
+
left: 50%;
|
|
156
|
+
transform: translateX(-50%);
|
|
157
|
+
display: inline-flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
gap: 10px;
|
|
160
|
+
padding: 8px 14px 8px 12px;
|
|
161
|
+
background: rgba(17, 17, 17, 0.78);
|
|
162
|
+
color: #fff;
|
|
163
|
+
border-radius: 999px;
|
|
164
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
165
|
+
font-size: 13px;
|
|
166
|
+
line-height: 1;
|
|
167
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
|
168
|
+
backdrop-filter: blur(8px);
|
|
169
|
+
-webkit-backdrop-filter: blur(8px);
|
|
170
|
+
z-index: 2147483646;
|
|
171
|
+
max-width: calc(100vw - 32px);
|
|
172
|
+
}
|
|
173
|
+
.dot {
|
|
174
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
175
|
+
background: #ef4444;
|
|
176
|
+
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6);
|
|
177
|
+
animation: pulse 1.6s ease-out infinite;
|
|
178
|
+
}
|
|
179
|
+
.dot[data-state="no-audio"] { background: #fbbf24; animation: none; }
|
|
180
|
+
.dot[data-state="finishing"] { background: #fbbf24; animation: none; }
|
|
181
|
+
.dot[data-state="done"] { background: #10b981; animation: none; }
|
|
182
|
+
.dot[data-state="error"] { background: #ef4444; animation: none; }
|
|
183
|
+
.label { font-weight: 500; letter-spacing: 0.01em; }
|
|
184
|
+
.spacer { width: 1px; height: 16px; background: rgba(255,255,255,0.18); margin: 0 2px; }
|
|
185
|
+
.btn {
|
|
186
|
+
appearance: none; border: 0; background: rgba(255,255,255,0.12);
|
|
187
|
+
color: #fff; font: inherit; font-weight: 600;
|
|
188
|
+
padding: 6px 12px; border-radius: 999px; cursor: pointer;
|
|
189
|
+
transition: background 0.15s ease;
|
|
190
|
+
}
|
|
191
|
+
.btn:hover { background: rgba(255,255,255,0.22); }
|
|
192
|
+
.btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
|
193
|
+
.btn[disabled] { opacity: 0.5; cursor: progress; }
|
|
194
|
+
.thanks {
|
|
195
|
+
position: fixed; inset: 0;
|
|
196
|
+
display: grid; place-items: center;
|
|
197
|
+
background: rgba(15, 15, 17, 0.78);
|
|
198
|
+
backdrop-filter: blur(6px);
|
|
199
|
+
-webkit-backdrop-filter: blur(6px);
|
|
200
|
+
color: #fff;
|
|
201
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
202
|
+
z-index: 2147483647;
|
|
203
|
+
padding: 24px;
|
|
204
|
+
text-align: center;
|
|
205
|
+
}
|
|
206
|
+
.thanks-card {
|
|
207
|
+
background: #fff; color: #111;
|
|
208
|
+
border-radius: 16px; padding: 28px 24px;
|
|
209
|
+
max-width: 360px; width: 100%;
|
|
210
|
+
box-shadow: 0 20px 50px rgba(0,0,0,0.25);
|
|
211
|
+
}
|
|
212
|
+
.thanks h2 { margin: 0 0 8px; font-size: 20px; }
|
|
213
|
+
.thanks p { margin: 0; font-size: 14px; line-height: 1.45; color: #4b5563; }
|
|
214
|
+
.thanks .check {
|
|
215
|
+
width: 44px; height: 44px; border-radius: 50%;
|
|
216
|
+
background: #10b981; color: #fff;
|
|
217
|
+
display: grid; place-items: center;
|
|
218
|
+
margin: 0 auto 12px;
|
|
219
|
+
font-size: 22px;
|
|
220
|
+
}
|
|
221
|
+
@keyframes pulse {
|
|
222
|
+
0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.55); }
|
|
223
|
+
70% { box-shadow: 0 0 0 10px rgba(239,68,68,0); }
|
|
224
|
+
100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
|
|
225
|
+
}
|
|
226
|
+
@media (prefers-reduced-motion: reduce) {
|
|
227
|
+
.dot { animation: none; }
|
|
228
|
+
}
|
|
229
|
+
`;
|
|
230
|
+
const bar = document.createElement("div");
|
|
231
|
+
bar.className = "bar";
|
|
232
|
+
bar.setAttribute("role", "status");
|
|
233
|
+
bar.setAttribute("aria-live", "polite");
|
|
234
|
+
const dot = document.createElement("span");
|
|
235
|
+
dot.className = "dot";
|
|
236
|
+
dot.setAttribute("data-state", store.indicatorState);
|
|
237
|
+
const label = document.createElement("span");
|
|
238
|
+
label.className = "label";
|
|
239
|
+
label.textContent = "Recording";
|
|
240
|
+
const spacer = document.createElement("span");
|
|
241
|
+
spacer.className = "spacer";
|
|
242
|
+
const btn = document.createElement("button");
|
|
243
|
+
btn.type = "button";
|
|
244
|
+
btn.className = "btn";
|
|
245
|
+
btn.textContent = "Finish";
|
|
246
|
+
btn.addEventListener("click", onFinish);
|
|
247
|
+
bar.appendChild(dot);
|
|
248
|
+
bar.appendChild(label);
|
|
249
|
+
bar.appendChild(spacer);
|
|
250
|
+
bar.appendChild(btn);
|
|
251
|
+
root.appendChild(style);
|
|
252
|
+
root.appendChild(bar);
|
|
253
|
+
return root;
|
|
254
|
+
}
|
|
255
|
+
function renderIndicatorState(store) {
|
|
256
|
+
const root = store.indicatorRoot;
|
|
257
|
+
if (!root) return;
|
|
258
|
+
const dot = root.querySelector(".dot");
|
|
259
|
+
const label = root.querySelector(".label");
|
|
260
|
+
const btn = root.querySelector(".btn");
|
|
261
|
+
if (!(dot instanceof HTMLElement) || !(label instanceof HTMLElement) || !btn) return;
|
|
262
|
+
dot.setAttribute("data-state", store.indicatorState);
|
|
263
|
+
switch (store.indicatorState) {
|
|
264
|
+
case "recording":
|
|
265
|
+
label.textContent = "Recording";
|
|
266
|
+
btn.textContent = "Finish";
|
|
267
|
+
btn.disabled = false;
|
|
268
|
+
break;
|
|
269
|
+
case "no-audio":
|
|
270
|
+
label.textContent = "No mic, replay only";
|
|
271
|
+
btn.textContent = "Finish";
|
|
272
|
+
btn.disabled = false;
|
|
273
|
+
break;
|
|
274
|
+
case "finishing":
|
|
275
|
+
label.textContent = "Saving";
|
|
276
|
+
btn.textContent = "Saving";
|
|
277
|
+
btn.disabled = true;
|
|
278
|
+
break;
|
|
279
|
+
case "done":
|
|
280
|
+
label.textContent = "Saved";
|
|
281
|
+
btn.textContent = "Done";
|
|
282
|
+
btn.disabled = true;
|
|
283
|
+
break;
|
|
284
|
+
case "error":
|
|
285
|
+
label.textContent = "Save failed";
|
|
286
|
+
btn.textContent = "Retry";
|
|
287
|
+
btn.disabled = false;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function showThanksScreen(root) {
|
|
292
|
+
const overlay = document.createElement("div");
|
|
293
|
+
overlay.className = "thanks";
|
|
294
|
+
overlay.innerHTML = `
|
|
295
|
+
<div class="thanks-card">
|
|
296
|
+
<div class="check" aria-hidden="true">✓</div>
|
|
297
|
+
<h2>Thanks for testing</h2>
|
|
298
|
+
<p>Your session was saved. You can close this tab.</p>
|
|
299
|
+
</div>
|
|
300
|
+
`;
|
|
301
|
+
root.appendChild(overlay);
|
|
302
|
+
}
|
|
303
|
+
async function createSession(apiUrl, slug, testerName) {
|
|
304
|
+
try {
|
|
305
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions`, {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: { "Content-Type": "application/json" },
|
|
308
|
+
body: JSON.stringify({ slug, ...testerName ? { testerName } : {} })
|
|
309
|
+
});
|
|
310
|
+
if (!res.ok) return null;
|
|
311
|
+
const json = await res.json();
|
|
312
|
+
if (typeof json.sessionId !== "string" || typeof json.clientId !== "string") return null;
|
|
313
|
+
return { sessionId: json.sessionId, clientId: json.clientId };
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async function finaliseSession(apiUrl, sessionId, durationSeconds) {
|
|
319
|
+
try {
|
|
320
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/finalise`, {
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers: { "Content-Type": "application/json" },
|
|
323
|
+
body: JSON.stringify({ durationSeconds: Math.max(0, Math.round(durationSeconds)) }),
|
|
324
|
+
keepalive: true
|
|
325
|
+
});
|
|
326
|
+
return res.ok;
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function flushPendingFromIdb(store, ctx) {
|
|
332
|
+
if (!store.sessionId) return;
|
|
333
|
+
const pending = await idbListChunks(store.sessionId);
|
|
334
|
+
for (const chunk of pending) {
|
|
335
|
+
const ok = await uploadChunkWithRetry(chunk.apiUrl, chunk.sessionId, chunk.chunkIndex, chunk.blob, ctx.logger, 3);
|
|
336
|
+
if (ok) await idbDeleteChunk(chunk.id);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function enqueueChunk(store, ctx, blob) {
|
|
340
|
+
if (store.cancelled || !store.sessionId || blob.size === 0) return;
|
|
341
|
+
const index = store.chunkIndex;
|
|
342
|
+
store.chunkIndex += 1;
|
|
343
|
+
store.pendingUploads += 1;
|
|
344
|
+
const sessionId = store.sessionId;
|
|
345
|
+
const apiUrl = store.options.apiUrl;
|
|
346
|
+
store.uploadQueue = store.uploadQueue.then(async () => {
|
|
347
|
+
const ok = await uploadChunkWithRetry(apiUrl, sessionId, index, blob, ctx.logger);
|
|
348
|
+
if (!ok) {
|
|
349
|
+
ctx.logger.warn(`chunk ${index} stashed for offline retry`);
|
|
350
|
+
await idbStashChunk({
|
|
351
|
+
id: `${sessionId}:${index}:${Date.now()}`,
|
|
352
|
+
sessionId,
|
|
353
|
+
apiUrl,
|
|
354
|
+
chunkIndex: index,
|
|
355
|
+
blob,
|
|
356
|
+
createdAt: Date.now()
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
store.pendingUploads -= 1;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
async function startRecording(store, ctx) {
|
|
363
|
+
if (!isMediaRecorderSupported()) {
|
|
364
|
+
ctx.logger.warn("MediaRecorder not supported, continuing without audio");
|
|
365
|
+
store.indicatorState = "no-audio";
|
|
366
|
+
renderIndicatorState(store);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
let stream;
|
|
370
|
+
try {
|
|
371
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
372
|
+
} catch (err) {
|
|
373
|
+
ctx.logger.warn("mic permission denied or unavailable", err);
|
|
374
|
+
store.indicatorState = "no-audio";
|
|
375
|
+
renderIndicatorState(store);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
store.stream = stream;
|
|
379
|
+
const mimeType = pickMimeType();
|
|
380
|
+
let recorder;
|
|
381
|
+
try {
|
|
382
|
+
recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
ctx.logger.error("MediaRecorder construction failed", err);
|
|
385
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
386
|
+
store.stream = null;
|
|
387
|
+
store.indicatorState = "no-audio";
|
|
388
|
+
renderIndicatorState(store);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
store.recorder = recorder;
|
|
392
|
+
recorder.addEventListener("dataavailable", (event) => {
|
|
393
|
+
if (event.data && event.data.size > 0) {
|
|
394
|
+
enqueueChunk(store, ctx, event.data);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
recorder.addEventListener("error", (event) => {
|
|
398
|
+
ctx.logger.error("MediaRecorder error", event);
|
|
399
|
+
});
|
|
400
|
+
recorder.start(store.options.chunkSeconds * 1e3);
|
|
401
|
+
}
|
|
402
|
+
function stopRecording(store) {
|
|
403
|
+
const recorder = store.recorder;
|
|
404
|
+
if (recorder && recorder.state !== "inactive") {
|
|
405
|
+
try {
|
|
406
|
+
recorder.requestData();
|
|
407
|
+
} catch {
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
recorder.stop();
|
|
411
|
+
} catch {
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
store.recorder = null;
|
|
415
|
+
if (store.stream) {
|
|
416
|
+
store.stream.getTracks().forEach((t) => t.stop());
|
|
417
|
+
store.stream = null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
async function finishFlow(store, ctx, opts) {
|
|
421
|
+
if (store.cancelled) return;
|
|
422
|
+
if (store.indicatorState === "finishing" || store.indicatorState === "done") return;
|
|
423
|
+
store.indicatorState = "finishing";
|
|
424
|
+
renderIndicatorState(store);
|
|
425
|
+
stopRecording(store);
|
|
426
|
+
await store.uploadQueue;
|
|
427
|
+
await flushPendingFromIdb(store, ctx);
|
|
428
|
+
const durationSeconds = (Date.now() - store.startedAt) / 1e3;
|
|
429
|
+
if (store.sessionId) {
|
|
430
|
+
const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds);
|
|
431
|
+
store.indicatorState = ok ? "done" : "error";
|
|
432
|
+
} else {
|
|
433
|
+
store.indicatorState = "error";
|
|
434
|
+
}
|
|
435
|
+
renderIndicatorState(store);
|
|
436
|
+
if (opts.showThanks && store.indicatorRoot && store.indicatorState === "done") {
|
|
437
|
+
showThanksScreen(store.indicatorRoot);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function userTest(options = {}) {
|
|
441
|
+
const merged = {
|
|
442
|
+
queryParam: options.queryParam ?? DEFAULT_OPTIONS.queryParam,
|
|
443
|
+
chunkSeconds: options.chunkSeconds ?? DEFAULT_OPTIONS.chunkSeconds,
|
|
444
|
+
apiUrl: options.apiUrl ?? DEFAULT_OPTIONS.apiUrl,
|
|
445
|
+
testerName: options.testerName ?? DEFAULT_OPTIONS.testerName,
|
|
446
|
+
hideIndicator: options.hideIndicator ?? DEFAULT_OPTIONS.hideIndicator
|
|
447
|
+
};
|
|
448
|
+
return {
|
|
449
|
+
name: "user-test",
|
|
450
|
+
onInit(ctx) {
|
|
451
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
452
|
+
const slug = getTestSlug(merged.queryParam);
|
|
453
|
+
if (!slug) return;
|
|
454
|
+
const apiUrl = merged.apiUrl || ctx.baseUrl || DEFAULT_API_URL;
|
|
455
|
+
const store = {
|
|
456
|
+
cancelled: false,
|
|
457
|
+
slug,
|
|
458
|
+
sessionId: null,
|
|
459
|
+
clientId: null,
|
|
460
|
+
recorder: null,
|
|
461
|
+
stream: null,
|
|
462
|
+
chunkIndex: 0,
|
|
463
|
+
uploadQueue: Promise.resolve(),
|
|
464
|
+
pendingUploads: 0,
|
|
465
|
+
startedAt: Date.now(),
|
|
466
|
+
indicator: null,
|
|
467
|
+
indicatorRoot: null,
|
|
468
|
+
indicatorState: "recording",
|
|
469
|
+
pageHideHandler: null,
|
|
470
|
+
options: { ...merged, apiUrl }
|
|
471
|
+
};
|
|
472
|
+
ctx.setStore(store);
|
|
473
|
+
const onFinish = () => {
|
|
474
|
+
void finishFlow(store, ctx, { showThanks: true });
|
|
475
|
+
};
|
|
476
|
+
if (!merged.hideIndicator) {
|
|
477
|
+
const host = document.createElement("div");
|
|
478
|
+
host.setAttribute("data-usero-user-test", "true");
|
|
479
|
+
document.body.appendChild(host);
|
|
480
|
+
store.indicator = host;
|
|
481
|
+
store.indicatorRoot = buildIndicator(host, store, onFinish);
|
|
482
|
+
renderIndicatorState(store);
|
|
483
|
+
}
|
|
484
|
+
const pageHide = () => {
|
|
485
|
+
void finishFlow(store, ctx, { showThanks: false });
|
|
486
|
+
};
|
|
487
|
+
store.pageHideHandler = pageHide;
|
|
488
|
+
window.addEventListener("pagehide", pageHide);
|
|
489
|
+
void (async () => {
|
|
490
|
+
const created = await createSession(apiUrl, slug, readTesterName(merged.testerName));
|
|
491
|
+
if (store.cancelled) return;
|
|
492
|
+
if (!created) {
|
|
493
|
+
ctx.logger.error("failed to create user-test session");
|
|
494
|
+
store.indicatorState = "error";
|
|
495
|
+
renderIndicatorState(store);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
store.sessionId = created.sessionId;
|
|
499
|
+
store.clientId = created.clientId;
|
|
500
|
+
await startRecording(store, ctx);
|
|
501
|
+
renderIndicatorState(store);
|
|
502
|
+
})();
|
|
503
|
+
},
|
|
504
|
+
onDestroy(ctx) {
|
|
505
|
+
const store = ctx.getStore();
|
|
506
|
+
if (!store) return;
|
|
507
|
+
store.cancelled = true;
|
|
508
|
+
if (store.pageHideHandler) {
|
|
509
|
+
window.removeEventListener("pagehide", store.pageHideHandler);
|
|
510
|
+
store.pageHideHandler = null;
|
|
511
|
+
}
|
|
512
|
+
stopRecording(store);
|
|
513
|
+
if (store.indicator && store.indicator.parentNode) {
|
|
514
|
+
store.indicator.parentNode.removeChild(store.indicator);
|
|
515
|
+
}
|
|
516
|
+
store.indicator = null;
|
|
517
|
+
store.indicatorRoot = null;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
var __test__ = { getTestSlug, pickMimeType, isMediaRecorderSupported };
|
|
522
|
+
|
|
523
|
+
export { __test__, userTest };
|
|
524
|
+
//# sourceMappingURL=user-test.js.map
|
|
525
|
+
//# sourceMappingURL=user-test.js.map
|