@sphoro/bugcatch-web-sdk 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sphoro/bugcatch-web-sdk",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Drop-in browser SDK — report bugs with console, network and environment context, without the extension.",
5
5
  "type": "module",
6
6
  "main": "dist/bugcatch.es.js",
@@ -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
- /** Return false to cancel the report, or a payload object to replace it. */
228
- beforeSend?: (payload: Record<string, any>) => Record<string, any> | false | void;
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
@@ -692,9 +726,11 @@ export declare class BugCatchClient {
692
726
  ): () => void;
693
727
  /** Someone signed out. `email` is who it was, where this browser knew. */
694
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;
695
731
  off(event: 'update', handler: (update: ReportUpdate) => void): this;
696
732
  off(
697
- event: 'call' | 'unread' | 'incoming-call' | 'signin' | 'signout' | 'report' | 'assistant' | 'error',
733
+ event: 'call' | 'unread' | 'incoming-call' | 'signin' | 'signout' | 'report' | 'assistant' | 'error' | 'feedback',
698
734
  handler: (payload: any) => void,
699
735
  ): this;
700
736
  /**
@@ -878,8 +914,11 @@ export declare class BugCatchClient {
878
914
  setContext(context: BugCatchContext | null): this;
879
915
  /** A copy of what setContext() holds. */
880
916
  getContext(): BugCatchContext;
881
- /** Open the built-in report panel (needs `widget: true`). */
882
- open(): this;
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;
883
922
  /** Close the report panel if it is open. */
884
923
  close(): this;
885
924
  isOpen(): boolean;
@@ -913,6 +952,17 @@ export declare class BugCatchClient {
913
952
  announcements(): Promise<Announcement[]>;
914
953
  /** Stop showing one in this browser until the team uses "Show it again". */
915
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 }>;
916
966
  /**
917
967
  * The known issues that look like what somebody is describing.
918
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
- result = this.config.beforeSend(payload);
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
- * Needs the built-in widget (`widget: true`, the default). With `widget:
1185
- * false` there is no panel to open: draw your own form and call report().
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
- this.widgetControls.open();
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
  *
@@ -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"
@@ -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
+ }
@@ -34,3 +34,9 @@ export const inbox = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
34
34
  * that happens to define --accent would have repainted our mark in their brand
35
35
  * colour. The --bc- prefix is ours and no host is going to set it by accident. */
