anton-bakker-feedback 0.1.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.
Files changed (63) hide show
  1. package/dist/chunks/i18n-RpKqSWAT.js +309 -0
  2. package/dist/chunks/i18n-RpKqSWAT.js.map +1 -0
  3. package/dist/core/capture.d.ts +57 -0
  4. package/dist/core/capture.d.ts.map +1 -0
  5. package/dist/core/config.d.ts +107 -0
  6. package/dist/core/config.d.ts.map +1 -0
  7. package/dist/core/errors.d.ts +80 -0
  8. package/dist/core/errors.d.ts.map +1 -0
  9. package/dist/core/i18n.d.ts +39 -0
  10. package/dist/core/i18n.d.ts.map +1 -0
  11. package/dist/core/index.d.ts +16 -0
  12. package/dist/core/index.d.ts.map +1 -0
  13. package/dist/core/locales/en.d.ts +6 -0
  14. package/dist/core/locales/en.d.ts.map +1 -0
  15. package/dist/core/locales/nl.d.ts +6 -0
  16. package/dist/core/locales/nl.d.ts.map +1 -0
  17. package/dist/core/observability.d.ts +54 -0
  18. package/dist/core/observability.d.ts.map +1 -0
  19. package/dist/core/transforms.d.ts +57 -0
  20. package/dist/core/transforms.d.ts.map +1 -0
  21. package/dist/core/transport.d.ts +44 -0
  22. package/dist/core/transport.d.ts.map +1 -0
  23. package/dist/core/types.d.ts +130 -0
  24. package/dist/core/types.d.ts.map +1 -0
  25. package/dist/core/validation.d.ts +48 -0
  26. package/dist/core/validation.d.ts.map +1 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +455 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/manifest.d.ts +2 -0
  32. package/dist/manifest.d.ts.map +1 -0
  33. package/dist/manifest.js +2 -0
  34. package/dist/manifest.js.map +1 -0
  35. package/dist/react/FeedbackButton.d.ts +19 -0
  36. package/dist/react/FeedbackButton.d.ts.map +1 -0
  37. package/dist/react/FeedbackConfirm.d.ts +15 -0
  38. package/dist/react/FeedbackConfirm.d.ts.map +1 -0
  39. package/dist/react/FeedbackContext.d.ts +172 -0
  40. package/dist/react/FeedbackContext.d.ts.map +1 -0
  41. package/dist/react/FeedbackDialogue.d.ts +14 -0
  42. package/dist/react/FeedbackDialogue.d.ts.map +1 -0
  43. package/dist/react/FeedbackPanel.d.ts +10 -0
  44. package/dist/react/FeedbackPanel.d.ts.map +1 -0
  45. package/dist/react/FeedbackProvider.d.ts +28 -0
  46. package/dist/react/FeedbackProvider.d.ts.map +1 -0
  47. package/dist/react/FeedbackRecorder.d.ts +14 -0
  48. package/dist/react/FeedbackRecorder.d.ts.map +1 -0
  49. package/dist/react/index.d.ts +33 -0
  50. package/dist/react/index.d.ts.map +1 -0
  51. package/dist/react/resolver/submitResolver.d.ts +60 -0
  52. package/dist/react/resolver/submitResolver.d.ts.map +1 -0
  53. package/dist/react/useFeedback.d.ts +27 -0
  54. package/dist/react/useFeedback.d.ts.map +1 -0
  55. package/dist/react.d.ts +2 -0
  56. package/dist/react.d.ts.map +1 -0
  57. package/dist/react.js +1576 -0
  58. package/dist/react.js.map +1 -0
  59. package/dist/server.d.ts +2 -0
  60. package/dist/server.d.ts.map +1 -0
  61. package/dist/server.js +2 -0
  62. package/dist/server.js.map +1 -0
  63. package/package.json +72 -0
