@alliance-droid/status-feedback-system 2.3.2 → 3.0.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/CHANGELOG.md +78 -67
- package/LICENSE +11 -11
- package/README.md +275 -273
- package/dist/components/DevFeedback.svelte +589 -585
- package/dist/components/DevFeedback.svelte.d.ts +7 -2
- package/dist/components/FeedbackBoard.svelte +110 -110
- package/dist/components/FeedbackButton.svelte +72 -72
- package/dist/components/FeedbackForm.svelte +155 -155
- package/dist/components/StatusBanner.svelte +105 -105
- package/dist/components/StatusPage.svelte +244 -244
- package/dist/components/board/FeedbackDetail.svelte +299 -299
- package/dist/components/board/FeedbackItem.svelte +117 -117
- package/dist/components/board/FeedbackList.svelte +58 -58
- package/dist/components/board/FeedbackSidebar.svelte +60 -60
- package/dist/components/board/FeedbackSubmitForm.svelte +123 -123
- package/dist/components/dev/DevContextMenu.svelte +178 -178
- package/dist/components/dev/DevOnboardingToast.svelte +73 -73
- package/dist/components/dev/environment.d.ts +2 -6
- package/dist/components/dev/environment.js +2 -23
- package/dist/components/shared/IncidentTimeline.svelte +55 -55
- package/dist/components/shared/PasteImageInput.svelte +169 -169
- package/dist/components/shared/StatusIndicator.svelte +50 -50
- package/dist/components/shared/SystemCard.svelte +30 -30
- package/dist/components/shared/UptimeBar.svelte +85 -85
- package/package.json +66 -66
|
@@ -1,585 +1,589 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
$
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
let
|
|
104
|
-
let
|
|
105
|
-
let
|
|
106
|
-
let
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
let
|
|
153
|
-
let
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
let
|
|
157
|
-
let
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
let
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
let
|
|
165
|
-
let
|
|
166
|
-
let
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
let
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
e.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
metadata.
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
if (
|
|
341
|
-
metadata.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
metadata.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
<
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
<Badge variant="info" size="sm">
|
|
490
|
-
<i class="fa-solid fa-
|
|
491
|
-
{window.
|
|
492
|
-
</Badge>
|
|
493
|
-
|
|
494
|
-
<
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
{
|
|
513
|
-
<div class="
|
|
514
|
-
<
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
<
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
{
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
<
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { createStatusClient, type StatusClient } from '../api.js';
|
|
3
|
+
import PasteImageInput from './shared/PasteImageInput.svelte';
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
Button,
|
|
7
|
+
Input,
|
|
8
|
+
Textarea,
|
|
9
|
+
Badge,
|
|
10
|
+
Spinner,
|
|
11
|
+
} from '@alliance-droid/svelte-component-library';
|
|
12
|
+
import DevContextMenu from './dev/DevContextMenu.svelte';
|
|
13
|
+
import DevOnboardingToast from './dev/DevOnboardingToast.svelte';
|
|
14
|
+
import {
|
|
15
|
+
getEnvironmentName,
|
|
16
|
+
getEnvVar,
|
|
17
|
+
getElementSelector,
|
|
18
|
+
getElementHtml,
|
|
19
|
+
} from './dev/environment.js';
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
apiUrl: string;
|
|
23
|
+
apiKey: string;
|
|
24
|
+
/**
|
|
25
|
+
* The sole gate for the widget — wire it to your role/permission check
|
|
26
|
+
* (e.g. `enabled={userHasFeedbackRole}`). When false, nothing renders and
|
|
27
|
+
* no listeners attach. A non-empty `userEmail` is also required. There is
|
|
28
|
+
* no environment auto-detection.
|
|
29
|
+
*/
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
/** Max console errors to capture */
|
|
32
|
+
maxConsoleErrors?: number;
|
|
33
|
+
/** Show onboarding toast on first visit */
|
|
34
|
+
showOnboarding?: boolean;
|
|
35
|
+
/** URL to the feedback board page. If omitted, "View Open Issues" is hidden from the context menu. */
|
|
36
|
+
boardUrl?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Authenticated user email. Required.
|
|
39
|
+
*
|
|
40
|
+
* Pass an empty string when the user is not signed in — the widget will
|
|
41
|
+
* render nothing (no floating bubble, no menu, no form). Anonymous
|
|
42
|
+
* feedback is not supported.
|
|
43
|
+
*/
|
|
44
|
+
userEmail: string;
|
|
45
|
+
/**
|
|
46
|
+
* Endpoint on the consumer app that issues render tokens.
|
|
47
|
+
* Default: '/svc/internal/issue-render-token'
|
|
48
|
+
*/
|
|
49
|
+
renderTokenEndpoint?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let {
|
|
53
|
+
apiUrl,
|
|
54
|
+
apiKey,
|
|
55
|
+
enabled,
|
|
56
|
+
maxConsoleErrors = 20,
|
|
57
|
+
showOnboarding = true,
|
|
58
|
+
boardUrl,
|
|
59
|
+
userEmail,
|
|
60
|
+
renderTokenEndpoint = '/svc/internal/issue-render-token',
|
|
61
|
+
}: Props = $props();
|
|
62
|
+
|
|
63
|
+
// Dev-mode warning when userEmail is empty
|
|
64
|
+
$effect(() => {
|
|
65
|
+
if (!userEmail && typeof console !== 'undefined') {
|
|
66
|
+
// eslint-disable-next-line no-restricted-globals -- safe typeof guard for universal code
|
|
67
|
+
const proc = typeof globalThis !== 'undefined' ? (globalThis as Record<string, unknown>).process as { env?: Record<string, string> } | undefined : undefined;
|
|
68
|
+
const env = proc?.env?.NODE_ENV || 'development';
|
|
69
|
+
if (env !== 'production') {
|
|
70
|
+
console.warn(
|
|
71
|
+
'[status-feedback-system] <DevFeedback> was rendered with an empty userEmail. ' +
|
|
72
|
+
"The widget will not appear. Pass the authenticated user's email, or do not render <DevFeedback> when no user is signed in.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// --- Client (lazy, stable) ---
|
|
79
|
+
let client: StatusClient | null = $state(null);
|
|
80
|
+
let lastApiUrl = '';
|
|
81
|
+
let lastApiKey = '';
|
|
82
|
+
|
|
83
|
+
function getClient(): StatusClient {
|
|
84
|
+
if (!client || lastApiUrl !== apiUrl || lastApiKey !== apiKey) {
|
|
85
|
+
client = createStatusClient({ apiUrl, apiKey });
|
|
86
|
+
lastApiUrl = apiUrl;
|
|
87
|
+
lastApiKey = apiKey;
|
|
88
|
+
}
|
|
89
|
+
return client;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- Activation gate ---
|
|
93
|
+
// `enabled` is the sole gate — wire it to your role/permission check.
|
|
94
|
+
// Combined with a signed-in user. No environment heuristics.
|
|
95
|
+
const hasUserEmail = $derived(!!userEmail);
|
|
96
|
+
const isEnabled = $derived(enabled && hasUserEmail);
|
|
97
|
+
const isMac = $derived(
|
|
98
|
+
typeof navigator !== 'undefined' &&
|
|
99
|
+
/Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '')
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// --- State ---
|
|
103
|
+
let menuOpen = $state(false);
|
|
104
|
+
let menuX = $state(0);
|
|
105
|
+
let menuY = $state(0);
|
|
106
|
+
let clickedElement = $state<HTMLElement | null>(null);
|
|
107
|
+
|
|
108
|
+
let formOpen = $state(false);
|
|
109
|
+
let formPopoverEl: HTMLDivElement | undefined = $state();
|
|
110
|
+
let formOriginalParent: HTMLElement | null = null;
|
|
111
|
+
let formPortalTarget: HTMLElement | null = null;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Find the topmost open modal dialog.
|
|
115
|
+
* showModal() makes everything outside the dialog inert — including
|
|
116
|
+
* top-layer popovers that aren't descendants of the dialog.
|
|
117
|
+
*/
|
|
118
|
+
function findOpenModalDialog(): HTMLDialogElement | null {
|
|
119
|
+
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog[open]');
|
|
120
|
+
for (const d of dialogs) {
|
|
121
|
+
if (d.open) return d;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Promote form modal to the browser's top layer when opened
|
|
127
|
+
// Portal into any open modal dialog to escape inertness
|
|
128
|
+
$effect(() => {
|
|
129
|
+
if (formOpen && formPopoverEl) {
|
|
130
|
+
// Portal into modal dialog if one is open
|
|
131
|
+
const modalDialog = findOpenModalDialog();
|
|
132
|
+
if (modalDialog && formPopoverEl.parentElement !== modalDialog) {
|
|
133
|
+
formOriginalParent = formPopoverEl.parentElement;
|
|
134
|
+
formPortalTarget = modalDialog;
|
|
135
|
+
modalDialog.appendChild(formPopoverEl);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (typeof formPopoverEl.showPopover === 'function') {
|
|
139
|
+
try { formPopoverEl.showPopover(); } catch { /* fallback to z-index */ }
|
|
140
|
+
}
|
|
141
|
+
} else if (!formOpen && formPopoverEl) {
|
|
142
|
+
if (typeof formPopoverEl.hidePopover === 'function') {
|
|
143
|
+
try { formPopoverEl.hidePopover(); } catch { /* already hidden */ }
|
|
144
|
+
}
|
|
145
|
+
// Move back to original parent
|
|
146
|
+
if (formPortalTarget && formOriginalParent) {
|
|
147
|
+
try { formOriginalParent.appendChild(formPopoverEl); } catch { /* already moved */ }
|
|
148
|
+
formPortalTarget = null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
let formType = $state<'bug' | 'feature'>('bug');
|
|
153
|
+
let formTitle = $state('');
|
|
154
|
+
let formMessage = $state('');
|
|
155
|
+
let pastedScreenshot = $state<string | null>(null);
|
|
156
|
+
let pastedFilename = $state('');
|
|
157
|
+
let messageTextareaWrapperEl: HTMLDivElement | undefined = $state();
|
|
158
|
+
let messageTextareaEl: HTMLTextAreaElement | undefined = $state();
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
let submitting = $state(false);
|
|
162
|
+
let submitResult = $state<'success' | 'error' | null>(null);
|
|
163
|
+
|
|
164
|
+
let renderContext = $state<{ url: string; scrollX: number; scrollY: number; renderToken: string; issuer: string } | null>(null);
|
|
165
|
+
let fetchingRenderToken = $state(false);
|
|
166
|
+
let renderTokenFailed = $state(false);
|
|
167
|
+
|
|
168
|
+
// Drag state
|
|
169
|
+
let isDragging = $state(false);
|
|
170
|
+
let dragOffsetX = 0;
|
|
171
|
+
let dragOffsetY = 0;
|
|
172
|
+
let modalX = $state(0);
|
|
173
|
+
let modalY = $state(0);
|
|
174
|
+
let modalPositioned = $state(false);
|
|
175
|
+
|
|
176
|
+
function handleDragStart(e: MouseEvent) {
|
|
177
|
+
isDragging = true;
|
|
178
|
+
const modalEl = (e.currentTarget as HTMLElement).closest('.sf-modal') as HTMLElement;
|
|
179
|
+
if (!modalEl) return;
|
|
180
|
+
const rect = modalEl.getBoundingClientRect();
|
|
181
|
+
dragOffsetX = e.clientX - rect.left;
|
|
182
|
+
dragOffsetY = e.clientY - rect.top;
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function handleDragMove(e: MouseEvent) {
|
|
187
|
+
if (!isDragging) return;
|
|
188
|
+
modalX = e.clientX - dragOffsetX;
|
|
189
|
+
modalY = e.clientY - dragOffsetY;
|
|
190
|
+
modalPositioned = true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function handleDragEnd() {
|
|
194
|
+
isDragging = false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Console error capture
|
|
198
|
+
let consoleErrors: string[] = [];
|
|
199
|
+
|
|
200
|
+
// Onboarding
|
|
201
|
+
let showToast = $state(false);
|
|
202
|
+
const ONBOARDING_KEY = 'sf-devfeedback-onboarded';
|
|
203
|
+
|
|
204
|
+
// Activate only when enabled — reactive, so a role check that resolves after
|
|
205
|
+
// mount arms the widget. The returned cleanup runs when disabled or destroyed.
|
|
206
|
+
$effect(() => {
|
|
207
|
+
if (!isEnabled) return;
|
|
208
|
+
|
|
209
|
+
// Capture recent console errors to attach to bug reports.
|
|
210
|
+
const originalConsoleError = console.error;
|
|
211
|
+
console.error = (...args: unknown[]) => {
|
|
212
|
+
consoleErrors.push(args.map(String).join(' '));
|
|
213
|
+
if (consoleErrors.length > maxConsoleErrors) consoleErrors.shift();
|
|
214
|
+
originalConsoleError.apply(console, args);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Ctrl/Cmd + Right-click handler + menu/drag listeners.
|
|
218
|
+
document.addEventListener('contextmenu', handleContextMenu);
|
|
219
|
+
document.addEventListener('click', closeMenu);
|
|
220
|
+
document.addEventListener('keydown', handleKeydown);
|
|
221
|
+
document.addEventListener('mousemove', handleDragMove);
|
|
222
|
+
document.addEventListener('mouseup', handleDragEnd);
|
|
223
|
+
|
|
224
|
+
// Onboarding toast on first visit.
|
|
225
|
+
if (showOnboarding && !localStorage.getItem(ONBOARDING_KEY)) {
|
|
226
|
+
showToast = true;
|
|
227
|
+
localStorage.setItem(ONBOARDING_KEY, 'true');
|
|
228
|
+
setTimeout(() => (showToast = false), 6000);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return () => {
|
|
232
|
+
console.error = originalConsoleError;
|
|
233
|
+
document.removeEventListener('contextmenu', handleContextMenu);
|
|
234
|
+
document.removeEventListener('click', closeMenu);
|
|
235
|
+
document.removeEventListener('keydown', handleKeydown);
|
|
236
|
+
document.removeEventListener('mousemove', handleDragMove);
|
|
237
|
+
document.removeEventListener('mouseup', handleDragEnd);
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
function handleContextMenu(e: MouseEvent) {
|
|
242
|
+
if (!e.ctrlKey) return;
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
e.stopPropagation();
|
|
245
|
+
|
|
246
|
+
clickedElement = e.target as HTMLElement;
|
|
247
|
+
menuX = e.clientX;
|
|
248
|
+
menuY = e.clientY;
|
|
249
|
+
menuOpen = true;
|
|
250
|
+
formOpen = false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function closeMenu() {
|
|
254
|
+
if (menuOpen) menuOpen = false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
258
|
+
if (e.key === 'Escape') {
|
|
259
|
+
menuOpen = false;
|
|
260
|
+
formOpen = false;
|
|
261
|
+
renderContext = null;
|
|
262
|
+
pastedScreenshot = null;
|
|
263
|
+
pastedFilename = '';
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function fetchRenderToken() {
|
|
268
|
+
fetchingRenderToken = true;
|
|
269
|
+
renderTokenFailed = false;
|
|
270
|
+
try {
|
|
271
|
+
const url = window.location.href;
|
|
272
|
+
const scrollX = Math.round(window.scrollX);
|
|
273
|
+
const scrollY = Math.round(window.scrollY);
|
|
274
|
+
const res = await fetch(renderTokenEndpoint, {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
credentials: 'include',
|
|
277
|
+
headers: { 'Content-Type': 'application/json' },
|
|
278
|
+
body: JSON.stringify({ url, scrollX, scrollY }),
|
|
279
|
+
});
|
|
280
|
+
if (!res.ok) {
|
|
281
|
+
renderTokenFailed = true;
|
|
282
|
+
console.warn('[DevFeedback] Render token fetch failed:', res.status);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const { token } = (await res.json()) as { token: string };
|
|
286
|
+
renderContext = { url, scrollX, scrollY, renderToken: token, issuer: new URL(url).origin };
|
|
287
|
+
} catch (err) {
|
|
288
|
+
renderTokenFailed = true;
|
|
289
|
+
console.warn('[DevFeedback] Render token fetch error:', err);
|
|
290
|
+
} finally {
|
|
291
|
+
fetchingRenderToken = false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
$effect(() => {
|
|
296
|
+
if (messageTextareaWrapperEl) {
|
|
297
|
+
messageTextareaEl = messageTextareaWrapperEl.querySelector('textarea') ?? undefined;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
function openForm(type: 'bug' | 'feature') {
|
|
302
|
+
formType = type;
|
|
303
|
+
formTitle = '';
|
|
304
|
+
formMessage = '';
|
|
305
|
+
pastedScreenshot = null;
|
|
306
|
+
pastedFilename = '';
|
|
307
|
+
submitResult = null;
|
|
308
|
+
menuOpen = false;
|
|
309
|
+
formOpen = true;
|
|
310
|
+
modalPositioned = false;
|
|
311
|
+
modalX = 0;
|
|
312
|
+
modalY = 0;
|
|
313
|
+
|
|
314
|
+
if (type === 'bug') {
|
|
315
|
+
fetchRenderToken();
|
|
316
|
+
} else {
|
|
317
|
+
renderContext = null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function handleSubmit() {
|
|
322
|
+
if (!formMessage.trim() || !hasUserEmail) return;
|
|
323
|
+
submitting = true;
|
|
324
|
+
submitResult = null;
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const metadata: Record<string, unknown> = {
|
|
328
|
+
type: formType,
|
|
329
|
+
environment: getEnvironmentName(),
|
|
330
|
+
route: window.location.pathname,
|
|
331
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
332
|
+
userAgent: navigator.userAgent,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
if (clickedElement) {
|
|
336
|
+
metadata.elementSelector = getElementSelector(clickedElement);
|
|
337
|
+
metadata.elementHtml = getElementHtml(clickedElement);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (consoleErrors.length > 0) {
|
|
341
|
+
metadata.consoleErrors = [...consoleErrors];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (pastedScreenshot) {
|
|
345
|
+
metadata.pastedScreenshot = pastedScreenshot;
|
|
346
|
+
metadata.pastedScreenshotFilename = pastedFilename;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (renderContext) {
|
|
350
|
+
metadata.renderToken = renderContext.renderToken;
|
|
351
|
+
metadata.issuer = renderContext.issuer;
|
|
352
|
+
metadata.scrollX = renderContext.scrollX;
|
|
353
|
+
metadata.scrollY = renderContext.scrollY;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const gitCommit = getEnvVar('VERCEL_GIT_COMMIT_SHA');
|
|
357
|
+
const gitBranch = getEnvVar('VERCEL_GIT_COMMIT_REF');
|
|
358
|
+
const deployUrl = getEnvVar('VERCEL_URL');
|
|
359
|
+
if (gitCommit) metadata.gitCommit = gitCommit;
|
|
360
|
+
if (gitBranch) metadata.gitBranch = gitBranch;
|
|
361
|
+
if (deployUrl) metadata.deploymentUrl = deployUrl;
|
|
362
|
+
|
|
363
|
+
await getClient().submitFeedback({
|
|
364
|
+
title: formTitle.trim() || undefined,
|
|
365
|
+
message: formMessage.trim(),
|
|
366
|
+
category: formType === 'bug' ? 'bug' : 'feature',
|
|
367
|
+
email: userEmail,
|
|
368
|
+
pageUrl: window.location.href,
|
|
369
|
+
metadata,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
submitResult = 'success';
|
|
373
|
+
setTimeout(() => {
|
|
374
|
+
formOpen = false;
|
|
375
|
+
submitResult = null;
|
|
376
|
+
renderContext = null;
|
|
377
|
+
pastedScreenshot = null;
|
|
378
|
+
pastedFilename = '';
|
|
379
|
+
}, 2000);
|
|
380
|
+
} catch {
|
|
381
|
+
submitResult = 'error';
|
|
382
|
+
} finally {
|
|
383
|
+
submitting = false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function openBoard() {
|
|
388
|
+
if (!boardUrl) return;
|
|
389
|
+
menuOpen = false;
|
|
390
|
+
window.open(boardUrl, '_blank');
|
|
391
|
+
}
|
|
392
|
+
</script>
|
|
393
|
+
|
|
394
|
+
{#if isEnabled}
|
|
395
|
+
<!-- Context menu -->
|
|
396
|
+
{#if menuOpen}
|
|
397
|
+
<DevContextMenu
|
|
398
|
+
x={menuX}
|
|
399
|
+
y={menuY}
|
|
400
|
+
{boardUrl}
|
|
401
|
+
onReportBug={() => openForm('bug')}
|
|
402
|
+
onRequestFeature={() => openForm('feature')}
|
|
403
|
+
onViewBoard={boardUrl ? openBoard : undefined}
|
|
404
|
+
/>
|
|
405
|
+
{/if}
|
|
406
|
+
|
|
407
|
+
<!-- Form modal -->
|
|
408
|
+
{#if formOpen}
|
|
409
|
+
<div
|
|
410
|
+
bind:this={formPopoverEl}
|
|
411
|
+
popover="manual"
|
|
412
|
+
class="sf-modal sf-feedback-popover w-full max-w-lg flex flex-col"
|
|
413
|
+
style={modalPositioned
|
|
414
|
+
? `left: ${modalX}px; top: ${modalY}px;`
|
|
415
|
+
: 'left: 50%; top: 50%; transform: translate(-50%, -50%);'
|
|
416
|
+
}
|
|
417
|
+
>
|
|
418
|
+
<Card variant="elevated" padding="none" class="flex flex-col max-h-[calc(100vh-2rem)] overflow-hidden">
|
|
419
|
+
<!-- Header (draggable) -->
|
|
420
|
+
<div
|
|
421
|
+
role="presentation"
|
|
422
|
+
class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0 select-none"
|
|
423
|
+
style="cursor: {isDragging ? 'grabbing' : 'grab'};"
|
|
424
|
+
onmousedown={handleDragStart}
|
|
425
|
+
>
|
|
426
|
+
<div class="flex items-center gap-2">
|
|
427
|
+
{#if formType === 'bug'}
|
|
428
|
+
<i class="fa-solid fa-bug text-red-400"></i>
|
|
429
|
+
<h3 class="text-base font-semibold">Report Bug</h3>
|
|
430
|
+
{:else}
|
|
431
|
+
<i class="fa-solid fa-lightbulb text-amber-400"></i>
|
|
432
|
+
<h3 class="text-base font-semibold">Request Feature</h3>
|
|
433
|
+
{/if}
|
|
434
|
+
<Badge variant="neutral" size="sm">{getEnvironmentName()}</Badge>
|
|
435
|
+
</div>
|
|
436
|
+
<button
|
|
437
|
+
class="text-foreground-tertiary hover:text-foreground transition-colors bg-transparent border-none cursor-pointer text-lg"
|
|
438
|
+
onclick={() => (formOpen = false)}
|
|
439
|
+
aria-label="Close feedback form"
|
|
440
|
+
>
|
|
441
|
+
<i class="fa-solid fa-xmark"></i>
|
|
442
|
+
</button>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
<!-- Body (scrollable) -->
|
|
446
|
+
<div class="p-5 space-y-4 overflow-y-auto">
|
|
447
|
+
{#if submitResult === 'success'}
|
|
448
|
+
<div class="text-center py-6">
|
|
449
|
+
<i class="fa-solid fa-check-circle text-3xl text-green-400"></i>
|
|
450
|
+
<p class="mt-2 font-semibold">Submitted!</p>
|
|
451
|
+
<p class="text-sm text-foreground-secondary">
|
|
452
|
+
{formType === 'bug' ? "We'll look into this." : "Thanks for the suggestion!"}
|
|
453
|
+
</p>
|
|
454
|
+
</div>
|
|
455
|
+
{:else}
|
|
456
|
+
<div class="flex items-center gap-1.5 text-xs text-foreground-tertiary">
|
|
457
|
+
<i class="fa-solid fa-user" aria-hidden="true"></i>
|
|
458
|
+
<span>Submitting as <span class="font-medium text-foreground-secondary">{userEmail}</span></span>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
<Input
|
|
462
|
+
bind:value={formTitle}
|
|
463
|
+
placeholder={formType === 'bug' ? 'Brief summary of the bug' : 'Feature title'}
|
|
464
|
+
label="Title"
|
|
465
|
+
/>
|
|
466
|
+
|
|
467
|
+
<div bind:this={messageTextareaWrapperEl}>
|
|
468
|
+
<Textarea
|
|
469
|
+
value={formMessage} oninput={(e) => (formMessage = (e.currentTarget as HTMLTextAreaElement).value)}
|
|
470
|
+
placeholder={formType === 'bug'
|
|
471
|
+
? 'What happened? What did you expect?'
|
|
472
|
+
: 'Describe the feature you\'d like...'}
|
|
473
|
+
label={formType === 'bug' ? 'Description' : 'Details'}
|
|
474
|
+
rows={4}
|
|
475
|
+
/>
|
|
476
|
+
</div>
|
|
477
|
+
<PasteImageInput
|
|
478
|
+
textareaEl={messageTextareaEl}
|
|
479
|
+
bind:image={pastedScreenshot}
|
|
480
|
+
bind:filename={pastedFilename}
|
|
481
|
+
previewClass="mt-2"
|
|
482
|
+
/>
|
|
483
|
+
|
|
484
|
+
<!-- Auto-captured context (bugs only) -->
|
|
485
|
+
{#if formType === 'bug'}
|
|
486
|
+
<div class="space-y-2">
|
|
487
|
+
<p class="text-xs font-semibold text-foreground-tertiary uppercase tracking-wide">Auto-captured</p>
|
|
488
|
+
<div class="flex flex-wrap gap-1.5">
|
|
489
|
+
<Badge variant="info" size="sm">
|
|
490
|
+
<i class="fa-solid fa-route mr-1"></i>
|
|
491
|
+
{window.location.pathname}
|
|
492
|
+
</Badge>
|
|
493
|
+
<Badge variant="info" size="sm">
|
|
494
|
+
<i class="fa-solid fa-display mr-1"></i>
|
|
495
|
+
{window.innerWidth}×{window.innerHeight}
|
|
496
|
+
</Badge>
|
|
497
|
+
{#if consoleErrors.length > 0}
|
|
498
|
+
<Badge variant="danger" size="sm">
|
|
499
|
+
<i class="fa-solid fa-triangle-exclamation mr-1"></i>
|
|
500
|
+
{consoleErrors.length} console error{consoleErrors.length !== 1 ? 's' : ''}
|
|
501
|
+
</Badge>
|
|
502
|
+
{/if}
|
|
503
|
+
{#if clickedElement}
|
|
504
|
+
<Badge variant="neutral" size="sm">
|
|
505
|
+
<i class="fa-solid fa-crosshairs mr-1"></i>
|
|
506
|
+
{getElementSelector(clickedElement).slice(-40)}
|
|
507
|
+
</Badge>
|
|
508
|
+
{/if}
|
|
509
|
+
</div>
|
|
510
|
+
|
|
511
|
+
<!-- Render token status -->
|
|
512
|
+
{#if fetchingRenderToken}
|
|
513
|
+
<div class="flex items-center gap-2 text-sm text-foreground-tertiary">
|
|
514
|
+
<Spinner /> Preparing screenshot...
|
|
515
|
+
</div>
|
|
516
|
+
{:else if renderTokenFailed && !pastedScreenshot}
|
|
517
|
+
<div class="rounded-md border border-border bg-surface p-3">
|
|
518
|
+
<div class="flex items-center gap-2 text-sm text-foreground-secondary mb-1">
|
|
519
|
+
<i class="fa-solid fa-paste" aria-hidden="true"></i>
|
|
520
|
+
<span>Paste a screenshot to attach one</span>
|
|
521
|
+
</div>
|
|
522
|
+
<p class="text-xs text-foreground-tertiary">
|
|
523
|
+
Capture a screenshot (<kbd class="px-1 py-0.5 rounded bg-surface-secondary border border-border text-[10px] font-mono">{isMac ? '⌘⇧4' : 'Win+Shift+S'}</kbd>),
|
|
524
|
+
then paste it into the description above with
|
|
525
|
+
<kbd class="px-1 py-0.5 rounded bg-surface-secondary border border-border text-[10px] font-mono">{isMac ? '⌘V' : 'Ctrl+V'}</kbd>.
|
|
526
|
+
</p>
|
|
527
|
+
</div>
|
|
528
|
+
{:else if renderContext && !pastedScreenshot}
|
|
529
|
+
<div class="rounded-md border border-border bg-surface-secondary p-2 flex items-center gap-2 text-sm text-foreground-secondary">
|
|
530
|
+
<i class="fa-solid fa-camera" aria-hidden="true"></i>
|
|
531
|
+
<span>Screenshot will be captured server-side on submit</span>
|
|
532
|
+
</div>
|
|
533
|
+
{/if}
|
|
534
|
+
</div>
|
|
535
|
+
{/if}
|
|
536
|
+
|
|
537
|
+
{#if submitResult === 'error'}
|
|
538
|
+
<p class="text-sm text-red-400">
|
|
539
|
+
<i class="fa-solid fa-exclamation-circle mr-1"></i>
|
|
540
|
+
Failed to submit. Please try again.
|
|
541
|
+
</p>
|
|
542
|
+
{/if}
|
|
543
|
+
{/if}
|
|
544
|
+
</div>
|
|
545
|
+
|
|
546
|
+
<!-- Footer -->
|
|
547
|
+
{#if submitResult !== 'success' && hasUserEmail}
|
|
548
|
+
<div class="flex justify-end gap-2 px-5 py-4 border-t border-border shrink-0">
|
|
549
|
+
<Button variant="neutral" style="outline" onclick={() => (formOpen = false)}>
|
|
550
|
+
Cancel
|
|
551
|
+
</Button>
|
|
552
|
+
<Button
|
|
553
|
+
variant={formType === 'bug' ? 'danger' : 'primary'}
|
|
554
|
+
disabled={submitting || !formMessage.trim()}
|
|
555
|
+
loading={submitting}
|
|
556
|
+
onclick={handleSubmit}
|
|
557
|
+
>
|
|
558
|
+
{formType === 'bug' ? 'Report Bug' : 'Submit Request'}
|
|
559
|
+
</Button>
|
|
560
|
+
</div>
|
|
561
|
+
{/if}
|
|
562
|
+
</Card>
|
|
563
|
+
</div>
|
|
564
|
+
{/if}
|
|
565
|
+
|
|
566
|
+
<!-- Onboarding toast -->
|
|
567
|
+
{#if showToast}
|
|
568
|
+
<DevOnboardingToast onDismiss={() => (showToast = false)} />
|
|
569
|
+
{/if}
|
|
570
|
+
{/if}
|
|
571
|
+
|
|
572
|
+
<style>
|
|
573
|
+
/* Override popover defaults — remove browser default styles */
|
|
574
|
+
.sf-feedback-popover {
|
|
575
|
+
position: fixed;
|
|
576
|
+
margin: 0;
|
|
577
|
+
padding: 0;
|
|
578
|
+
border: none;
|
|
579
|
+
background: transparent;
|
|
580
|
+
overflow: visible;
|
|
581
|
+
/* Fallback for browsers without popover support */
|
|
582
|
+
z-index: 99998;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/* Floating modal shadow — no backdrop so needs strong depth cue */
|
|
586
|
+
.sf-modal :global(.card) {
|
|
587
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
|
588
|
+
}
|
|
589
|
+
</style>
|