36
36
  export const BRAND = '<svg class="bc-brand" viewBox="0 0 254 60" fill="none" role="img" aria-label="sphoro BugCatch"><g fill="currentColor"><path transform="translate(0.00 40.00) scale(0.04000 -0.04000)" d="M276.00 -14.00Q179.00 -14.00 117.00 28.00Q55.00 70.00 42.00 148.00L158.00 178.00Q165.00 143.00 181.50 123.00Q198.00 103.00 222.50 94.50Q247.00 86.00 276.00 86.00Q320.00 86.00 341.00 101.50Q362.00 117.00 362.00 140.00Q362.00 163.00 342.00 175.50Q322.00 188.00 278.00 196.00L250.00 201.00Q198.00 211.00 155.00 228.50Q112.00 246.00 86.00 277.00Q60.00 308.00 60.00 357.00Q60.00 431.00 114.00 470.50Q168.00 510.00 256.00 510.00Q339.00 510.00 394.00 473.00Q449.00 436.00 466.00 376.00L349.00 340.00Q341.00 378.00 316.50 394.00Q292.00 410.00 256.00 410.00Q220.00 410.00 201.00 397.50Q182.00 385.00 182.00 363.00Q182.00 339.00 202.00 327.50Q222.00 316.00 256.00 310.00L284.00 305.00Q340.00 295.00 385.50 278.50Q431.00 262.00 457.50 231.50Q484.00 201.00 484.00 149.00Q484.00 71.00 427.50 28.50Q371.00 -14.00 276.00 -14.00Z"/><path transform="translate(19.56 40.00) scale(0.04000 -0.04000)" d="M70.00 -200.00V496.00H194.00V436.00H212.00Q229.00 465.00 265.00 487.50Q301.00 510.00 368.00 510.00Q428.00 510.00 479.00 480.50Q530.00 451.00 561.00 394.00Q592.00 337.00 592.00 256.00V240.00Q592.00 159.00 561.00 102.00Q530.00 45.00 479.00 15.50Q428.00 -14.00 368.00 -14.00Q323.00 -14.00 292.50 -3.50Q262.00 7.00 243.50 23.50Q225.00 40.00 214.00 57.00H196.00V-200.00ZM330.00 96.00Q389.00 96.00 427.50 133.50Q466.00 171.00 466.00 243.00V253.00Q466.00 325.00 427.00 362.50Q388.00 400.00 330.00 400.00Q272.00 400.00 233.00 362.50Q194.00 325.00 194.00 253.00V243.00Q194.00 171.00 233.00 133.50Q272.00 96.00 330.00 96.00Z"/><path transform="translate(43.68 40.00) scale(0.04000 -0.04000)" d="M70.00 0.00V700.00H196.00V435.00H214.00Q222.00 451.00 239.00 467.00Q256.00 483.00 284.50 493.50Q313.00 504.00 357.00 504.00Q415.00 504.00 458.50 477.50Q502.00 451.00 526.00 404.50Q550.00 358.00 550.00 296.00V0.00H424.00V286.00Q424.00 342.00 396.50 370.00Q369.00 398.00 318.00 398.00Q260.00 398.00 228.00 359.50Q196.00 321.00 196.00 252.00V0.00Z"/><path transform="translate(66.92 40.00) scale(0.04000 -0.04000)" d="M306.00 -14.00Q232.00 -14.00 173.00 16.00Q114.00 46.00 80.00 103.00Q46.00 160.00 46.00 240.00V256.00Q46.00 336.00 80.00 393.00Q114.00 450.00 173.00 480.00Q232.00 510.00 306.00 510.00Q380.00 510.00 439.00 480.00Q498.00 450.00 532.00 393.00Q566.00 336.00 566.00 256.00V240.00Q566.00 160.00 532.00 103.00Q498.00 46.00 439.00 16.00Q380.00 -14.00 306.00 -14.00ZM306.00 98.00Q364.00 98.00 402.00 135.50Q440.00 173.00 440.00 243.00V253.00Q440.00 323.00 402.50 360.50Q365.00 398.00 306.00 398.00Q248.00 398.00 210.00 360.50Q172.00 323.00 172.00 253.00V243.00Q172.00 173.00 210.00 135.50Q248.00 98.00 306.00 98.00Z"/><path transform="translate(90.00 40.00) scale(0.04000 -0.04000)" d="M70.00 0.00V496.00H194.00V440.00H212.00Q223.00 470.00 248.50 484.00Q274.00 498.00 308.00 498.00H368.00V386.00H306.00Q258.00 386.00 227.00 360.50Q196.00 335.00 196.00 282.00V0.00Z"/><path transform="translate(104.44 40.00) scale(0.04000 -0.04000)" d="M306.00 -14.00Q232.00 -14.00 173.00 16.00Q114.00 46.00 80.00 103.00Q46.00 160.00 46.00 240.00V256.00Q46.00 336.00 80.00 393.00Q114.00 450.00 173.00 480.00Q232.00 510.00 306.00 510.00Q380.00 510.00 439.00 480.00Q498.00 450.00 532.00 393.00Q566.00 336.00 566.00 256.00V240.00Q566.00 160.00 532.00 103.00Q498.00 46.00 439.00 16.00Q380.00 -14.00 306.00 -14.00ZM306.00 98.00Q364.00 98.00 402.00 135.50Q440.00 173.00 440.00 243.00V253.00Q440.00 323.00 402.50 360.50Q365.00 398.00 306.00 398.00Q248.00 398.00 210.00 360.50Q172.00 323.00 172.00 253.00V243.00Q172.00 173.00 210.00 135.50Q248.00 98.00 306.00 98.00Z"/></g><rect x="139.52" y="16" width="1.6" height="26" rx="0.8" fill="var(--bc-brand-mark, #5647ee)" opacity="0.4"/><g fill="var(--bc-brand-mark, #5647ee)"><path transform="translate(154.52 35.00) scale(0.01400 -0.01400)" d="M46.35 0.00V105.85H139.75V594.15H46.35V700.00H404.08Q467.38 700.00 514.79 678.41Q562.20 656.83 588.70 617.33Q615.20 577.83 615.20 523.35V513.70Q615.20 465.52 597.03 434.76Q578.85 404.00 553.91 387.24Q528.98 370.48 506.28 363.30V346.35Q528.98 340.35 555.24 323.50Q581.50 306.65 600.00 275.48Q618.50 244.30 618.50 194.48V184.48Q618.50 126.65 591.41 85.24Q564.33 43.83 516.83 21.91Q469.33 0.00 406.85 0.00ZM259.85 109.15H392.60Q441.20 109.15 469.80 132.60Q498.40 156.05 498.40 198.55V208.20Q498.40 250.70 470.21 274.15Q442.02 297.60 392.60 297.60H259.85ZM259.85 406.75H391.65Q437.55 406.75 466.32 430.11Q495.10 453.48 495.10 493.80V503.80Q495.10 544.95 466.74 567.90Q438.37 590.85 391.65 590.85H259.85Z"/><path transform="translate(167.00 35.00) scale(0.01400 -0.01400)" d="M335.30 -14.00Q251.22 -14.00 190.60 16.63Q129.97 47.25 97.56 104.96Q65.15 162.68 65.15 243.03V700.00H185.25V239.73Q185.25 170.48 224.03 131.99Q262.80 93.50 335.30 93.50Q407.80 93.50 446.57 131.99Q485.35 170.48 485.35 239.73V700.00H605.45V243.03Q605.45 162.68 573.13 104.96Q540.80 47.25 480.09 16.63Q419.38 -14.00 335.30 -14.00Z"/><path transform="translate(179.60 35.00) scale(0.01400 -0.01400)" d="M311.85 -14.00Q238.45 -14.00 179.69 18.50Q120.92 51.00 86.69 113.67Q52.45 176.35 52.45 266.87V433.13Q52.45 569.00 128.01 641.50Q203.57 714.00 332.35 714.00Q460.13 714.00 529.23 645.59Q598.33 577.17 598.33 460.87V456.70H479.87V464.52Q479.87 504.67 463.76 536.86Q447.65 569.05 414.99 587.77Q382.32 606.50 332.35 606.50Q257.63 606.50 215.09 560.70Q172.55 514.90 172.55 435.13V264.87Q172.55 185.93 215.09 138.89Q257.63 91.85 334.00 91.85Q410.02 91.85 445.12 132.83Q480.22 173.80 480.22 237.92V249.67H302.85V351.88H598.33V0.00H487.52V69.10H470.57Q462.70 50.87 446.06 31.44Q429.42 12.00 397.64 -1.00Q365.85 -14.00 311.85 -14.00Z"/><path transform="translate(192.05 35.00) scale(0.01400 -0.01400)" d="M332.35 -14.00Q203.57 -14.00 128.01 58.50Q52.45 131.00 52.45 266.87V433.13Q52.45 569.00 128.01 641.50Q203.57 714.00 332.35 714.00Q460.13 714.00 529.58 643.85Q599.03 573.70 599.03 451.57V445.75H480.57V455.22Q480.57 521.42 444.15 563.96Q407.72 606.50 332.35 606.50Q257.63 606.50 215.09 560.70Q172.55 514.90 172.55 435.13V264.87Q172.55 185.93 215.09 139.71Q257.63 93.50 332.35 93.50Q407.72 93.50 444.15 136.45Q480.57 179.40 480.57 244.78V262.25H599.03V248.43Q599.03 126.30 529.58 56.15Q460.13 -14.00 332.35 -14.00Z"/><path transform="translate(204.25 35.00) scale(0.01400 -0.01400)" d="M19.75 0.00 211.62 700.00H421.33L613.20 0.00H489.27L447.42 159.60H185.53L143.68 0.00ZM214.25 270.40H418.70L324.95 624.87H308.00Z"/><path transform="translate(216.31 35.00) scale(0.01400 -0.01400)" d="M233.95 0.00V590.85H24.70V700.00H563.30V590.85H354.05V0.00Z"/><path transform="translate(227.75 35.00) scale(0.01400 -0.01400)" d="M332.35 -14.00Q203.57 -14.00 128.01 58.50Q52.45 131.00 52.45 266.87V433.13Q52.45 569.00 128.01 641.50Q203.57 714.00 332.35 714.00Q460.13 714.00 529.58 643.85Q599.03 573.70 599.03 451.57V445.75H480.57V455.22Q480.57 521.42 444.15 563.96Q407.72 606.50 332.35 606.50Q257.63 606.50 215.09 560.70Q172.55 514.90 172.55 435.13V264.87Q172.55 185.93 215.09 139.71Q257.63 93.50 332.35 93.50Q407.72 93.50 444.15 136.45Q480.57 179.40 480.57 244.78V262.25H599.03V248.43Q599.03 126.30 529.58 56.15Q460.13 -14.00 332.35 -14.00Z"/><path transform="translate(239.95 35.00) scale(0.01400 -0.01400)" d="M69.50 0.00V700.00H189.60V405.58H466.40V700.00H586.50V0.00H466.40V296.42H189.60V0.00Z"/></g></svg>';
37
+ // "Give feedback": the switch's glyph and the four kinds of feedback.
38
+ export const feedback = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 5.5h15a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-8l-4.3 3.2a.5.5 0 0 1-.8-.4v-2.8H4.5a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/><path d="M12 8.4l.9 1.9 2 .3-1.5 1.4.4 2-1.8-1-1.8 1 .4-2-1.5-1.4 2-.3z"/></svg>';
39
+ export const bulb = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17.5h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.6 10.8c.6.5 1 1.2 1 2V17h5.2v-1.2c0-.8.4-1.5 1-2A6 6 0 0 0 12 3z"/></svg>';
40
+ export const heart = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20s-7.5-4.6-7.5-10.2A4.3 4.3 0 0 1 12 7.2a4.3 4.3 0 0 1 7.5 2.6C19.5 15.4 12 20 12 20z"/></svg>';
41
+ export const question = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.4a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17h.01"/></svg>';
42
+ export const dots = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round"><path d="M6.5 12h.01M12 12h.01M17.5 12h.01"/></svg>';