package/dist/react.js ADDED
@@ -0,0 +1,1576 @@
1
+ import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
+ import React, { createContext, lazy, useMemo, useReducer, useEffect, useCallback, Suspense, useContext, useState, useRef } from "react";
3
+ import { v as validateConfig, b as resolveConfig, d as detectLocale, t, w as withSpan } from "./chunks/i18n-RpKqSWAT.js";
4
+ const FeedbackContext = createContext(null);
5
+ FeedbackContext.displayName = "FeedbackContext";
6
+ const INITIAL_PANEL_STATE = { status: "idle" };
7
+ function buildInitialCapturedContext() {
8
+ const isClient = typeof window !== "undefined";
9
+ return {
10
+ url: isClient ? window.location.href : "",
11
+ pageTitle: isClient ? document.title : "",
12
+ userAgent: isClient ? navigator.userAgent : "",
13
+ viewport: isClient ? { width: window.innerWidth, height: window.innerHeight } : { width: 0, height: 0 },
14
+ locale: isClient ? navigator.language : "en",
15
+ consoleErrors: [],
16
+ screenshots: [],
17
+ duplicateCandidates: []
18
+ };
19
+ }
20
+ const INITIAL_INTERNAL_STATE = {
21
+ panel: INITIAL_PANEL_STATE,
22
+ capturedContext: null,
23
+ capabilities: { bedrock: false, dynamodb: false, detected: false },
24
+ preservedClassification: {}
25
+ };
26
+ function feedbackReducer(state, action) {
27
+ switch (action.type) {
28
+ case "OPEN": {
29
+ if (state.panel.status !== "idle") return state;
30
+ const base = buildInitialCapturedContext();
31
+ const merged = "payload" in action && action.payload ? { ...base, ...action.payload } : base;
32
+ const newPanel = state.capabilities.bedrock ? {
33
+ status: "dialogue",
34
+ history: [],
35
+ step: 0,
36
+ totalSteps: 5,
37
+ awaitingResponse: false,
38
+ classification: {},
39
+ error: null
40
+ } : {
41
+ status: "confirming",
42
+ classification: {},
43
+ submitting: false,
44
+ error: null
45
+ };
46
+ return { ...state, panel: newPanel, capturedContext: merged };
47
+ }
48
+ case "CLOSE":
49
+ return {
50
+ ...state,
51
+ panel: INITIAL_PANEL_STATE,
52
+ capturedContext: null,
53
+ preservedClassification: {}
54
+ };
55
+ case "SEND_MESSAGE": {
56
+ if (state.panel.status !== "dialogue") return state;
57
+ const newHistory = [
58
+ ...state.panel.history,
59
+ {
60
+ role: "user",
61
+ content: action.payload.message,
62
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
63
+ }
64
+ ];
65
+ return {
66
+ ...state,
67
+ panel: {
68
+ ...state.panel,
69
+ history: newHistory,
70
+ awaitingResponse: true,
71
+ error: null
72
+ }
73
+ };
74
+ }
75
+ case "RECEIVE_RESPONSE": {
76
+ if (state.panel.status !== "dialogue") return state;
77
+ const { response, step, totalSteps, classification, done } = action.payload;
78
+ const mergedClassification = {
79
+ ...state.panel.classification,
80
+ ...classification ?? {}
81
+ };
82
+ const newHistory = [
83
+ ...state.panel.history,
84
+ {
85
+ role: "assistant",
86
+ content: response,
87
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
88
+ }
89
+ ];
90
+ if (done === true) {
91
+ return {
92
+ ...state,
93
+ panel: {
94
+ status: "confirming",
95
+ classification: mergedClassification,
96
+ submitting: false,
97
+ error: null
98
+ },
99
+ preservedClassification: mergedClassification
100
+ };
101
+ }
102
+ return {
103
+ ...state,
104
+ panel: {
105
+ ...state.panel,
106
+ history: newHistory,
107
+ step,
108
+ totalSteps,
109
+ awaitingResponse: false,
110
+ classification: mergedClassification
111
+ }
112
+ };
113
+ }
114
+ case "DIALOGUE_ERROR": {
115
+ if (state.panel.status !== "dialogue") return state;
116
+ return {
117
+ ...state,
118
+ panel: {
119
+ ...state.panel,
120
+ awaitingResponse: false,
121
+ error: action.payload.error
122
+ }
123
+ };
124
+ }
125
+ case "START_RECORDING": {
126
+ const currentClassification = state.panel.status === "dialogue" || state.panel.status === "confirming" ? state.panel.classification : state.preservedClassification;
127
+ return {
128
+ ...state,
129
+ panel: {
130
+ status: "recording",
131
+ recordingMode: "capture",
132
+ captureCount: state.capturedContext?.screenshots.length ?? 0
133
+ },
134
+ preservedClassification: currentClassification
135
+ };
136
+ }
137
+ case "SET_RECORDING_MODE": {
138
+ if (state.panel.status !== "recording") return state;
139
+ return {
140
+ ...state,
141
+ panel: {
142
+ ...state.panel,
143
+ recordingMode: action.payload.mode
144
+ }
145
+ };
146
+ }
147
+ case "ADD_SCREENSHOT": {
148
+ if (!state.capturedContext) return state;
149
+ const existing = state.capturedContext.screenshots;
150
+ if (existing.length >= 10) return state;
151
+ const updated = [...existing, action.payload];
152
+ return {
153
+ ...state,
154
+ capturedContext: { ...state.capturedContext, screenshots: updated },
155
+ panel: state.panel.status === "recording" ? { ...state.panel, captureCount: updated.length } : state.panel
156
+ };
157
+ }
158
+ case "REMOVE_SCREENSHOT": {
159
+ if (!state.capturedContext) return state;
160
+ const filtered = state.capturedContext.screenshots.filter(
161
+ (s) => s.id !== action.payload.id
162
+ );
163
+ return {
164
+ ...state,
165
+ capturedContext: { ...state.capturedContext, screenshots: filtered },
166
+ panel: state.panel.status === "recording" ? { ...state.panel, captureCount: filtered.length } : state.panel
167
+ };
168
+ }
169
+ case "CONFIRM": {
170
+ const classification = "payload" in action && action.payload?.classification ? action.payload.classification : state.preservedClassification;
171
+ return {
172
+ ...state,
173
+ panel: {
174
+ status: "confirming",
175
+ classification,
176
+ submitting: false,
177
+ error: null
178
+ },
179
+ preservedClassification: classification
180
+ };
181
+ }
182
+ case "SUBMIT_START": {
183
+ if (state.panel.status !== "confirming") return state;
184
+ return {
185
+ ...state,
186
+ panel: { ...state.panel, submitting: true, error: null }
187
+ };
188
+ }
189
+ case "SUBMIT_SUCCESS": {
190
+ return {
191
+ ...state,
192
+ panel: {
193
+ status: "submitted",
194
+ feedbackId: action.payload.feedbackId
195
+ }
196
+ };
197
+ }
198
+ case "SUBMIT_ERROR": {
199
+ if (state.panel.status !== "confirming") return state;
200
+ return {
201
+ ...state,
202
+ panel: {
203
+ ...state.panel,
204
+ submitting: false,
205
+ error: action.payload.error
206
+ }
207
+ };
208
+ }
209
+ case "SET_CAPABILITIES": {
210
+ return { ...state, capabilities: action.payload };
211
+ }
212
+ default:
213
+ return state;
214
+ }
215
+ }
216
+ async function detectCapabilities(submitUrl, dialogueUrl) {
217
+ let dynamodb = true;
218
+ let bedrock = !!dialogueUrl;
219
+ if (dialogueUrl) {
220
+ try {
221
+ const ctrl = new AbortController();
222
+ const timeoutId = setTimeout(() => ctrl.abort(), 3e3);
223
+ await fetch(dialogueUrl, { method: "HEAD", signal: ctrl.signal });
224
+ clearTimeout(timeoutId);
225
+ bedrock = true;
226
+ } catch {
227
+ bedrock = false;
228
+ }
229
+ }
230
+ try {
231
+ const ctrl = new AbortController();
232
+ const timeoutId = setTimeout(() => ctrl.abort(), 3e3);
233
+ await fetch(submitUrl, { method: "HEAD", signal: ctrl.signal });
234
+ clearTimeout(timeoutId);
235
+ dynamodb = true;
236
+ } catch {
237
+ dynamodb = false;
238
+ }
239
+ return { bedrock, dynamodb, detected: true };
240
+ }
241
+ const FeedbackPanelLazy = lazy(
242
+ () => Promise.resolve().then(() => FeedbackPanel$1).then((m) => ({ default: m.FeedbackPanel }))
243
+ );
244
+ function FeedbackProvider({
245
+ config,
246
+ canSubmitFeedback,
247
+ locale: localeProp,
248
+ user,
249
+ children
250
+ }) {
251
+ const resolvedConfig = useMemo(() => {
252
+ const validation = validateConfig(config);
253
+ if (!validation.valid) {
254
+ console.error(
255
+ "[anton-bakker-feedback/react] FeedbackProvider received invalid config:\n" + validation.errors.map((e) => ` • ${e}`).join("\n") + "\nFeedbackPanel will not render until config is corrected."
256
+ );
257
+ return null;
258
+ }
259
+ return resolveConfig(config);
260
+ }, [config]);
261
+ const locale = useMemo(() => {
262
+ const preferred = localeProp ?? (typeof navigator !== "undefined" ? navigator.language : "en");
263
+ return detectLocale(preferred);
264
+ }, [localeProp]);
265
+ const authorised = useMemo(
266
+ () => canSubmitFeedback(user),
267
+ [canSubmitFeedback, user]
268
+ );
269
+ const [internalState, dispatch] = useReducer(
270
+ feedbackReducer,
271
+ INITIAL_INTERNAL_STATE
272
+ );
273
+ useEffect(() => {
274
+ if (!resolvedConfig) return;
275
+ let cancelled = false;
276
+ void detectCapabilities(
277
+ resolvedConfig.transport.submitUrl,
278
+ resolvedConfig.transport.dialogueUrl
279
+ ).then((caps) => {
280
+ if (!cancelled) {
281
+ dispatch({ type: "SET_CAPABILITIES", payload: caps });
282
+ }
283
+ });
284
+ return () => {
285
+ cancelled = true;
286
+ };
287
+ }, [resolvedConfig]);
288
+ const open = useCallback(
289
+ (trigger) => {
290
+ if (trigger !== void 0) {
291
+ dispatch({ type: "OPEN", payload: trigger });
292
+ } else {
293
+ dispatch({ type: "OPEN" });
294
+ }
295
+ },
296
+ []
297
+ );
298
+ const close = useCallback(() => {
299
+ dispatch({ type: "CLOSE" });
300
+ }, []);
301
+ const contextValue = useMemo(() => {
302
+ if (!resolvedConfig) return null;
303
+ return {
304
+ config: resolvedConfig,
305
+ state: internalState.panel,
306
+ isOpen: internalState.panel.status !== "idle",
307
+ capturedContext: internalState.capturedContext,
308
+ capabilities: internalState.capabilities,
309
+ locale,
310
+ open,
311
+ close,
312
+ dispatch
313
+ };
314
+ }, [resolvedConfig, internalState, locale, open, close]);
315
+ if (!contextValue) {
316
+ return React.createElement(React.Fragment, null, children);
317
+ }
318
+ return /* @__PURE__ */ jsxs(FeedbackContext.Provider, { value: contextValue, children: [
319
+ children,
320
+ authorised && /* @__PURE__ */ jsx(Suspense, { fallback: null, children: /* @__PURE__ */ jsx(FeedbackPanelLazy, {}) })
321
+ ] });
322
+ }
323
+ function useFeedback() {
324
+ const ctx = useContext(FeedbackContext);
325
+ if (ctx === null) {
326
+ throw new Error(
327
+ '[useFeedback] Could not find FeedbackContext. Make sure your component is rendered inside a <FeedbackProvider>. Import from: import { FeedbackProvider } from "anton-bakker-feedback/react"'
328
+ );
329
+ }
330
+ return {
331
+ open: ctx.open,
332
+ close: ctx.close,
333
+ state: ctx.state,
334
+ isOpen: ctx.isOpen,
335
+ capturedContext: ctx.capturedContext,
336
+ _context: ctx
337
+ };
338
+ }
339
+ const BUTTON_STYLES = `
340
+ .feedback-button {
341
+ position: fixed;
342
+ bottom: 24px;
343
+ right: 24px;
344
+ z-index: var(--feedback-z-index, 9998);
345
+ display: flex;
346
+ align-items: center;
347
+ justify-content: center;
348
+ gap: 6px;
349
+ padding: 12px 16px;
350
+ border-radius: 24px;
351
+ border: none;
352
+ cursor: pointer;
353
+ font-family: inherit;
354
+ font-size: 14px;
355
+ font-weight: 600;
356
+ line-height: 1;
357
+ white-space: nowrap;
358
+ background-color: var(--feedback-button-bg, #4f46e5);
359
+ color: var(--feedback-button-color, #ffffff);
360
+ box-shadow: var(--feedback-button-shadow, 0 4px 12px rgba(79, 70, 229, 0.35));
361
+ transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
362
+ -webkit-user-select: none;
363
+ user-select: none;
364
+ outline: none;
365
+ }
366
+
367
+ .feedback-button:hover {
368
+ background-color: var(--feedback-button-hover-bg, #4338ca);
369
+ box-shadow: var(--feedback-button-hover-shadow, 0 6px 16px rgba(79, 70, 229, 0.45));
370
+ }
371
+
372
+ .feedback-button:focus-visible {
373
+ outline: 2px solid var(--feedback-button-focus-ring, #4f46e5);
374
+ outline-offset: 2px;
375
+ box-shadow: var(--feedback-button-shadow, 0 4px 12px rgba(79, 70, 229, 0.35)),
376
+ 0 0 0 4px var(--feedback-button-focus-ring-spread, rgba(79, 70, 229, 0.2));
377
+ }
378
+
379
+ .feedback-button:active {
380
+ background-color: var(--feedback-button-active-bg, #3730a3);
381
+ transform: scale(0.97);
382
+ }
383
+
384
+ .feedback-button:disabled {
385
+ opacity: 0.5;
386
+ cursor: not-allowed;
387
+ transform: none;
388
+ }
389
+
390
+ .feedback-button__icon {
391
+ width: 18px;
392
+ height: 18px;
393
+ flex-shrink: 0;
394
+ }
395
+ `;
396
+ let stylesInjected = false;
397
+ function injectStyles() {
398
+ if (stylesInjected || typeof document === "undefined") return;
399
+ const style = document.createElement("style");
400
+ style.setAttribute("data-feedback-button", "");
401
+ style.textContent = BUTTON_STYLES;
402
+ document.head.appendChild(style);
403
+ stylesInjected = true;
404
+ }
405
+ function FeedbackButton({ className, label }) {
406
+ const ctx = useContext(FeedbackContext);
407
+ const { state, open } = useFeedback();
408
+ React.useEffect(() => {
409
+ injectStyles();
410
+ }, []);
411
+ if (state.status !== "idle") return null;
412
+ const locale = ctx?.locale ?? "en";
413
+ const customTranslations = ctx?.config.i18n.customTranslations;
414
+ const tooltipFromI18n = t("feedback.button.tooltip", locale, customTranslations) || t("feedback.button.open", locale, customTranslations);
415
+ const tooltip = label ?? tooltipFromI18n;
416
+ const handleClick = () => {
417
+ open();
418
+ };
419
+ const handleKeyDown = (e) => {
420
+ if (e.key === "Enter" || e.key === " ") {
421
+ e.preventDefault();
422
+ open();
423
+ }
424
+ };
425
+ const classNames = ["feedback-button", className].filter(Boolean).join(" ");
426
+ return /* @__PURE__ */ jsxs(
427
+ "button",
428
+ {
429
+ type: "button",
430
+ className: classNames,
431
+ "aria-label": tooltip,
432
+ title: tooltip,
433
+ onClick: handleClick,
434
+ onKeyDown: handleKeyDown,
435
+ children: [
436
+ /* @__PURE__ */ jsx(
437
+ "svg",
438
+ {
439
+ className: "feedback-button__icon",
440
+ "aria-hidden": "true",
441
+ viewBox: "0 0 24 24",
442
+ fill: "none",
443
+ stroke: "currentColor",
444
+ strokeWidth: 2,
445
+ strokeLinecap: "round",
446
+ strokeLinejoin: "round",
447
+ children: /* @__PURE__ */ jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" })
448
+ }
449
+ ),
450
+ tooltip
451
+ ]
452
+ }
453
+ );
454
+ }
455
+ function MessageBubble({ entry }) {
456
+ const isUser = entry.role === "user";
457
+ return /* @__PURE__ */ jsxs(
458
+ "div",
459
+ {
460
+ className: `feedback-dialogue__bubble feedback-dialogue__bubble--${entry.role}`,
461
+ "data-role": entry.role,
462
+ children: [
463
+ /* @__PURE__ */ jsx("span", { className: "feedback-dialogue__bubble-label", children: isUser ? "You" : "Assistant" }),
464
+ /* @__PURE__ */ jsx("p", { className: "feedback-dialogue__bubble-content", children: entry.content })
465
+ ]
466
+ }
467
+ );
468
+ }
469
+ function TypingIndicator() {
470
+ return /* @__PURE__ */ jsxs("div", { className: "feedback-dialogue__typing", "aria-live": "polite", "aria-label": "Assistant is typing", children: [
471
+ /* @__PURE__ */ jsx("span", { className: "feedback-dialogue__typing-dot" }),
472
+ /* @__PURE__ */ jsx("span", { className: "feedback-dialogue__typing-dot" }),
473
+ /* @__PURE__ */ jsx("span", { className: "feedback-dialogue__typing-dot" })
474
+ ] });
475
+ }
476
+ function FeedbackDialogue({ className }) {
477
+ const ctx = useContext(FeedbackContext);
478
+ const { state, _context } = useFeedback();
479
+ const [inputValue, setInputValue] = useState("");
480
+ const messagesEndRef = useRef(null);
481
+ const locale = ctx?.locale ?? "en";
482
+ const customTranslations = ctx?.config.i18n.customTranslations;
483
+ const capabilities = ctx?.capabilities;
484
+ const dialogueUrl = ctx?.config.transport.dialogueUrl;
485
+ const isDialogue = state.status === "dialogue";
486
+ const history = isDialogue ? state.history : [];
487
+ const step = isDialogue ? state.step : 0;
488
+ const totalSteps = isDialogue ? state.totalSteps : 5;
489
+ const awaitingResponse = isDialogue && state.awaitingResponse;
490
+ const error = isDialogue ? state.error : null;
491
+ const canSkipToForm = capabilities?.bedrock === false || error !== null && error !== "";
492
+ useEffect(() => {
493
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
494
+ }, [history.length, awaitingResponse]);
495
+ const handleSend = useCallback(() => {
496
+ const trimmed = inputValue.trim();
497
+ if (!trimmed || awaitingResponse || !isDialogue) return;
498
+ _context.dispatch({ type: "SEND_MESSAGE", payload: { message: trimmed } });
499
+ setInputValue("");
500
+ if (dialogueUrl) {
501
+ const url = dialogueUrl;
502
+ const newStep = step + 1;
503
+ const done = newStep >= totalSteps;
504
+ void fetch(url, {
505
+ method: "POST",
506
+ headers: { "Content-Type": "application/json" },
507
+ body: JSON.stringify({ message: trimmed, step: newStep })
508
+ }).then(async (res) => {
509
+ if (!res.ok) throw new Error(`Dialogue error: ${res.status}`);
510
+ const data = await res.json();
511
+ _context.dispatch({
512
+ type: "RECEIVE_RESPONSE",
513
+ payload: {
514
+ response: data.response,
515
+ step: newStep,
516
+ totalSteps,
517
+ ...data.classification !== void 0 && { classification: data.classification },
518
+ done: data.done ?? done
519
+ }
520
+ });
521
+ }).catch((err) => {
522
+ _context.dispatch({
523
+ type: "DIALOGUE_ERROR",
524
+ payload: {
525
+ error: err instanceof Error ? err.message : "Dialogue request failed"
526
+ }
527
+ });
528
+ });
529
+ } else {
530
+ setTimeout(() => {
531
+ const newStepVal = step + 1;
532
+ const doneVal = newStepVal >= totalSteps;
533
+ _context.dispatch({
534
+ type: "RECEIVE_RESPONSE",
535
+ payload: {
536
+ response: doneVal ? t("feedback.dialogue.complete", locale, customTranslations) : t("feedback.dialogue.turnPrompt", locale, customTranslations),
537
+ step: newStepVal,
538
+ totalSteps,
539
+ done: doneVal
540
+ }
541
+ });
542
+ }, 800);
543
+ }
544
+ }, [inputValue, awaitingResponse, isDialogue, _context, step, totalSteps, locale, customTranslations, dialogueUrl]);
545
+ const handleKeyDown = useCallback(
546
+ (e) => {
547
+ if (e.key === "Enter" && !e.shiftKey) {
548
+ e.preventDefault();
549
+ handleSend();
550
+ }
551
+ },
552
+ [handleSend]
553
+ );
554
+ if (state.status !== "dialogue") return null;
555
+ const handleSkipToForm = () => {
556
+ _context.dispatch({ type: "CONFIRM", payload: { classification: {} } });
557
+ };
558
+ const handleStartRecording = () => {
559
+ _context.dispatch({ type: "START_RECORDING" });
560
+ };
561
+ const hasExchange = history.length > 0;
562
+ const classNames = ["feedback-dialogue", className].filter(Boolean).join(" ");
563
+ return /* @__PURE__ */ jsxs("div", { className: classNames, role: "region", "aria-label": "Feedback dialogue", children: [
564
+ /* @__PURE__ */ jsx("div", { className: "feedback-dialogue__progress", "aria-label": `Step ${step} of ${totalSteps}`, children: /* @__PURE__ */ jsx("span", { className: "feedback-dialogue__step", children: step > 0 ? `${step} / ${totalSteps}` : "" }) }),
565
+ /* @__PURE__ */ jsxs(
566
+ "div",
567
+ {
568
+ className: "feedback-dialogue__messages",
569
+ "aria-live": "polite",
570
+ "aria-relevant": "additions",
571
+ children: [
572
+ history.length === 0 && /* @__PURE__ */ jsx("p", { className: "feedback-dialogue__opening", children: t("feedback.dialogue.opening", locale, customTranslations) }),
573
+ history.map((entry, idx) => /* @__PURE__ */ jsx(MessageBubble, { entry }, `${entry.role}-${idx}`)),
574
+ awaitingResponse && /* @__PURE__ */ jsx(TypingIndicator, {}),
575
+ /* @__PURE__ */ jsx("div", { ref: messagesEndRef })
576
+ ]
577
+ }
578
+ ),
579
+ error && /* @__PURE__ */ jsx("div", { className: "feedback-dialogue__error", role: "alert", children: error }),
580
+ /* @__PURE__ */ jsxs("div", { className: "feedback-dialogue__actions", children: [
581
+ hasExchange && /* @__PURE__ */ jsx(
582
+ "button",
583
+ {
584
+ type: "button",
585
+ className: "feedback-dialogue__action-btn",
586
+ onClick: handleStartRecording,
587
+ children: t("feedback.button.screenshot", locale, customTranslations)
588
+ }
589
+ ),
590
+ canSkipToForm && /* @__PURE__ */ jsx(
591
+ "button",
592
+ {
593
+ type: "button",
594
+ className: "feedback-dialogue__skip-btn",
595
+ onClick: handleSkipToForm,
596
+ "aria-label": "Skip to form",
597
+ children: "Skip to form"
598
+ }
599
+ )
600
+ ] }),
601
+ /* @__PURE__ */ jsxs("div", { className: "feedback-dialogue__input-area", children: [
602
+ /* @__PURE__ */ jsx(
603
+ "textarea",
604
+ {
605
+ className: "feedback-dialogue__textarea",
606
+ value: inputValue,
607
+ onChange: (e) => setInputValue(e.target.value),
608
+ onKeyDown: handleKeyDown,
609
+ disabled: awaitingResponse,
610
+ placeholder: awaitingResponse ? t("feedback.dialogue.thinking", locale, customTranslations) : "Type your message… (Enter to send, Shift+Enter for newline)",
611
+ "aria-label": "Dialogue input",
612
+ rows: 3
613
+ }
614
+ ),
615
+ /* @__PURE__ */ jsx(
616
+ "button",
617
+ {
618
+ type: "button",
619
+ className: "feedback-dialogue__send-btn",
620
+ onClick: handleSend,
621
+ disabled: awaitingResponse || !inputValue.trim(),
622
+ "aria-label": "Send message",
623
+ children: awaitingResponse ? /* @__PURE__ */ jsx("span", { className: "feedback-dialogue__spinner", "aria-hidden": "true" }) : "→"
624
+ }
625
+ )
626
+ ] })
627
+ ] });
628
+ }
629
+ async function captureScreenshot() {
630
+ try {
631
+ const h2cName = "html2canvas";
632
+ const html2canvasModule = await import(
633
+ /* @vite-ignore */
634
+ h2cName
635
+ ).catch(
636
+ () => null
637
+ );
638
+ if (html2canvasModule?.default) {
639
+ const canvas = await html2canvasModule.default(document.body);
640
+ return canvas.toDataURL("image/png");
641
+ }
642
+ } catch {
643
+ }
644
+ try {
645
+ if (typeof navigator !== "undefined" && navigator.mediaDevices && "getDisplayMedia" in navigator.mediaDevices) {
646
+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
647
+ const video = document.createElement("video");
648
+ video.srcObject = stream;
649
+ await video.play();
650
+ const canvas = document.createElement("canvas");
651
+ canvas.width = video.videoWidth;
652
+ canvas.height = video.videoHeight;
653
+ canvas.getContext("2d")?.drawImage(video, 0, 0);
654
+ stream.getTracks().forEach((track) => track.stop());
655
+ return canvas.toDataURL("image/png");
656
+ }
657
+ } catch {
658
+ }
659
+ return null;
660
+ }
661
+ function FeedbackRecorder({ className }) {
662
+ const ctx = useContext(FeedbackContext);
663
+ const { state, capturedContext, _context } = useFeedback();
664
+ const [apiSupported, setApiSupported] = useState(null);
665
+ const [capturing, setCapturing] = useState(false);
666
+ const locale = ctx?.locale ?? "en";
667
+ const customTranslations = ctx?.config.i18n.customTranslations;
668
+ const isRecording = state.status === "recording";
669
+ const recordingMode = isRecording ? state.recordingMode : null;
670
+ const captureCount = isRecording ? state.captureCount : 0;
671
+ const screenshots = capturedContext?.screenshots ?? [];
672
+ useEffect(() => {
673
+ if (!isRecording) return;
674
+ const hasGetDisplayMedia = typeof navigator !== "undefined" && !!navigator.mediaDevices && "getDisplayMedia" in navigator.mediaDevices;
675
+ setApiSupported(hasGetDisplayMedia);
676
+ }, [isRecording]);
677
+ const handleCaptureClick = useCallback(async () => {
678
+ if (capturing || screenshots.length >= 10) return;
679
+ setCapturing(true);
680
+ try {
681
+ const dataUrl = await captureScreenshot();
682
+ if (dataUrl) {
683
+ const entry = {
684
+ id: crypto.randomUUID(),
685
+ dataUrl,
686
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
687
+ };
688
+ _context.dispatch({ type: "ADD_SCREENSHOT", payload: entry });
689
+ _context.dispatch({
690
+ type: "SET_RECORDING_MODE",
691
+ payload: { mode: "review" }
692
+ });
693
+ } else {
694
+ setApiSupported(false);
695
+ }
696
+ } catch {
697
+ setApiSupported(false);
698
+ } finally {
699
+ setCapturing(false);
700
+ }
701
+ }, [capturing, screenshots.length, _context]);
702
+ if (state.status !== "recording") return null;
703
+ if (recordingMode === "minimised") {
704
+ return /* @__PURE__ */ jsxs(
705
+ "button",
706
+ {
707
+ type: "button",
708
+ className: ["feedback-recorder__badge", className].filter(Boolean).join(" "),
709
+ onClick: () => _context.dispatch({
710
+ type: "SET_RECORDING_MODE",
711
+ payload: { mode: "capture" }
712
+ }),
713
+ "aria-label": `${captureCount} screenshot${captureCount !== 1 ? "s" : ""} captured. Click to resume`,
714
+ children: [
715
+ "📷 ",
716
+ captureCount
717
+ ]
718
+ }
719
+ );
720
+ }
721
+ if (apiSupported === false) {
722
+ return /* @__PURE__ */ jsxs("div", { className: ["feedback-recorder", className].filter(Boolean).join(" "), children: [
723
+ /* @__PURE__ */ jsxs("p", { className: "feedback-recorder__unsupported-msg", children: [
724
+ t("feedback.toast.screenshotError", locale, customTranslations),
725
+ " ",
726
+ "Screenshot capture is not supported in this browser."
727
+ ] }),
728
+ /* @__PURE__ */ jsx(
729
+ "button",
730
+ {
731
+ type: "button",
732
+ className: "feedback-recorder__continue-btn",
733
+ onClick: () => _context.dispatch({ type: "CONFIRM", payload: {} }),
734
+ children: "Continue without screenshots"
735
+ }
736
+ )
737
+ ] });
738
+ }
739
+ if (recordingMode === "capture") {
740
+ return /* @__PURE__ */ jsx(
741
+ "div",
742
+ {
743
+ className: ["feedback-recorder", "feedback-recorder--capture", className].filter(Boolean).join(" "),
744
+ style: { cursor: "crosshair" },
745
+ onClick: () => void handleCaptureClick(),
746
+ role: "button",
747
+ tabIndex: 0,
748
+ "aria-label": "Click anywhere to capture screenshot",
749
+ onKeyDown: (e) => {
750
+ if (e.key === "Enter" || e.key === " ") {
751
+ e.preventDefault();
752
+ void handleCaptureClick();
753
+ }
754
+ },
755
+ children: /* @__PURE__ */ jsxs("div", { className: "feedback-recorder__capture-overlay", children: [
756
+ /* @__PURE__ */ jsx("p", { children: capturing ? "Capturing…" : "Click anywhere to capture a screenshot" }),
757
+ /* @__PURE__ */ jsx(
758
+ "button",
759
+ {
760
+ type: "button",
761
+ className: "feedback-recorder__minimise-btn",
762
+ onClick: (e) => {
763
+ e.stopPropagation();
764
+ _context.dispatch({
765
+ type: "SET_RECORDING_MODE",
766
+ payload: { mode: "minimised" }
767
+ });
768
+ },
769
+ children: "Minimise"
770
+ }
771
+ ),
772
+ screenshots.length > 0 && /* @__PURE__ */ jsxs(
773
+ "button",
774
+ {
775
+ type: "button",
776
+ className: "feedback-recorder__review-btn",
777
+ onClick: (e) => {
778
+ e.stopPropagation();
779
+ _context.dispatch({
780
+ type: "SET_RECORDING_MODE",
781
+ payload: { mode: "review" }
782
+ });
783
+ },
784
+ children: [
785
+ "Review (",
786
+ screenshots.length,
787
+ ")"
788
+ ]
789
+ }
790
+ )
791
+ ] })
792
+ }
793
+ );
794
+ }
795
+ return /* @__PURE__ */ jsxs(
796
+ "div",
797
+ {
798
+ className: ["feedback-recorder", "feedback-recorder--review", className].filter(Boolean).join(" "),
799
+ children: [
800
+ /* @__PURE__ */ jsx("h3", { className: "feedback-recorder__title", children: screenshots.length === 0 ? "No screenshots yet" : `${screenshots.length} screenshot${screenshots.length !== 1 ? "s" : ""}` }),
801
+ screenshots.length > 0 && /* @__PURE__ */ jsx("div", { className: "feedback-recorder__thumbnails", role: "list", children: screenshots.map((shot) => /* @__PURE__ */ jsxs(
802
+ "div",
803
+ {
804
+ className: "feedback-recorder__thumbnail",
805
+ role: "listitem",
806
+ children: [
807
+ /* @__PURE__ */ jsx(
808
+ "img",
809
+ {
810
+ src: shot.dataUrl,
811
+ alt: `Screenshot captured at ${shot.capturedAt}`,
812
+ className: "feedback-recorder__thumbnail-img"
813
+ }
814
+ ),
815
+ /* @__PURE__ */ jsx(
816
+ "button",
817
+ {
818
+ type: "button",
819
+ className: "feedback-recorder__delete-btn",
820
+ "aria-label": `${t("feedback.button.removeScreenshot", locale, customTranslations)} captured at ${shot.capturedAt}`,
821
+ onClick: () => _context.dispatch({
822
+ type: "REMOVE_SCREENSHOT",
823
+ payload: { id: shot.id }
824
+ }),
825
+ children: "×"
826
+ }
827
+ )
828
+ ]
829
+ },
830
+ shot.id
831
+ )) }),
832
+ /* @__PURE__ */ jsxs("div", { className: "feedback-recorder__review-actions", children: [
833
+ screenshots.length < 10 && /* @__PURE__ */ jsx(
834
+ "button",
835
+ {
836
+ type: "button",
837
+ className: "feedback-recorder__add-btn",
838
+ onClick: () => _context.dispatch({
839
+ type: "SET_RECORDING_MODE",
840
+ payload: { mode: "capture" }
841
+ }),
842
+ children: t("feedback.button.screenshot", locale, customTranslations)
843
+ }
844
+ ),
845
+ /* @__PURE__ */ jsx(
846
+ "button",
847
+ {
848
+ type: "button",
849
+ className: "feedback-recorder__done-btn",
850
+ onClick: () => _context.dispatch({ type: "CONFIRM", payload: {} }),
851
+ children: "Done"
852
+ }
853
+ )
854
+ ] })
855
+ ]
856
+ }
857
+ );
858
+ }
859
+ function validateForm(values) {
860
+ const errors = {};
861
+ if (!values.title || values.title.trim().length < 10) {
862
+ errors.title = "Title is required (minimum 10 characters)";
863
+ }
864
+ if (!values.type) {
865
+ errors.type = "Type is required";
866
+ }
867
+ return errors;
868
+ }
869
+ function FeedbackConfirm({ className }) {
870
+ const ctx = useContext(FeedbackContext);
871
+ const { state, capturedContext, _context } = useFeedback();
872
+ const locale = ctx?.locale ?? "en";
873
+ const customTranslations = ctx?.config.i18n.customTranslations;
874
+ const submitUrl = ctx?.config.transport.submitUrl ?? "";
875
+ const confirming = state.status === "confirming";
876
+ const classification = confirming ? state.classification : {};
877
+ const submitting = confirming && state.submitting;
878
+ const submitError = confirming ? state.error : null;
879
+ const [values, setValues] = useState(() => ({
880
+ title: classification.title ?? "",
881
+ description: classification.description ?? "",
882
+ type: classification.type ?? "",
883
+ scope: classification.scope ?? "",
884
+ size: classification.size ?? ""
885
+ }));
886
+ const [touched, setTouched] = useState({});
887
+ useEffect(() => {
888
+ if (!confirming) return;
889
+ setValues((prev) => ({
890
+ title: classification.title ?? prev.title,
891
+ description: classification.description ?? prev.description,
892
+ type: classification.type ?? prev.type,
893
+ scope: classification.scope ?? prev.scope,
894
+ size: classification.size ?? prev.size
895
+ }));
896
+ }, [confirming, classification.title, classification.description, classification.type, classification.scope, classification.size]);
897
+ const formErrors = validateForm(values);
898
+ const isValid = Object.keys(formErrors).length === 0;
899
+ const screenshots = capturedContext?.screenshots ?? [];
900
+ const consoleErrors = capturedContext?.consoleErrors ?? [];
901
+ const duplicates = capturedContext?.duplicateCandidates ?? [];
902
+ const handleChange = useCallback(
903
+ (field, value) => {
904
+ setValues((prev) => ({ ...prev, [field]: value }));
905
+ setTouched((prev) => ({ ...prev, [field]: true }));
906
+ },
907
+ []
908
+ );
909
+ const handleSubmit = useCallback(async () => {
910
+ setTouched({ title: true, type: true });
911
+ if (!isValid || !values.type) return;
912
+ _context.dispatch({ type: "SUBMIT_START" });
913
+ const submissionPayload = {
914
+ title: values.title.trim(),
915
+ type: values.type
916
+ };
917
+ const descTrimmed = values.description.trim();
918
+ if (descTrimmed) submissionPayload.description = descTrimmed;
919
+ if (values.scope) submissionPayload.scope = values.scope;
920
+ if (values.size) submissionPayload.size = values.size;
921
+ if (screenshots.length > 0) {
922
+ submissionPayload.screenshots = screenshots.map((s) => ({
923
+ dataUrl: s.dataUrl,
924
+ capturedAt: s.capturedAt
925
+ }));
926
+ }
927
+ if (consoleErrors.length > 0) submissionPayload.consoleErrors = consoleErrors;
928
+ if (capturedContext) {
929
+ submissionPayload.systemInfo = {
930
+ url: capturedContext.url,
931
+ userAgent: capturedContext.userAgent,
932
+ viewport: capturedContext.viewport,
933
+ locale: capturedContext.locale
934
+ };
935
+ }
936
+ try {
937
+ const response = await fetch(submitUrl, {
938
+ method: "POST",
939
+ headers: { "Content-Type": "application/json" },
940
+ body: JSON.stringify(submissionPayload)
941
+ });
942
+ if (!response.ok) {
943
+ const body = await response.json().catch(() => ({}));
944
+ _context.dispatch({
945
+ type: "SUBMIT_ERROR",
946
+ payload: {
947
+ error: body.error ?? t("feedback.status.error", locale, customTranslations)
948
+ }
949
+ });
950
+ return;
951
+ }
952
+ const result = await response.json();
953
+ const feedbackId = result.id ?? result.feedbackId ?? "unknown";
954
+ _context.dispatch({ type: "SUBMIT_SUCCESS", payload: { feedbackId } });
955
+ } catch (err) {
956
+ _context.dispatch({
957
+ type: "SUBMIT_ERROR",
958
+ payload: {
959
+ error: err instanceof Error ? err.message : t("feedback.status.error", locale, customTranslations)
960
+ }
961
+ });
962
+ }
963
+ }, [isValid, values, screenshots, consoleErrors, capturedContext, locale, customTranslations, submitUrl, _context]);
964
+ if (state.status !== "confirming") return null;
965
+ const classNames = ["feedback-confirm", className].filter(Boolean).join(" ");
966
+ return /* @__PURE__ */ jsxs(
967
+ "form",
968
+ {
969
+ className: classNames,
970
+ onSubmit: (e) => {
971
+ e.preventDefault();
972
+ void handleSubmit();
973
+ },
974
+ "aria-label": "Submit feedback",
975
+ noValidate: true,
976
+ children: [
977
+ /* @__PURE__ */ jsx("h2", { id: "feedback-confirm-title", className: "feedback-confirm__title", children: "Review your feedback" }),
978
+ duplicates.length > 0 && /* @__PURE__ */ jsxs("div", { className: "feedback-confirm__duplicate-warning", role: "alert", children: [
979
+ /* @__PURE__ */ jsx("strong", { children: "Similar feedback already exists:" }),
980
+ /* @__PURE__ */ jsx("ul", { children: duplicates.map((d) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("a", { href: `#${d.id}`, className: "feedback-confirm__duplicate-link", children: [
981
+ d.title,
982
+ " (",
983
+ d.id,
984
+ ")"
985
+ ] }) }, d.id)) })
986
+ ] }),
987
+ /* @__PURE__ */ jsxs("div", { className: "feedback-confirm__field", children: [
988
+ /* @__PURE__ */ jsxs("label", { htmlFor: "feedback-confirm-title-input", className: "feedback-confirm__label", children: [
989
+ t("feedback.confirm.titleLabel", locale, customTranslations) || "Title",
990
+ " ",
991
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "feedback-confirm__required", children: "*" }),
992
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: "(required)" })
993
+ ] }),
994
+ /* @__PURE__ */ jsx(
995
+ "input",
996
+ {
997
+ id: "feedback-confirm-title-input",
998
+ type: "text",
999
+ className: "feedback-confirm__input",
1000
+ value: values.title,
1001
+ onChange: (e) => handleChange("title", e.target.value),
1002
+ "aria-required": "true",
1003
+ "aria-invalid": touched.title && !!formErrors.title,
1004
+ "aria-describedby": touched.title && formErrors.title ? "feedback-confirm-title-error" : void 0
1005
+ }
1006
+ ),
1007
+ touched.title && formErrors.title && /* @__PURE__ */ jsx(
1008
+ "span",
1009
+ {
1010
+ id: "feedback-confirm-title-error",
1011
+ className: "feedback-confirm__error",
1012
+ role: "alert",
1013
+ children: formErrors.title
1014
+ }
1015
+ )
1016
+ ] }),
1017
+ /* @__PURE__ */ jsxs("div", { className: "feedback-confirm__field", children: [
1018
+ /* @__PURE__ */ jsx("label", { htmlFor: "feedback-confirm-description", className: "feedback-confirm__label", children: t("feedback.confirm.descriptionLabel", locale, customTranslations) || "Description" }),
1019
+ /* @__PURE__ */ jsx(
1020
+ "textarea",
1021
+ {
1022
+ id: "feedback-confirm-description",
1023
+ className: "feedback-confirm__textarea",
1024
+ value: values.description,
1025
+ onChange: (e) => handleChange("description", e.target.value),
1026
+ rows: 4
1027
+ }
1028
+ )
1029
+ ] }),
1030
+ /* @__PURE__ */ jsxs("div", { className: "feedback-confirm__field", children: [
1031
+ /* @__PURE__ */ jsxs("label", { htmlFor: "feedback-confirm-type", className: "feedback-confirm__label", children: [
1032
+ t("feedback.confirm.typeLabel", locale, customTranslations) || "Type",
1033
+ " ",
1034
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "feedback-confirm__required", children: "*" }),
1035
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: "(required)" })
1036
+ ] }),
1037
+ /* @__PURE__ */ jsxs(
1038
+ "select",
1039
+ {
1040
+ id: "feedback-confirm-type",
1041
+ className: "feedback-confirm__select",
1042
+ value: values.type,
1043
+ onChange: (e) => handleChange("type", e.target.value),
1044
+ "aria-required": "true",
1045
+ "aria-invalid": touched.type && !!formErrors.type,
1046
+ "aria-describedby": touched.type && formErrors.type ? "feedback-confirm-type-error" : void 0,
1047
+ children: [
1048
+ /* @__PURE__ */ jsx("option", { value: "", children: t("feedback.form.typePlaceholder", locale, customTranslations) }),
1049
+ /* @__PURE__ */ jsx("option", { value: "bug", children: t("feedback.types.bug", locale, customTranslations) }),
1050
+ /* @__PURE__ */ jsx("option", { value: "feature", children: t("feedback.types.feature", locale, customTranslations) }),
1051
+ /* @__PURE__ */ jsx("option", { value: "ux", children: t("feedback.types.ux", locale, customTranslations) }),
1052
+ /* @__PURE__ */ jsx("option", { value: "other", children: t("feedback.types.other", locale, customTranslations) })
1053
+ ]
1054
+ }
1055
+ ),
1056
+ touched.type && formErrors.type && /* @__PURE__ */ jsx(
1057
+ "span",
1058
+ {
1059
+ id: "feedback-confirm-type-error",
1060
+ className: "feedback-confirm__error",
1061
+ role: "alert",
1062
+ children: formErrors.type
1063
+ }
1064
+ )
1065
+ ] }),
1066
+ /* @__PURE__ */ jsxs("div", { className: "feedback-confirm__field", children: [
1067
+ /* @__PURE__ */ jsx("label", { htmlFor: "feedback-confirm-scope", className: "feedback-confirm__label", children: t("feedback.confirm.scopeLabel", locale, customTranslations) || "Scope" }),
1068
+ /* @__PURE__ */ jsxs(
1069
+ "select",
1070
+ {
1071
+ id: "feedback-confirm-scope",
1072
+ className: "feedback-confirm__select",
1073
+ value: values.scope,
1074
+ onChange: (e) => handleChange("scope", e.target.value),
1075
+ children: [
1076
+ /* @__PURE__ */ jsx("option", { value: "", children: "Select scope (optional)" }),
1077
+ /* @__PURE__ */ jsx("option", { value: "isolated", children: "Isolated" }),
1078
+ /* @__PURE__ */ jsx("option", { value: "cross-cutting", children: "Cross-cutting" }),
1079
+ /* @__PURE__ */ jsx("option", { value: "systemic", children: "Systemic" })
1080
+ ]
1081
+ }
1082
+ )
1083
+ ] }),
1084
+ /* @__PURE__ */ jsxs("div", { className: "feedback-confirm__field", children: [
1085
+ /* @__PURE__ */ jsx("label", { htmlFor: "feedback-confirm-size", className: "feedback-confirm__label", children: t("feedback.confirm.sizeLabel", locale, customTranslations) || "Size" }),
1086
+ /* @__PURE__ */ jsxs(
1087
+ "select",
1088
+ {
1089
+ id: "feedback-confirm-size",
1090
+ className: "feedback-confirm__select",
1091
+ value: values.size,
1092
+ onChange: (e) => handleChange("size", e.target.value),
1093
+ children: [
1094
+ /* @__PURE__ */ jsx("option", { value: "", children: "Select size (optional)" }),
1095
+ /* @__PURE__ */ jsx("option", { value: "trivial", children: "Trivial" }),
1096
+ /* @__PURE__ */ jsx("option", { value: "small", children: "Small" }),
1097
+ /* @__PURE__ */ jsx("option", { value: "medium", children: "Medium" }),
1098
+ /* @__PURE__ */ jsx("option", { value: "large", children: "Large" })
1099
+ ]
1100
+ }
1101
+ )
1102
+ ] }),
1103
+ /* @__PURE__ */ jsxs("div", { className: "feedback-confirm__context-section", "aria-label": "Captured context (read-only)", children: [
1104
+ screenshots.length > 0 && /* @__PURE__ */ jsxs("div", { className: "feedback-confirm__screenshots", children: [
1105
+ /* @__PURE__ */ jsxs("p", { className: "feedback-confirm__context-label", children: [
1106
+ "Screenshots (",
1107
+ screenshots.length,
1108
+ ")"
1109
+ ] }),
1110
+ /* @__PURE__ */ jsx("div", { className: "feedback-confirm__screenshot-thumbnails", children: screenshots.map((s) => /* @__PURE__ */ jsx(
1111
+ "img",
1112
+ {
1113
+ src: s.dataUrl,
1114
+ alt: `Screenshot from ${s.capturedAt}`,
1115
+ className: "feedback-confirm__screenshot-thumb"
1116
+ },
1117
+ s.id
1118
+ )) })
1119
+ ] }),
1120
+ consoleErrors.length > 0 && /* @__PURE__ */ jsxs("p", { className: "feedback-confirm__console-count", children: [
1121
+ consoleErrors.length,
1122
+ " console error",
1123
+ consoleErrors.length !== 1 ? "s" : "",
1124
+ " captured"
1125
+ ] }),
1126
+ capturedContext && /* @__PURE__ */ jsxs("p", { className: "feedback-confirm__system-info", children: [
1127
+ capturedContext.url,
1128
+ " — ",
1129
+ capturedContext.userAgent.slice(0, 60),
1130
+ "…"
1131
+ ] })
1132
+ ] }),
1133
+ submitError && /* @__PURE__ */ jsxs("div", { className: "feedback-confirm__submit-error", role: "alert", children: [
1134
+ submitError,
1135
+ /* @__PURE__ */ jsx(
1136
+ "button",
1137
+ {
1138
+ type: "button",
1139
+ className: "feedback-confirm__retry-btn",
1140
+ onClick: () => void handleSubmit(),
1141
+ children: t("feedback.status.retry", locale, customTranslations)
1142
+ }
1143
+ )
1144
+ ] }),
1145
+ /* @__PURE__ */ jsx(
1146
+ "button",
1147
+ {
1148
+ type: "submit",
1149
+ className: "feedback-confirm__submit-btn",
1150
+ disabled: submitting,
1151
+ "aria-label": submitting ? t("feedback.status.submitting", locale, customTranslations) : t("feedback.button.submit", locale, customTranslations),
1152
+ children: submitting ? /* @__PURE__ */ jsxs(Fragment, { children: [
1153
+ /* @__PURE__ */ jsx("span", { className: "feedback-confirm__spinner", "aria-hidden": "true" }),
1154
+ t("feedback.status.submitting", locale, customTranslations)
1155
+ ] }) : t("feedback.button.submit", locale, customTranslations)
1156
+ }
1157
+ )
1158
+ ]
1159
+ }
1160
+ );
1161
+ }
1162
+ function Portal({ children }) {
1163
+ const [isClient, setIsClient] = useState(false);
1164
+ useEffect(() => {
1165
+ setIsClient(true);
1166
+ }, []);
1167
+ if (!isClient || typeof document === "undefined") {
1168
+ return React.createElement(React.Fragment, null, children);
1169
+ }
1170
+ return React.createElement(
1171
+ React.Fragment,
1172
+ null,
1173
+ children
1174
+ );
1175
+ }
1176
+ const FOCUSABLE_SELECTORS = [
1177
+ "a[href]",
1178
+ "button:not([disabled])",
1179
+ "textarea:not([disabled])",
1180
+ "input:not([disabled])",
1181
+ "select:not([disabled])",
1182
+ '[tabindex]:not([tabindex="-1"])'
1183
+ ].join(", ");
1184
+ function getFocusableElements(container) {
1185
+ return Array.from(
1186
+ container.querySelectorAll(FOCUSABLE_SELECTORS)
1187
+ ).filter((el) => el.offsetParent !== null);
1188
+ }
1189
+ function useFocusTrap(containerRef, isActive) {
1190
+ useEffect(() => {
1191
+ if (!isActive || !containerRef.current) return;
1192
+ const container = containerRef.current;
1193
+ const focusables = getFocusableElements(container);
1194
+ if (focusables.length > 0) {
1195
+ focusables[0]?.focus();
1196
+ } else {
1197
+ container.focus();
1198
+ }
1199
+ function handleKeyDown(e) {
1200
+ if (e.key !== "Tab") return;
1201
+ const elements = getFocusableElements(container);
1202
+ if (elements.length === 0) {
1203
+ e.preventDefault();
1204
+ return;
1205
+ }
1206
+ const first = elements[0];
1207
+ const last = elements[elements.length - 1];
1208
+ if (e.shiftKey) {
1209
+ if (document.activeElement === first) {
1210
+ e.preventDefault();
1211
+ last?.focus();
1212
+ }
1213
+ } else {
1214
+ if (document.activeElement === last) {
1215
+ e.preventDefault();
1216
+ first?.focus();
1217
+ }
1218
+ }
1219
+ }
1220
+ document.addEventListener("keydown", handleKeyDown);
1221
+ return () => {
1222
+ document.removeEventListener("keydown", handleKeyDown);
1223
+ };
1224
+ }, [isActive, containerRef]);
1225
+ }
1226
+ function SubmittedView({
1227
+ feedbackId,
1228
+ onClose,
1229
+ locale,
1230
+ customTranslations
1231
+ }) {
1232
+ return /* @__PURE__ */ jsxs("div", { className: "feedback-panel__submitted", role: "status", children: [
1233
+ /* @__PURE__ */ jsx("p", { className: "feedback-panel__submitted-icon", "aria-hidden": "true", children: "✓" }),
1234
+ /* @__PURE__ */ jsx("h3", { className: "feedback-panel__submitted-title", children: t("feedback.status.success", locale, customTranslations) }),
1235
+ /* @__PURE__ */ jsxs("p", { className: "feedback-panel__submitted-id", children: [
1236
+ "Your feedback ID: ",
1237
+ /* @__PURE__ */ jsx("strong", { children: feedbackId })
1238
+ ] }),
1239
+ /* @__PURE__ */ jsx(
1240
+ "button",
1241
+ {
1242
+ type: "button",
1243
+ className: "feedback-panel__close-btn",
1244
+ onClick: onClose,
1245
+ children: t("feedback.button.close", locale, customTranslations)
1246
+ }
1247
+ )
1248
+ ] });
1249
+ }
1250
+ function FeedbackPanel() {
1251
+ const ctx = useContext(FeedbackContext);
1252
+ const { state, isOpen, close, _context } = useFeedback();
1253
+ const dialogRef = useRef(null);
1254
+ const triggerRef = useRef(null);
1255
+ const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
1256
+ const locale = ctx?.locale ?? "en";
1257
+ const customTranslations = ctx?.config.i18n.customTranslations;
1258
+ useEffect(() => {
1259
+ if (isOpen) {
1260
+ triggerRef.current = document.activeElement;
1261
+ } else {
1262
+ setShowDiscardConfirm(false);
1263
+ }
1264
+ }, [isOpen]);
1265
+ useEffect(() => {
1266
+ if (!isOpen && triggerRef.current) {
1267
+ triggerRef.current.focus();
1268
+ triggerRef.current = null;
1269
+ }
1270
+ }, [isOpen]);
1271
+ useFocusTrap(dialogRef, isOpen);
1272
+ const handleEscape = useCallback(() => {
1273
+ const hasStarted = state.status === "dialogue" && (state.history.length > 0 || state.step > 0);
1274
+ if (hasStarted) {
1275
+ setShowDiscardConfirm(true);
1276
+ } else {
1277
+ close();
1278
+ }
1279
+ }, [state, close]);
1280
+ useEffect(() => {
1281
+ if (!isOpen) return;
1282
+ function onKeyDown(e) {
1283
+ if (e.key === "Escape") {
1284
+ e.preventDefault();
1285
+ handleEscape();
1286
+ }
1287
+ }
1288
+ document.addEventListener("keydown", onKeyDown);
1289
+ return () => document.removeEventListener("keydown", onKeyDown);
1290
+ }, [isOpen, handleEscape]);
1291
+ const handleBackdropClick = useCallback(
1292
+ (e) => {
1293
+ if (e.target === e.currentTarget) {
1294
+ handleEscape();
1295
+ }
1296
+ },
1297
+ [handleEscape]
1298
+ );
1299
+ const handleDiscardConfirm = () => {
1300
+ setShowDiscardConfirm(false);
1301
+ close();
1302
+ };
1303
+ const handleDiscardCancel = () => {
1304
+ setShowDiscardConfirm(false);
1305
+ };
1306
+ const modalTitleId = "feedback-panel-title";
1307
+ const modalTitle = state.status === "idle" ? "" : state.status === "dialogue" ? "Feedback conversation" : state.status === "recording" ? "Capture screenshot" : state.status === "confirming" ? "Review your feedback" : "Feedback submitted";
1308
+ return /* @__PURE__ */ jsxs(Portal, { children: [
1309
+ state.status === "idle" && /* @__PURE__ */ jsx(FeedbackButton, {}),
1310
+ isOpen && /* @__PURE__ */ jsx(
1311
+ "div",
1312
+ {
1313
+ className: "feedback-panel__backdrop",
1314
+ style: {
1315
+ position: "fixed",
1316
+ inset: 0,
1317
+ zIndex: 9999,
1318
+ backgroundColor: "var(--feedback-backdrop-bg, rgba(0, 0, 0, 0.5))",
1319
+ display: "flex",
1320
+ alignItems: "center",
1321
+ justifyContent: "center"
1322
+ },
1323
+ onClick: handleBackdropClick,
1324
+ "aria-hidden": "false",
1325
+ children: /* @__PURE__ */ jsxs(
1326
+ "div",
1327
+ {
1328
+ ref: dialogRef,
1329
+ role: "dialog",
1330
+ "aria-modal": "true",
1331
+ "aria-labelledby": modalTitleId,
1332
+ tabIndex: -1,
1333
+ className: "feedback-panel__dialog",
1334
+ style: {
1335
+ position: "relative",
1336
+ background: "var(--feedback-panel-bg, #ffffff)",
1337
+ borderRadius: "var(--feedback-panel-radius, 12px)",
1338
+ boxShadow: "var(--feedback-panel-shadow, 0 20px 60px rgba(0,0,0,0.3))",
1339
+ width: "var(--feedback-panel-width, 540px)",
1340
+ maxWidth: "95vw",
1341
+ maxHeight: "90vh",
1342
+ overflow: "auto",
1343
+ padding: "var(--feedback-panel-padding, 24px)"
1344
+ },
1345
+ children: [
1346
+ /* @__PURE__ */ jsx("h2", { id: modalTitleId, className: "feedback-panel__title", "aria-live": "polite", children: modalTitle }),
1347
+ /* @__PURE__ */ jsx(
1348
+ "button",
1349
+ {
1350
+ type: "button",
1351
+ className: "feedback-panel__close-button",
1352
+ "aria-label": t("feedback.button.close", locale, customTranslations),
1353
+ onClick: handleEscape,
1354
+ style: {
1355
+ position: "absolute",
1356
+ top: 16,
1357
+ right: 16,
1358
+ background: "none",
1359
+ border: "none",
1360
+ cursor: "pointer",
1361
+ fontSize: 20,
1362
+ lineHeight: 1,
1363
+ padding: "4px 8px",
1364
+ color: "inherit"
1365
+ },
1366
+ children: "×"
1367
+ }
1368
+ ),
1369
+ showDiscardConfirm && /* @__PURE__ */ jsxs(
1370
+ "div",
1371
+ {
1372
+ className: "feedback-panel__discard-confirm",
1373
+ role: "alertdialog",
1374
+ "aria-labelledby": "feedback-panel-discard-title",
1375
+ style: {
1376
+ position: "absolute",
1377
+ inset: 0,
1378
+ background: "var(--feedback-panel-bg, #ffffff)",
1379
+ borderRadius: "inherit",
1380
+ display: "flex",
1381
+ flexDirection: "column",
1382
+ alignItems: "center",
1383
+ justifyContent: "center",
1384
+ gap: 16,
1385
+ padding: 24,
1386
+ zIndex: 1
1387
+ },
1388
+ children: [
1389
+ /* @__PURE__ */ jsx("p", { id: "feedback-panel-discard-title", style: { fontWeight: 600 }, children: "Discard your feedback?" }),
1390
+ /* @__PURE__ */ jsx("p", { children: "Your progress will be lost." }),
1391
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 12 }, children: [
1392
+ /* @__PURE__ */ jsx(
1393
+ "button",
1394
+ {
1395
+ type: "button",
1396
+ className: "feedback-panel__discard-confirm-btn",
1397
+ onClick: handleDiscardConfirm,
1398
+ children: "Discard"
1399
+ }
1400
+ ),
1401
+ /* @__PURE__ */ jsx(
1402
+ "button",
1403
+ {
1404
+ type: "button",
1405
+ className: "feedback-panel__discard-cancel-btn",
1406
+ onClick: handleDiscardCancel,
1407
+ autoFocus: true,
1408
+ children: "Continue editing"
1409
+ }
1410
+ )
1411
+ ] })
1412
+ ]
1413
+ }
1414
+ ),
1415
+ state.status === "dialogue" && /* @__PURE__ */ jsx(FeedbackDialogue, {}),
1416
+ state.status === "recording" && /* @__PURE__ */ jsx(FeedbackRecorder, {}),
1417
+ state.status === "confirming" && /* @__PURE__ */ jsx(FeedbackConfirm, {}),
1418
+ state.status === "submitted" && /* @__PURE__ */ jsx(
1419
+ SubmittedView,
1420
+ {
1421
+ feedbackId: state.feedbackId,
1422
+ onClose: () => _context.dispatch({ type: "CLOSE" }),
1423
+ locale,
1424
+ customTranslations: customTranslations ?? void 0
1425
+ }
1426
+ )
1427
+ ]
1428
+ }
1429
+ )
1430
+ }
1431
+ )
1432
+ ] });
1433
+ }
1434
+ const FeedbackPanel$1 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
1435
+ __proto__: null,
1436
+ FeedbackPanel
1437
+ }, Symbol.toStringTag, { value: "Module" }));
1438
+ const VALID_TYPES = ["bug", "feature", "ux", "other"];
1439
+ function validatePayload(body) {
1440
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
1441
+ return { valid: false, errors: ["Request body must be a JSON object"] };
1442
+ }
1443
+ const obj = body;
1444
+ const errors = [];
1445
+ if (!obj["title"] || typeof obj["title"] !== "string" || obj["title"].trim().length === 0) {
1446
+ errors.push("title is required and must be a non-empty string");
1447
+ } else if (obj["title"].trim().length < 1) {
1448
+ errors.push("title must be at least 1 character");
1449
+ }
1450
+ if (!obj["type"]) {
1451
+ errors.push("type is required");
1452
+ } else if (!VALID_TYPES.includes(obj["type"])) {
1453
+ errors.push(`type must be one of: ${VALID_TYPES.join(", ")} (got: ${String(obj["type"])})`);
1454
+ }
1455
+ if (obj["description"] !== void 0 && typeof obj["description"] !== "string") {
1456
+ errors.push("description must be a string if provided");
1457
+ }
1458
+ if (errors.length > 0) {
1459
+ return { valid: false, errors };
1460
+ }
1461
+ return {
1462
+ valid: true,
1463
+ payload: {
1464
+ title: obj["title"].trim(),
1465
+ ...obj["description"] !== void 0 && { description: obj["description"] },
1466
+ type: obj["type"],
1467
+ ...obj["scope"] !== void 0 && { scope: obj["scope"] },
1468
+ ...obj["size"] !== void 0 && { size: obj["size"] },
1469
+ ...obj["screenshots"] !== void 0 && { screenshots: obj["screenshots"] },
1470
+ ...obj["consoleErrors"] !== void 0 && { consoleErrors: obj["consoleErrors"] },
1471
+ ...obj["systemInfo"] !== void 0 && { systemInfo: obj["systemInfo"] }
1472
+ }
1473
+ };
1474
+ }
1475
+ async function storeFeedback(payload, config, feedbackId) {
1476
+ if (!config.tableName || !config.awsRegion) {
1477
+ return;
1478
+ }
1479
+ const dynamoPrefix = ["@aws", "-sdk/client-dynamo", "db"].join("");
1480
+ const { DynamoDBClient, PutItemCommand } = await import(
1481
+ /* @vite-ignore */
1482
+ dynamoPrefix
1483
+ );
1484
+ const client = new DynamoDBClient({ region: config.awsRegion });
1485
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1486
+ await client.send(
1487
+ new PutItemCommand({
1488
+ TableName: config.tableName,
1489
+ Item: {
1490
+ PK: { S: `PROJECT#${config.appName}` },
1491
+ SK: { S: `FB#${now}#${feedbackId}` },
1492
+ id: { S: feedbackId },
1493
+ appName: { S: config.appName },
1494
+ type: { S: payload.type },
1495
+ status: { S: "open" },
1496
+ content: { S: payload.title },
1497
+ submittedAt: { S: now },
1498
+ lastModifiedAt: { S: now },
1499
+ environment: { S: config.environment ?? "production" }
1500
+ }
1501
+ })
1502
+ );
1503
+ }
1504
+ function generateId() {
1505
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1506
+ const uuid8 = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
1507
+ return `FB-${date}-${uuid8}`;
1508
+ }
1509
+ function createIntakeResolver(config) {
1510
+ return async (req, res) => {
1511
+ const contentType = (req.headers?.["content-type"] ?? "").toString();
1512
+ if (!contentType.includes("application/json")) {
1513
+ res.status(400).json({
1514
+ errors: ["Content-Type must be application/json"]
1515
+ });
1516
+ return;
1517
+ }
1518
+ const validation = validatePayload(req.body);
1519
+ if (!validation.valid) {
1520
+ res.status(400).json({ errors: validation.errors });
1521
+ return;
1522
+ }
1523
+ const { payload } = validation;
1524
+ const feedbackId = generateId();
1525
+ try {
1526
+ await withSpan(
1527
+ "feedback.submit",
1528
+ {
1529
+ app: config.appName,
1530
+ environment: config.environment ?? "production"
1531
+ },
1532
+ async (span) => {
1533
+ try {
1534
+ await storeFeedback(payload, config, feedbackId);
1535
+ if (span) {
1536
+ span.setAttribute?.("feedbackId", feedbackId);
1537
+ }
1538
+ res.status(201).json({
1539
+ id: feedbackId,
1540
+ status: "open"
1541
+ });
1542
+ } catch (storageError) {
1543
+ const isDynamoUnavailable = storageError instanceof Error && (storageError.name === "ServiceUnavailableException" || storageError.name === "ProvisionedThroughputExceededException" || storageError.message.includes("ECONNREFUSED") || storageError.message.includes("unavailable") || storageError.message.includes("DynamoDB"));
1544
+ if (isDynamoUnavailable) {
1545
+ res.status(503).json({
1546
+ retryable: true,
1547
+ error: "Storage service temporarily unavailable. Please try again."
1548
+ });
1549
+ } else {
1550
+ res.status(503).json({
1551
+ retryable: true,
1552
+ error: "Failed to store feedback. Please try again."
1553
+ });
1554
+ }
1555
+ }
1556
+ }
1557
+ );
1558
+ } catch {
1559
+ res.status(503).json({
1560
+ retryable: true,
1561
+ error: "Internal error. Please try again."
1562
+ });
1563
+ }
1564
+ };
1565
+ }
1566
+ export {
1567
+ FeedbackButton,
1568
+ FeedbackConfirm,
1569
+ FeedbackDialogue,
1570
+ FeedbackPanel,
1571
+ FeedbackProvider,
1572
+ FeedbackRecorder,
1573
+ createIntakeResolver,
1574
+ useFeedback
1575
+ };
1576
+ //# sourceMappingURL=react.js.map