@sphoro/bugcatch-web-sdk 0.19.0 → 0.21.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/README.md +54 -1
- package/dist/bugcatch-call.umd.js +1 -1
- package/dist/bugcatch-core.es.js +652 -497
- package/dist/bugcatch-core.es.js.map +1 -1
- package/dist/bugcatch-panel.es.js +1307 -964
- package/dist/bugcatch-panel.es.js.map +1 -1
- package/dist/bugcatch-panel.umd.js +291 -158
- package/dist/bugcatch-panel.umd.js.map +1 -1
- package/dist/bugcatch.standalone.umd.js +284 -151
- package/dist/bugcatch.standalone.umd.js.map +1 -1
- package/dist/bugcatch.umd.js +8 -8
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/feedback.js +94 -0
- package/src/identity.js +1 -1
- package/src/index.d.ts +65 -6
- package/src/index.js +111 -7
- package/src/panel/call.js +3 -2
- package/src/panel/compose.js +8 -4
- package/src/panel/feedback.js +205 -0
- package/src/panel/icons.js +6 -0
- package/src/panel/index.js +91 -12
- package/src/panel/rate-due.js +155 -0
- package/src/panel/rating.js +87 -0
- package/src/panel/signin.js +1 -1
- package/src/panel/styles.js +75 -0
- package/src/panel/thread.js +3 -72
- package/src/widget.js +14 -1
package/package.json
CHANGED
package/src/feedback.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Feedback: an idea, praise, something that confused somebody — sent from the
|
|
2
|
+
// widget's "Give feedback" or from feedback() — and whether this project takes
|
|
3
|
+
// it at all.
|
|
4
|
+
//
|
|
5
|
+
// In the script every page loads, not the panel's chunk, because feedback() is
|
|
6
|
+
// part of the public API: a site running `widget: false` sends it from a form
|
|
7
|
+
// of its own and never fetches the panel.
|
|
8
|
+
|
|
9
|
+
import { tooManyMessage } from './busy.js';
|
|
10
|
+
|
|
11
|
+
/** What the widget offers under "What kind is it?", and what the API keeps. */
|
|
12
|
+
export const FEEDBACK_KINDS = ['idea', 'praise', 'confusing', 'other'];
|
|
13
|
+
|
|
14
|
+
/** 1 (terrible) to 5 (great). */
|
|
15
|
+
export const FEEDBACK_SCALE = 5;
|
|
16
|
+
|
|
17
|
+
/** The API refuses longer, so the widget's box stops here too. */
|
|
18
|
+
export const MAX_FEEDBACK_MESSAGE = 4000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether this project wants the option shown. False on every failure — an
|
|
22
|
+
* API too old to know the route, a network error, a key it refuses — because
|
|
23
|
+
* a form that the server would then refuse is worse than no form.
|
|
24
|
+
*/
|
|
25
|
+
export async function fetchFeedbackState({ apiBase, apiKey, projectId }) {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(`${apiBase}/ingest/feedback?projectId=${encodeURIComponent(projectId)}`, {
|
|
28
|
+
headers: { 'X-BugCatch-Key': apiKey },
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok) return false;
|
|
31
|
+
const body = await res.json();
|
|
32
|
+
return body?.enabled === true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The fields as they go on the wire: the message trimmed and bounded, and
|
|
40
|
+
* anything the API would only drop left out rather than sent to be dropped.
|
|
41
|
+
* Throws for the one thing that cannot be sent: no message.
|
|
42
|
+
*/
|
|
43
|
+
export function feedbackFields({ message, kind, score, email, name } = {}) {
|
|
44
|
+
const text = typeof message === 'string' ? message.trim().slice(0, MAX_FEEDBACK_MESSAGE) : '';
|
|
45
|
+
if (!text) throw new Error('[BugCatch] feedback needs a message.');
|
|
46
|
+
const n = typeof score === 'string' && score.trim() !== '' ? Number(score) : score;
|
|
47
|
+
const contact = typeof email === 'string' ? email.trim() : '';
|
|
48
|
+
const who = typeof name === 'string' ? name.trim() : '';
|
|
49
|
+
return {
|
|
50
|
+
message: text,
|
|
51
|
+
...(FEEDBACK_KINDS.includes(kind) ? { kind } : {}),
|
|
52
|
+
...(Number.isInteger(n) && n >= 1 && n <= FEEDBACK_SCALE ? { score: n } : {}),
|
|
53
|
+
...(contact ? { email: contact.slice(0, 254) } : {}),
|
|
54
|
+
...(who ? { name: who.slice(0, 120) } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Send it. Resolves to `{ id }`; rejects with an Error whose message is fit to
|
|
60
|
+
* show the person who wrote it, with the `[BugCatch] ` prefix the widget strips.
|
|
61
|
+
*/
|
|
62
|
+
export async function postFeedback({ apiBase, apiKey, token, payload }) {
|
|
63
|
+
let res;
|
|
64
|
+
try {
|
|
65
|
+
res = await fetch(`${apiBase}/ingest/feedback`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
'X-BugCatch-Key': apiKey,
|
|
70
|
+
// Present only where they signed in, so the server can put a proved
|
|
71
|
+
// name and address on it. A stale one is ignored there, not refused.
|
|
72
|
+
...(token ? { 'X-BugCatch-Session': token } : {}),
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(payload),
|
|
75
|
+
});
|
|
76
|
+
} catch (cause) {
|
|
77
|
+
const err = new Error(`[BugCatch] could not reach ${apiBase} — check your connection and try again.`);
|
|
78
|
+
err.cause = cause;
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
let detail = '';
|
|
83
|
+
try {
|
|
84
|
+
const body = await res.json();
|
|
85
|
+
detail = Array.isArray(body.message) ? body.message.join(', ') : body.message || '';
|
|
86
|
+
} catch { /* not JSON */ }
|
|
87
|
+
const err = res.status === 429
|
|
88
|
+
? new Error(`[BugCatch] ${tooManyMessage(detail)}`)
|
|
89
|
+
: new Error(`[BugCatch] ${detail || `feedback could not be sent (HTTP ${res.status})`}`);
|
|
90
|
+
err.status = res.status;
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
return res.json();
|
|
94
|
+
}
|
package/src/identity.js
CHANGED
|
@@ -207,7 +207,7 @@ export const KNOWN_OPTIONS = [
|
|
|
207
207
|
'user', 'context', 'updates', 'updateToast', 'onUpdate', 'voice', 'onCallState',
|
|
208
208
|
'onIncomingCall', 'onReportSubmitted', 'onAssistantPhase', 'onError', 'debug',
|
|
209
209
|
'drafts', 'knownIssues', 'agentIssues', 'release', 'environment', 'buildId',
|
|
210
|
-
'chunkBase', 'announcements',
|
|
210
|
+
'chunkBase', 'announcements', 'feedback',
|
|
211
211
|
];
|
|
212
212
|
|
|
213
213
|
const CALLBACK_OPTIONS = [
|
package/src/index.d.ts
CHANGED
|
@@ -64,8 +64,29 @@ export interface ReportSubmittedEvent {
|
|
|
64
64
|
result: ReportResult;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** What feedback() takes. Only the message is required. */
|
|
68
|
+
export interface FeedbackInput {
|
|
69
|
+
message: string;
|
|
70
|
+
kind?: FeedbackKind | null;
|
|
71
|
+
/** How the product feels so far: 1 (terrible) to 5 (great). */
|
|
72
|
+
score?: 1 | 2 | 3 | 4 | 5 | null;
|
|
73
|
+
/** Where to reply, if they want one. identify()'s address is used otherwise. */
|
|
74
|
+
email?: string;
|
|
75
|
+
name?: string;
|
|
76
|
+
/** Merged over setContext() for this one send. */
|
|
77
|
+
context?: BugCatchContext | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type FeedbackKind = 'idea' | 'praise' | 'confusing' | 'other';
|
|
81
|
+
|
|
82
|
+
export interface FeedbackSentEvent {
|
|
83
|
+
id: string | null;
|
|
84
|
+
kind: FeedbackKind | null;
|
|
85
|
+
score: number | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
67
88
|
export interface BugCatchErrorEvent {
|
|
68
|
-
action: 'report' | 'call';
|
|
89
|
+
action: 'report' | 'call' | 'feedback';
|
|
69
90
|
error: Error & { status?: number };
|
|
70
91
|
}
|
|
71
92
|
|
|
@@ -218,14 +239,27 @@ export interface BugCatchConfig {
|
|
|
218
239
|
* announcements() still answers, for a site that shows them its own way.
|
|
219
240
|
*/
|
|
220
241
|
announcements?: boolean;
|
|
242
|
+
/**
|
|
243
|
+
* Offer "Give feedback" beside "Report a bug" in the panel — ideas, praise,
|
|
244
|
+
* something confusing — where the project has it switched on (it is, by
|
|
245
|
+
* default; Settings → Project → General). false hides it on this site;
|
|
246
|
+
* feedback() still sends.
|
|
247
|
+
*/
|
|
248
|
+
feedback?: boolean;
|
|
221
249
|
/** Default 5. */
|
|
222
250
|
maxAttachments?: number;
|
|
223
251
|
/** Default 25 MB. */
|
|
224
252
|
maxAttachmentBytes?: number;
|
|
225
253
|
/** Hard cap on a single recording. Default 120000 (2 minutes). */
|
|
226
254
|
maxRecordingMs?: number;
|
|
227
|
-
/**
|
|
228
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Return false to cancel, or a payload object to replace it. Runs for
|
|
257
|
+
* reports and for feedback; the second argument says which.
|
|
258
|
+
*/
|
|
259
|
+
beforeSend?: (
|
|
260
|
+
payload: Record<string, any>,
|
|
261
|
+
info: { type: 'report' | 'feedback' },
|
|
262
|
+
) => Record<string, any> | false | void;
|
|
229
263
|
user?: BugCatchUser | null;
|
|
230
264
|
/**
|
|
231
265
|
* Tell the reporter, live and in your app, when a bug they filed changes
|
|
@@ -470,6 +504,15 @@ export interface MyReport {
|
|
|
470
504
|
rating: number | null;
|
|
471
505
|
/** Settled, on a project that asks for ratings — so a list can say "rate it". */
|
|
472
506
|
ratingAsked: boolean;
|
|
507
|
+
/** When it was resolved or closed; null while it is open. */
|
|
508
|
+
resolvedAt: string | null;
|
|
509
|
+
/**
|
|
510
|
+
* Whether the widget's first screen asks for this report's rating: 'ask' with
|
|
511
|
+
* a Not now, 'require' before another report can be sent from the widget, or
|
|
512
|
+
* null. Set by the project (Settings → Resolutions → Where the widget asks),
|
|
513
|
+
* for reports settled in the last 30 days and not yet rated for this fix.
|
|
514
|
+
*/
|
|
515
|
+
ratingDue: 'ask' | 'require' | null;
|
|
473
516
|
}
|
|
474
517
|
|
|
475
518
|
/**
|
|
@@ -683,9 +726,11 @@ export declare class BugCatchClient {
|
|
|
683
726
|
): () => void;
|
|
684
727
|
/** Someone signed out. `email` is who it was, where this browser knew. */
|
|
685
728
|
on(event: 'signout', handler: (who: { email: string | null }) => void): () => void;
|
|
729
|
+
/** Feedback was accepted — from the widget or from feedback(). */
|
|
730
|
+
on(event: 'feedback', handler: (event: FeedbackSentEvent) => void): () => void;
|
|
686
731
|
off(event: 'update', handler: (update: ReportUpdate) => void): this;
|
|
687
732
|
off(
|
|
688
|
-
event: 'call' | 'unread' | 'incoming-call' | 'signin' | 'signout' | 'report' | 'assistant' | 'error',
|
|
733
|
+
event: 'call' | 'unread' | 'incoming-call' | 'signin' | 'signout' | 'report' | 'assistant' | 'error' | 'feedback',
|
|
689
734
|
handler: (payload: any) => void,
|
|
690
735
|
): this;
|
|
691
736
|
/**
|
|
@@ -869,8 +914,11 @@ export declare class BugCatchClient {
|
|
|
869
914
|
setContext(context: BugCatchContext | null): this;
|
|
870
915
|
/** A copy of what setContext() holds. */
|
|
871
916
|
getContext(): BugCatchContext;
|
|
872
|
-
/**
|
|
873
|
-
|
|
917
|
+
/**
|
|
918
|
+
* Open the built-in panel (needs `widget: true`): the report form, or with
|
|
919
|
+
* `{ mode: 'feedback' }` the feedback form where the project takes it.
|
|
920
|
+
*/
|
|
921
|
+
open(options?: { mode?: 'report' | 'feedback' }): this;
|
|
874
922
|
/** Close the report panel if it is open. */
|
|
875
923
|
close(): this;
|
|
876
924
|
isOpen(): boolean;
|
|
@@ -904,6 +952,17 @@ export declare class BugCatchClient {
|
|
|
904
952
|
announcements(): Promise<Announcement[]>;
|
|
905
953
|
/** Stop showing one in this browser until the team uses "Show it again". */
|
|
906
954
|
dismissAnnouncement(announcement: Announcement): void;
|
|
955
|
+
/**
|
|
956
|
+
* Whether this project takes feedback — what decides whether the widget
|
|
957
|
+
* draws "Give feedback". Asked once per page; false on any failure.
|
|
958
|
+
*/
|
|
959
|
+
feedbackEnabled(): Promise<boolean>;
|
|
960
|
+
/**
|
|
961
|
+
* Send feedback to this project's team: not a bug, so not triaged or
|
|
962
|
+
* answered in the widget — it lands on the dashboard's Feedback page.
|
|
963
|
+
* Rejects with an Error whose message is fit to show the person.
|
|
964
|
+
*/
|
|
965
|
+
feedback(input: FeedbackInput): Promise<{ id: string } | { cancelled: true }>;
|
|
907
966
|
/**
|
|
908
967
|
* The known issues that look like what somebody is describing.
|
|
909
968
|
*
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { fetchAgentIssues } from './agent-issues.js';
|
|
|
25
25
|
import {
|
|
26
26
|
fetchAnnouncements, visibleAnnouncements, dismissAnnouncement as rememberDismissed,
|
|
27
27
|
} from './announcements.js';
|
|
28
|
+
import { fetchFeedbackState, feedbackFields, postFeedback } from './feedback.js';
|
|
28
29
|
import { normalizeUser, mergeContext, checkOptions, cleanApiBase } from './identity.js';
|
|
29
30
|
import { tooManyMessage } from './busy.js';
|
|
30
31
|
|
|
@@ -57,6 +58,10 @@ const DEFAULTS = {
|
|
|
57
58
|
// ones written for that. false draws nothing; announcements() still answers,
|
|
58
59
|
// for a site that shows them its own way.
|
|
59
60
|
announcements: true,
|
|
61
|
+
// Offer "Give feedback" beside "Report a bug" in the panel, where the
|
|
62
|
+
// project has it switched on (Settings → Project → General). false hides it
|
|
63
|
+
// on this site; feedback() still sends, for a site with a form of its own.
|
|
64
|
+
feedback: true,
|
|
60
65
|
// Screenshot / screen recording / file attach. Capture needs getDisplayMedia,
|
|
61
66
|
// so it is offered only where the browser has it — file attach always works.
|
|
62
67
|
attachments: true,
|
|
@@ -620,11 +625,14 @@ class BugCatchClient {
|
|
|
620
625
|
*
|
|
621
626
|
* Returns the payload to send, or false when the host cancelled.
|
|
622
627
|
*/
|
|
623
|
-
#screen(payload) {
|
|
628
|
+
#screen(payload, info = { type: 'report' }) {
|
|
624
629
|
if (typeof this.config.beforeSend !== 'function') return payload;
|
|
625
630
|
let result;
|
|
626
631
|
try {
|
|
627
|
-
|
|
632
|
+
// The second argument says what is being sent — 'report' or 'feedback'
|
|
633
|
+
// — for a hook that scrubs the two differently. A hook written before
|
|
634
|
+
// feedback existed ignores it and still sees the capture it cleans.
|
|
635
|
+
result = this.config.beforeSend(payload, info);
|
|
628
636
|
} catch (err) {
|
|
629
637
|
// A throwing hook is not permission to send what it was meant to clean.
|
|
630
638
|
this.warn(`beforeSend threw, so nothing was sent: ${err?.message || err}`);
|
|
@@ -1181,13 +1189,22 @@ class BugCatchClient {
|
|
|
1181
1189
|
* Open the report panel, as if the floating button had been pressed — for a
|
|
1182
1190
|
* "Report a problem" item in your own menu.
|
|
1183
1191
|
*
|
|
1184
|
-
*
|
|
1185
|
-
*
|
|
1192
|
+
* BugCatch.open(); // the report form
|
|
1193
|
+
* BugCatch.open({ mode: 'feedback' }); // straight to "Give feedback"
|
|
1194
|
+
*
|
|
1195
|
+
* Feedback opens only where this project takes it; otherwise the report form
|
|
1196
|
+
* does. An open panel moves to the mode asked for. Needs the built-in widget
|
|
1197
|
+
* (`widget: true`, the default). With `widget: false` there is no panel to
|
|
1198
|
+
* open: draw your own form and call report() or feedback().
|
|
1186
1199
|
*/
|
|
1187
|
-
open() {
|
|
1200
|
+
open({ mode } = {}) {
|
|
1188
1201
|
if (!this.started) { this.warn('open() was called before init() — nothing to open yet.'); return this; }
|
|
1189
1202
|
if (!this.widgetControls) { this.warn('open() needs the built-in widget; it was turned off with widget: false.'); return this; }
|
|
1190
|
-
|
|
1203
|
+
if (mode !== undefined && mode !== 'report' && mode !== 'feedback') {
|
|
1204
|
+
this.warn(`open({ mode: "${mode}" }) is not 'report' or 'feedback' — opened the report form.`);
|
|
1205
|
+
mode = 'report';
|
|
1206
|
+
}
|
|
1207
|
+
this.widgetControls.open(mode);
|
|
1191
1208
|
return this;
|
|
1192
1209
|
}
|
|
1193
1210
|
|
|
@@ -1262,7 +1279,7 @@ class BugCatchClient {
|
|
|
1262
1279
|
issue: typeof issue === 'string' && issue ? issue.slice(0, 32) : null,
|
|
1263
1280
|
});
|
|
1264
1281
|
|
|
1265
|
-
const screened = this.#screen(payload);
|
|
1282
|
+
const screened = this.#screen(payload, { type: 'report' });
|
|
1266
1283
|
if (screened === false) return { cancelled: true };
|
|
1267
1284
|
payload = screened;
|
|
1268
1285
|
|
|
@@ -1443,6 +1460,93 @@ class BugCatchClient {
|
|
|
1443
1460
|
rememberDismissed(announcement);
|
|
1444
1461
|
}
|
|
1445
1462
|
|
|
1463
|
+
/**
|
|
1464
|
+
* Whether this project takes feedback from the widget — the answer that
|
|
1465
|
+
* decides whether "Give feedback" is drawn.
|
|
1466
|
+
*
|
|
1467
|
+
* if (await BugCatch.feedbackEnabled()) showMyFeedbackButton();
|
|
1468
|
+
*
|
|
1469
|
+
* Switched on and off on the project's settings page, not here. Asked once
|
|
1470
|
+
* per page and kept; false on any failure.
|
|
1471
|
+
*/
|
|
1472
|
+
feedbackEnabled() {
|
|
1473
|
+
if (this.feedbackState) return this.feedbackState;
|
|
1474
|
+
this.feedbackState = fetchFeedbackState({
|
|
1475
|
+
apiBase: this.config.apiBase,
|
|
1476
|
+
apiKey: this.config.apiKey,
|
|
1477
|
+
projectId: this.config.projectId,
|
|
1478
|
+
});
|
|
1479
|
+
return this.feedbackState;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Send feedback — an idea, praise, something that confused them — to this
|
|
1484
|
+
* project's team. Not a bug: it is not triaged, assigned or answered in the
|
|
1485
|
+
* widget; it lands on the dashboard's Feedback page.
|
|
1486
|
+
*
|
|
1487
|
+
* await BugCatch.feedback({
|
|
1488
|
+
* message: 'Export to CSV would save me an hour a week',
|
|
1489
|
+
* kind: 'idea', // 'idea' | 'praise' | 'confusing' | 'other' (optional)
|
|
1490
|
+
* score: 4, // 1 (terrible) – 5 (great), how it feels so far (optional)
|
|
1491
|
+
* email: 'jo@acme.com', // only if they want a reply; identify()'s is used otherwise
|
|
1492
|
+
* });
|
|
1493
|
+
* // → { id }
|
|
1494
|
+
*
|
|
1495
|
+
* Sent with the page and browser details, who identify() says they are and
|
|
1496
|
+
* the setContext() context — never the console or network logs, which say
|
|
1497
|
+
* nothing about an opinion. Goes through beforeSend, whose second argument is
|
|
1498
|
+
* `{ type: 'feedback' }`. Rejects with a message fit to show the person.
|
|
1499
|
+
*/
|
|
1500
|
+
async feedback({ message, kind, score, email, name, context = null } = {}) {
|
|
1501
|
+
if (!this.started) throw new Error('[BugCatch] call init() first.');
|
|
1502
|
+
const fields = feedbackFields({
|
|
1503
|
+
message,
|
|
1504
|
+
kind,
|
|
1505
|
+
score,
|
|
1506
|
+
// What they typed wins: it is the address they asked to be answered at.
|
|
1507
|
+
email: email ?? this.config.user?.email,
|
|
1508
|
+
name: name ?? this.config.user?.name,
|
|
1509
|
+
});
|
|
1510
|
+
if (kind !== undefined && kind !== null && !fields.kind) {
|
|
1511
|
+
this.warn(`feedback({ kind: "${kind}" }) is not one of idea, praise, confusing, other — sent without one.`);
|
|
1512
|
+
}
|
|
1513
|
+
const ctx = context ? mergeContext(this.context, context, (m) => this.warn(m.replace('setContext()', 'feedback({ context })'))) : this.context;
|
|
1514
|
+
let payload = {
|
|
1515
|
+
projectId: this.config.projectId,
|
|
1516
|
+
...fields,
|
|
1517
|
+
url: window.location.href,
|
|
1518
|
+
metadata: {
|
|
1519
|
+
...(await collectEnvironment()),
|
|
1520
|
+
release: this.config.release,
|
|
1521
|
+
environment: this.config.environment,
|
|
1522
|
+
buildId: this.config.buildId,
|
|
1523
|
+
user: this.config.user || undefined,
|
|
1524
|
+
...(Object.keys(ctx).length ? { context: ctx } : {}),
|
|
1525
|
+
sdk: SDK_IDENTITY,
|
|
1526
|
+
},
|
|
1527
|
+
};
|
|
1528
|
+
const screened = this.#screen(payload, { type: 'feedback' });
|
|
1529
|
+
if (screened === false) return { cancelled: true };
|
|
1530
|
+
payload = screened;
|
|
1531
|
+
|
|
1532
|
+
this.debugLog('sending feedback', { kind: payload.kind, score: payload.score, user: payload.metadata?.user });
|
|
1533
|
+
try {
|
|
1534
|
+
const result = await postFeedback({
|
|
1535
|
+
apiBase: this.config.apiBase,
|
|
1536
|
+
apiKey: this.config.apiKey,
|
|
1537
|
+
token: readSession(this.config.projectId)?.token,
|
|
1538
|
+
payload,
|
|
1539
|
+
});
|
|
1540
|
+
const detail = { id: result?.id ?? null, kind: payload.kind ?? null, score: payload.score ?? null };
|
|
1541
|
+
this.debugLog('feedback accepted', detail);
|
|
1542
|
+
this.emit('feedback', detail);
|
|
1543
|
+
return result;
|
|
1544
|
+
} catch (err) {
|
|
1545
|
+
this.#failed('feedback', err);
|
|
1546
|
+
throw err;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1446
1550
|
/**
|
|
1447
1551
|
* Who this browser is signed in as on this project, from storage alone.
|
|
1448
1552
|
*
|
package/src/panel/call.js
CHANGED
|
@@ -34,8 +34,9 @@ export function mountCallOffer(ctx) {
|
|
|
34
34
|
ctx.preloadCall();
|
|
35
35
|
// Never over a panel that is still asking who this is: the call would
|
|
36
36
|
// file a report, and on a project that requires sign-in that is the one
|
|
37
|
-
// thing this panel is holding back.
|
|
38
|
-
|
|
37
|
+
// thing this panel is holding back. Nor over one holding the form for
|
|
38
|
+
// a rating (rate-due.js), for the same reason.
|
|
39
|
+
callBtn.hidden = ctx.mustSignIn() || ctx.mustRate();
|
|
39
40
|
})
|
|
40
41
|
.catch(() => { /* not available is the safe answer to every failure */ });
|
|
41
42
|
}
|
package/src/panel/compose.js
CHANGED
|
@@ -27,7 +27,7 @@ export function composeHtml(cfg) {
|
|
|
27
27
|
const canShoot = canCapture && screenshotSupported();
|
|
28
28
|
const canRecord = canCapture && captureSupported();
|
|
29
29
|
return `
|
|
30
|
-
<div class="compose">
|
|
30
|
+
<div class="compose bug-only">
|
|
31
31
|
<div class="field">
|
|
32
32
|
<label for="bc-title">What went wrong?</label>
|
|
33
33
|
<input id="bc-title" type="text" placeholder="e.g. The checkout button does nothing" autocomplete="off"
|
|
@@ -132,7 +132,9 @@ export function mountCompose(ctx) {
|
|
|
132
132
|
void persistDraft();
|
|
133
133
|
};
|
|
134
134
|
}).catch(() => { draftStatus.textContent = 'Draft storage is unavailable. Keep this tab open.'; })
|
|
135
|
-
|
|
135
|
+
// Back to whatever the rating card wants: it may have held the form
|
|
136
|
+
// while storage was being read (rate-due.js).
|
|
137
|
+
.finally(() => { compose.inert = ctx.mustRate(); if (!draftStatus.querySelector('[data-resume]')) draftLoaded = true; });
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
// Saved on the way out, before anything else is taken down.
|
|
@@ -173,8 +175,10 @@ export function mountCompose(ctx) {
|
|
|
173
175
|
|
|
174
176
|
function refreshSendState() {
|
|
175
177
|
const busy = items.some((it) => it.status === 'uploading');
|
|
176
|
-
|
|
177
|
-
|
|
178
|
+
const held = ctx.mustRate();
|
|
179
|
+
sendBtn.disabled = ctx.sending || busy || held || items.some((it) => it.status === 'failed');
|
|
180
|
+
note.textContent = held ? 'Rate your last report to send a new one.'
|
|
181
|
+
: busy ? 'Waiting for the upload to finish…' : items.some((it) => it.status === 'failed') ? 'Retry or remove failed attachments before sending.' : '';
|
|
178
182
|
const max = cfg.maxAttachments ?? 5;
|
|
179
183
|
scrim.querySelectorAll('.tool').forEach((t) => {
|
|
180
184
|
t.disabled = ctx.sending || items.length >= max;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// "Give feedback": the panel's second form, beside "Report a bug".
|
|
2
|
+
//
|
|
3
|
+
// For what is not a bug — an idea, praise, something that confused them —
|
|
4
|
+
// and so deliberately smaller than the report form: one box that matters, two
|
|
5
|
+
// optional taps, and an address only when the page does not already know one.
|
|
6
|
+
// No attachments, no severity, no record to look up, no sign-in: feedback is
|
|
7
|
+
// read by the team, not worked like a report.
|
|
8
|
+
//
|
|
9
|
+
// The switch between the two forms lives in index.js, because it is the
|
|
10
|
+
// panel's frame that changes (the header, the footer's button) as much as the
|
|
11
|
+
// body.
|
|
12
|
+
|
|
13
|
+
import * as ICONS from './icons.js';
|
|
14
|
+
import { escapeHtml } from './render.js';
|
|
15
|
+
import { FEEDBACK_KINDS, MAX_FEEDBACK_MESSAGE } from '../feedback.js';
|
|
16
|
+
|
|
17
|
+
const KINDS = {
|
|
18
|
+
idea: { label: 'Idea', icon: ICONS.bulb },
|
|
19
|
+
praise: { label: 'Praise', icon: ICONS.heart },
|
|
20
|
+
confusing: { label: 'Confusing', icon: ICONS.question },
|
|
21
|
+
other: { label: 'Other', icon: ICONS.dots },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The five faces, worst to best. The word is what a screen reader says. */
|
|
25
|
+
export const FACES = [
|
|
26
|
+
{ score: 1, word: 'Terrible', mouth: 'M7.8 17.2c1.1-2 2.5-2.9 4.2-2.9s3.1.9 4.2 2.9M8 8l2.4 1.2M16 8l-2.4 1.2' },
|
|
27
|
+
{ score: 2, word: 'Not great', mouth: 'M8.6 16c1-.8 2.1-1.2 3.4-1.2s2.4.4 3.4 1.2' },
|
|
28
|
+
{ score: 3, word: 'Okay', mouth: 'M8.8 15.2h6.4' },
|
|
29
|
+
{ score: 4, word: 'Good', mouth: 'M8.6 14.4c1 .9 2.1 1.4 3.4 1.4s2.4-.5 3.4-1.4' },
|
|
30
|
+
{ score: 5, word: 'Great', mouth: 'M7.8 13.2h8.4a4.2 4.2 0 0 1-8.4 0z' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function face(mouth) {
|
|
34
|
+
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
|
|
35
|
+
+ '<circle cx="12" cy="12" r="9.2"/><path d="M9 9.9h.01M15 9.9h.01" stroke-width="2.6"/>'
|
|
36
|
+
+ `<path d="${mouth}"/></svg>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The form's markup, for the panel's body. Hidden until the panel is in feedback mode. */
|
|
40
|
+
export function feedbackHtml() {
|
|
41
|
+
return `
|
|
42
|
+
<div class="feedback fb-only">
|
|
43
|
+
<div class="field">
|
|
44
|
+
<label for="bc-fb-msg">What's on your mind?</label>
|
|
45
|
+
<textarea id="bc-fb-msg" maxlength="${MAX_FEEDBACK_MESSAGE}" aria-describedby="bc-fb-err"
|
|
46
|
+
placeholder="An idea, something you like, or something that confused you."></textarea>
|
|
47
|
+
<p class="field-err" id="bc-fb-err" hidden></p>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="field">
|
|
50
|
+
<label id="bc-fb-kind-l">What kind is it? <span class="opt">(optional)</span></label>
|
|
51
|
+
<div class="kinds" role="group" aria-labelledby="bc-fb-kind-l">
|
|
52
|
+
${FEEDBACK_KINDS.map((k) => `
|
|
53
|
+
<button class="tool" type="button" data-kind="${k}" aria-pressed="false">${KINDS[k].icon}${KINDS[k].label}</button>
|
|
54
|
+
`).join('')}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="field">
|
|
58
|
+
<div class="field-head">
|
|
59
|
+
<label id="bc-fb-score-l">How's your experience so far? <span class="opt">(optional)</span></label>
|
|
60
|
+
<span class="count face-word" aria-hidden="true"></span>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="faces" role="group" aria-labelledby="bc-fb-score-l">
|
|
63
|
+
${FACES.map((f) => `
|
|
64
|
+
<button class="face" type="button" data-score="${f.score}" aria-pressed="false"
|
|
65
|
+
aria-label="${f.word}" title="${f.word}">${face(f.mouth)}</button>
|
|
66
|
+
`).join('')}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<!-- Who it is from: an address to write back to when the page knows
|
|
70
|
+
none, and a line saying which one when it does. -->
|
|
71
|
+
<div class="field fb-who"></div>
|
|
72
|
+
</div>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Wire the form. Sets ctx.focusFeedback, called when the panel switches to it.
|
|
77
|
+
*
|
|
78
|
+
* The draft is kept in localStorage while it is written, so a click outside
|
|
79
|
+
* the panel does not cost somebody three paragraphs — the reason the report
|
|
80
|
+
* form keeps one. Text only, and cleared on send.
|
|
81
|
+
*/
|
|
82
|
+
export function mountFeedback(ctx) {
|
|
83
|
+
const { client, cfg, scrim, $, say, close } = ctx;
|
|
84
|
+
const box = $('.feedback');
|
|
85
|
+
const message = $('#bc-fb-msg');
|
|
86
|
+
const err = $('#bc-fb-err');
|
|
87
|
+
const sendBtn = $('[data-act="send-feedback"]');
|
|
88
|
+
const word = $('.face-word');
|
|
89
|
+
let kind = null;
|
|
90
|
+
let score = null;
|
|
91
|
+
let sending = false;
|
|
92
|
+
let sent = false;
|
|
93
|
+
|
|
94
|
+
/* ---------- who it is from ---------- */
|
|
95
|
+
|
|
96
|
+
const who = $('.fb-who');
|
|
97
|
+
const known = client.signedInAs()?.email || cfg.user?.email || null;
|
|
98
|
+
if (known) {
|
|
99
|
+
who.innerHTML = `<p class="fb-as">Sent as <strong>${escapeHtml(known)}</strong></p>`;
|
|
100
|
+
} else {
|
|
101
|
+
who.innerHTML = `
|
|
102
|
+
<label for="bc-fb-email">Your email <span class="opt">(optional — only if you'd like a reply)</span></label>
|
|
103
|
+
<input id="bc-fb-email" type="email" autocomplete="email" placeholder="you@example.com" />`;
|
|
104
|
+
}
|
|
105
|
+
const emailInput = $('#bc-fb-email');
|
|
106
|
+
|
|
107
|
+
/* ---------- the draft ---------- */
|
|
108
|
+
|
|
109
|
+
const draftKey = `bugcatch.feedbackDraft:${cfg.projectId || ''}`;
|
|
110
|
+
const keepDrafts = cfg.drafts !== false;
|
|
111
|
+
if (keepDrafts) {
|
|
112
|
+
try { message.value = localStorage.getItem(draftKey) || ''; } catch { /* storage off */ }
|
|
113
|
+
}
|
|
114
|
+
let draftTimer;
|
|
115
|
+
function saveDraft() {
|
|
116
|
+
clearTimeout(draftTimer);
|
|
117
|
+
if (!keepDrafts) return;
|
|
118
|
+
try {
|
|
119
|
+
if (message.value.trim()) localStorage.setItem(draftKey, message.value);
|
|
120
|
+
else localStorage.removeItem(draftKey);
|
|
121
|
+
} catch { /* storage off: the box still works */ }
|
|
122
|
+
}
|
|
123
|
+
message.addEventListener('input', () => {
|
|
124
|
+
if (message.value.trim()) flag('');
|
|
125
|
+
clearTimeout(draftTimer);
|
|
126
|
+
draftTimer = setTimeout(saveDraft, 250);
|
|
127
|
+
});
|
|
128
|
+
ctx.onDestroy(() => { if (!sent) saveDraft(); });
|
|
129
|
+
|
|
130
|
+
/* ---------- the two taps ---------- */
|
|
131
|
+
|
|
132
|
+
// Both are optional, so a second press takes the choice back rather than
|
|
133
|
+
// leaving no way to say "none of these".
|
|
134
|
+
box.querySelectorAll('[data-kind]').forEach((btn) => {
|
|
135
|
+
btn.addEventListener('click', () => {
|
|
136
|
+
kind = kind === btn.dataset.kind ? null : btn.dataset.kind;
|
|
137
|
+
box.querySelectorAll('[data-kind]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.kind === kind)));
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
box.querySelectorAll('[data-score]').forEach((btn) => {
|
|
141
|
+
btn.addEventListener('click', () => {
|
|
142
|
+
const picked = Number(btn.dataset.score);
|
|
143
|
+
score = score === picked ? null : picked;
|
|
144
|
+
box.querySelectorAll('[data-score]').forEach((b) => b.setAttribute('aria-pressed', String(Number(b.dataset.score) === score)));
|
|
145
|
+
word.textContent = score ? FACES[score - 1].word : '';
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/* ---------- send ---------- */
|
|
150
|
+
|
|
151
|
+
function flag(text) {
|
|
152
|
+
err.hidden = !text;
|
|
153
|
+
err.innerHTML = text ? `${ICONS.alert}<span>${escapeHtml(text)}</span>` : '';
|
|
154
|
+
message.classList.toggle('invalid', Boolean(text));
|
|
155
|
+
message.setAttribute('aria-invalid', String(Boolean(text)));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
sendBtn.addEventListener('click', async () => {
|
|
159
|
+
if (sending) return;
|
|
160
|
+
if (!message.value.trim()) {
|
|
161
|
+
flag('Write a few words — a sentence is plenty.');
|
|
162
|
+
message.focus();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
sending = true;
|
|
166
|
+
ctx.sending = true;
|
|
167
|
+
say('');
|
|
168
|
+
const original = sendBtn.innerHTML;
|
|
169
|
+
sendBtn.disabled = true;
|
|
170
|
+
sendBtn.innerHTML = '<span class="spinner" aria-hidden="true"></span>Sending…';
|
|
171
|
+
try {
|
|
172
|
+
const res = await client.feedback({
|
|
173
|
+
message: message.value,
|
|
174
|
+
kind,
|
|
175
|
+
score,
|
|
176
|
+
...(emailInput && emailInput.value.trim() ? { email: emailInput.value.trim() } : {}),
|
|
177
|
+
});
|
|
178
|
+
if (res?.cancelled) throw Object.assign(new Error(''), { quiet: true });
|
|
179
|
+
sent = true;
|
|
180
|
+
clearTimeout(draftTimer);
|
|
181
|
+
if (keepDrafts) { try { localStorage.removeItem(draftKey); } catch { /* storage off */ } }
|
|
182
|
+
thanks();
|
|
183
|
+
} catch (e) {
|
|
184
|
+
sending = false;
|
|
185
|
+
ctx.sending = false;
|
|
186
|
+
sendBtn.disabled = false;
|
|
187
|
+
sendBtn.innerHTML = original;
|
|
188
|
+
if (!e.quiet) say(String(e.message || 'That did not send. Please try again.').replace('[BugCatch] ', ''));
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/** The panel's last screen: said, and then out of the way. */
|
|
193
|
+
function thanks() {
|
|
194
|
+
ctx.sending = false;
|
|
195
|
+
scrim.querySelector('.panel').innerHTML = `
|
|
196
|
+
<div class="done">
|
|
197
|
+
<div class="tick">${ICONS.check}</div>
|
|
198
|
+
<h3>Thanks — that's with the team.</h3>
|
|
199
|
+
<p>Everything sent here is read by the people who build this.</p>
|
|
200
|
+
</div>`;
|
|
201
|
+
setTimeout(close, 2600);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
ctx.focusFeedback = () => message.focus();
|
|
205
|
+
}
|