@sphoro/bugcatch-web-sdk 0.18.0 → 0.20.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 +51 -3
- package/dist/bugcatch-call.es.js +201 -200
- package/dist/bugcatch-call.es.js.map +1 -1
- package/dist/bugcatch-call.umd.js +1 -1
- package/dist/bugcatch-call.umd.js.map +1 -1
- package/dist/bugcatch-core.es.js +1085 -1007
- package/dist/bugcatch-core.es.js.map +1 -1
- package/dist/bugcatch-panel.es.js +1337 -1067
- package/dist/bugcatch-panel.es.js.map +1 -1
- package/dist/bugcatch-panel.umd.js +427 -315
- package/dist/bugcatch-panel.umd.js.map +1 -1
- package/dist/bugcatch.standalone.umd.js +553 -429
- package/dist/bugcatch.standalone.umd.js.map +1 -1
- package/dist/bugcatch.umd.js +132 -120
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/accent.js +38 -0
- package/src/incoming-call.js +17 -15
- package/src/index.d.ts +52 -2
- package/src/index.js +26 -7
- package/src/panel/call.js +3 -2
- package/src/panel/compose.js +7 -3
- package/src/panel/icons.js +2 -0
- package/src/panel/index.js +26 -3
- package/src/panel/messages.js +2 -2
- package/src/panel/picks.js +6 -2
- package/src/panel/rate-due.js +155 -0
- package/src/panel/rating.js +87 -0
- package/src/panel/refs.js +1 -1
- package/src/panel/render.js +11 -3
- package/src/panel/signin.js +1 -1
- package/src/panel/styles.js +270 -186
- package/src/panel/thread.js +48 -7
- package/src/updates.js +77 -27
- package/src/widget.js +160 -141
package/package.json
CHANGED
package/src/accent.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The colour the widget's buttons wear, and the colour of the words on them.
|
|
2
|
+
//
|
|
3
|
+
// With no `accent` configured the widget is ink: a black pill on a light page
|
|
4
|
+
// and a white one on a dark page, which sits on any brand without arguing with
|
|
5
|
+
// it. A configured accent is the host's own colour, and the text on it is
|
|
6
|
+
// whichever of white or ink can be read against it — a pale brand colour with
|
|
7
|
+
// white words on it is a button nobody can read.
|
|
8
|
+
|
|
9
|
+
export const INK = '#0e0e10';
|
|
10
|
+
export const PAPER = '#ffffff';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* White or ink, whichever reads on `color`. Only colours this can parse (#rgb,
|
|
14
|
+
* #rrggbb, rgb()) are measured; anything else (a name, hsl(), a variable) gets
|
|
15
|
+
* white, which is what every accent got before this existed.
|
|
16
|
+
*/
|
|
17
|
+
export function readableOn(color) {
|
|
18
|
+
const rgb = parse(color);
|
|
19
|
+
if (!rgb) return PAPER;
|
|
20
|
+
const [r, g, b] = rgb.map((c) => {
|
|
21
|
+
const s = c / 255;
|
|
22
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
23
|
+
});
|
|
24
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
25
|
+
// The crossover where ink starts to out-contrast white.
|
|
26
|
+
return luminance > 0.179 ? INK : PAPER;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parse(color) {
|
|
30
|
+
const s = String(color || '').trim();
|
|
31
|
+
let m = /^#([0-9a-f]{3})$/i.exec(s);
|
|
32
|
+
if (m) return [...m[1]].map((h) => parseInt(h + h, 16));
|
|
33
|
+
m = /^#([0-9a-f]{6})$/i.exec(s);
|
|
34
|
+
if (m) return [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16));
|
|
35
|
+
m = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i.exec(s);
|
|
36
|
+
if (m) return [m[1], m[2], m[3]].map(Number);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
package/src/incoming-call.js
CHANGED
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
// yet, which is its rule and not ours — the card rings silently rather than not
|
|
38
38
|
// appearing.
|
|
39
39
|
|
|
40
|
+
import { readableOn, INK, PAPER } from './accent.js';
|
|
41
|
+
|
|
40
42
|
/**
|
|
41
43
|
* A quiet double-pulse, once every three seconds.
|
|
42
44
|
*
|
|
@@ -157,8 +159,8 @@ const BUTTON = [
|
|
|
157
159
|
'flex:1',
|
|
158
160
|
'padding:9px 12px',
|
|
159
161
|
'border:0',
|
|
160
|
-
'border-radius:
|
|
161
|
-
'font:
|
|
162
|
+
'border-radius:999px',
|
|
163
|
+
'font:560 13px/1.4 inherit',
|
|
162
164
|
'cursor:pointer',
|
|
163
165
|
].join(';');
|
|
164
166
|
|
|
@@ -168,7 +170,8 @@ const BUTTON = [
|
|
|
168
170
|
* @param {object} opts
|
|
169
171
|
* @param {object} opts.call The `incomingCall` object off the update stream —
|
|
170
172
|
* `callId`, `from`, `project`, `reference`, `ringsUntil`, `recorded`.
|
|
171
|
-
* @param {string} opts.accent The integration's own colour, for Answer
|
|
173
|
+
* @param {string|null} opts.accent The integration's own colour, for Answer;
|
|
174
|
+
* null is the ink default, which on this dark card is a white button.
|
|
172
175
|
* @param {string} [opts.position] Same two words the toast takes.
|
|
173
176
|
* @param {() => void} opts.onAnswer Pressed Answer.
|
|
174
177
|
* @param {() => void} opts.onDecline Pressed Decline.
|
|
@@ -181,7 +184,7 @@ const BUTTON = [
|
|
|
181
184
|
* an ordinary dismissal; the caller passes one only for its own logging.
|
|
182
185
|
*/
|
|
183
186
|
export function showIncomingCall({
|
|
184
|
-
call, accent =
|
|
187
|
+
call, accent = null, position = 'bottom-right',
|
|
185
188
|
onAnswer, onDecline, onExpire, touched = () => false,
|
|
186
189
|
}) {
|
|
187
190
|
if (typeof document === 'undefined' || !document.body) return () => {};
|
|
@@ -200,13 +203,12 @@ export function showIncomingCall({
|
|
|
200
203
|
'z-index:2147483647',
|
|
201
204
|
'width:300px',
|
|
202
205
|
'max-width:calc(100vw - 40px)',
|
|
203
|
-
'padding:
|
|
204
|
-
'border-radius:
|
|
205
|
-
'background:#
|
|
206
|
-
'color:#
|
|
206
|
+
'padding:16px',
|
|
207
|
+
'border-radius:16px',
|
|
208
|
+
'background:#111113',
|
|
209
|
+
'color:#f3f2ef',
|
|
207
210
|
'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
|
208
|
-
|
|
209
|
-
`border:1px solid ${accent}`,
|
|
211
|
+
`box-shadow:0 0 0 1px ${accent ? `color-mix(in srgb, ${accent} 55%, transparent)` : 'rgba(255,255,255,.12)'},0 24px 50px -12px rgba(0,0,0,.6)`,
|
|
210
212
|
'opacity:0',
|
|
211
213
|
'transform:translateY(8px)',
|
|
212
214
|
'transition:opacity .2s ease,transform .2s ease',
|
|
@@ -219,23 +221,23 @@ export function showIncomingCall({
|
|
|
219
221
|
heading.textContent = `${call.from || 'Support'} is calling`;
|
|
220
222
|
|
|
221
223
|
const about = document.createElement('div');
|
|
222
|
-
about.style.cssText = 'color:#
|
|
224
|
+
about.style.cssText = 'color:#b3b0aa;margin-bottom:10px';
|
|
223
225
|
about.textContent = call.project
|
|
224
226
|
? `About your report ${call.reference} · ${call.project}`
|
|
225
227
|
: `About your report ${call.reference}`;
|
|
226
228
|
|
|
227
229
|
const countdown = document.createElement('div');
|
|
228
|
-
countdown.style.cssText = 'color:#
|
|
230
|
+
countdown.style.cssText = 'color:#8f8c86;font-size:12px;margin-bottom:10px';
|
|
229
231
|
|
|
230
232
|
const answer = document.createElement('button');
|
|
231
233
|
answer.type = 'button';
|
|
232
234
|
answer.textContent = 'Answer';
|
|
233
|
-
answer.style.cssText = `${BUTTON};background:${accent};color
|
|
235
|
+
answer.style.cssText = `${BUTTON};background:${accent || PAPER};color:${accent ? readableOn(accent) : INK}`;
|
|
234
236
|
|
|
235
237
|
const decline = document.createElement('button');
|
|
236
238
|
decline.type = 'button';
|
|
237
239
|
decline.textContent = 'Not now';
|
|
238
|
-
decline.style.cssText = `${BUTTON};background
|
|
240
|
+
decline.style.cssText = `${BUTTON};background:rgba(255,255,255,.08);color:#f3f2ef`;
|
|
239
241
|
|
|
240
242
|
const actions = document.createElement('div');
|
|
241
243
|
actions.style.cssText = 'display:flex;gap:8px';
|
|
@@ -244,7 +246,7 @@ export function showIncomingCall({
|
|
|
244
246
|
el.append(heading, about, countdown);
|
|
245
247
|
if (call.recorded) {
|
|
246
248
|
const kept = document.createElement('div');
|
|
247
|
-
kept.style.cssText = 'color:#
|
|
249
|
+
kept.style.cssText = 'color:#8f8c86;font-size:12px;margin-bottom:10px';
|
|
248
250
|
kept.textContent = 'This call will be recorded.';
|
|
249
251
|
el.append(kept);
|
|
250
252
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -196,8 +196,12 @@ export interface BugCatchConfig {
|
|
|
196
196
|
widgetPosition?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
197
197
|
/** 'auto' follows the visitor's system setting. Default 'auto'. */
|
|
198
198
|
theme?: 'auto' | 'light' | 'dark';
|
|
199
|
-
/**
|
|
200
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Any CSS colour, for the launcher, the buttons and the assistant's orb. The
|
|
201
|
+
* words on it are white or near-black, whichever reads. Default null: ink,
|
|
202
|
+
* which is a black button on light and a white one on dark.
|
|
203
|
+
*/
|
|
204
|
+
accent?: string | null;
|
|
201
205
|
/** Offer screenshot / recording / file attach in the widget. Default true. */
|
|
202
206
|
attachments?: boolean;
|
|
203
207
|
/**
|
|
@@ -462,6 +466,34 @@ export interface MyReport {
|
|
|
462
466
|
project: string;
|
|
463
467
|
workspace: string;
|
|
464
468
|
attachments: number;
|
|
469
|
+
/** Their score for how it was handled, 1–5, or null when they have not rated it. */
|
|
470
|
+
rating: number | null;
|
|
471
|
+
/** Settled, on a project that asks for ratings — so a list can say "rate it". */
|
|
472
|
+
ratingAsked: boolean;
|
|
473
|
+
/** When it was resolved or closed; null while it is open. */
|
|
474
|
+
resolvedAt: string | null;
|
|
475
|
+
/**
|
|
476
|
+
* Whether the widget's first screen asks for this report's rating: 'ask' with
|
|
477
|
+
* a Not now, 'require' before another report can be sent from the widget, or
|
|
478
|
+
* null. Set by the project (Settings → Resolutions → Where the widget asks),
|
|
479
|
+
* for reports settled in the last 30 days and not yet rated for this fix.
|
|
480
|
+
*/
|
|
481
|
+
ratingDue: 'ask' | 'require' | null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* A reporter's own rating of how a settled report was handled.
|
|
486
|
+
*
|
|
487
|
+
* `current` is false when the report has been reopened and settled again since
|
|
488
|
+
* it was given — the rating stands, but it was for an earlier fix.
|
|
489
|
+
*/
|
|
490
|
+
export interface ReportRating {
|
|
491
|
+
/** 1–5, five best. */
|
|
492
|
+
score: number;
|
|
493
|
+
comment: string | null;
|
|
494
|
+
ratedAt: string;
|
|
495
|
+
via: 'widget' | 'portal' | 'email';
|
|
496
|
+
current: boolean;
|
|
465
497
|
}
|
|
466
498
|
|
|
467
499
|
/** A report of this browser's changed status. */
|
|
@@ -552,6 +584,18 @@ export interface ReportDetail {
|
|
|
552
584
|
attachments: { type: string; mime: string; createdAt: string; url: string | null }[];
|
|
553
585
|
/** Every step from filing to now, oldest first, both sides' messages included. */
|
|
554
586
|
timeline: ReportTimelineEntry[];
|
|
587
|
+
/** When it was last settled, or null while it is open. */
|
|
588
|
+
resolvedAt: string | null;
|
|
589
|
+
/** Their answer to "does the fix work?", or null when they have not given one. */
|
|
590
|
+
verification: 'CONFIRMED' | 'STILL_HAPPENING' | null;
|
|
591
|
+
/** Their rating of how it was handled, or null. See rate(). */
|
|
592
|
+
rating: ReportRating | null;
|
|
593
|
+
/**
|
|
594
|
+
* Whether a rating is being asked for: the report is settled and the project
|
|
595
|
+
* has not switched ratings off. The widget draws its stars only when this is
|
|
596
|
+
* true; an integration's own view should do the same.
|
|
597
|
+
*/
|
|
598
|
+
ratingAsked: boolean;
|
|
555
599
|
/**
|
|
556
600
|
* The same figure they were given when they filed it, so coming back to check
|
|
557
601
|
* is not a worse experience than not checking. Null on a settled report — the
|
|
@@ -787,6 +831,12 @@ export declare class BugCatchClient {
|
|
|
787
831
|
endCall(): this;
|
|
788
832
|
/** Tell the team whether their fix worked. STILL_HAPPENING re-sends this browser's context. */
|
|
789
833
|
verifyFix(bugId: string, feedback: { result: 'CONFIRMED' | 'STILL_HAPPENING'; resolvedAt: string; note?: string }): Promise<{ verification: string; status: string }>;
|
|
834
|
+
/**
|
|
835
|
+
* Rate how a settled report was handled, 1–5, five best. Rating again
|
|
836
|
+
* replaces the earlier score. Rejects while the report is open, or on a
|
|
837
|
+
* project that does not ask for ratings (`ratingAsked` is false).
|
|
838
|
+
*/
|
|
839
|
+
rate(bugId: string, rating: { score: 1 | 2 | 3 | 4 | 5; comment?: string }): Promise<ReportRating>;
|
|
790
840
|
/** One report with its full timeline, or null if this browser cannot prove it filed it. */
|
|
791
841
|
myReport(bugId: string): Promise<ReportDetail | null>;
|
|
792
842
|
/**
|
package/src/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { mountWidget } from './widget.js';
|
|
|
6
6
|
import { uploadAttachment, normalizeMime, REPORT_MIMES } from './capture.js';
|
|
7
7
|
import {
|
|
8
8
|
watchUpdates, createUpdateNotifier, fetchMyReports, fetchReport, writeToken, readToken,
|
|
9
|
-
clearToken, scopeReports, sendMessage, verifyFix, callKindsForProject,
|
|
9
|
+
clearToken, scopeReports, sendMessage, verifyFix, rateReport, callKindsForProject,
|
|
10
10
|
reportByCall, liveCallAvailable, requestLiveCall, giveUpLiveCall,
|
|
11
11
|
answerIncomingCall, declineIncomingCall,
|
|
12
12
|
readUnread, rememberUpdate, forgetUpdate, forgetReportUpdates, forgetAllUpdates,
|
|
@@ -49,7 +49,9 @@ const DEFAULTS = {
|
|
|
49
49
|
widgetPosition: 'bottom-right',
|
|
50
50
|
// 'auto' follows the visitor's system setting; 'light' / 'dark' pin it.
|
|
51
51
|
theme: 'auto',
|
|
52
|
-
|
|
52
|
+
// null is ink: black buttons on light, white on dark (accent.js). Any CSS
|
|
53
|
+
// colour makes the buttons, the launcher and the assistant's orb that colour.
|
|
54
|
+
accent: null,
|
|
53
55
|
// Draw what the project's team is telling visitors — written on the Widget
|
|
54
56
|
// announcements settings page — in the panel, and beside the button for the
|
|
55
57
|
// ones written for that. false draws nothing; announcements() still answers,
|
|
@@ -456,6 +458,28 @@ class BugCatchClient {
|
|
|
456
458
|
this.notifier.render(unread);
|
|
457
459
|
}
|
|
458
460
|
|
|
461
|
+
/**
|
|
462
|
+
* Tell the team whether their fix worked. STILL_HAPPENING reopens the report
|
|
463
|
+
* and re-sends this browser's context with it.
|
|
464
|
+
*/
|
|
465
|
+
async verifyFix(bugId, { result, resolvedAt, note }) {
|
|
466
|
+
const context = result === 'STILL_HAPPENING' ? await this.#payload({}) : {};
|
|
467
|
+
return verifyFix({ apiBase: this.config.apiBase, bugId, result, resolvedAt, note, metadata: context.metadata, logs: context.logs });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Rate how a settled report was handled, 1 to 5, five best.
|
|
472
|
+
*
|
|
473
|
+
* await BugCatch.rate(report.id, { score: 4, comment: 'Quick, thanks' });
|
|
474
|
+
*
|
|
475
|
+
* Only once the report is RESOLVED or CLOSED, on a project that asks for
|
|
476
|
+
* ratings — `myReport(id).ratingAsked` says whether it does. Rating again
|
|
477
|
+
* replaces the earlier score rather than adding a second one.
|
|
478
|
+
*/
|
|
479
|
+
async rate(bugId, { score, comment } = {}) {
|
|
480
|
+
return rateReport({ apiBase: this.config.apiBase, bugId, score, comment });
|
|
481
|
+
}
|
|
482
|
+
|
|
459
483
|
/**
|
|
460
484
|
* One report in full, with its timeline — every step from filing to now.
|
|
461
485
|
*
|
|
@@ -464,11 +488,6 @@ class BugCatchClient {
|
|
|
464
488
|
*
|
|
465
489
|
* Resolves to null when this browser cannot prove it filed that report.
|
|
466
490
|
*/
|
|
467
|
-
async verifyFix(bugId, { result, resolvedAt, note }) {
|
|
468
|
-
const context = result === 'STILL_HAPPENING' ? await this.#payload({}) : {};
|
|
469
|
-
return verifyFix({ apiBase: this.config.apiBase, bugId, result, resolvedAt, note, metadata: context.metadata, logs: context.logs });
|
|
470
|
-
}
|
|
471
|
-
|
|
472
491
|
async myReport(bugId) {
|
|
473
492
|
const report = await fetchReport({ apiBase: this.config.apiBase, bugId });
|
|
474
493
|
this.#notePhase(bugId, report?.assistant);
|
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
|
@@ -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;
|
package/src/panel/icons.js
CHANGED
|
@@ -17,6 +17,8 @@ export const retry = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
17
17
|
// The assistant's mark: a four-point spark, drawn wherever it speaks.
|
|
18
18
|
export const sparkle = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.5c.5 4.6 2.9 7 7.5 7.5v.1c-4.6.5-7 2.9-7.5 7.5h-.1c-.5-4.6-2.9-7-7.5-7.5V10c4.6-.5 7-2.9 7.5-7.5z"/><path d="M18.6 15.2c.2 1.9 1.2 2.9 3.1 3.1v.1c-1.9.2-2.9 1.2-3.1 3.1h-.1c-.2-1.9-1.2-2.9-3.1-3.1v-.1c1.9-.2 2.9-1.2 3.1-3.1z" opacity=".7"/></svg>';
|
|
19
19
|
export const chevronRight = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 6l6 6-6 6"/></svg>';
|
|
20
|
+
// A rating star. Filled; the empty and chosen states are colour, set in CSS.
|
|
21
|
+
export const star = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.9l2.75 5.82 6.37.76-4.7 4.37 1.24 6.3L12 17.03l-5.66 3.12 1.24-6.3-4.7-4.37 6.37-.76z"/></svg>';
|
|
20
22
|
export const inbox = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 13.5l2.6-7.2A1.5 1.5 0 0 1 7.5 5.3h9a1.5 1.5 0 0 1 1.4 1L20.5 13.5V18a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 18z"/><path d="M3.5 13.5h4.8l1.2 2.2h5l1.2-2.2h4.8"/></svg>';
|
|
21
23
|
|
|
22
24
|
/* The sphoro lockup, as outlines rather than text.
|
package/src/panel/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// One dialog doing several jobs, one file each:
|
|
9
9
|
//
|
|
10
10
|
// compose.js the report form: drafts, severity, attachments, recording, Send
|
|
11
|
+
// rate-due.js "how did we do?" about a settled report, above the form
|
|
11
12
|
// refs.js "which record is this about", asked at Send
|
|
12
13
|
// notices.js what the project's team is telling visitors, at the top
|
|
13
14
|
// known.js what the project already knows is broken, and "me too"
|
|
@@ -37,6 +38,7 @@ import { mountPicks } from './picks.js';
|
|
|
37
38
|
import { mountCallOffer } from './call.js';
|
|
38
39
|
import { mountSignIn } from './signin.js';
|
|
39
40
|
import { mountThread } from './thread.js';
|
|
41
|
+
import { mountRateDue } from './rate-due.js';
|
|
40
42
|
|
|
41
43
|
/**
|
|
42
44
|
* Open the panel over the page. `preloadCall` fetches the call code ahead of a
|
|
@@ -44,10 +46,11 @@ import { mountThread } from './thread.js';
|
|
|
44
46
|
* to load code of its own.
|
|
45
47
|
*/
|
|
46
48
|
export function openPanel({
|
|
47
|
-
root, host, dock, client, onClose, preloadCall = () => {},
|
|
49
|
+
root, host, dock, client, onClose, preloadCall = () => {}, themeControls = null,
|
|
48
50
|
}) {
|
|
49
51
|
addStyles(root);
|
|
50
52
|
const cfg = client.config;
|
|
53
|
+
const themeLabel = () => themeControls?.label() || '';
|
|
51
54
|
|
|
52
55
|
const scrim = document.createElement('div');
|
|
53
56
|
// The panel opens out of whichever corner the dock is in, clearing it by the
|
|
@@ -59,11 +62,17 @@ export function openPanel({
|
|
|
59
62
|
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="bc-h">
|
|
60
63
|
${headHtml({
|
|
61
64
|
id: 'bc-h',
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
// Not "Tell us what went wrong": that is the first field's own label,
|
|
66
|
+
// a few pixels below. The header names the errand the launcher named.
|
|
67
|
+
title: 'Report a bug',
|
|
68
|
+
sub: 'We attach the page details for you.',
|
|
69
|
+
theme: themeLabel(),
|
|
64
70
|
})}
|
|
65
71
|
<div class="body">
|
|
66
72
|
<div class="slot" role="alert"></div>
|
|
73
|
+
<!-- A settled report still waiting for its rating. Drawn only when the
|
|
74
|
+
project asks for one here; see rate-due.js. -->
|
|
75
|
+
<div class="rate-due fix-rating" tabindex="-1" hidden></div>
|
|
67
76
|
<!-- What the project's team is telling visitors. Drawn only when it
|
|
68
77
|
has published something. -->
|
|
69
78
|
<div class="notices" hidden></div>
|
|
@@ -127,12 +136,14 @@ export function openPanel({
|
|
|
127
136
|
notices: $('.notices'),
|
|
128
137
|
known: $('.known'),
|
|
129
138
|
picks: $('.picks'),
|
|
139
|
+
rateDue: $('.rate-due'),
|
|
130
140
|
// A report or a call is on its way. Escape and a click outside wait for it.
|
|
131
141
|
sending: false,
|
|
132
142
|
closed: false,
|
|
133
143
|
say,
|
|
134
144
|
close,
|
|
135
145
|
preloadCall,
|
|
146
|
+
themeLabel,
|
|
136
147
|
onDestroy: (fn) => { cleanups.push(fn); },
|
|
137
148
|
// compose.js
|
|
138
149
|
refreshSendState: null,
|
|
@@ -150,6 +161,8 @@ export function openPanel({
|
|
|
150
161
|
// signin.js
|
|
151
162
|
mustSignIn: null,
|
|
152
163
|
recheckSignIn: null,
|
|
164
|
+
// rate-due.js
|
|
165
|
+
mustRate: () => false,
|
|
153
166
|
// thread.js
|
|
154
167
|
succeed: null,
|
|
155
168
|
refreshThread: null,
|
|
@@ -166,6 +179,7 @@ export function openPanel({
|
|
|
166
179
|
mountCallOffer(ctx);
|
|
167
180
|
mountSignIn(ctx);
|
|
168
181
|
mountThread(ctx);
|
|
182
|
+
mountRateDue(ctx);
|
|
169
183
|
|
|
170
184
|
/* ---------- lifecycle ---------- */
|
|
171
185
|
|
|
@@ -218,6 +232,15 @@ export function openPanel({
|
|
|
218
232
|
document.addEventListener('keydown', onKeydown, true);
|
|
219
233
|
scrim.addEventListener('click', (e) => { if (e.target === scrim && !ctx.sending) close(); });
|
|
220
234
|
$('[data-act="close"]').addEventListener('click', close);
|
|
235
|
+
// On the scrim rather than the button: every screen draws its own header, and
|
|
236
|
+
// the scrim is the one element all of them are drawn inside.
|
|
237
|
+
scrim.addEventListener('click', (e) => {
|
|
238
|
+
const btn = e.target.closest('[data-act="theme"]');
|
|
239
|
+
if (!btn || !themeControls) return;
|
|
240
|
+
const next = themeControls.cycle();
|
|
241
|
+
btn.title = `Theme: ${next.label}`;
|
|
242
|
+
btn.setAttribute('aria-label', `Theme: ${next.label}. Change theme`);
|
|
243
|
+
});
|
|
221
244
|
|
|
222
245
|
/** The banner at the top of the form: an error, or with `info`, a notice. */
|
|
223
246
|
function say(text, kind = 'err') {
|
package/src/panel/messages.js
CHANGED
|
@@ -100,7 +100,7 @@ export function messageHtml(message, state = '') {
|
|
|
100
100
|
let avatar = '';
|
|
101
101
|
if (automated) {
|
|
102
102
|
who = '<span class="msg-who auto">Assistant <span class="tag">AI</span></span>';
|
|
103
|
-
avatar = `<span class="av bot" aria-hidden="true"
|
|
103
|
+
avatar = `<span class="av bot orb" aria-hidden="true"></span>`;
|
|
104
104
|
} else if (!mine) {
|
|
105
105
|
const name = message.author || 'Support team';
|
|
106
106
|
who = `<span class="msg-who">${escapeHtml(name)}</span>`;
|
|
@@ -140,7 +140,7 @@ export function messageHtml(message, state = '') {
|
|
|
140
140
|
export function typingHtml() {
|
|
141
141
|
return `
|
|
142
142
|
<div class="typing-row">
|
|
143
|
-
<span class="av bot" aria-hidden="true"
|
|
143
|
+
<span class="av bot orb" aria-hidden="true"></span>
|
|
144
144
|
<span class="msg-who auto">Assistant <span class="tag">AI</span></span>
|
|
145
145
|
<div class="bubble typing" role="img" aria-label="The assistant is working on it"><i></i><i></i><i></i></div>
|
|
146
146
|
</div>`;
|
package/src/panel/picks.js
CHANGED
|
@@ -18,14 +18,18 @@ export function mountPicks(ctx) {
|
|
|
18
18
|
function drawPicks() {
|
|
19
19
|
if (!agentIssues.length) { picks.hidden = true; picks.innerHTML = ''; return; }
|
|
20
20
|
picks.hidden = false;
|
|
21
|
+
// Chips, not cards: they are a shortcut in front of the form, and three
|
|
22
|
+
// two-line cards pushed the form itself off the first screen. The hint is
|
|
23
|
+
// still there — as the chip's tooltip and its accessible description.
|
|
21
24
|
picks.innerHTML = `
|
|
22
25
|
<p class="picks-h">Get an answer right away</p>
|
|
23
26
|
<div class="picks-grid" role="group" aria-label="Common questions">
|
|
24
|
-
${agentIssues.map((issue) => `
|
|
27
|
+
${agentIssues.map((issue, i) => `
|
|
25
28
|
<button type="button" class="pick" data-pick="${escapeHtml(issue.key)}"
|
|
29
|
+
${issue.hint ? `title="${escapeHtml(issue.hint)}" aria-describedby="bc-pick-h${i}"` : ''}
|
|
26
30
|
aria-pressed="${pickedIssue?.key === issue.key}">
|
|
27
31
|
<span class="t">${escapeHtml(issue.title)}</span>
|
|
28
|
-
${issue.hint ? `<span class="h">${escapeHtml(issue.hint)}</span>` : ''}
|
|
32
|
+
${issue.hint ? `<span class="h sr-only" id="bc-pick-h${i}">${escapeHtml(issue.hint)}</span>` : ''}
|
|
29
33
|
</button>`).join('')}
|
|
30
34
|
</div>
|
|
31
35
|
${pickedIssue ? `<p class="picks-note">${ICONS.check}<span>Our assistant will check your account and reply here. Add anything else below, then send.</span></p>` : ''}`;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The rating a settled report is still waiting for, asked on the first screen,
|
|
2
|
+
// above the form, rather than only inside Your reports, which few reporters
|
|
3
|
+
// ever open again.
|
|
4
|
+
//
|
|
5
|
+
// How hard it asks is the project's choice (Settings → Resolutions → Where the
|
|
6
|
+
// widget asks), sent with each report as `ratingDue`:
|
|
7
|
+
//
|
|
8
|
+
// 'ask' a card with a Not now.
|
|
9
|
+
// 'require' the same card, and the form waits: Send and Talk to the team
|
|
10
|
+
// come back once the report is rated.
|
|
11
|
+
//
|
|
12
|
+
// One report per open, the one settled most recently. A reporter with three
|
|
13
|
+
// settled reports is not handed three questions at the door; the next one is
|
|
14
|
+
// waiting the next time they come.
|
|
15
|
+
|
|
16
|
+
import * as ICONS from './icons.js';
|
|
17
|
+
import { escapeHtml, relativeDay } from './render.js';
|
|
18
|
+
import { drawRating } from './rating.js';
|
|
19
|
+
|
|
20
|
+
// The reports somebody pressed Not now on, on this device. Kept so the card
|
|
21
|
+
// does not come back on every open; a project that wants an answer regardless
|
|
22
|
+
// asks with 'require', which this list does not excuse.
|
|
23
|
+
const LATER_KEY = 'bugcatch.rateLater';
|
|
24
|
+
const LATER_MAX = 50;
|
|
25
|
+
|
|
26
|
+
function putOffList() {
|
|
27
|
+
try {
|
|
28
|
+
const list = JSON.parse(window.localStorage.getItem(LATER_KEY) || '[]');
|
|
29
|
+
return Array.isArray(list) ? list : [];
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function putOff(bugId) {
|
|
36
|
+
try {
|
|
37
|
+
const list = putOffList().filter((id) => id !== bugId);
|
|
38
|
+
list.push(bugId);
|
|
39
|
+
window.localStorage.setItem(LATER_KEY, JSON.stringify(list.slice(-LATER_MAX)));
|
|
40
|
+
} catch { /* private mode: the card comes back next time, which is all that is lost */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The report to ask about now, or null. Exported for the tests. */
|
|
44
|
+
export function pickDue(reports, putOffIds = putOffList()) {
|
|
45
|
+
const later = new Set(putOffIds);
|
|
46
|
+
const due = (reports || []).filter((r) => (r.ratingDue === 'require'
|
|
47
|
+
|| (r.ratingDue === 'ask' && !later.has(r.id))));
|
|
48
|
+
if (!due.length) return null;
|
|
49
|
+
const settledAt = (r) => new Date(r.resolvedAt || r.updatedAt || 0).getTime() || 0;
|
|
50
|
+
return due.reduce((latest, r) => (settledAt(r) > settledAt(latest) ? r : latest));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function mountRateDue(ctx) {
|
|
54
|
+
const {
|
|
55
|
+
client, rateDue: card, compose, picks, known, callBtn, titleInput, scrim,
|
|
56
|
+
} = ctx;
|
|
57
|
+
|
|
58
|
+
let held = false;
|
|
59
|
+
ctx.mustRate = () => held;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hold the form, or let it go. Held, it stays on screen, dimmed: what they
|
|
63
|
+
* came to do is still in sight, one rating away.
|
|
64
|
+
*/
|
|
65
|
+
function hold(on) {
|
|
66
|
+
held = on;
|
|
67
|
+
for (const el of [compose, picks, known]) {
|
|
68
|
+
el.classList.toggle('held', on);
|
|
69
|
+
el.inert = on;
|
|
70
|
+
}
|
|
71
|
+
if (on) callBtn.hidden = true;
|
|
72
|
+
else if (ctx.callOffered() && !ctx.mustSignIn()) callBtn.hidden = false;
|
|
73
|
+
ctx.refreshSendState();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function release() {
|
|
77
|
+
if (held) hold(false);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function dismiss() {
|
|
81
|
+
card.hidden = true;
|
|
82
|
+
card.replaceChildren();
|
|
83
|
+
release();
|
|
84
|
+
titleInput.focus();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function draw(report) {
|
|
88
|
+
const required = report.ratingDue === 'require';
|
|
89
|
+
const when = report.resolvedAt ? relativeDay(report.resolvedAt) : '';
|
|
90
|
+
const done = report.status === 'CLOSED' ? 'closed' : 'fixed';
|
|
91
|
+
card.hidden = false;
|
|
92
|
+
card.classList.toggle('is-required', required);
|
|
93
|
+
card.innerHTML = `
|
|
94
|
+
<div class="rd-head">
|
|
95
|
+
<span class="rd-eyebrow">${required ? 'Before you report something new' : `Your report was ${done}`}</span>
|
|
96
|
+
${required ? '' : '<button type="button" class="link rd-later" data-act="rate-later">Not now</button>'}
|
|
97
|
+
</div>
|
|
98
|
+
<p class="rd-report">
|
|
99
|
+
<span class="rd-ico" aria-hidden="true">${ICONS.check}</span>
|
|
100
|
+
<span class="rd-ttl">${escapeHtml(report.title)}</span>
|
|
101
|
+
<span class="rd-sub">${escapeHtml(report.reference)}${when ? ` · ${done} ${escapeHtml(when)}` : ''}</span>
|
|
102
|
+
</p>
|
|
103
|
+
<div class="rd-stars"></div>`;
|
|
104
|
+
|
|
105
|
+
card.querySelector('[data-act="rate-later"]')?.addEventListener('click', () => {
|
|
106
|
+
putOff(report.id);
|
|
107
|
+
dismiss();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
drawRating(card.querySelector('.rd-stars'), {
|
|
111
|
+
client,
|
|
112
|
+
report,
|
|
113
|
+
rating: null,
|
|
114
|
+
question: required ? 'How did we handle it? Rate it to send a new one.' : 'How did we do?',
|
|
115
|
+
onRated: () => {
|
|
116
|
+
card.querySelector('.rd-eyebrow').textContent = `Your report was ${done}`;
|
|
117
|
+
card.querySelector('[data-act="skip"]')?.remove();
|
|
118
|
+
card.classList.remove('is-required');
|
|
119
|
+
release();
|
|
120
|
+
},
|
|
121
|
+
// The rating could not be sent. A held form must not turn into a locked
|
|
122
|
+
// door because of our outage: offer the way past.
|
|
123
|
+
onError: () => {
|
|
124
|
+
if (!held || card.querySelector('[data-act="skip"]')) return;
|
|
125
|
+
const skip = document.createElement('button');
|
|
126
|
+
skip.type = 'button';
|
|
127
|
+
skip.className = 'link rd-later';
|
|
128
|
+
skip.dataset.act = 'skip';
|
|
129
|
+
skip.textContent = 'Skip for now';
|
|
130
|
+
skip.addEventListener('click', dismiss);
|
|
131
|
+
card.querySelector('.rd-head').append(skip);
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (required) {
|
|
136
|
+
hold(true);
|
|
137
|
+
// Focus moves to the card, out of the form that just went inert. The
|
|
138
|
+
// card, not its first star: a focused star lights up, and one lit star
|
|
139
|
+
// reads as a rating of 1 somebody never gave.
|
|
140
|
+
const active = scrim.getRootNode().activeElement;
|
|
141
|
+
if (!active || !card.contains(active)) card.focus();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Only a browser that has filed something has anything to rate. A failed
|
|
146
|
+
// read asks nothing and holds nothing: the form is what they came for.
|
|
147
|
+
if (!client.hasReports()) return;
|
|
148
|
+
client.myReports()
|
|
149
|
+
.then(({ reports }) => {
|
|
150
|
+
if (ctx.closed) return;
|
|
151
|
+
const report = pickDue(reports);
|
|
152
|
+
if (report) draw(report);
|
|
153
|
+
})
|
|
154
|
+
.catch(() => {});
|
|
155
|
+
}
|