@mhosaic/feedback 0.5.3 → 0.6.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.
@@ -1,870 +0,0 @@
1
- // src/api/client.ts
2
- var SCALAR_FIELDS = [
3
- "description",
4
- "feedback_type",
5
- "severity",
6
- "env",
7
- "page_url",
8
- "user_agent",
9
- "capture_method"
10
- ];
11
- function createApiClient(options) {
12
- const endpoint = (options.endpoint ?? "").replace(/\/+$/, "");
13
- if (!endpoint) {
14
- throw new Error(
15
- '[mhosaic-feedback] `endpoint` is required (e.g. "https://feedback.example.com").'
16
- );
17
- }
18
- const fetcher = options.fetch ?? globalThis.fetch;
19
- async function submitReport(input) {
20
- let payload = input;
21
- if (options.beforeSend) payload = await options.beforeSend(input);
22
- if (payload === false) throw new Error("Submission cancelled by beforeSend");
23
- const form = new FormData();
24
- for (const field of SCALAR_FIELDS) {
25
- form.append(field, String(payload[field]));
26
- }
27
- form.append("technical_context", JSON.stringify(payload.technical_context));
28
- if (payload.screenshot) form.append("screenshot", payload.screenshot, "screenshot.png");
29
- if (payload.synthetic) form.append("synthetic", "true");
30
- const response = await fetcher(`${endpoint}/api/feedback/v1/reports/`, {
31
- method: "POST",
32
- headers: { Authorization: `Bearer ${options.apiKey}` },
33
- body: form
34
- });
35
- if (!response.ok) {
36
- const text = await response.text().catch(() => "");
37
- throw new Error(`Feedback submit failed: ${response.status} ${text}`);
38
- }
39
- return response.json();
40
- }
41
- return { submitReport };
42
- }
43
-
44
- // src/capture/urlSanitizer.ts
45
- var SENSITIVE = /token|key|password|secret|auth|session|sig/i;
46
- function sanitizeUrl(url) {
47
- try {
48
- const isAbsolute = /^https?:\/\//i.test(url);
49
- const isRootRelative = url.startsWith("/");
50
- if (!isAbsolute && !isRootRelative) return url;
51
- const base = typeof window !== "undefined" ? window.location.origin : "http://localhost";
52
- const u = new URL(url, base);
53
- const clean = new URLSearchParams();
54
- u.searchParams.forEach((value, name) => {
55
- clean.set(name, SENSITIVE.test(name) ? "[redacted]" : value);
56
- });
57
- u.search = clean.toString();
58
- return u.toString();
59
- } catch {
60
- return url;
61
- }
62
- }
63
-
64
- // src/capture/device.ts
65
- function collectDevice() {
66
- const nav = navigator;
67
- const connection = nav.connection?.effectiveType;
68
- const deviceMemory = nav.deviceMemory;
69
- const referrer = document.referrer || void 0;
70
- return {
71
- viewport: { w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio || 1 },
72
- screen: { w: window.screen.width, h: window.screen.height },
73
- platform: nav.platform,
74
- language: nav.language,
75
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
76
- timezoneOffset: (/* @__PURE__ */ new Date()).getTimezoneOffset(),
77
- ...connection !== void 0 && { connection },
78
- online: nav.onLine,
79
- ...deviceMemory !== void 0 && { deviceMemory },
80
- hardwareConcurrency: nav.hardwareConcurrency,
81
- ...referrer !== void 0 && { referrer },
82
- title: document.title,
83
- pathname: window.location.pathname
84
- };
85
- }
86
-
87
- // src/capture/console.ts
88
- function safeStringify(arg) {
89
- if (arg == null) return String(arg);
90
- if (typeof arg === "string") return arg;
91
- if (typeof arg === "number" || typeof arg === "boolean") return String(arg);
92
- if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
93
- if (arg instanceof Element) return `<${arg.tagName.toLowerCase()}>`;
94
- try {
95
- return JSON.stringify(arg, (_k, v) => typeof v === "bigint" ? v.toString() : v);
96
- } catch {
97
- try {
98
- return String(arg);
99
- } catch {
100
- return "[unserializable]";
101
- }
102
- }
103
- }
104
- function installConsolePatch(buffer) {
105
- const levels = ["log", "info", "warn", "error", "debug"];
106
- const originals = {};
107
- for (const level of levels) {
108
- const original = console[level];
109
- if (typeof original !== "function") continue;
110
- originals[level] = original;
111
- console[level] = function patched(...args) {
112
- try {
113
- const message = args.map(safeStringify).join(" ").slice(0, 2e3);
114
- const entry = { level, message, ts: Date.now() };
115
- if (level === "error") {
116
- const stack = new Error().stack;
117
- if (stack) entry.stack = stack.split("\n").slice(2, 8).join("\n");
118
- }
119
- buffer.push(entry);
120
- } catch {
121
- }
122
- original.apply(console, args);
123
- };
124
- }
125
- return () => {
126
- for (const [level, fn] of Object.entries(originals)) {
127
- console[level] = fn;
128
- }
129
- };
130
- }
131
-
132
- // src/capture/network.ts
133
- function installFetchPatch(buffer, sanitize) {
134
- if (typeof window === "undefined" || typeof window.fetch !== "function") return () => {
135
- };
136
- const original = window.fetch.bind(window);
137
- window.fetch = async function patched(input, init) {
138
- const start = performance.now();
139
- const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
140
- const method = (init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
141
- try {
142
- const response = await original(input, init);
143
- buffer.push({ url: sanitize(url), method, status: response.status, durationMs: Math.round(performance.now() - start), ts: Date.now() });
144
- return response;
145
- } catch (err) {
146
- buffer.push({
147
- url: sanitize(url),
148
- method,
149
- status: 0,
150
- durationMs: Math.round(performance.now() - start),
151
- ts: Date.now(),
152
- error: err instanceof Error ? err.message : String(err)
153
- });
154
- throw err;
155
- }
156
- };
157
- return () => {
158
- window.fetch = original;
159
- };
160
- }
161
- function installXhrPatch(buffer, sanitize) {
162
- if (typeof window === "undefined" || typeof window.XMLHttpRequest !== "function") return () => {
163
- };
164
- const Original = window.XMLHttpRequest;
165
- const originalOpen = Original.prototype.open;
166
- const originalSend = Original.prototype.send;
167
- Original.prototype.open = function patchedOpen(method, url) {
168
- this.__mfb = { method: method.toUpperCase(), url: typeof url === "string" ? url : url.toString(), start: performance.now() };
169
- return originalOpen.apply(this, arguments);
170
- };
171
- Original.prototype.send = function patchedSend(body) {
172
- this.addEventListener("loadend", () => {
173
- try {
174
- const ctx = this.__mfb;
175
- if (!ctx) return;
176
- buffer.push({
177
- url: sanitize(ctx.url),
178
- method: ctx.method,
179
- status: this.status,
180
- durationMs: Math.round(performance.now() - ctx.start),
181
- ts: Date.now()
182
- });
183
- } catch {
184
- }
185
- });
186
- return originalSend.call(this, body ?? null);
187
- };
188
- return () => {
189
- Original.prototype.open = originalOpen;
190
- Original.prototype.send = originalSend;
191
- };
192
- }
193
-
194
- // src/capture/errors.ts
195
- function installErrorHandlers(buffer) {
196
- if (typeof window === "undefined") return () => {
197
- };
198
- const onError = (e) => {
199
- const stack = e.error instanceof Error ? e.error.stack : void 0;
200
- buffer.push({
201
- message: e.message || "Unknown error",
202
- ...stack !== void 0 && { stack },
203
- ts: Date.now(),
204
- source: "window.error"
205
- });
206
- };
207
- const onRejection = (e) => {
208
- const reason = e.reason;
209
- const message = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : (() => {
210
- try {
211
- return JSON.stringify(reason);
212
- } catch {
213
- return String(reason);
214
- }
215
- })();
216
- const stack = reason instanceof Error ? reason.stack : void 0;
217
- buffer.push({
218
- message,
219
- ...stack !== void 0 && { stack },
220
- ts: Date.now(),
221
- source: "unhandledrejection"
222
- });
223
- };
224
- window.addEventListener("error", onError);
225
- window.addEventListener("unhandledrejection", onRejection);
226
- return () => {
227
- window.removeEventListener("error", onError);
228
- window.removeEventListener("unhandledrejection", onRejection);
229
- };
230
- }
231
-
232
- // src/capture/performance.ts
233
- function createPerformanceCollector(slowResourceMs = 1e3) {
234
- const longTasks = [];
235
- const slowResources = [];
236
- let observer = null;
237
- if (typeof PerformanceObserver !== "undefined") {
238
- try {
239
- observer = new PerformanceObserver((list) => {
240
- for (const entry of list.getEntries()) {
241
- if (entry.entryType === "longtask") {
242
- longTasks.push({ duration: entry.duration, startTime: entry.startTime });
243
- while (longTasks.length > 20) longTasks.shift();
244
- } else if (entry.entryType === "resource") {
245
- const e = entry;
246
- if (e.duration > slowResourceMs) {
247
- slowResources.push({ name: e.name, duration: e.duration, initiatorType: e.initiatorType });
248
- while (slowResources.length > 20) slowResources.shift();
249
- }
250
- }
251
- }
252
- });
253
- observer.observe({ entryTypes: ["longtask", "resource"] });
254
- } catch {
255
- }
256
- }
257
- return {
258
- snapshot() {
259
- const nav = typeof performance !== "undefined" ? performance.getEntriesByType("navigation")[0] : void 0;
260
- const navigation = nav ? { type: nav.type, duration: nav.duration } : void 0;
261
- return {
262
- ...navigation !== void 0 && { navigation },
263
- longTasks: longTasks.slice(),
264
- slowResources: slowResources.slice()
265
- };
266
- },
267
- dispose() {
268
- observer?.disconnect();
269
- }
270
- };
271
- }
272
-
273
- // src/capture/ringBuffer.ts
274
- var RingBuffer = class {
275
- constructor(max) {
276
- this.max = max;
277
- }
278
- max;
279
- items = [];
280
- push(item) {
281
- this.items.push(item);
282
- while (this.items.length > this.max) this.items.shift();
283
- }
284
- snapshot() {
285
- return this.items.slice();
286
- }
287
- clear() {
288
- this.items.length = 0;
289
- }
290
- };
291
-
292
- // src/capture/index.ts
293
- function installCapture(options = {}) {
294
- const { maxConsole = 50, maxNetwork = 50, maxErrors = 20 } = options;
295
- const sanitize = options.sanitizeUrl ?? sanitizeUrl;
296
- const consoleBuf = new RingBuffer(maxConsole);
297
- const networkBuf = new RingBuffer(maxNetwork);
298
- const errorBuf = new RingBuffer(maxErrors);
299
- const uninstallConsole = installConsolePatch(consoleBuf);
300
- const uninstallFetch = installFetchPatch(networkBuf, sanitize);
301
- const uninstallXhr = installXhrPatch(networkBuf, sanitize);
302
- const uninstallErrors = installErrorHandlers(errorBuf);
303
- const perf = createPerformanceCollector();
304
- return {
305
- snapshot() {
306
- return {
307
- consoleLogs: consoleBuf.snapshot(),
308
- networkRequests: networkBuf.snapshot(),
309
- errors: errorBuf.snapshot(),
310
- device: collectDevice(),
311
- capturedAt: Date.now()
312
- };
313
- },
314
- clear() {
315
- consoleBuf.clear();
316
- networkBuf.clear();
317
- errorBuf.clear();
318
- },
319
- dispose() {
320
- uninstallConsole();
321
- uninstallFetch();
322
- uninstallXhr();
323
- uninstallErrors();
324
- perf.dispose();
325
- }
326
- };
327
- }
328
-
329
- // src/screenshot/index.ts
330
- function isMaskedElement(el) {
331
- return el.hasAttribute("data-mfb-mask") || el.classList.contains("mfb-mask");
332
- }
333
- function prepareMaskMatcher(selectors) {
334
- const joined = selectors.join(",").trim();
335
- if (!joined) return isMaskedElement;
336
- return (el) => isMaskedElement(el) || el.matches(joined);
337
- }
338
- async function takeScreenshot(target, options = {}) {
339
- if (typeof document === "undefined") return null;
340
- try {
341
- const html2canvas = (await import("html2canvas-pro")).default;
342
- const matcher = prepareMaskMatcher(options.mask ?? []);
343
- const canvas = await html2canvas(target, {
344
- ignoreElements: (el) => matcher(el),
345
- useCORS: true,
346
- logging: false,
347
- backgroundColor: null
348
- });
349
- return await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
350
- } catch {
351
- return null;
352
- }
353
- }
354
-
355
- // src/widget/i18n.ts
356
- var DEFAULT_STRINGS = {
357
- "fab.label": "Send feedback",
358
- "form.title": "Send feedback",
359
- "form.description.label": "What happened?",
360
- "form.description.placeholder": "Describe the issue or idea in one or two sentences.",
361
- "form.type.label": "Type",
362
- "form.severity.label": "Severity",
363
- "form.submit": "Send",
364
- "form.cancel": "Cancel",
365
- "form.close": "Close",
366
- "form.submitting": "Sending\u2026",
367
- "form.success": "Thanks \u2014 your feedback was sent.",
368
- "form.error": "Could not send. Please try again.",
369
- "type.bug": "Bug",
370
- "type.feature": "Feature request",
371
- "type.question": "Question",
372
- "type.praise": "Praise",
373
- "type.typo": "Typo",
374
- "severity.blocker": "Blocker",
375
- "severity.high": "High",
376
- "severity.medium": "Medium",
377
- "severity.low": "Low"
378
- };
379
- var FRENCH_STRINGS = {
380
- "fab.label": "Envoyer un commentaire",
381
- "form.title": "Envoyer un commentaire",
382
- "form.description.label": "Qu\u2019est-il arriv\xE9 ?",
383
- "form.description.placeholder": "D\xE9crivez le probl\xE8me ou l\u2019id\xE9e en une ou deux phrases.",
384
- "form.type.label": "Type",
385
- "form.severity.label": "S\xE9v\xE9rit\xE9",
386
- "form.submit": "Envoyer",
387
- "form.cancel": "Annuler",
388
- "form.close": "Fermer",
389
- "form.submitting": "Envoi\u2026",
390
- "form.success": "Merci \u2014 votre commentaire a \xE9t\xE9 envoy\xE9.",
391
- "form.error": "\xC9chec d\u2019envoi. Veuillez r\xE9essayer.",
392
- "type.bug": "Bogue",
393
- "type.feature": "Suggestion",
394
- "type.question": "Question",
395
- "type.praise": "Compliment",
396
- "type.typo": "Coquille",
397
- "severity.blocker": "Bloquant",
398
- "severity.high": "\xC9lev\xE9e",
399
- "severity.medium": "Moyenne",
400
- "severity.low": "Faible"
401
- };
402
- var LOCALE_PACKS = {
403
- fr: FRENCH_STRINGS
404
- };
405
- function packFor(locale) {
406
- if (!locale) return null;
407
- const tag = locale.toLowerCase().split(/[-_]/)[0];
408
- return LOCALE_PACKS[tag] ?? null;
409
- }
410
- function resolveStrings(overrides, options = {}) {
411
- const localePack = packFor(options.locale) ?? {};
412
- return {
413
- ...DEFAULT_STRINGS,
414
- ...localePack,
415
- ...overrides
416
- };
417
- }
418
-
419
- // src/widget/mount.tsx
420
- import { h, render } from "preact";
421
- import { useCallback } from "preact/hooks";
422
-
423
- // src/widget/Fab.tsx
424
- import { jsx } from "preact/jsx-runtime";
425
- function Fab({ label, onClick }) {
426
- return /* @__PURE__ */ jsx("button", { type: "button", class: "fab", "aria-label": label, onClick, children: "\u{1F4AC}" });
427
- }
428
-
429
- // src/widget/Form.tsx
430
- import { useState } from "preact/hooks";
431
- import { jsx as jsx2, jsxs } from "preact/jsx-runtime";
432
- var TYPES = ["bug", "feature", "question", "praise", "typo"];
433
- var SEVERITIES = ["blocker", "high", "medium", "low"];
434
- function Form({ strings, onSubmit, onCancel, status, errorMessage }) {
435
- const [description, setDescription] = useState("");
436
- const [feedbackType, setFeedbackType] = useState("bug");
437
- const [severity, setSeverity] = useState("medium");
438
- const [localError, setLocalError] = useState("");
439
- const submitting = status === "submitting";
440
- const submitLabel = submitting ? strings["form.submitting"] : strings["form.submit"];
441
- const handleSubmit = (e) => {
442
- e.preventDefault();
443
- if (!description.trim()) {
444
- setLocalError(strings["form.description.placeholder"]);
445
- return;
446
- }
447
- setLocalError("");
448
- onSubmit({ description: description.trim(), feedback_type: feedbackType, severity });
449
- };
450
- return /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, children: [
451
- /* @__PURE__ */ jsx2("h2", { children: strings["form.title"] }),
452
- /* @__PURE__ */ jsxs("div", { class: "field", children: [
453
- /* @__PURE__ */ jsx2("label", { for: "mfb-desc", children: strings["form.description.label"] }),
454
- /* @__PURE__ */ jsx2(
455
- "textarea",
456
- {
457
- id: "mfb-desc",
458
- value: description,
459
- placeholder: strings["form.description.placeholder"],
460
- onInput: (e) => setDescription(e.target.value)
461
- }
462
- )
463
- ] }),
464
- /* @__PURE__ */ jsxs("div", { class: "row", children: [
465
- /* @__PURE__ */ jsxs("div", { class: "field", children: [
466
- /* @__PURE__ */ jsx2("label", { for: "mfb-type", children: strings["form.type.label"] }),
467
- /* @__PURE__ */ jsx2(
468
- "select",
469
- {
470
- id: "mfb-type",
471
- value: feedbackType,
472
- onChange: (e) => setFeedbackType(e.target.value),
473
- children: TYPES.map((t) => /* @__PURE__ */ jsx2("option", { value: t, children: strings[`type.${t}`] }))
474
- }
475
- )
476
- ] }),
477
- /* @__PURE__ */ jsxs("div", { class: "field", children: [
478
- /* @__PURE__ */ jsx2("label", { for: "mfb-sev", children: strings["form.severity.label"] }),
479
- /* @__PURE__ */ jsx2(
480
- "select",
481
- {
482
- id: "mfb-sev",
483
- value: severity,
484
- onChange: (e) => setSeverity(e.target.value),
485
- children: SEVERITIES.map((s) => /* @__PURE__ */ jsx2("option", { value: s, children: strings[`severity.${s}`] }))
486
- }
487
- )
488
- ] })
489
- ] }),
490
- localError && /* @__PURE__ */ jsx2("div", { class: "error", children: localError }),
491
- status === "error" && errorMessage && /* @__PURE__ */ jsx2("div", { class: "error", children: errorMessage }),
492
- status === "success" && /* @__PURE__ */ jsx2("div", { class: "success", children: strings["form.success"] }),
493
- /* @__PURE__ */ jsxs("div", { class: "actions", children: [
494
- /* @__PURE__ */ jsx2("button", { type: "button", class: "btn", onClick: onCancel, disabled: submitting, children: strings["form.cancel"] }),
495
- /* @__PURE__ */ jsx2("button", { type: "submit", class: "btn btn--primary", disabled: submitting, children: submitLabel })
496
- ] })
497
- ] });
498
- }
499
-
500
- // src/widget/Modal.tsx
501
- import { useEffect, useRef } from "preact/hooks";
502
- import { jsx as jsx3, jsxs as jsxs2 } from "preact/jsx-runtime";
503
- function Modal({ onDismiss, children, closeLabel = "Close" }) {
504
- const modalRef = useRef(null);
505
- const previouslyFocused = useRef(null);
506
- useEffect(() => {
507
- previouslyFocused.current = document.activeElement;
508
- const onKey = (e) => {
509
- if (e.key === "Escape") {
510
- e.stopPropagation();
511
- onDismiss();
512
- }
513
- };
514
- window.addEventListener("keydown", onKey);
515
- const first = modalRef.current?.querySelector(
516
- "textarea, input, select, button"
517
- );
518
- first?.focus();
519
- return () => {
520
- window.removeEventListener("keydown", onKey);
521
- const prev = previouslyFocused.current;
522
- if (prev && typeof prev.focus === "function") prev.focus();
523
- };
524
- }, [onDismiss]);
525
- return /* @__PURE__ */ jsx3(
526
- "div",
527
- {
528
- class: "backdrop",
529
- role: "presentation",
530
- onClick: (e) => {
531
- if (e.target === e.currentTarget) onDismiss();
532
- },
533
- children: /* @__PURE__ */ jsxs2("div", { ref: modalRef, class: "modal", role: "dialog", "aria-modal": "true", children: [
534
- /* @__PURE__ */ jsx3(
535
- "button",
536
- {
537
- type: "button",
538
- class: "modal-close",
539
- "aria-label": closeLabel,
540
- onClick: onDismiss,
541
- children: "\xD7"
542
- }
543
- ),
544
- children
545
- ] })
546
- }
547
- );
548
- }
549
-
550
- // src/widget/styles.ts
551
- var WIDGET_STYLES = `
552
- :host {
553
- --mfb-accent: #3b82f6;
554
- --mfb-accent-contrast: #ffffff;
555
- --mfb-bg: #ffffff;
556
- --mfb-surface: #f9fafb;
557
- --mfb-text: #0a0a0a;
558
- --mfb-text-muted: #6b7280;
559
- --mfb-border: #e5e7eb;
560
- --mfb-radius: 8px;
561
- --mfb-font: system-ui, -apple-system, sans-serif;
562
- --mfb-z-index: 2147483640;
563
-
564
- all: initial;
565
- font-family: var(--mfb-font);
566
- color: var(--mfb-text);
567
- position: fixed;
568
- z-index: var(--mfb-z-index);
569
- }
570
-
571
- @media (prefers-color-scheme: dark) {
572
- :host {
573
- --mfb-bg: #111827;
574
- --mfb-surface: #1f2937;
575
- --mfb-text: #f9fafb;
576
- --mfb-text-muted: #9ca3af;
577
- --mfb-border: #374151;
578
- }
579
- }
580
-
581
- .fab {
582
- position: fixed;
583
- bottom: 24px;
584
- right: 24px;
585
- width: 52px;
586
- height: 52px;
587
- border-radius: 999px;
588
- background: var(--mfb-accent);
589
- color: var(--mfb-accent-contrast);
590
- border: none;
591
- cursor: pointer;
592
- box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
593
- font-size: 22px;
594
- display: grid;
595
- place-items: center;
596
- }
597
-
598
- .fab:focus-visible { outline: 2px solid var(--mfb-accent); outline-offset: 2px; }
599
-
600
- .backdrop {
601
- position: fixed;
602
- inset: 0;
603
- background: rgba(0, 0, 0, 0.45);
604
- display: grid;
605
- place-items: center;
606
- }
607
-
608
- .modal {
609
- background: var(--mfb-bg);
610
- border-radius: calc(var(--mfb-radius) * 1.5);
611
- box-shadow: 0 20px 48px rgba(0, 0, 0, 0.25);
612
- width: min(420px, 92vw);
613
- padding: 20px;
614
- display: flex;
615
- flex-direction: column;
616
- gap: 12px;
617
- position: relative;
618
- /* Cap modal height on short viewports (mobile landscape, tiny laptops)
619
- so the form scrolls inside the modal rather than overflowing the
620
- viewport with no way to reach the actions row. */
621
- max-height: calc(100vh - 32px);
622
- overflow-y: auto;
623
- }
624
-
625
- .modal h2 { margin: 0 0 4px; font-size: 18px; font-weight: 600; padding-right: 28px; }
626
-
627
- .modal-close {
628
- position: absolute;
629
- top: 8px;
630
- right: 8px;
631
- width: 32px;
632
- height: 32px;
633
- display: grid;
634
- place-items: center;
635
- background: transparent;
636
- border: none;
637
- border-radius: var(--mfb-radius);
638
- color: var(--mfb-text-muted);
639
- font: inherit;
640
- font-size: 22px;
641
- line-height: 1;
642
- cursor: pointer;
643
- }
644
- .modal-close:hover { background: var(--mfb-surface); color: var(--mfb-text); }
645
- .modal-close:focus-visible { outline: 2px solid var(--mfb-accent); outline-offset: 2px; }
646
-
647
- .field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
648
-
649
- .field label { color: var(--mfb-text-muted); }
650
-
651
- .field input, .field select, .field textarea {
652
- font-family: inherit;
653
- font-size: 14px;
654
- color: inherit;
655
- padding: 8px 10px;
656
- border: 1px solid var(--mfb-border);
657
- border-radius: var(--mfb-radius);
658
- background: var(--mfb-surface);
659
- }
660
-
661
- .field textarea { min-height: 88px; resize: vertical; }
662
-
663
- .row { display: flex; gap: 8px; }
664
- .row > * { flex: 1; }
665
-
666
- .actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 8px; }
667
-
668
- .btn {
669
- padding: 8px 14px;
670
- border-radius: var(--mfb-radius);
671
- border: 1px solid var(--mfb-border);
672
- background: var(--mfb-bg);
673
- color: var(--mfb-text);
674
- font: inherit;
675
- cursor: pointer;
676
- }
677
-
678
- .btn--primary {
679
- background: var(--mfb-accent);
680
- color: var(--mfb-accent-contrast);
681
- border-color: var(--mfb-accent);
682
- }
683
-
684
- .btn[disabled] { opacity: 0.6; cursor: not-allowed; }
685
-
686
- .error { color: #dc2626; font-size: 13px; }
687
- .success { color: #059669; font-size: 13px; }
688
- `;
689
-
690
- // src/widget/mount.tsx
691
- import { Fragment, jsx as jsx4, jsxs as jsxs3 } from "preact/jsx-runtime";
692
- function mountWidget(options) {
693
- const shadow = options.host.attachShadow({ mode: "open" });
694
- const style = document.createElement("style");
695
- style.textContent = WIDGET_STYLES;
696
- shadow.appendChild(style);
697
- const mountPoint = document.createElement("div");
698
- shadow.appendChild(mountPoint);
699
- let currentState = { open: false, status: "idle" };
700
- function rerender(state) {
701
- currentState = state;
702
- render(h(Root, { state }), mountPoint);
703
- }
704
- function Root({ state }) {
705
- const handleSubmit = useCallback(async (values) => {
706
- rerender({ open: true, status: "submitting" });
707
- try {
708
- await options.onSubmit(values);
709
- rerender({ open: true, status: "success" });
710
- setTimeout(() => rerender({ open: false, status: "idle" }), 1200);
711
- } catch (err) {
712
- rerender({ open: true, status: "error", error: err instanceof Error ? err.message : String(err) });
713
- }
714
- }, []);
715
- return /* @__PURE__ */ jsxs3(Fragment, { children: [
716
- options.showFAB && /* @__PURE__ */ jsx4(
717
- Fab,
718
- {
719
- label: options.strings["fab.label"],
720
- onClick: () => rerender({ ...currentState, open: true })
721
- }
722
- ),
723
- state.open && /* @__PURE__ */ jsx4(
724
- Modal,
725
- {
726
- onDismiss: () => rerender({ open: false, status: "idle" }),
727
- closeLabel: options.strings["form.close"],
728
- children: /* @__PURE__ */ jsx4(
729
- Form,
730
- {
731
- strings: options.strings,
732
- onSubmit: handleSubmit,
733
- onCancel: () => rerender({ open: false, status: "idle" }),
734
- status: state.status,
735
- ...state.error !== void 0 && { errorMessage: state.error }
736
- }
737
- )
738
- }
739
- )
740
- ] });
741
- }
742
- rerender(currentState);
743
- return {
744
- open() {
745
- rerender({ ...currentState, open: true });
746
- },
747
- close() {
748
- rerender({ ...currentState, open: false, status: "idle" });
749
- },
750
- dispose() {
751
- render(null, mountPoint);
752
- options.host.innerHTML = "";
753
- }
754
- };
755
- }
756
-
757
- // src/core.ts
758
- function createFeedback(config) {
759
- const env = config.env ?? "prod";
760
- const locale = config.locale ?? (typeof navigator !== "undefined" ? navigator.language : void 0);
761
- const strings = resolveStrings(
762
- config.translations ?? {},
763
- locale !== void 0 ? { locale } : {}
764
- );
765
- const capture = installCapture({
766
- ...config.sanitizeUrl !== void 0 && { sanitizeUrl: config.sanitizeUrl }
767
- });
768
- const api = createApiClient({
769
- apiKey: config.apiKey,
770
- endpoint: config.endpoint,
771
- ...config.fetchImpl !== void 0 && { fetch: config.fetchImpl },
772
- ...config.beforeSend !== void 0 && { beforeSend: config.beforeSend }
773
- });
774
- let user = config.user;
775
- let metadata = config.metadata ?? {};
776
- const transformers = [];
777
- const host = document.createElement("div");
778
- host.className = "mhosaic-feedback";
779
- if (config.attachTo) {
780
- const attach = typeof config.attachTo === "string" ? document.querySelector(config.attachTo) : config.attachTo;
781
- attach?.appendChild(host);
782
- } else {
783
- document.body.appendChild(host);
784
- }
785
- async function buildAndSubmit(values) {
786
- const screenshot = values.synthetic ? void 0 : await takeScreenshot(document.body, { mask: [".mhosaic-feedback", "[data-mfb-mask]"] });
787
- const technical_context = capture.snapshot();
788
- if (user) technical_context.user = user;
789
- if (metadata && Object.keys(metadata).length > 0) {
790
- technical_context.metadata = { ...metadata };
791
- }
792
- const payload = {
793
- description: values.description,
794
- feedback_type: values.feedback_type ?? "bug",
795
- severity: values.severity ?? "medium",
796
- env,
797
- page_url: window.location.href,
798
- user_agent: navigator.userAgent,
799
- capture_method: screenshot ? "html2canvas" : "none",
800
- technical_context
801
- };
802
- if (screenshot) payload.screenshot = screenshot;
803
- if (values.synthetic) payload.synthetic = true;
804
- let finalPayload = payload;
805
- for (const t of transformers) finalPayload = await t(finalPayload);
806
- try {
807
- const result = await api.submitReport(finalPayload);
808
- config.onSubmitSuccess?.(result);
809
- capture.clear();
810
- return result;
811
- } catch (err) {
812
- const error = err instanceof Error ? err : new Error(String(err));
813
- config.onError?.(error);
814
- throw error;
815
- }
816
- }
817
- const handle = mountWidget({
818
- host,
819
- strings,
820
- showFAB: config.showFAB ?? true,
821
- onSubmit: async (values) => {
822
- await buildAndSubmit(values);
823
- }
824
- });
825
- const instance = {
826
- show() {
827
- handle.open();
828
- },
829
- hide() {
830
- handle.close();
831
- },
832
- open(opts) {
833
- handle.open();
834
- void opts;
835
- },
836
- async submit(partial) {
837
- return buildAndSubmit({
838
- description: partial.description,
839
- ...partial.feedback_type !== void 0 && { feedback_type: partial.feedback_type },
840
- ...partial.severity !== void 0 && { severity: partial.severity },
841
- ...partial.synthetic !== void 0 && { synthetic: partial.synthetic }
842
- });
843
- },
844
- identify(u) {
845
- user = u;
846
- },
847
- setMetadata(kv) {
848
- metadata = { ...metadata, ...kv };
849
- },
850
- shutdown() {
851
- handle.dispose();
852
- capture.dispose();
853
- host.remove();
854
- const w = window;
855
- if (w.mhosaicFeedback === instance) {
856
- delete w.mhosaicFeedback;
857
- }
858
- },
859
- _registerTransformer(fn) {
860
- transformers.push(fn);
861
- }
862
- };
863
- window.mhosaicFeedback = instance;
864
- return instance;
865
- }
866
-
867
- export {
868
- createFeedback
869
- };
870
- //# sourceMappingURL=chunk-GRN245GB.mjs.map