@jarve/bug-reporter 0.4.2 → 1.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/dist/index.mjs CHANGED
@@ -20,26 +20,26 @@ var __spreadValues = (a, b) => {
20
20
  var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
21
21
 
22
22
  // src/bug-reporter.tsx
23
- import { useState as useState4, useEffect as useEffect4, useCallback as useCallback3 } from "react";
23
+ import { useState as useState4, useEffect as useEffect4, useCallback as useCallback3, useMemo as useMemo2 } from "react";
24
24
 
25
25
  // src/floating-button.tsx
26
26
  import { useState, useEffect, useRef } from "react";
27
27
  import { Bug } from "lucide-react";
28
-
29
- // src/cn.ts
30
- import { clsx } from "clsx";
31
- import { twMerge } from "tailwind-merge";
32
- function cn(...inputs) {
33
- return twMerge(clsx(inputs));
34
- }
35
-
36
- // src/floating-button.tsx
28
+ import { cn, on } from "@jarve/widget-shared";
37
29
  import { jsx, jsxs } from "react/jsx-runtime";
38
30
  var STACK_OFFSET = 56;
39
- function FloatingButton({ isActive, onClick, position = "right" }) {
31
+ function hasLauncherElement() {
32
+ return typeof document !== "undefined" && !!document.querySelector("[data-jarve-launcher]");
33
+ }
34
+ function FloatingButton({
35
+ isActive,
36
+ onClick,
37
+ position = "right",
38
+ zIndexBase = 1e4
39
+ }) {
40
40
  const [hovered, setHovered] = useState(false);
41
41
  const [stackOffset, setStackOffset] = useState(0);
42
- const [launcherPresent, setLauncherPresent] = useState(false);
42
+ const [launcherPresent, setLauncherPresent] = useState(hasLauncherElement);
43
43
  const ref = useRef(null);
44
44
  const isLeft = position === "left";
45
45
  const sideClasses = isLeft ? "left-4 md:left-6" : "right-4 md:right-6";
@@ -48,6 +48,7 @@ function FloatingButton({ isActive, onClick, position = "right" }) {
48
48
  if (!el) return;
49
49
  let rafId;
50
50
  const recalculate = () => {
51
+ if (!ref.current) return;
51
52
  const widgets = Array.from(
52
53
  document.querySelectorAll(`[data-jarve-widget][data-jarve-position="${position}"]`)
53
54
  );
@@ -59,25 +60,26 @@ function FloatingButton({ isActive, onClick, position = "right" }) {
59
60
  const index = widgets.indexOf(el);
60
61
  setStackOffset(index > 0 ? index * STACK_OFFSET : 0);
61
62
  };
62
- recalculate();
63
- const observer = new MutationObserver(() => {
63
+ const scheduleRecalculate = () => {
64
64
  cancelAnimationFrame(rafId);
65
65
  rafId = requestAnimationFrame(recalculate);
66
- });
67
- observer.observe(document.body, { childList: true, subtree: true });
66
+ };
67
+ recalculate();
68
+ const offReg = on("jarve:widget-registered", () => scheduleRecalculate());
69
+ const offDereg = on("jarve:widget-deregistered", () => scheduleRecalculate());
68
70
  return () => {
69
- observer.disconnect();
71
+ offReg();
72
+ offDereg();
70
73
  cancelAnimationFrame(rafId);
71
74
  };
72
75
  }, [position]);
73
76
  useEffect(() => {
74
- const checkLauncher = () => {
75
- setLauncherPresent(!!document.querySelector("[data-jarve-launcher]"));
77
+ const offMounted = on("jarve:launcher-mounted", () => setLauncherPresent(true));
78
+ const offUnmounted = on("jarve:launcher-unmounted", () => setLauncherPresent(false));
79
+ return () => {
80
+ offMounted();
81
+ offUnmounted();
76
82
  };
77
- checkLauncher();
78
- const observer = new MutationObserver(checkLauncher);
79
- observer.observe(document.body, { childList: true, subtree: true });
80
- return () => observer.disconnect();
81
83
  }, []);
82
84
  if (launcherPresent) return null;
83
85
  return /* @__PURE__ */ jsxs(
@@ -86,8 +88,10 @@ function FloatingButton({ isActive, onClick, position = "right" }) {
86
88
  ref,
87
89
  "data-jarve-widget": "bug-reporter",
88
90
  "data-jarve-position": position,
89
- className: cn("fixed z-[9999]", "bottom-4 md:bottom-6", sideClasses),
90
- style: stackOffset > 0 ? { transform: `translateY(-${stackOffset}px)` } : void 0,
91
+ className: cn("fixed z-[calc(var(--jarve-z-base)-1)]", "bottom-4 md:bottom-6", sideClasses),
92
+ style: __spreadValues({
93
+ ["--jarve-z-base"]: String(zIndexBase)
94
+ }, stackOffset > 0 ? { transform: `translateY(-${stackOffset}px)` } : {}),
91
95
  onMouseEnter: () => setHovered(true),
92
96
  onMouseLeave: () => setHovered(false),
93
97
  children: [
@@ -121,7 +125,7 @@ function FloatingButton({ isActive, onClick, position = "right" }) {
121
125
  onClick,
122
126
  className: cn(
123
127
  "flex items-center justify-center rounded-full shadow-lg transition-all duration-200",
124
- "hover:scale-110 focus:ring-2 focus:ring-offset-2 focus:outline-none",
128
+ "focus:ring-2 focus:ring-offset-2 focus:outline-none motion-safe:hover:scale-110",
125
129
  "h-11 w-11 md:h-12 md:w-12",
126
130
  isActive ? "animate-pulse bg-red-500 text-white focus:ring-red-400" : "bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-400"
127
131
  ),
@@ -137,10 +141,9 @@ function FloatingButton({ isActive, onClick, position = "right" }) {
137
141
 
138
142
  // src/capture-overlay.tsx
139
143
  import { useEffect as useEffect2, useState as useState2, useCallback, useRef as useRef2 } from "react";
140
- import { toPng } from "html-to-image";
141
144
 
142
145
  // src/utils.ts
143
- import { UAParser } from "ua-parser-js";
146
+ import { parseUserAgent as parseSharedUserAgent } from "@jarve/widget-shared";
144
147
  function getNearestSection(element) {
145
148
  let current = element;
146
149
  while (current && current !== document.body) {
@@ -170,13 +173,7 @@ function getDeviceType() {
170
173
  return "desktop";
171
174
  }
172
175
  function parseUserAgent() {
173
- const parser = new UAParser(navigator.userAgent);
174
- const browser = parser.getBrowser();
175
- const osInfo = parser.getOS();
176
- return {
177
- browser: `${browser.name || "Unknown"} ${browser.version || ""}`.trim(),
178
- os: `${osInfo.name || "Unknown"} ${osInfo.version || ""}`.trim()
179
- };
176
+ return parseSharedUserAgent();
180
177
  }
181
178
  function buildSelectorPath(element, stopAt) {
182
179
  const parts = [];
@@ -247,84 +244,114 @@ function collectMetadata(sectionElement, siteId, reporterName, reporterEmail, cl
247
244
  };
248
245
  }
249
246
 
247
+ // src/console-capture.ts
248
+ import { createPatchManager } from "@jarve/widget-shared";
249
+
250
+ // src/redact.ts
251
+ var SENSITIVE_JSON_KEY = /("(?:token|password|secret|api_?key|authorization)"\s*:\s*")[^"]*"/gi;
252
+ var BEARER_TOKEN = /(bearer)\s+[^\s"',;}]+/gi;
253
+ var COOKIE_HEADER = /(set-cookie|cookie)\s*:\s*[^\r\n]+/gi;
254
+ function redactSensitive(input) {
255
+ if (input === null || input === void 0) return input;
256
+ return input.replace(COOKIE_HEADER, (_, header) => `${header}: [REDACTED]`).replace(SENSITIVE_JSON_KEY, (_, keyPrefix) => `${keyPrefix}[REDACTED]"`).replace(BEARER_TOKEN, (_, word) => `${word} [REDACTED]`);
257
+ }
258
+
250
259
  // src/console-capture.ts
251
260
  var MAX_ERRORS = 50;
261
+ var MAX_MSG_LEN = 500;
262
+ function serializeArg(arg) {
263
+ var _a;
264
+ if (typeof arg === "string") return arg;
265
+ if (arg instanceof Error) return `${arg.name}: ${arg.message}
266
+ ${(_a = arg.stack) != null ? _a : ""}`;
267
+ try {
268
+ return JSON.stringify(arg);
269
+ } catch (e) {
270
+ return String(arg);
271
+ }
272
+ }
252
273
  var capturedErrors = [];
253
- var isCapturing = false;
254
274
  var originalConsoleError = null;
275
+ var patchedConsoleErrorRef = null;
255
276
  var errorListener = null;
256
277
  var rejectionListener = null;
257
- function startCapturing() {
258
- if (isCapturing) return;
259
- if (console.error.__bugReporterPatched) return;
260
- isCapturing = true;
261
- capturedErrors = [];
262
- originalConsoleError = console.error;
263
- const patchedConsoleError = (...args) => {
264
- const MAX_MSG_LEN = 500;
265
- const message = args.map((a) => {
266
- if (typeof a === "string") return a.slice(0, MAX_MSG_LEN);
267
- try {
268
- const s = JSON.stringify(a);
269
- return s.slice(0, MAX_MSG_LEN);
270
- } catch (e) {
271
- return String(a).slice(0, MAX_MSG_LEN);
278
+ var releasePatch = null;
279
+ var patchManager = createPatchManager({
280
+ markerKey: "__jarveBugReporterConsolePatch",
281
+ install: () => {
282
+ capturedErrors = [];
283
+ originalConsoleError = console.error;
284
+ const patchedConsoleError = (...args) => {
285
+ const message = args.map((arg) => redactSensitive(serializeArg(arg)).slice(0, MAX_MSG_LEN)).join(" ").slice(0, MAX_MSG_LEN);
286
+ capturedErrors.push({
287
+ message,
288
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
289
+ });
290
+ if (capturedErrors.length > MAX_ERRORS) {
291
+ capturedErrors = capturedErrors.slice(-MAX_ERRORS);
272
292
  }
273
- }).join(" ").slice(0, MAX_MSG_LEN);
274
- capturedErrors.push({
275
- message,
276
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
277
- });
278
- if (capturedErrors.length > MAX_ERRORS) {
279
- capturedErrors = capturedErrors.slice(-MAX_ERRORS);
293
+ originalConsoleError.apply(console, args);
294
+ };
295
+ patchedConsoleError.__bugReporterPatched = true;
296
+ patchedConsoleErrorRef = patchedConsoleError;
297
+ console.error = patchedConsoleError;
298
+ errorListener = (event) => {
299
+ capturedErrors.push({
300
+ message: event.message,
301
+ source: event.filename,
302
+ lineno: event.lineno,
303
+ colno: event.colno,
304
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
305
+ });
306
+ if (capturedErrors.length > MAX_ERRORS) {
307
+ capturedErrors = capturedErrors.slice(-MAX_ERRORS);
308
+ }
309
+ };
310
+ window.addEventListener("error", errorListener);
311
+ rejectionListener = (event) => {
312
+ capturedErrors.push({
313
+ message: `Unhandled Promise Rejection: ${event.reason instanceof Error ? event.reason.message : String(event.reason)}`,
314
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
315
+ });
316
+ if (capturedErrors.length > MAX_ERRORS) {
317
+ capturedErrors = capturedErrors.slice(-MAX_ERRORS);
318
+ }
319
+ };
320
+ window.addEventListener("unhandledrejection", rejectionListener);
321
+ },
322
+ uninstall: () => {
323
+ if (console.error === patchedConsoleErrorRef && originalConsoleError) {
324
+ console.error = originalConsoleError;
325
+ } else {
326
+ console.warn(
327
+ "Bug reporter: console.error was replaced by another library after we patched it. Leaving the foreign patch in place \u2014 original console.error is no longer reachable via our handle."
328
+ );
280
329
  }
281
- originalConsoleError.apply(console, args);
282
- };
283
- patchedConsoleError.__bugReporterPatched = true;
284
- console.error = patchedConsoleError;
285
- errorListener = (event) => {
286
- capturedErrors.push({
287
- message: event.message,
288
- source: event.filename,
289
- lineno: event.lineno,
290
- colno: event.colno,
291
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
292
- });
293
- if (capturedErrors.length > MAX_ERRORS) {
294
- capturedErrors = capturedErrors.slice(-MAX_ERRORS);
330
+ if (patchedConsoleErrorRef) {
331
+ delete patchedConsoleErrorRef.__bugReporterPatched;
295
332
  }
296
- };
297
- window.addEventListener("error", errorListener);
298
- rejectionListener = (event) => {
299
- capturedErrors.push({
300
- message: `Unhandled Promise Rejection: ${event.reason instanceof Error ? event.reason.message : String(event.reason)}`,
301
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
302
- });
303
- if (capturedErrors.length > MAX_ERRORS) {
304
- capturedErrors = capturedErrors.slice(-MAX_ERRORS);
333
+ originalConsoleError = null;
334
+ patchedConsoleErrorRef = null;
335
+ if (errorListener) {
336
+ window.removeEventListener("error", errorListener);
337
+ errorListener = null;
305
338
  }
306
- };
307
- window.addEventListener("unhandledrejection", rejectionListener);
308
- }
309
- function stopCapturing() {
310
- if (!isCapturing) return;
311
- isCapturing = false;
312
- if (originalConsoleError) {
313
- const restoreTarget = originalConsoleError;
314
- console.error = restoreTarget;
315
- if (console.error !== restoreTarget) {
316
- console.warn("Bug reporter: failed to restore original console.error");
339
+ if (rejectionListener) {
340
+ window.removeEventListener("unhandledrejection", rejectionListener);
341
+ rejectionListener = null;
317
342
  }
318
- originalConsoleError = null;
319
- }
320
- if (errorListener) {
321
- window.removeEventListener("error", errorListener);
322
- errorListener = null;
323
- }
324
- if (rejectionListener) {
325
- window.removeEventListener("unhandledrejection", rejectionListener);
326
- rejectionListener = null;
327
343
  }
344
+ });
345
+ function startCapturing() {
346
+ if (typeof window === "undefined") return;
347
+ if (releasePatch) return;
348
+ releasePatch = patchManager.acquire();
349
+ }
350
+ function stopCapturing() {
351
+ if (!releasePatch) return;
352
+ const release = releasePatch;
353
+ releasePatch = null;
354
+ release();
328
355
  }
329
356
  function getCapturedErrors() {
330
357
  return [...capturedErrors];
@@ -334,17 +361,38 @@ function clearCapturedErrors() {
334
361
  }
335
362
 
336
363
  // src/network-capture.ts
364
+ import { createPatchManager as createPatchManager2 } from "@jarve/widget-shared";
337
365
  var MAX_REQUESTS = 30;
338
366
  var MAX_BODY_READ_BYTES = 64 * 1024;
339
367
  var TRUNCATE_LEN = 500;
340
368
  var capturedRequests = [];
341
- var isCapturing2 = false;
342
369
  var originalFetch = null;
370
+ var patchedFetchRef = null;
371
+ var releasePatch2 = null;
372
+ var bodyCaptureMode = "off";
343
373
  function truncateBody(body, maxLen = TRUNCATE_LEN) {
344
374
  if (!body) return null;
345
375
  if (body.length <= maxLen) return body;
346
376
  return body.slice(0, maxLen) + "...(truncated)";
347
377
  }
378
+ function sanitizeRequestUrl(raw) {
379
+ try {
380
+ const parsed = new URL(raw);
381
+ return {
382
+ url: parsed.origin + parsed.pathname,
383
+ hasQueryString: parsed.search.length > 0 || parsed.hash.length > 0
384
+ };
385
+ } catch (e) {
386
+ const queryIdx = raw.indexOf("?");
387
+ const hashIdx = raw.indexOf("#");
388
+ const cutoffs = [queryIdx, hashIdx].filter((i) => i >= 0);
389
+ if (cutoffs.length === 0) {
390
+ return { url: raw, hasQueryString: false };
391
+ }
392
+ const cut = Math.min(...cutoffs);
393
+ return { url: raw.slice(0, cut), hasQueryString: true };
394
+ }
395
+ }
348
396
  async function readBoundedBody(response) {
349
397
  try {
350
398
  const contentLength = response.headers.get("Content-Length");
@@ -372,72 +420,98 @@ async function readBoundedBody(response) {
372
420
  }
373
421
  const decoder = new TextDecoder();
374
422
  const text2 = chunks.map((c) => decoder.decode(c, { stream: true })).join("");
375
- return truncateBody(text2);
423
+ return truncateBody(redactSensitive(text2));
376
424
  }
377
425
  const text = await cloned.text();
378
- return truncateBody(text);
426
+ return truncateBody(redactSensitive(text));
379
427
  } catch (e) {
380
428
  return null;
381
429
  }
382
430
  }
383
- function startNetworkCapture() {
384
- if (isCapturing2 || typeof window === "undefined") return;
385
- if (window.fetch.__bugReporterPatched) return;
386
- isCapturing2 = true;
387
- capturedRequests = [];
388
- originalFetch = window.fetch;
389
- const patchedFetch = async function patchedFetch2(input, init) {
390
- const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
391
- const method = (init == null ? void 0 : init.method) || (typeof input !== "string" && !(input instanceof URL) ? input.method : "GET") || "GET";
392
- if (url.includes("/api/bug-reporter/") || url.includes("/bug-reporter/external/") || url.startsWith("data:") || url.startsWith("blob:")) {
393
- return originalFetch.call(window, input, init);
394
- }
395
- try {
396
- const response = await originalFetch.call(window, input, init);
397
- if (response.status >= 400) {
398
- const responseBody = await readBoundedBody(response);
431
+ var patchManager2 = createPatchManager2({
432
+ markerKey: "__jarveBugReporterFetchPatch",
433
+ install: () => {
434
+ if (typeof window === "undefined") return;
435
+ capturedRequests = [];
436
+ originalFetch = window.fetch;
437
+ const patchedFetch = async function patchedFetch2(input, init) {
438
+ const rawUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
439
+ const method = (init == null ? void 0 : init.method) || (typeof input !== "string" && !(input instanceof URL) ? input.method : "GET") || "GET";
440
+ if (rawUrl.includes("/api/bug-reporter/") || rawUrl.includes("/bug-reporter/external/") || rawUrl.startsWith("data:") || rawUrl.startsWith("blob:")) {
441
+ return originalFetch.call(window, input, init);
442
+ }
443
+ const { url, hasQueryString } = sanitizeRequestUrl(rawUrl);
444
+ try {
445
+ const response = await originalFetch.call(window, input, init);
446
+ if (response.status >= 400) {
447
+ const responseBody = bodyCaptureMode === "on" ? await readBoundedBody(response) : null;
448
+ capturedRequests.push({
449
+ url,
450
+ hasQueryString,
451
+ method: method.toUpperCase(),
452
+ status: response.status,
453
+ statusText: response.statusText,
454
+ responseBody,
455
+ contentType: response.headers.get("content-type"),
456
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
457
+ });
458
+ if (capturedRequests.length > MAX_REQUESTS) {
459
+ capturedRequests = capturedRequests.slice(-MAX_REQUESTS);
460
+ }
461
+ }
462
+ return response;
463
+ } catch (error) {
399
464
  capturedRequests.push({
400
465
  url,
466
+ hasQueryString,
401
467
  method: method.toUpperCase(),
402
- status: response.status,
403
- statusText: response.statusText,
404
- responseBody,
468
+ status: 0,
469
+ statusText: error instanceof Error ? error.message : "Network error",
470
+ responseBody: null,
471
+ contentType: null,
405
472
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
406
473
  });
407
474
  if (capturedRequests.length > MAX_REQUESTS) {
408
475
  capturedRequests = capturedRequests.slice(-MAX_REQUESTS);
409
476
  }
477
+ throw error;
410
478
  }
411
- return response;
412
- } catch (error) {
413
- capturedRequests.push({
414
- url,
415
- method: method.toUpperCase(),
416
- status: 0,
417
- statusText: error instanceof Error ? error.message : "Network error",
418
- responseBody: null,
419
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
420
- });
421
- if (capturedRequests.length > MAX_REQUESTS) {
422
- capturedRequests = capturedRequests.slice(-MAX_REQUESTS);
423
- }
424
- throw error;
479
+ };
480
+ patchedFetch.__bugReporterPatched = true;
481
+ patchedFetchRef = patchedFetch;
482
+ window.fetch = patchedFetch;
483
+ },
484
+ uninstall: () => {
485
+ if (typeof window === "undefined") {
486
+ originalFetch = null;
487
+ patchedFetchRef = null;
488
+ return;
425
489
  }
426
- };
427
- patchedFetch.__bugReporterPatched = true;
428
- window.fetch = patchedFetch;
429
- }
430
- function stopNetworkCapture() {
431
- if (!isCapturing2) return;
432
- isCapturing2 = false;
433
- if (originalFetch) {
434
- const restoreTarget = originalFetch;
435
- window.fetch = restoreTarget;
436
- if (window.fetch !== restoreTarget) {
437
- console.warn("Bug reporter: failed to restore original fetch");
490
+ if (window.fetch === patchedFetchRef && originalFetch) {
491
+ window.fetch = originalFetch;
492
+ } else {
493
+ console.warn(
494
+ "Bug reporter: window.fetch was replaced by another library after we patched it. Leaving the foreign patch in place \u2014 original fetch is no longer reachable via our handle."
495
+ );
496
+ }
497
+ if (patchedFetchRef) {
498
+ delete patchedFetchRef.__bugReporterPatched;
438
499
  }
439
500
  originalFetch = null;
501
+ patchedFetchRef = null;
440
502
  }
503
+ });
504
+ function startNetworkCapture(options = {}) {
505
+ if (typeof window === "undefined") return;
506
+ bodyCaptureMode = options.captureResponseBodies ? "on" : "off";
507
+ if (releasePatch2) return;
508
+ releasePatch2 = patchManager2.acquire();
509
+ }
510
+ function stopNetworkCapture() {
511
+ if (!releasePatch2) return;
512
+ const release = releasePatch2;
513
+ releasePatch2 = null;
514
+ release();
441
515
  }
442
516
  function getCapturedNetworkErrors() {
443
517
  return [...capturedRequests];
@@ -451,11 +525,16 @@ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
451
525
  function dataUrlToBlob(dataUrl) {
452
526
  var _a;
453
527
  const [header, base64] = dataUrl.split(",");
528
+ if (!header || !base64) return new Blob();
454
529
  const mime = ((_a = header.match(/:(.*?);/)) == null ? void 0 : _a[1]) || "image/png";
455
- const bytes = atob(base64);
456
- const arr = new Uint8Array(bytes.length);
457
- for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);
458
- return new Blob([arr], { type: mime });
530
+ try {
531
+ const bytes = atob(base64);
532
+ const arr = new Uint8Array(bytes.length);
533
+ for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);
534
+ return new Blob([arr], { type: mime });
535
+ } catch (e) {
536
+ return new Blob();
537
+ }
459
538
  }
460
539
  function CaptureOverlay({
461
540
  isActive,
@@ -463,11 +542,12 @@ function CaptureOverlay({
463
542
  reporterName,
464
543
  reporterEmail,
465
544
  onCapture,
466
- onCancel
545
+ onCancel,
546
+ zIndexBase = 1e4
467
547
  }) {
468
548
  const [hoveredElement, setHoveredElement] = useState2(null);
469
549
  const [hoveredRect, setHoveredRect] = useState2(null);
470
- const [isCapturing3, setIsCapturing] = useState2(false);
550
+ const [isCapturing, setIsCapturing] = useState2(false);
471
551
  const [isTouchMode, setIsTouchMode] = useState2(false);
472
552
  const [selectedSection, setSelectedSection] = useState2(null);
473
553
  const [selectedRect, setSelectedRect] = useState2(null);
@@ -481,10 +561,20 @@ function CaptureOverlay({
481
561
  setIsTouchMode(isTouchCapable());
482
562
  }
483
563
  }, [isActive]);
564
+ useEffect2(() => {
565
+ if (typeof document === "undefined") return;
566
+ if (!isActive || isTouchMode) return;
567
+ const root = document.documentElement;
568
+ root.classList.add("jarve-capturing");
569
+ return () => {
570
+ root.classList.remove("jarve-capturing");
571
+ };
572
+ }, [isActive, isTouchMode]);
484
573
  const captureScreenshot = useCallback(
485
574
  async (section, target, coords) => {
486
575
  const elementInfo = collectElementInfo(target, section, coords);
487
576
  setIsCapturing(true);
577
+ const { toPng } = await import("html-to-image");
488
578
  try {
489
579
  setHoveredElement(null);
490
580
  setHoveredRect(null);
@@ -561,7 +651,7 @@ function CaptureOverlay({
561
651
  );
562
652
  const handleMouseMove = useCallback(
563
653
  (e) => {
564
- if (!isActive || isCapturing3 || isTouchMode) return;
654
+ if (!isActive || isCapturing || isTouchMode) return;
565
655
  if (rafRef.current) return;
566
656
  rafRef.current = requestAnimationFrame(() => {
567
657
  rafRef.current = null;
@@ -579,11 +669,11 @@ function CaptureOverlay({
579
669
  setHoveredRect(section ? section.getBoundingClientRect() : null);
580
670
  });
581
671
  },
582
- [isActive, isCapturing3, isTouchMode]
672
+ [isActive, isCapturing, isTouchMode]
583
673
  );
584
674
  const handleClick = useCallback(
585
675
  async (e) => {
586
- if (!isActive || isCapturing3 || isTouchMode) return;
676
+ if (!isActive || isCapturing || isTouchMode) return;
587
677
  const target = e.target;
588
678
  if (!(target instanceof HTMLElement)) return;
589
679
  if (target.closest("[data-bug-reporter]")) return;
@@ -593,16 +683,18 @@ function CaptureOverlay({
593
683
  if (!section) return;
594
684
  await captureScreenshot(section, target, extractCoordinates(e));
595
685
  },
596
- [isActive, isCapturing3, isTouchMode, captureScreenshot]
686
+ [isActive, isCapturing, isTouchMode, captureScreenshot]
597
687
  );
598
688
  const handleTouchEnd = useCallback(
599
689
  (e) => {
600
- if (!isActive || isCapturing3) return;
690
+ if (!isActive || isCapturing) return;
601
691
  const touch = e.changedTouches[0];
602
692
  if (!touch) return;
603
693
  const target = document.elementFromPoint(touch.clientX, touch.clientY);
604
694
  if (!(target instanceof HTMLElement)) return;
605
695
  if (target.closest("[data-bug-reporter]")) return;
696
+ if (e.cancelable) e.preventDefault();
697
+ e.stopPropagation();
606
698
  const section = getNearestSection(target);
607
699
  if (!section) return;
608
700
  setSelectedSection(section);
@@ -610,7 +702,7 @@ function CaptureOverlay({
610
702
  setSelectedTarget(target);
611
703
  touchCoordsRef.current = extractCoordinates(touch);
612
704
  },
613
- [isActive, isCapturing3]
705
+ [isActive, isCapturing]
614
706
  );
615
707
  const handleConfirmCapture = useCallback(async () => {
616
708
  if (!selectedSection || !selectedTarget || !touchCoordsRef.current) return;
@@ -660,7 +752,7 @@ function CaptureOverlay({
660
752
  document.addEventListener("keydown", handleKeyDown);
661
753
  window.addEventListener("scroll", handleScroll, { passive: true });
662
754
  if (isTouchMode) {
663
- document.addEventListener("touchend", handleTouchEnd, { passive: true });
755
+ document.addEventListener("touchend", handleTouchEnd, { passive: false });
664
756
  } else {
665
757
  document.addEventListener("mousemove", handleMouseMove, true);
666
758
  document.addEventListener("click", handleClick, true);
@@ -692,9 +784,10 @@ function CaptureOverlay({
692
784
  "div",
693
785
  {
694
786
  "data-bug-reporter": true,
695
- role: "alert",
696
- "aria-live": "assertive",
697
- className: "fixed top-0 right-0 left-0 z-[10000] flex items-center justify-center gap-3 bg-indigo-600 px-4 py-2 text-center text-sm font-medium text-white",
787
+ role: "status",
788
+ "aria-live": "polite",
789
+ className: "fixed top-0 right-0 left-0 z-[calc(var(--jarve-z-base))] flex items-center justify-center gap-3 bg-indigo-600 px-4 py-2 text-center text-sm font-medium text-white",
790
+ style: { ["--jarve-z-base"]: String(zIndexBase) },
698
791
  children: isTouchMode ? /* @__PURE__ */ jsxs2(Fragment, { children: [
699
792
  /* @__PURE__ */ jsx2("span", { children: "Tap the section with the bug" }),
700
793
  /* @__PURE__ */ jsx2(
@@ -718,8 +811,9 @@ function CaptureOverlay({
718
811
  {
719
812
  ref: overlayRef,
720
813
  "data-bug-reporter": true,
721
- className: "pointer-events-none fixed z-[9998] rounded-sm border-2 border-indigo-500 transition-all duration-150 ease-out",
814
+ className: "pointer-events-none fixed z-[calc(var(--jarve-z-base)-2)] rounded-sm border-2 border-indigo-500 transition-all duration-150 ease-out",
722
815
  style: {
816
+ ["--jarve-z-base"]: String(zIndexBase),
723
817
  top: highlightRect.top - 2,
724
818
  left: highlightRect.left - 2,
725
819
  width: highlightRect.width + 4,
@@ -728,12 +822,15 @@ function CaptureOverlay({
728
822
  }
729
823
  }
730
824
  ),
731
- isTouchMode && selectedSection && !isCapturing3 && /* @__PURE__ */ jsx2(
825
+ isTouchMode && selectedSection && !isCapturing && /* @__PURE__ */ jsx2(
732
826
  "div",
733
827
  {
734
828
  "data-bug-reporter": true,
735
- className: "fixed right-0 bottom-0 left-0 z-[10000] border-t border-gray-200 bg-white shadow-lg",
736
- style: { paddingBottom: "env(safe-area-inset-bottom, 0px)" },
829
+ className: "fixed right-0 bottom-0 left-0 z-[calc(var(--jarve-z-base))] border-t border-gray-200 bg-white shadow-lg",
830
+ style: {
831
+ ["--jarve-z-base"]: String(zIndexBase),
832
+ paddingBottom: "env(safe-area-inset-bottom, 0px)"
833
+ },
737
834
  children: /* @__PURE__ */ jsxs2("div", { className: "flex items-center justify-between gap-3 px-4 py-3", children: [
738
835
  /* @__PURE__ */ jsx2("span", { className: "truncate text-sm font-medium text-gray-900", children: "Capture this section?" }),
739
836
  /* @__PURE__ */ jsxs2("div", { className: "flex shrink-0 gap-2", children: [
@@ -761,132 +858,186 @@ function CaptureOverlay({
761
858
  ] })
762
859
  ] })
763
860
  }
764
- ),
765
- !isTouchMode && /* @__PURE__ */ jsx2("style", { children: `* { cursor: crosshair !important; }` })
861
+ )
766
862
  ] });
767
863
  }
768
864
 
769
865
  // src/report-modal.tsx
770
866
  import { useState as useState3, useRef as useRef3, useEffect as useEffect3, useCallback as useCallback2, useMemo } from "react";
771
867
  import { X, Send, Loader2, CheckCircle2 } from "lucide-react";
868
+ import {
869
+ cn as cn2,
870
+ createTimeoutAbortSignal,
871
+ useEscapeKey,
872
+ useFocusTrap,
873
+ useResolvedTheme,
874
+ useReturnFocus
875
+ } from "@jarve/widget-shared";
772
876
  import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
877
+ var STARTER_ASSISTANT_MESSAGE = {
878
+ role: "assistant",
879
+ content: "I can see you've captured a section of the page. What's going wrong here? Please describe the issue you're experiencing."
880
+ };
881
+ var DRAFT_STORAGE_PREFIX = "jarve:draft:bug-reporter:";
882
+ function draftStorageKey(siteId) {
883
+ return `${DRAFT_STORAGE_PREFIX}${siteId}`;
884
+ }
885
+ function readDraft(siteId) {
886
+ if (typeof sessionStorage === "undefined") return null;
887
+ try {
888
+ const raw = sessionStorage.getItem(draftStorageKey(siteId));
889
+ if (!raw) return null;
890
+ const parsed = JSON.parse(raw);
891
+ if (typeof (parsed == null ? void 0 : parsed.input) !== "string" || !Array.isArray(parsed == null ? void 0 : parsed.messages)) return null;
892
+ return parsed;
893
+ } catch (e) {
894
+ return null;
895
+ }
896
+ }
897
+ function writeDraft(siteId, draft) {
898
+ if (typeof sessionStorage === "undefined") return;
899
+ try {
900
+ sessionStorage.setItem(draftStorageKey(siteId), JSON.stringify(draft));
901
+ } catch (e) {
902
+ }
903
+ }
904
+ function clearDraft(siteId) {
905
+ if (typeof sessionStorage === "undefined") return;
906
+ try {
907
+ sessionStorage.removeItem(draftStorageKey(siteId));
908
+ } catch (e) {
909
+ }
910
+ }
773
911
  function ReportModal({
774
912
  isOpen,
775
913
  captureResult,
776
914
  apiConfig,
777
915
  siteId,
778
- user,
779
- onClose
916
+ onClose,
917
+ zIndexBase = 1e4,
918
+ theme = "auto",
919
+ onBeforeSubmit
780
920
  }) {
781
- const [messages, setMessages] = useState3([]);
921
+ const resolvedTheme = useResolvedTheme(theme);
922
+ const [messages, setMessages] = useState3([STARTER_ASSISTANT_MESSAGE]);
782
923
  const [input, setInput] = useState3("");
783
924
  const [isLoading, setIsLoading] = useState3(false);
784
925
  const [modalState, setModalState] = useState3("chatting");
785
926
  const [reportId, setReportId] = useState3(null);
786
927
  const [screenshotUrl, setScreenshotUrl] = useState3(null);
787
928
  const [errorMessage, setErrorMessage] = useState3(null);
788
- const chatEndRef = useRef3(null);
929
+ const [submitAlert, setSubmitAlert] = useState3(null);
930
+ const chatScrollRef = useRef3(null);
789
931
  const inputRef = useRef3(null);
790
- const hasInitRef = useRef3(false);
932
+ const dialogRef = useRef3(null);
933
+ const [sessionToken, setSessionToken] = useState3(() => "");
934
+ const activeSessionRef = useRef3("");
935
+ const abortControllerRef = useRef3(null);
936
+ if (abortControllerRef.current === null) {
937
+ abortControllerRef.current = new AbortController();
938
+ }
939
+ useEffect3(() => {
940
+ var _a, _b, _c;
941
+ if (isOpen) {
942
+ const next = crypto.randomUUID();
943
+ activeSessionRef.current = next;
944
+ setSessionToken(next);
945
+ const draft = readDraft(siteId);
946
+ setMessages(draft && draft.messages.length > 0 ? draft.messages : [STARTER_ASSISTANT_MESSAGE]);
947
+ setInput((_a = draft == null ? void 0 : draft.input) != null ? _a : "");
948
+ setIsLoading(false);
949
+ setModalState("chatting");
950
+ setReportId(null);
951
+ setErrorMessage(null);
952
+ setSubmitAlert(null);
953
+ if ((_b = abortControllerRef.current) == null ? void 0 : _b.signal.aborted) {
954
+ abortControllerRef.current = new AbortController();
955
+ }
956
+ } else {
957
+ (_c = abortControllerRef.current) == null ? void 0 : _c.abort();
958
+ }
959
+ }, [isOpen, siteId]);
960
+ useEffect3(() => {
961
+ return () => {
962
+ var _a;
963
+ (_a = abortControllerRef.current) == null ? void 0 : _a.abort();
964
+ };
965
+ }, []);
966
+ useEffect3(() => {
967
+ if (!isOpen) return;
968
+ if (modalState === "submitted" || modalState === "submitting") return;
969
+ const hasNonStarterMessages = messages.length > 1 || messages.length === 1 && messages[0] !== STARTER_ASSISTANT_MESSAGE;
970
+ if (!input && !hasNonStarterMessages) return;
971
+ writeDraft(siteId, { input, messages });
972
+ }, [isOpen, modalState, input, messages, siteId]);
791
973
  const apiHeaders = useMemo(
792
974
  () => ({
793
975
  "Content-Type": "application/json",
794
- "X-Bug-Reporter-Key": (apiConfig.apiKey || "").trim()
976
+ "X-Bug-Reporter-Key": (apiConfig.apiKey || "").trim(),
977
+ "X-Jarve-Session": sessionToken
978
+ }),
979
+ [apiConfig.apiKey, sessionToken]
980
+ );
981
+ const submitHeaders = useMemo(
982
+ () => ({
983
+ "X-Bug-Reporter-Key": (apiConfig.apiKey || "").trim(),
984
+ "X-Jarve-Session": sessionToken
795
985
  }),
796
- [apiConfig.apiKey]
986
+ [apiConfig.apiKey, sessionToken]
797
987
  );
798
988
  useEffect3(() => {
799
- if ((captureResult == null ? void 0 : captureResult.screenshot) && captureResult.screenshot.size > 0) {
800
- const url = URL.createObjectURL(captureResult.screenshot);
801
- setScreenshotUrl((prev) => {
802
- if (prev) URL.revokeObjectURL(prev);
803
- return url;
804
- });
805
- return () => {
806
- URL.revokeObjectURL(url);
807
- setScreenshotUrl(null);
808
- };
989
+ const blob = captureResult == null ? void 0 : captureResult.screenshot;
990
+ if (!blob || blob.size === 0) {
991
+ setScreenshotUrl(null);
992
+ return;
809
993
  }
810
- setScreenshotUrl((prev) => {
811
- if (prev) URL.revokeObjectURL(prev);
812
- return null;
813
- });
994
+ const url = URL.createObjectURL(blob);
995
+ setScreenshotUrl(url);
996
+ return () => {
997
+ URL.revokeObjectURL(url);
998
+ };
814
999
  }, [captureResult]);
815
- const sendInitialMessage = useCallback2(async () => {
816
- if (!captureResult) return;
817
- setIsLoading(true);
818
- try {
819
- const response = await fetch(`${apiConfig.apiUrl}/chat`, {
820
- method: "POST",
821
- headers: apiHeaders,
822
- body: JSON.stringify({
823
- messages: [],
824
- metadata: captureResult.metadata,
825
- consoleErrors: captureResult.consoleErrors,
826
- networkErrors: captureResult.networkErrors,
827
- clickedElement: captureResult.metadata.clickedElement || null
828
- })
829
- });
830
- if (response.status === 401) {
831
- console.error(
832
- "Bug reporter: invalid or missing API key. Check your BugReporter apiKey prop."
833
- );
834
- setMessages([
835
- {
836
- role: "assistant",
837
- content: "The bug reporter service isn't configured correctly. Please let the site administrator know."
838
- }
839
- ]);
840
- return;
841
- }
842
- if (!response.ok) throw new Error("Failed to get AI response");
843
- const data = await response.json();
844
- setMessages([{ role: "assistant", content: data.message }]);
845
- } catch (e) {
846
- setMessages([
847
- {
848
- role: "assistant",
849
- content: "I can see you've captured a section of the page. What's going wrong here? Please describe the issue you're experiencing."
850
- }
851
- ]);
852
- } finally {
853
- setIsLoading(false);
854
- }
855
- }, [captureResult, apiConfig.apiUrl, apiHeaders]);
856
1000
  useEffect3(() => {
857
- if (isOpen && captureResult && !hasInitRef.current) {
858
- hasInitRef.current = true;
859
- sendInitialMessage();
1001
+ const container = chatScrollRef.current;
1002
+ if (!container || typeof container.scrollTo !== "function") return;
1003
+ const gap = container.scrollHeight - container.scrollTop - container.clientHeight;
1004
+ if (gap < 100) {
1005
+ container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
860
1006
  }
861
- }, [isOpen, captureResult, sendInitialMessage]);
862
- useEffect3(() => {
863
- var _a;
864
- (_a = chatEndRef.current) == null ? void 0 : _a.scrollIntoView({ behavior: "smooth" });
865
1007
  }, [messages]);
1008
+ useReturnFocus(isOpen);
1009
+ useFocusTrap(isOpen, dialogRef);
866
1010
  useEffect3(() => {
867
1011
  var _a;
868
1012
  if (isOpen && !isLoading) {
869
1013
  (_a = inputRef.current) == null ? void 0 : _a.focus();
870
1014
  }
871
1015
  }, [isOpen, isLoading, messages]);
1016
+ const buildFetchSignal = useCallback2(() => {
1017
+ const controller = abortControllerRef.current;
1018
+ return createTimeoutAbortSignal(controller.signal, 3e4);
1019
+ }, []);
1020
+ const runWithFetchSignal = useCallback2(
1021
+ async (input2, init) => {
1022
+ const { signal, cleanup } = buildFetchSignal();
1023
+ try {
1024
+ return await fetch(input2, __spreadProps(__spreadValues({}, init), { signal }));
1025
+ } finally {
1026
+ cleanup();
1027
+ }
1028
+ },
1029
+ [buildFetchSignal]
1030
+ );
872
1031
  const submitReport = useCallback2(
873
1032
  async (conversation, structuredReport) => {
874
1033
  if (!captureResult || modalState !== "chatting") return;
875
1034
  setModalState("submitting");
1035
+ const sessionAtDispatch = activeSessionRef.current;
876
1036
  try {
877
- let screenshotBase64;
878
- if (captureResult.screenshot.size > 0) {
879
- const reader = new FileReader();
880
- screenshotBase64 = await new Promise((resolve, reject) => {
881
- reader.onload = () => resolve(reader.result);
882
- reader.onerror = reject;
883
- reader.readAsDataURL(captureResult.screenshot);
884
- });
885
- }
886
1037
  let report = structuredReport;
887
1038
  if (!report) {
888
1039
  try {
889
- const summaryResponse = await fetch(`${apiConfig.apiUrl}/chat`, {
1040
+ const summaryResponse = await runWithFetchSignal(`${apiConfig.apiUrl}/chat`, {
890
1041
  method: "POST",
891
1042
  headers: apiHeaders,
892
1043
  body: JSON.stringify({
@@ -898,6 +1049,7 @@ function ReportModal({
898
1049
  requestSummary: true
899
1050
  })
900
1051
  });
1052
+ if (sessionAtDispatch !== activeSessionRef.current) return;
901
1053
  if (summaryResponse.ok) {
902
1054
  const data2 = await summaryResponse.json();
903
1055
  report = data2.structuredReport;
@@ -905,46 +1057,87 @@ function ReportModal({
905
1057
  } catch (e) {
906
1058
  }
907
1059
  }
908
- const response = await fetch(`${apiConfig.apiUrl}/submit`, {
1060
+ if (sessionAtDispatch !== activeSessionRef.current) return;
1061
+ const basePayload = {
1062
+ metadata: captureResult.metadata,
1063
+ conversation,
1064
+ structuredReport: report,
1065
+ consoleErrors: captureResult.consoleErrors,
1066
+ networkErrors: captureResult.networkErrors,
1067
+ clickedElement: captureResult.metadata.clickedElement || null
1068
+ };
1069
+ let finalPayload = basePayload;
1070
+ if (onBeforeSubmit) {
1071
+ try {
1072
+ const maybe = await onBeforeSubmit(basePayload);
1073
+ if (sessionAtDispatch !== activeSessionRef.current) return;
1074
+ if (maybe === null || maybe === false) {
1075
+ setSubmitAlert("Submission cancelled. Update your report and try again.");
1076
+ setModalState("chatting");
1077
+ return;
1078
+ }
1079
+ finalPayload = maybe;
1080
+ } catch (hookErr) {
1081
+ if (sessionAtDispatch !== activeSessionRef.current) return;
1082
+ console.error("Bug reporter: onBeforeSubmit hook rejected the payload", hookErr);
1083
+ setSubmitAlert("Submission failed \u2014 please try again.");
1084
+ setModalState("chatting");
1085
+ return;
1086
+ }
1087
+ }
1088
+ const formData = new FormData();
1089
+ if (captureResult.screenshot.size > 0) {
1090
+ formData.append("screenshot", captureResult.screenshot);
1091
+ }
1092
+ formData.append("payload", JSON.stringify(finalPayload));
1093
+ const response = await runWithFetchSignal(`${apiConfig.apiUrl}/submit`, {
909
1094
  method: "POST",
910
- headers: apiHeaders,
911
- body: JSON.stringify({
912
- screenshot: screenshotBase64,
913
- metadata: captureResult.metadata,
914
- conversation,
915
- structuredReport: report,
916
- consoleErrors: captureResult.consoleErrors,
917
- networkErrors: captureResult.networkErrors,
918
- clickedElement: captureResult.metadata.clickedElement || null
919
- })
1095
+ headers: submitHeaders,
1096
+ body: formData
920
1097
  });
1098
+ if (sessionAtDispatch !== activeSessionRef.current) return;
921
1099
  if (!response.ok) {
922
1100
  const errorData = await response.json().catch(() => ({}));
923
1101
  throw new Error(errorData.error || "Failed to submit report");
924
1102
  }
925
1103
  const data = await response.json();
1104
+ if (sessionAtDispatch !== activeSessionRef.current) return;
926
1105
  setReportId(data.id);
927
1106
  setModalState("submitted");
1107
+ clearDraft(siteId);
928
1108
  } catch (err) {
1109
+ if (sessionAtDispatch !== activeSessionRef.current) return;
929
1110
  console.error("Bug reporter: failed to submit report", err);
930
1111
  setErrorMessage(err instanceof Error ? err.message : "Failed to submit report");
931
1112
  setModalState("error");
932
1113
  }
933
1114
  },
934
- [captureResult, apiConfig.apiUrl, apiHeaders, modalState]
1115
+ [
1116
+ captureResult,
1117
+ apiConfig.apiUrl,
1118
+ apiHeaders,
1119
+ submitHeaders,
1120
+ modalState,
1121
+ siteId,
1122
+ onBeforeSubmit,
1123
+ runWithFetchSignal
1124
+ ]
935
1125
  );
936
1126
  const handleManualSubmit = useCallback2(() => {
1127
+ setSubmitAlert(null);
937
1128
  submitReport(messages);
938
1129
  }, [submitReport, messages]);
939
1130
  async function sendMessage() {
940
1131
  if (!input.trim() || isLoading || !captureResult) return;
941
1132
  const userMessage = input.trim();
942
1133
  setInput("");
1134
+ setSubmitAlert(null);
943
1135
  const newMessages = [...messages, { role: "user", content: userMessage }];
944
1136
  setMessages(newMessages);
945
1137
  setIsLoading(true);
1138
+ const sessionAtDispatch = activeSessionRef.current;
946
1139
  try {
947
- const response = await fetch(`${apiConfig.apiUrl}/chat`, {
1140
+ const response = await runWithFetchSignal(`${apiConfig.apiUrl}/chat`, {
948
1141
  method: "POST",
949
1142
  headers: apiHeaders,
950
1143
  body: JSON.stringify({
@@ -955,6 +1148,7 @@ function ReportModal({
955
1148
  clickedElement: captureResult.metadata.clickedElement || null
956
1149
  })
957
1150
  });
1151
+ if (sessionAtDispatch !== activeSessionRef.current) return;
958
1152
  if (response.status === 401) {
959
1153
  console.error(
960
1154
  "Bug reporter: invalid or missing API key. Check your BugReporter apiKey prop."
@@ -970,6 +1164,7 @@ function ReportModal({
970
1164
  }
971
1165
  if (!response.ok) throw new Error("Failed to get AI response");
972
1166
  const data = await response.json();
1167
+ if (sessionAtDispatch !== activeSessionRef.current) return;
973
1168
  setMessages([...newMessages, { role: "assistant", content: data.message }]);
974
1169
  if (data.readyToSubmit && data.structuredReport) {
975
1170
  await submitReport(
@@ -978,6 +1173,7 @@ function ReportModal({
978
1173
  );
979
1174
  }
980
1175
  } catch (e) {
1176
+ if (sessionAtDispatch !== activeSessionRef.current) return;
981
1177
  setMessages([
982
1178
  ...newMessages,
983
1179
  {
@@ -986,22 +1182,23 @@ function ReportModal({
986
1182
  }
987
1183
  ]);
988
1184
  } finally {
989
- setIsLoading(false);
1185
+ if (sessionAtDispatch === activeSessionRef.current) {
1186
+ setIsLoading(false);
1187
+ }
990
1188
  }
991
1189
  }
992
1190
  function handleClose() {
993
- setMessages([]);
1191
+ var _a;
1192
+ (_a = abortControllerRef.current) == null ? void 0 : _a.abort();
1193
+ setMessages([STARTER_ASSISTANT_MESSAGE]);
994
1194
  setInput("");
995
1195
  setModalState("chatting");
996
1196
  setReportId(null);
997
1197
  setErrorMessage(null);
998
- if (screenshotUrl) {
999
- URL.revokeObjectURL(screenshotUrl);
1000
- }
1001
- setScreenshotUrl(null);
1002
- hasInitRef.current = false;
1198
+ setSubmitAlert(null);
1003
1199
  onClose();
1004
1200
  }
1201
+ useEscapeKey(handleClose, isOpen);
1005
1202
  function handleKeyDown(e) {
1006
1203
  if (e.key === "Enter" && !e.shiftKey && !isTouchCapable()) {
1007
1204
  e.preventDefault();
@@ -1013,14 +1210,24 @@ function ReportModal({
1013
1210
  "div",
1014
1211
  {
1015
1212
  "data-bug-reporter": true,
1016
- className: "fixed inset-0 z-[10001] flex items-center justify-center bg-black/50 backdrop-blur-sm",
1213
+ className: "fixed inset-0 z-[calc(var(--jarve-z-base)+1)] flex items-center justify-center bg-black/50 backdrop-blur-sm",
1214
+ style: { ["--jarve-z-base"]: String(zIndexBase) },
1017
1215
  onClick: (e) => {
1018
1216
  if (e.target === e.currentTarget) handleClose();
1019
1217
  },
1020
1218
  children: /* @__PURE__ */ jsxs3(
1021
1219
  "div",
1022
1220
  {
1023
- className: cn(
1221
+ ref: dialogRef,
1222
+ role: "dialog",
1223
+ "aria-modal": "true",
1224
+ "aria-labelledby": "jarve-bug-reporter-title",
1225
+ className: cn2(
1226
+ // Self-owned theme wrapper — `dark` toggles the existing Tailwind
1227
+ // `dark:` utilities via the shared `@custom-variant dark (&:is(.dark *))`
1228
+ // rule in styles.css. This makes the widget independent of the
1229
+ // host app's Tailwind darkMode config. See Issue E11.
1230
+ resolvedTheme === "dark" ? "jarve-theme-dark dark" : "jarve-theme-light",
1024
1231
  "flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl dark:border-gray-800 dark:bg-gray-950",
1025
1232
  "mx-4 w-full max-w-lg",
1026
1233
  "max-[768px]:mx-0 max-[768px]:h-full max-[768px]:max-w-none max-[768px]:rounded-none",
@@ -1029,7 +1236,14 @@ function ReportModal({
1029
1236
  children: [
1030
1237
  /* @__PURE__ */ jsxs3("div", { className: "flex items-center justify-between border-b border-gray-200 bg-gray-50/30 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/30", children: [
1031
1238
  /* @__PURE__ */ jsxs3("div", { children: [
1032
- /* @__PURE__ */ jsx3("h2", { className: "text-sm font-semibold text-gray-900 dark:text-gray-100", children: "Bug Report" }),
1239
+ /* @__PURE__ */ jsx3(
1240
+ "h2",
1241
+ {
1242
+ id: "jarve-bug-reporter-title",
1243
+ className: "text-sm font-semibold text-gray-900 dark:text-gray-100",
1244
+ children: "Bug Report"
1245
+ }
1246
+ ),
1033
1247
  /* @__PURE__ */ jsx3("p", { className: "text-xs text-gray-500 dark:text-gray-400", children: siteId })
1034
1248
  ] }),
1035
1249
  /* @__PURE__ */ jsx3(
@@ -1050,58 +1264,76 @@ function ReportModal({
1050
1264
  className: "max-h-40 w-full rounded-md border border-gray-200 object-contain dark:border-gray-700"
1051
1265
  }
1052
1266
  ) }),
1053
- /* @__PURE__ */ jsx3("div", { className: "min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-3", children: modalState === "submitted" ? /* @__PURE__ */ jsxs3("div", { className: "flex flex-col items-center justify-center py-8 text-center", children: [
1054
- /* @__PURE__ */ jsx3(CheckCircle2, { className: "mb-3 h-12 w-12 text-green-500" }),
1055
- /* @__PURE__ */ jsx3("h3", { className: "text-lg font-semibold text-gray-900 dark:text-gray-100", children: "Report Submitted" }),
1056
- /* @__PURE__ */ jsxs3("p", { className: "mt-1 text-sm text-gray-500 dark:text-gray-400", children: [
1057
- "Reference:",
1058
- " ",
1059
- /* @__PURE__ */ jsx3("code", { className: "rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800", children: reportId == null ? void 0 : reportId.slice(0, 8) })
1060
- ] }),
1061
- /* @__PURE__ */ jsx3("p", { className: "mt-2 text-sm text-gray-500 dark:text-gray-400", children: "Thanks for the report \u2014 we'll look into it." }),
1062
- /* @__PURE__ */ jsx3(
1063
- "button",
1064
- {
1065
- onClick: handleClose,
1066
- className: "mt-4 rounded-md bg-indigo-600 px-4 py-2 text-sm text-white transition-colors hover:bg-indigo-700",
1067
- children: "Done"
1068
- }
1069
- )
1070
- ] }) : modalState === "error" ? /* @__PURE__ */ jsxs3("div", { className: "flex flex-col items-center justify-center py-8 text-center", children: [
1071
- /* @__PURE__ */ jsx3(X, { className: "mb-3 h-12 w-12 text-red-500" }),
1072
- /* @__PURE__ */ jsx3("h3", { className: "text-lg font-semibold text-gray-900 dark:text-gray-100", children: "Submission Failed" }),
1073
- /* @__PURE__ */ jsx3("p", { className: "mt-1 text-sm text-gray-500 dark:text-gray-400", children: errorMessage || "Something went wrong. Please try again." }),
1267
+ /* @__PURE__ */ jsx3(
1268
+ "div",
1269
+ {
1270
+ ref: chatScrollRef,
1271
+ "data-testid": "chat-scroll",
1272
+ className: "min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-3",
1273
+ children: modalState === "submitted" ? /* @__PURE__ */ jsxs3("div", { className: "flex flex-col items-center justify-center py-8 text-center", children: [
1274
+ /* @__PURE__ */ jsx3(CheckCircle2, { className: "mb-3 h-12 w-12 text-green-500" }),
1275
+ /* @__PURE__ */ jsx3("h3", { className: "text-lg font-semibold text-gray-900 dark:text-gray-100", children: "Report Submitted" }),
1276
+ /* @__PURE__ */ jsxs3("p", { className: "mt-1 text-sm text-gray-500 dark:text-gray-400", children: [
1277
+ "Reference:",
1278
+ " ",
1279
+ /* @__PURE__ */ jsx3("code", { className: "rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800", children: reportId == null ? void 0 : reportId.slice(0, 8) })
1280
+ ] }),
1281
+ /* @__PURE__ */ jsx3("p", { className: "mt-2 text-sm text-gray-500 dark:text-gray-400", children: "Thanks for the report \u2014 we'll look into it." }),
1282
+ /* @__PURE__ */ jsx3(
1283
+ "button",
1284
+ {
1285
+ onClick: handleClose,
1286
+ className: "mt-4 rounded-md bg-indigo-600 px-4 py-2 text-sm text-white transition-colors hover:bg-indigo-700",
1287
+ children: "Done"
1288
+ }
1289
+ )
1290
+ ] }) : modalState === "error" ? /* @__PURE__ */ jsxs3("div", { className: "flex flex-col items-center justify-center py-8 text-center", children: [
1291
+ /* @__PURE__ */ jsx3(X, { className: "mb-3 h-12 w-12 text-red-500" }),
1292
+ /* @__PURE__ */ jsx3("h3", { className: "text-lg font-semibold text-gray-900 dark:text-gray-100", children: "Submission Failed" }),
1293
+ /* @__PURE__ */ jsx3("p", { className: "mt-1 text-sm text-gray-500 dark:text-gray-400", children: errorMessage || "Something went wrong. Please try again." }),
1294
+ /* @__PURE__ */ jsx3(
1295
+ "button",
1296
+ {
1297
+ onClick: () => setModalState("chatting"),
1298
+ className: "mt-4 rounded-md bg-indigo-600 px-4 py-2 text-sm text-white transition-colors hover:bg-indigo-700",
1299
+ children: "Try Again"
1300
+ }
1301
+ )
1302
+ ] }) : modalState === "submitting" ? /* @__PURE__ */ jsxs3("div", { className: "flex flex-col items-center justify-center py-8", children: [
1303
+ /* @__PURE__ */ jsx3(Loader2, { className: "mb-3 h-8 w-8 animate-spin text-indigo-500" }),
1304
+ /* @__PURE__ */ jsx3("p", { className: "text-sm text-gray-500 dark:text-gray-400", children: "Submitting your report..." })
1305
+ ] }) : /* @__PURE__ */ jsxs3(Fragment2, { children: [
1306
+ (captureResult == null ? void 0 : captureResult.screenshot.size) === 0 && /* @__PURE__ */ jsx3("p", { className: "text-xs text-amber-600", children: "Screenshot could not be captured. Please describe the visual issue in detail." }),
1307
+ messages.map((msg, i) => /* @__PURE__ */ jsx3(
1308
+ "div",
1309
+ {
1310
+ className: cn2(
1311
+ "text-sm leading-relaxed",
1312
+ msg.role === "assistant" ? "rounded-lg bg-gray-100/50 p-3 text-gray-900 dark:bg-gray-800/50 dark:text-gray-100" : "ml-8 rounded-lg bg-indigo-600 p-3 text-white"
1313
+ ),
1314
+ children: msg.content
1315
+ },
1316
+ i
1317
+ )),
1318
+ isLoading && /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-2 rounded-lg bg-gray-100/50 p-3 dark:bg-gray-800/50", children: [
1319
+ /* @__PURE__ */ jsx3(Loader2, { className: "h-3.5 w-3.5 animate-spin text-gray-500" }),
1320
+ /* @__PURE__ */ jsx3("span", { className: "text-sm text-gray-500 dark:text-gray-400", children: "Thinking..." })
1321
+ ] })
1322
+ ] })
1323
+ }
1324
+ ),
1325
+ modalState === "chatting" && /* @__PURE__ */ jsx3("div", { className: "border-t border-gray-200 px-4 py-3 dark:border-gray-800", children: /* @__PURE__ */ jsxs3("div", { className: "flex flex-col gap-2", children: [
1326
+ submitAlert && // onBeforeSubmit rejected the payload — keep the draft and
1327
+ // let the user retry. role="alert" announces to AT users.
1328
+ // See Issue B8.
1074
1329
  /* @__PURE__ */ jsx3(
1075
- "button",
1330
+ "p",
1076
1331
  {
1077
- onClick: () => setModalState("chatting"),
1078
- className: "mt-4 rounded-md bg-indigo-600 px-4 py-2 text-sm text-white transition-colors hover:bg-indigo-700",
1079
- children: "Try Again"
1332
+ role: "alert",
1333
+ className: "rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300",
1334
+ children: submitAlert
1080
1335
  }
1081
- )
1082
- ] }) : modalState === "submitting" ? /* @__PURE__ */ jsxs3("div", { className: "flex flex-col items-center justify-center py-8", children: [
1083
- /* @__PURE__ */ jsx3(Loader2, { className: "mb-3 h-8 w-8 animate-spin text-indigo-500" }),
1084
- /* @__PURE__ */ jsx3("p", { className: "text-sm text-gray-500 dark:text-gray-400", children: "Submitting your report..." })
1085
- ] }) : /* @__PURE__ */ jsxs3(Fragment2, { children: [
1086
- (captureResult == null ? void 0 : captureResult.screenshot.size) === 0 && /* @__PURE__ */ jsx3("p", { className: "text-xs text-amber-600", children: "Screenshot could not be captured. Please describe the visual issue in detail." }),
1087
- messages.map((msg, i) => /* @__PURE__ */ jsx3(
1088
- "div",
1089
- {
1090
- className: cn(
1091
- "text-sm leading-relaxed",
1092
- msg.role === "assistant" ? "rounded-lg bg-gray-100/50 p-3 text-gray-900 dark:bg-gray-800/50 dark:text-gray-100" : "ml-8 rounded-lg bg-indigo-600 p-3 text-white"
1093
- ),
1094
- children: msg.content
1095
- },
1096
- i
1097
- )),
1098
- isLoading && /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-2 rounded-lg bg-gray-100/50 p-3 dark:bg-gray-800/50", children: [
1099
- /* @__PURE__ */ jsx3(Loader2, { className: "h-3.5 w-3.5 animate-spin text-gray-500" }),
1100
- /* @__PURE__ */ jsx3("span", { className: "text-sm text-gray-500 dark:text-gray-400", children: "Thinking..." })
1101
- ] }),
1102
- /* @__PURE__ */ jsx3("div", { ref: chatEndRef })
1103
- ] }) }),
1104
- modalState === "chatting" && /* @__PURE__ */ jsx3("div", { className: "border-t border-gray-200 px-4 py-3 dark:border-gray-800", children: /* @__PURE__ */ jsxs3("div", { className: "flex flex-col gap-2", children: [
1336
+ ),
1105
1337
  /* @__PURE__ */ jsx3(
1106
1338
  "textarea",
1107
1339
  {
@@ -1159,34 +1391,66 @@ function ReportModal({
1159
1391
  }
1160
1392
 
1161
1393
  // src/bug-reporter.tsx
1394
+ import { emit, useHasMounted } from "@jarve/widget-shared";
1162
1395
  import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1396
+ function isValidApiUrl(raw) {
1397
+ if (typeof raw !== "string" || raw.length === 0) return false;
1398
+ try {
1399
+ const parsed = new URL(raw);
1400
+ if (parsed.protocol === "https:") return true;
1401
+ if (parsed.protocol === "http:" && parsed.hostname === "localhost") return true;
1402
+ return false;
1403
+ } catch (e) {
1404
+ return false;
1405
+ }
1406
+ }
1163
1407
  function JarveBugReporter({
1164
1408
  apiUrl,
1165
1409
  apiKey,
1410
+ siteId,
1166
1411
  user,
1167
1412
  buttonPosition,
1413
+ zIndexBase,
1414
+ captureResponseBodies = false,
1415
+ theme,
1416
+ onBeforeSubmit,
1168
1417
  children
1169
1418
  }) {
1170
- const safeApiKey = apiKey || "";
1419
+ const mounted = useHasMounted();
1420
+ const apiUrlValid = useMemo2(() => isValidApiUrl(apiUrl), [apiUrl]);
1421
+ useEffect4(() => {
1422
+ if (!apiUrlValid) {
1423
+ console.error(
1424
+ `Jarve bug reporter: invalid apiUrl \u2014 widget disabled (received: ${JSON.stringify(apiUrl)})`
1425
+ );
1426
+ }
1427
+ }, [apiUrl, apiUrlValid]);
1171
1428
  const [captureMode, setCaptureMode] = useState4(false);
1172
1429
  const [captureResult, setCaptureResult] = useState4(null);
1173
1430
  const [showModal, setShowModal] = useState4(false);
1431
+ const [isPrimary, setIsPrimary] = useState4(false);
1174
1432
  useEffect4(() => {
1433
+ if (!apiUrlValid || !isPrimary) return;
1175
1434
  startCapturing();
1176
- startNetworkCapture();
1435
+ startNetworkCapture({ captureResponseBodies });
1177
1436
  return () => {
1178
1437
  stopCapturing();
1179
1438
  stopNetworkCapture();
1180
1439
  };
1181
- }, []);
1440
+ }, [apiUrlValid, isPrimary, captureResponseBodies]);
1182
1441
  const toggleCaptureMode = useCallback3(() => {
1183
1442
  setCaptureMode((prev) => !prev);
1184
1443
  }, []);
1185
1444
  useEffect4(() => {
1186
1445
  if (typeof window === "undefined") return;
1446
+ if (!apiUrlValid) return;
1187
1447
  if (!window.__jarve_widgets) {
1188
1448
  window.__jarve_widgets = /* @__PURE__ */ new Map();
1189
1449
  }
1450
+ if (window.__jarve_widgets.has("bug-reporter")) {
1451
+ console.warn("JarveBugReporter: instance already mounted; additional instance ignored");
1452
+ return;
1453
+ }
1190
1454
  window.__jarve_widgets.set("bug-reporter", {
1191
1455
  type: "bug-reporter",
1192
1456
  label: "Report a Bug",
@@ -1196,28 +1460,25 @@ function JarveBugReporter({
1196
1460
  isActive: false,
1197
1461
  trigger: toggleCaptureMode
1198
1462
  });
1199
- window.dispatchEvent(
1200
- new CustomEvent("jarve:widget-registered", { detail: { type: "bug-reporter" } })
1201
- );
1463
+ setIsPrimary(true);
1464
+ emit("jarve:widget-registered", { type: "bug-reporter" });
1202
1465
  return () => {
1203
1466
  var _a;
1204
1467
  (_a = window.__jarve_widgets) == null ? void 0 : _a.delete("bug-reporter");
1205
- window.dispatchEvent(
1206
- new CustomEvent("jarve:widget-deregistered", { detail: { type: "bug-reporter" } })
1207
- );
1468
+ setIsPrimary(false);
1469
+ emit("jarve:widget-deregistered", { type: "bug-reporter" });
1208
1470
  };
1209
- }, [toggleCaptureMode]);
1471
+ }, [toggleCaptureMode, apiUrlValid]);
1210
1472
  useEffect4(() => {
1211
1473
  var _a;
1212
1474
  if (typeof window === "undefined") return;
1475
+ if (!isPrimary) return;
1213
1476
  const entry = (_a = window.__jarve_widgets) == null ? void 0 : _a.get("bug-reporter");
1214
1477
  if (entry) {
1215
1478
  entry.isActive = captureMode || showModal;
1216
- window.dispatchEvent(
1217
- new CustomEvent("jarve:state-change", { detail: { type: "bug-reporter" } })
1218
- );
1479
+ emit("jarve:state-change", { type: "bug-reporter", isActive: entry.isActive });
1219
1480
  }
1220
- }, [captureMode, showModal]);
1481
+ }, [captureMode, showModal, isPrimary]);
1221
1482
  const handleCapture = useCallback3((result) => {
1222
1483
  setCaptureResult(result);
1223
1484
  setCaptureMode(false);
@@ -1227,49 +1488,66 @@ function JarveBugReporter({
1227
1488
  setCaptureMode(false);
1228
1489
  }, []);
1229
1490
  const handleCloseModal = useCallback3(() => {
1491
+ setCaptureMode(false);
1230
1492
  setShowModal(false);
1231
1493
  setCaptureResult(null);
1232
1494
  clearCapturedErrors();
1233
1495
  clearCapturedNetworkErrors();
1234
1496
  }, []);
1235
- const siteId = safeApiKey.startsWith("brk_") ? safeApiKey.slice(4, 12) : "external";
1497
+ useEffect4(() => {
1498
+ if (typeof window === "undefined") return;
1499
+ if (!isPrimary) return;
1500
+ window.addEventListener("jarve:close-bug-modal", handleCloseModal);
1501
+ return () => {
1502
+ window.removeEventListener("jarve:close-bug-modal", handleCloseModal);
1503
+ };
1504
+ }, [isPrimary, handleCloseModal]);
1236
1505
  const reporterName = (user == null ? void 0 : user.name) || "Anonymous";
1237
1506
  const reporterEmail = (user == null ? void 0 : user.email) || "unknown@external";
1507
+ if (!apiUrlValid) {
1508
+ return /* @__PURE__ */ jsx4(Fragment3, { children });
1509
+ }
1238
1510
  return /* @__PURE__ */ jsxs4(Fragment3, { children: [
1239
1511
  children,
1240
- /* @__PURE__ */ jsx4(
1241
- FloatingButton,
1242
- {
1243
- isActive: captureMode,
1244
- onClick: toggleCaptureMode,
1245
- position: buttonPosition
1246
- }
1247
- ),
1248
- /* @__PURE__ */ jsx4(
1249
- CaptureOverlay,
1250
- {
1251
- isActive: captureMode,
1252
- siteId,
1253
- reporterName,
1254
- reporterEmail,
1255
- onCapture: handleCapture,
1256
- onCancel: handleCancelCapture
1257
- }
1258
- ),
1259
- /* @__PURE__ */ jsx4(
1260
- ReportModal,
1261
- {
1262
- isOpen: showModal,
1263
- captureResult,
1264
- apiConfig: { apiUrl, apiKey },
1265
- siteId,
1266
- user: { name: reporterName, email: reporterEmail },
1267
- onClose: handleCloseModal
1268
- }
1269
- )
1512
+ mounted && isPrimary && /* @__PURE__ */ jsxs4(Fragment3, { children: [
1513
+ /* @__PURE__ */ jsx4(
1514
+ FloatingButton,
1515
+ {
1516
+ isActive: captureMode,
1517
+ onClick: toggleCaptureMode,
1518
+ position: buttonPosition,
1519
+ zIndexBase
1520
+ }
1521
+ ),
1522
+ /* @__PURE__ */ jsx4(
1523
+ CaptureOverlay,
1524
+ {
1525
+ isActive: captureMode,
1526
+ siteId,
1527
+ reporterName,
1528
+ reporterEmail,
1529
+ onCapture: handleCapture,
1530
+ onCancel: handleCancelCapture,
1531
+ zIndexBase
1532
+ }
1533
+ ),
1534
+ /* @__PURE__ */ jsx4(
1535
+ ReportModal,
1536
+ {
1537
+ isOpen: showModal,
1538
+ captureResult,
1539
+ apiConfig: { apiUrl, apiKey },
1540
+ siteId,
1541
+ user: { name: reporterName, email: reporterEmail },
1542
+ onClose: handleCloseModal,
1543
+ zIndexBase,
1544
+ theme,
1545
+ onBeforeSubmit
1546
+ }
1547
+ )
1548
+ ] })
1270
1549
  ] });
1271
1550
  }
1272
1551
  export {
1273
1552
  JarveBugReporter
1274
1553
  };
1275
- //# sourceMappingURL=index.mjs.map