@stephenov/feedbackwidget 2.0.0 → 2.0.2

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
@@ -1,18 +1,1445 @@
1
1
  'use client';
2
- import {
3
- FeedbackApiClient,
4
- FeedbackPanel,
5
- FeedbackTrigger,
6
- FeedbackWidget,
7
- VoiceRecorder,
8
- cn,
9
- createApiClient,
10
- formatDuration,
11
- isVoiceSupported
12
- } from "./chunk-WCFS4MGE.mjs";
2
+
3
+ // src/components/FeedbackWidget.tsx
4
+ import { useState as useState4, useEffect as useEffect4 } from "react";
5
+
6
+ // src/components/FeedbackTrigger.tsx
7
+ import { useState, useEffect, useRef, useCallback } from "react";
8
+
9
+ // src/utils.ts
10
+ function cn(...classes) {
11
+ return classes.filter(Boolean).join(" ");
12
+ }
13
+ function formatDuration(seconds) {
14
+ const mins = Math.floor(seconds / 60);
15
+ const secs = seconds % 60;
16
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
17
+ }
18
+ function getBrowserInfo() {
19
+ if (typeof window === "undefined") return {};
20
+ return {
21
+ userAgent: navigator.userAgent,
22
+ language: navigator.language,
23
+ viewport: {
24
+ width: window.innerWidth,
25
+ height: window.innerHeight
26
+ },
27
+ screen: {
28
+ width: screen.width,
29
+ height: screen.height
30
+ },
31
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
32
+ };
33
+ }
34
+ function isVoiceSupported() {
35
+ if (typeof window === "undefined") return false;
36
+ return !!(navigator.mediaDevices && typeof MediaRecorder !== "undefined");
37
+ }
38
+ var DEFAULT_LABELS = {
39
+ triggerTooltip: "Send feedback",
40
+ panelTitle: "Send Feedback",
41
+ voiceButton: "Voice",
42
+ textButton: "Type",
43
+ submitButton: "Send Feedback",
44
+ successMessage: "Thanks for your feedback!",
45
+ placeholder: "What's on your mind?"
46
+ };
47
+
48
+ // src/components/icons.tsx
49
+ import { jsx, jsxs } from "react/jsx-runtime";
50
+ function ZapIcon({ className, size = 24 }) {
51
+ return /* @__PURE__ */ jsx(
52
+ "svg",
53
+ {
54
+ xmlns: "http://www.w3.org/2000/svg",
55
+ width: size,
56
+ height: size,
57
+ viewBox: "0 0 24 24",
58
+ fill: "none",
59
+ stroke: "currentColor",
60
+ strokeWidth: "2.5",
61
+ strokeLinecap: "round",
62
+ strokeLinejoin: "round",
63
+ className,
64
+ children: /* @__PURE__ */ jsx("polygon", { points: "13 2 3 14 12 14 11 22 21 10 12 10 13 2" })
65
+ }
66
+ );
67
+ }
68
+ function MicIcon({ className, size = 24 }) {
69
+ return /* @__PURE__ */ jsxs(
70
+ "svg",
71
+ {
72
+ xmlns: "http://www.w3.org/2000/svg",
73
+ width: size,
74
+ height: size,
75
+ viewBox: "0 0 24 24",
76
+ fill: "none",
77
+ stroke: "currentColor",
78
+ strokeWidth: "2",
79
+ strokeLinecap: "round",
80
+ strokeLinejoin: "round",
81
+ className,
82
+ children: [
83
+ /* @__PURE__ */ jsx("path", { d: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" }),
84
+ /* @__PURE__ */ jsx("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }),
85
+ /* @__PURE__ */ jsx("line", { x1: "12", x2: "12", y1: "19", y2: "22" })
86
+ ]
87
+ }
88
+ );
89
+ }
90
+ function TypeIcon({ className, size = 24 }) {
91
+ return /* @__PURE__ */ jsxs(
92
+ "svg",
93
+ {
94
+ xmlns: "http://www.w3.org/2000/svg",
95
+ width: size,
96
+ height: size,
97
+ viewBox: "0 0 24 24",
98
+ fill: "none",
99
+ stroke: "currentColor",
100
+ strokeWidth: "2",
101
+ strokeLinecap: "round",
102
+ strokeLinejoin: "round",
103
+ className,
104
+ children: [
105
+ /* @__PURE__ */ jsx("polyline", { points: "4 7 4 4 20 4 20 7" }),
106
+ /* @__PURE__ */ jsx("line", { x1: "9", x2: "15", y1: "20", y2: "20" }),
107
+ /* @__PURE__ */ jsx("line", { x1: "12", x2: "12", y1: "4", y2: "20" })
108
+ ]
109
+ }
110
+ );
111
+ }
112
+ function XIcon({ className, size = 24 }) {
113
+ return /* @__PURE__ */ jsxs(
114
+ "svg",
115
+ {
116
+ xmlns: "http://www.w3.org/2000/svg",
117
+ width: size,
118
+ height: size,
119
+ viewBox: "0 0 24 24",
120
+ fill: "none",
121
+ stroke: "currentColor",
122
+ strokeWidth: "2",
123
+ strokeLinecap: "round",
124
+ strokeLinejoin: "round",
125
+ className,
126
+ children: [
127
+ /* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }),
128
+ /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })
129
+ ]
130
+ }
131
+ );
132
+ }
133
+ function ImagePlusIcon({ className, size = 24 }) {
134
+ return /* @__PURE__ */ jsxs(
135
+ "svg",
136
+ {
137
+ xmlns: "http://www.w3.org/2000/svg",
138
+ width: size,
139
+ height: size,
140
+ viewBox: "0 0 24 24",
141
+ fill: "none",
142
+ stroke: "currentColor",
143
+ strokeWidth: "2",
144
+ strokeLinecap: "round",
145
+ strokeLinejoin: "round",
146
+ className,
147
+ children: [
148
+ /* @__PURE__ */ jsx("path", { d: "M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7" }),
149
+ /* @__PURE__ */ jsx("line", { x1: "16", x2: "22", y1: "5", y2: "5" }),
150
+ /* @__PURE__ */ jsx("line", { x1: "19", x2: "19", y1: "2", y2: "8" }),
151
+ /* @__PURE__ */ jsx("circle", { cx: "9", cy: "9", r: "2" }),
152
+ /* @__PURE__ */ jsx("path", { d: "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" })
153
+ ]
154
+ }
155
+ );
156
+ }
157
+ function CheckIcon({ className, size = 24 }) {
158
+ return /* @__PURE__ */ jsx(
159
+ "svg",
160
+ {
161
+ xmlns: "http://www.w3.org/2000/svg",
162
+ width: size,
163
+ height: size,
164
+ viewBox: "0 0 24 24",
165
+ fill: "none",
166
+ stroke: "currentColor",
167
+ strokeWidth: "2",
168
+ strokeLinecap: "round",
169
+ strokeLinejoin: "round",
170
+ className,
171
+ children: /* @__PURE__ */ jsx("polyline", { points: "20 6 9 17 4 12" })
172
+ }
173
+ );
174
+ }
175
+ function LoaderIcon({ className, size = 24 }) {
176
+ return /* @__PURE__ */ jsx(
177
+ "svg",
178
+ {
179
+ xmlns: "http://www.w3.org/2000/svg",
180
+ width: size,
181
+ height: size,
182
+ viewBox: "0 0 24 24",
183
+ fill: "none",
184
+ stroke: "currentColor",
185
+ strokeWidth: "2",
186
+ strokeLinecap: "round",
187
+ strokeLinejoin: "round",
188
+ className,
189
+ style: { animation: "spin 1s linear infinite" },
190
+ children: /* @__PURE__ */ jsx("path", { d: "M21 12a9 9 0 1 1-6.219-8.56" })
191
+ }
192
+ );
193
+ }
194
+ function SquareIcon({ className, size = 24 }) {
195
+ return /* @__PURE__ */ jsx(
196
+ "svg",
197
+ {
198
+ xmlns: "http://www.w3.org/2000/svg",
199
+ width: size,
200
+ height: size,
201
+ viewBox: "0 0 24 24",
202
+ fill: "currentColor",
203
+ stroke: "currentColor",
204
+ strokeWidth: "2",
205
+ strokeLinecap: "round",
206
+ strokeLinejoin: "round",
207
+ className,
208
+ children: /* @__PURE__ */ jsx("rect", { width: "14", height: "14", x: "5", y: "5", rx: "2" })
209
+ }
210
+ );
211
+ }
212
+ function AlertCircleIcon({ className, size = 24 }) {
213
+ return /* @__PURE__ */ jsxs(
214
+ "svg",
215
+ {
216
+ xmlns: "http://www.w3.org/2000/svg",
217
+ width: size,
218
+ height: size,
219
+ viewBox: "0 0 24 24",
220
+ fill: "none",
221
+ stroke: "currentColor",
222
+ strokeWidth: "2",
223
+ strokeLinecap: "round",
224
+ strokeLinejoin: "round",
225
+ className,
226
+ children: [
227
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "10" }),
228
+ /* @__PURE__ */ jsx("line", { x1: "12", x2: "12", y1: "8", y2: "12" }),
229
+ /* @__PURE__ */ jsx("line", { x1: "12", x2: "12.01", y1: "16", y2: "16" })
230
+ ]
231
+ }
232
+ );
233
+ }
234
+
235
+ // src/components/FeedbackTrigger.tsx
236
+ import { jsx as jsx2 } from "react/jsx-runtime";
237
+ var POSITION_KEY = "feedbackwidget-trigger-position";
238
+ var DEFAULT_OFFSET = 24;
239
+ var BUTTON_SIZE = 52;
240
+ function FeedbackTrigger({
241
+ onClick,
242
+ isOpen,
243
+ position = "bottom-right",
244
+ accentColor = "#BDE0C2",
245
+ tooltip = "Send feedback"
246
+ }) {
247
+ const [offsets, setOffsets] = useState(null);
248
+ const [isDragging, setIsDragging] = useState(false);
249
+ const [dragStart, setDragStart] = useState(null);
250
+ const [hasMoved, setHasMoved] = useState(false);
251
+ const buttonRef = useRef(null);
252
+ const getDefaultOffsets = useCallback(() => {
253
+ return {
254
+ right: DEFAULT_OFFSET,
255
+ bottom: DEFAULT_OFFSET + 80,
256
+ // Extra offset from bottom
257
+ hasBeenDragged: false
258
+ };
259
+ }, []);
260
+ useEffect(() => {
261
+ const saved = localStorage.getItem(POSITION_KEY);
262
+ if (saved) {
263
+ try {
264
+ const parsed = JSON.parse(saved);
265
+ if (typeof parsed.right === "number" && typeof parsed.bottom === "number") {
266
+ setOffsets({
267
+ right: Math.max(DEFAULT_OFFSET, parsed.right),
268
+ bottom: Math.max(DEFAULT_OFFSET, parsed.bottom),
269
+ hasBeenDragged: true
270
+ });
271
+ } else {
272
+ setOffsets(getDefaultOffsets());
273
+ }
274
+ } catch {
275
+ setOffsets(getDefaultOffsets());
276
+ }
277
+ } else {
278
+ setOffsets(getDefaultOffsets());
279
+ }
280
+ }, [getDefaultOffsets]);
281
+ const savePosition = useCallback((newOffsets) => {
282
+ localStorage.setItem(POSITION_KEY, JSON.stringify({
283
+ right: newOffsets.right,
284
+ bottom: newOffsets.bottom
285
+ }));
286
+ }, []);
287
+ const handleMouseDown = (e) => {
288
+ if (e.button !== 0) return;
289
+ e.preventDefault();
290
+ setIsDragging(true);
291
+ setHasMoved(false);
292
+ setDragStart({ x: e.clientX, y: e.clientY });
293
+ };
294
+ const handleTouchStart = (e) => {
295
+ const touch = e.touches[0];
296
+ setIsDragging(true);
297
+ setHasMoved(false);
298
+ setDragStart({ x: touch.clientX, y: touch.clientY });
299
+ };
300
+ const handleMove = useCallback(
301
+ (clientX, clientY) => {
302
+ if (!isDragging || !dragStart || !offsets) return;
303
+ const deltaX = clientX - dragStart.x;
304
+ const deltaY = clientY - dragStart.y;
305
+ if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
306
+ setHasMoved(true);
307
+ }
308
+ const maxRight = window.innerWidth - BUTTON_SIZE - DEFAULT_OFFSET;
309
+ const maxBottom = window.innerHeight - BUTTON_SIZE - DEFAULT_OFFSET;
310
+ const newRight = Math.min(Math.max(DEFAULT_OFFSET, offsets.right - deltaX), maxRight);
311
+ const newBottom = Math.min(Math.max(DEFAULT_OFFSET, offsets.bottom - deltaY), maxBottom);
312
+ setOffsets({
313
+ right: newRight,
314
+ bottom: newBottom,
315
+ hasBeenDragged: true
316
+ });
317
+ setDragStart({ x: clientX, y: clientY });
318
+ },
319
+ [isDragging, dragStart, offsets]
320
+ );
321
+ const handleMouseMove = useCallback(
322
+ (e) => handleMove(e.clientX, e.clientY),
323
+ [handleMove]
324
+ );
325
+ const handleTouchMove = useCallback(
326
+ (e) => handleMove(e.touches[0].clientX, e.touches[0].clientY),
327
+ [handleMove]
328
+ );
329
+ const handleEnd = useCallback(() => {
330
+ if (isDragging && offsets) {
331
+ savePosition(offsets);
332
+ }
333
+ setIsDragging(false);
334
+ setDragStart(null);
335
+ }, [isDragging, offsets, savePosition]);
336
+ useEffect(() => {
337
+ if (isDragging) {
338
+ window.addEventListener("mousemove", handleMouseMove);
339
+ window.addEventListener("mouseup", handleEnd);
340
+ window.addEventListener("touchmove", handleTouchMove, { passive: false });
341
+ window.addEventListener("touchend", handleEnd);
342
+ }
343
+ return () => {
344
+ window.removeEventListener("mousemove", handleMouseMove);
345
+ window.removeEventListener("mouseup", handleEnd);
346
+ window.removeEventListener("touchmove", handleTouchMove);
347
+ window.removeEventListener("touchend", handleEnd);
348
+ };
349
+ }, [isDragging, handleMouseMove, handleTouchMove, handleEnd]);
350
+ const handleClick = () => {
351
+ if (!hasMoved) {
352
+ onClick();
353
+ }
354
+ };
355
+ if (!offsets || isOpen) return null;
356
+ return /* @__PURE__ */ jsx2(
357
+ "button",
358
+ {
359
+ ref: buttonRef,
360
+ onMouseDown: handleMouseDown,
361
+ onTouchStart: handleTouchStart,
362
+ onClick: handleClick,
363
+ className: cn(
364
+ "fw-trigger",
365
+ isDragging && "fw-trigger--dragging"
366
+ ),
367
+ style: {
368
+ right: offsets.right,
369
+ bottom: offsets.bottom,
370
+ width: BUTTON_SIZE,
371
+ height: BUTTON_SIZE,
372
+ backgroundColor: accentColor
373
+ },
374
+ title: tooltip,
375
+ "aria-label": tooltip,
376
+ children: /* @__PURE__ */ jsx2(ZapIcon, { size: 24 })
377
+ }
378
+ );
379
+ }
380
+
381
+ // src/components/FeedbackPanel.tsx
382
+ import * as React from "react";
383
+ import { useState as useState3, useRef as useRef3, useEffect as useEffect3 } from "react";
384
+
385
+ // src/components/VoiceRecorder.tsx
386
+ import { useState as useState2, useRef as useRef2, useEffect as useEffect2, useCallback as useCallback2 } from "react";
387
+ import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
388
+ var MAX_DURATION_SECONDS = 120;
389
+ function VoiceRecorder({
390
+ onSubmit,
391
+ isSubmitting,
392
+ maxDuration = MAX_DURATION_SECONDS,
393
+ accentColor = "#BDE0C2"
394
+ }) {
395
+ const [isRecording, setIsRecording] = useState2(false);
396
+ const [duration, setDuration] = useState2(0);
397
+ const [error, setError] = useState2(null);
398
+ const [isSupported, setIsSupported] = useState2(true);
399
+ const [audioLevel, setAudioLevel] = useState2(0);
400
+ const mediaRecorderRef = useRef2(null);
401
+ const chunksRef = useRef2([]);
402
+ const timerRef = useRef2(null);
403
+ const streamRef = useRef2(null);
404
+ const analyzerRef = useRef2(null);
405
+ const animationRef = useRef2(null);
406
+ const durationRef = useRef2(0);
407
+ useEffect2(() => {
408
+ setIsSupported(isVoiceSupported());
409
+ }, []);
410
+ useEffect2(() => {
411
+ return () => {
412
+ stopRecording();
413
+ if (animationRef.current) {
414
+ cancelAnimationFrame(animationRef.current);
415
+ }
416
+ };
417
+ }, []);
418
+ const updateAudioLevel = useCallback2(() => {
419
+ if (!analyzerRef.current || !isRecording) return;
420
+ const dataArray = new Uint8Array(analyzerRef.current.frequencyBinCount);
421
+ analyzerRef.current.getByteFrequencyData(dataArray);
422
+ const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
423
+ setAudioLevel(average / 255);
424
+ animationRef.current = requestAnimationFrame(updateAudioLevel);
425
+ }, [isRecording]);
426
+ const startRecording = async () => {
427
+ setError(null);
428
+ chunksRef.current = [];
429
+ try {
430
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
431
+ streamRef.current = stream;
432
+ const audioContext = new AudioContext();
433
+ const source = audioContext.createMediaStreamSource(stream);
434
+ const analyzer = audioContext.createAnalyser();
435
+ analyzer.fftSize = 256;
436
+ source.connect(analyzer);
437
+ analyzerRef.current = analyzer;
438
+ const mimeType = MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : MediaRecorder.isTypeSupported("audio/mp4") ? "audio/mp4" : "audio/ogg";
439
+ const mediaRecorder = new MediaRecorder(stream, { mimeType });
440
+ mediaRecorderRef.current = mediaRecorder;
441
+ mediaRecorder.ondataavailable = (e) => {
442
+ if (e.data.size > 0) {
443
+ chunksRef.current.push(e.data);
444
+ }
445
+ };
446
+ mediaRecorder.onstop = async () => {
447
+ const audioBlob = new Blob(chunksRef.current, { type: mimeType });
448
+ const finalDuration = durationRef.current;
449
+ if (streamRef.current) {
450
+ streamRef.current.getTracks().forEach((track) => track.stop());
451
+ streamRef.current = null;
452
+ }
453
+ if (audioBlob.size > 0 && finalDuration > 0) {
454
+ await onSubmit(audioBlob, finalDuration);
455
+ }
456
+ };
457
+ mediaRecorder.start(1e3);
458
+ setIsRecording(true);
459
+ setDuration(0);
460
+ durationRef.current = 0;
461
+ timerRef.current = setInterval(() => {
462
+ setDuration((prev) => {
463
+ const newDuration = prev + 1;
464
+ durationRef.current = newDuration;
465
+ if (newDuration >= maxDuration) {
466
+ stopRecording();
467
+ }
468
+ return newDuration;
469
+ });
470
+ }, 1e3);
471
+ animationRef.current = requestAnimationFrame(updateAudioLevel);
472
+ } catch (err) {
473
+ console.error("Error starting recording:", err);
474
+ if (err instanceof Error) {
475
+ if (err.name === "NotAllowedError") {
476
+ setError("Microphone access denied. Please allow microphone access.");
477
+ } else if (err.name === "NotFoundError") {
478
+ setError("No microphone found. Please connect a microphone.");
479
+ } else {
480
+ setError("Failed to start recording. Please try typing instead.");
481
+ }
482
+ }
483
+ }
484
+ };
485
+ const stopRecording = () => {
486
+ if (timerRef.current) {
487
+ clearInterval(timerRef.current);
488
+ timerRef.current = null;
489
+ }
490
+ if (animationRef.current) {
491
+ cancelAnimationFrame(animationRef.current);
492
+ animationRef.current = null;
493
+ }
494
+ if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
495
+ mediaRecorderRef.current.stop();
496
+ }
497
+ setIsRecording(false);
498
+ setAudioLevel(0);
499
+ };
500
+ if (!isSupported) {
501
+ return /* @__PURE__ */ jsxs2("div", { className: "fw-voice-unsupported", children: [
502
+ /* @__PURE__ */ jsx3("div", { className: "fw-voice-error-icon", children: /* @__PURE__ */ jsx3(AlertCircleIcon, { size: 32 }) }),
503
+ /* @__PURE__ */ jsxs2("p", { children: [
504
+ "Voice recording not supported in this browser.",
505
+ /* @__PURE__ */ jsx3("br", {}),
506
+ "Please use the Type option instead."
507
+ ] })
508
+ ] });
509
+ }
510
+ if (error) {
511
+ return /* @__PURE__ */ jsxs2("div", { className: "fw-voice-error", children: [
512
+ /* @__PURE__ */ jsx3("div", { className: "fw-voice-error-icon fw-voice-error-icon--error", children: /* @__PURE__ */ jsx3(AlertCircleIcon, { size: 32 }) }),
513
+ /* @__PURE__ */ jsx3("p", { children: error }),
514
+ /* @__PURE__ */ jsx3(
515
+ "button",
516
+ {
517
+ onClick: () => setError(null),
518
+ className: "fw-voice-retry-btn",
519
+ children: "Try Again"
520
+ }
521
+ )
522
+ ] });
523
+ }
524
+ return /* @__PURE__ */ jsxs2("div", { className: "fw-voice-recorder", children: [
525
+ /* @__PURE__ */ jsxs2(
526
+ "button",
527
+ {
528
+ onClick: isRecording ? stopRecording : startRecording,
529
+ disabled: isSubmitting,
530
+ className: cn(
531
+ "fw-voice-btn",
532
+ isRecording && "fw-voice-btn--recording",
533
+ isSubmitting && "fw-voice-btn--disabled"
534
+ ),
535
+ style: {
536
+ backgroundColor: isRecording ? "#ef4444" : accentColor
537
+ },
538
+ children: [
539
+ isRecording && /* @__PURE__ */ jsx3(
540
+ "div",
541
+ {
542
+ className: "fw-voice-pulse",
543
+ style: {
544
+ transform: `scale(${1 + audioLevel * 0.3})`,
545
+ opacity: 0.5 + audioLevel * 0.5
546
+ }
547
+ }
548
+ ),
549
+ isSubmitting ? /* @__PURE__ */ jsx3(LoaderIcon, { size: 40, className: "fw-voice-icon fw-voice-icon--spin" }) : isRecording ? /* @__PURE__ */ jsx3(SquareIcon, { size: 40, className: "fw-voice-icon" }) : /* @__PURE__ */ jsx3(MicIcon, { size: 40, className: "fw-voice-icon" })
550
+ ]
551
+ }
552
+ ),
553
+ /* @__PURE__ */ jsx3("div", { className: "fw-voice-status", children: isSubmitting ? /* @__PURE__ */ jsx3("p", { className: "fw-voice-text", children: "Sending feedback..." }) : isRecording ? /* @__PURE__ */ jsxs2(Fragment, { children: [
554
+ /* @__PURE__ */ jsx3("p", { className: "fw-voice-timer", children: formatDuration(duration) }),
555
+ /* @__PURE__ */ jsx3("p", { className: "fw-voice-hint", children: "Tap to stop and send" }),
556
+ /* @__PURE__ */ jsxs2("p", { className: "fw-voice-limit", children: [
557
+ "Max ",
558
+ Math.floor(maxDuration / 60),
559
+ " minutes"
560
+ ] })
561
+ ] }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
562
+ /* @__PURE__ */ jsx3("p", { className: "fw-voice-cta", children: "Tap to record" }),
563
+ /* @__PURE__ */ jsx3("p", { className: "fw-voice-hint", children: "Share your feedback by voice" })
564
+ ] }) }),
565
+ isRecording && /* @__PURE__ */ jsx3("div", { className: "fw-voice-visualizer", children: [...Array(7)].map((_, i) => /* @__PURE__ */ jsx3(
566
+ "div",
567
+ {
568
+ className: "fw-voice-bar",
569
+ style: {
570
+ height: `${Math.max(8, audioLevel * 100 * Math.sin(i + Date.now() / 200) % 32)}px`,
571
+ backgroundColor: "#ef4444"
572
+ }
573
+ },
574
+ i
575
+ )) })
576
+ ] });
577
+ }
578
+
579
+ // src/api/client.ts
580
+ var API_BASE = "https://feedbackwidget-api.vercel.app/api/v1";
581
+ var FeedbackApiClient = class {
582
+ constructor(apiKey, baseUrl) {
583
+ this.apiKey = apiKey;
584
+ this.baseUrl = baseUrl || API_BASE;
585
+ }
586
+ /** Validate API key and get project config */
587
+ async validate() {
588
+ try {
589
+ const response = await fetch(`${this.baseUrl}/validate`, {
590
+ method: "GET",
591
+ headers: {
592
+ Authorization: `Bearer ${this.apiKey}`
593
+ }
594
+ });
595
+ if (!response.ok) {
596
+ const error = await response.json().catch(() => ({}));
597
+ return {
598
+ valid: false,
599
+ error: error.message || `HTTP ${response.status}`
600
+ };
601
+ }
602
+ return await response.json();
603
+ } catch (error) {
604
+ return {
605
+ valid: false,
606
+ error: error instanceof Error ? error.message : "Network error"
607
+ };
608
+ }
609
+ }
610
+ /** Submit feedback */
611
+ async submit(params) {
612
+ const formData = new FormData();
613
+ formData.append("type", params.type);
614
+ formData.append("page_path", params.pagePath);
615
+ formData.append("page_title", params.pageTitle);
616
+ formData.append("browser_info", JSON.stringify(getBrowserInfo()));
617
+ if (params.type === "text" && params.message) {
618
+ formData.append("message", params.message);
619
+ }
620
+ if (params.type === "voice" && params.audioBlob) {
621
+ formData.append("audio", params.audioBlob, "feedback.webm");
622
+ if (params.audioDuration) {
623
+ formData.append("audio_duration", String(Math.round(params.audioDuration)));
624
+ }
625
+ }
626
+ if (params.screenshots && params.screenshots.length > 0) {
627
+ params.screenshots.forEach((file, index) => {
628
+ formData.append(`screenshot_${index}`, file);
629
+ });
630
+ formData.append("screenshot_count", String(params.screenshots.length));
631
+ }
632
+ if (params.user) {
633
+ if (params.user.id) formData.append("user_id", params.user.id);
634
+ if (params.user.email) formData.append("user_email", params.user.email);
635
+ if (params.user.name) formData.append("user_name", params.user.name);
636
+ const { id, email, name, ...rest } = params.user;
637
+ if (Object.keys(rest).length > 0) {
638
+ formData.append("user_metadata", JSON.stringify(rest));
639
+ }
640
+ }
641
+ if (params.metadata) {
642
+ formData.append("custom_metadata", JSON.stringify(params.metadata));
643
+ }
644
+ const response = await fetch(`${this.baseUrl}/feedback`, {
645
+ method: "POST",
646
+ headers: {
647
+ Authorization: `Bearer ${this.apiKey}`
648
+ },
649
+ body: formData
650
+ });
651
+ if (!response.ok) {
652
+ const error = await response.json().catch(() => ({}));
653
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
654
+ }
655
+ return await response.json();
656
+ }
657
+ };
658
+ function createApiClient(apiKey, baseUrl) {
659
+ return new FeedbackApiClient(apiKey, baseUrl);
660
+ }
661
+
662
+ // src/components/FeedbackPanel.tsx
663
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
664
+ function FeedbackPanel({
665
+ isOpen,
666
+ onClose,
667
+ apiKey,
668
+ apiBaseUrl,
669
+ accentColor = "#BDE0C2",
670
+ allowVoice = true,
671
+ allowText = true,
672
+ allowScreenshots = true,
673
+ maxVoiceDuration = 120,
674
+ user,
675
+ metadata,
676
+ labels = {},
677
+ onSubmit,
678
+ onError
679
+ }) {
680
+ const mergedLabels = { ...DEFAULT_LABELS, ...labels };
681
+ const defaultMode = allowVoice ? "voice" : "text";
682
+ const [mode, setMode] = useState3(defaultMode);
683
+ const [message, setMessage] = useState3("");
684
+ const [screenshots, setScreenshots] = useState3([]);
685
+ const [isSubmitting, setIsSubmitting] = useState3(false);
686
+ const [isSuccess, setIsSuccess] = useState3(false);
687
+ const fileInputRef = useRef3(null);
688
+ const apiClient = React.useMemo(
689
+ () => createApiClient(apiKey, apiBaseUrl),
690
+ [apiKey, apiBaseUrl]
691
+ );
692
+ useEffect3(() => {
693
+ if (!isOpen) {
694
+ const timer = setTimeout(() => {
695
+ setMode(defaultMode);
696
+ setMessage("");
697
+ setScreenshots([]);
698
+ setIsSuccess(false);
699
+ }, 300);
700
+ return () => clearTimeout(timer);
701
+ }
702
+ }, [isOpen, defaultMode]);
703
+ const handleScreenshotUpload = (e) => {
704
+ const files = Array.from(e.target.files || []);
705
+ if (files.length === 0) return;
706
+ const remaining = 5 - screenshots.length;
707
+ if (remaining <= 0) return;
708
+ const newFiles = files.slice(0, remaining);
709
+ setScreenshots((prev) => [...prev, ...newFiles]);
710
+ if (fileInputRef.current) {
711
+ fileInputRef.current.value = "";
712
+ }
713
+ };
714
+ const removeScreenshot = (index) => {
715
+ setScreenshots((prev) => prev.filter((_, i) => i !== index));
716
+ };
717
+ const getPageInfo = () => ({
718
+ pagePath: typeof window !== "undefined" ? window.location.pathname : "/",
719
+ pageTitle: typeof document !== "undefined" ? document.title : "Unknown"
720
+ });
721
+ const handleVoiceSubmit = async (audioBlob, duration) => {
722
+ await submitFeedback("voice", void 0, audioBlob, duration);
723
+ };
724
+ const handleTextSubmit = async () => {
725
+ if (!message.trim()) return;
726
+ await submitFeedback("text", message.trim());
727
+ };
728
+ const submitFeedback = async (type, text, audioBlob, audioDuration) => {
729
+ setIsSubmitting(true);
730
+ try {
731
+ const { pagePath: pagePath2, pageTitle } = getPageInfo();
732
+ const result = await apiClient.submit({
733
+ type,
734
+ message: text,
735
+ audioBlob,
736
+ audioDuration,
737
+ screenshots,
738
+ pagePath: pagePath2,
739
+ pageTitle,
740
+ user,
741
+ metadata
742
+ });
743
+ setIsSuccess(true);
744
+ if (onSubmit) {
745
+ onSubmit({
746
+ id: result.id,
747
+ type,
748
+ message: text,
749
+ pagePath: pagePath2,
750
+ pageTitle,
751
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
752
+ });
753
+ }
754
+ setTimeout(() => {
755
+ onClose();
756
+ }, 1500);
757
+ } catch (error) {
758
+ console.error("Feedback submission error:", error);
759
+ if (onError && error instanceof Error) {
760
+ onError(error);
761
+ }
762
+ } finally {
763
+ setIsSubmitting(false);
764
+ }
765
+ };
766
+ const { pagePath } = getPageInfo();
767
+ return /* @__PURE__ */ jsxs3(Fragment2, { children: [
768
+ isOpen && /* @__PURE__ */ jsx4("div", { className: "fw-backdrop", onClick: onClose }),
769
+ /* @__PURE__ */ jsxs3(
770
+ "div",
771
+ {
772
+ className: cn(
773
+ "fw-panel",
774
+ isOpen ? "fw-panel--open" : "fw-panel--closed"
775
+ ),
776
+ children: [
777
+ /* @__PURE__ */ jsxs3("div", { className: "fw-panel-header", children: [
778
+ /* @__PURE__ */ jsx4("h2", { className: "fw-panel-title", children: mergedLabels.panelTitle }),
779
+ /* @__PURE__ */ jsx4("button", { onClick: onClose, className: "fw-panel-close", children: /* @__PURE__ */ jsx4(XIcon, { size: 20 }) })
780
+ ] }),
781
+ /* @__PURE__ */ jsx4("div", { className: "fw-panel-content", children: isSuccess ? /* @__PURE__ */ jsxs3("div", { className: "fw-success", children: [
782
+ /* @__PURE__ */ jsx4(
783
+ "div",
784
+ {
785
+ className: "fw-success-icon",
786
+ style: { backgroundColor: accentColor },
787
+ children: /* @__PURE__ */ jsx4(CheckIcon, { size: 40 })
788
+ }
789
+ ),
790
+ /* @__PURE__ */ jsx4("h3", { className: "fw-success-title", children: "Thanks!" }),
791
+ /* @__PURE__ */ jsx4("p", { className: "fw-success-text", children: mergedLabels.successMessage })
792
+ ] }) : /* @__PURE__ */ jsxs3(Fragment2, { children: [
793
+ /* @__PURE__ */ jsxs3("div", { className: "fw-context", children: [
794
+ /* @__PURE__ */ jsx4("span", { className: "fw-context-label", children: "Page:" }),
795
+ /* @__PURE__ */ jsx4("span", { className: "fw-context-value", children: pagePath })
796
+ ] }),
797
+ allowVoice && allowText && /* @__PURE__ */ jsxs3("div", { className: "fw-mode-selector", children: [
798
+ /* @__PURE__ */ jsxs3(
799
+ "button",
800
+ {
801
+ onClick: () => setMode("voice"),
802
+ disabled: isSubmitting,
803
+ className: cn(
804
+ "fw-mode-btn",
805
+ mode === "voice" && "fw-mode-btn--active"
806
+ ),
807
+ style: mode === "voice" ? { backgroundColor: accentColor } : {},
808
+ children: [
809
+ /* @__PURE__ */ jsx4(MicIcon, { size: 16 }),
810
+ mergedLabels.voiceButton
811
+ ]
812
+ }
813
+ ),
814
+ /* @__PURE__ */ jsxs3(
815
+ "button",
816
+ {
817
+ onClick: () => setMode("text"),
818
+ disabled: isSubmitting,
819
+ className: cn(
820
+ "fw-mode-btn",
821
+ mode === "text" && "fw-mode-btn--active"
822
+ ),
823
+ style: mode === "text" ? { backgroundColor: accentColor } : {},
824
+ children: [
825
+ /* @__PURE__ */ jsx4(TypeIcon, { size: 16 }),
826
+ mergedLabels.textButton
827
+ ]
828
+ }
829
+ )
830
+ ] }),
831
+ /* @__PURE__ */ jsx4("div", { className: "fw-input-area", children: mode === "voice" ? /* @__PURE__ */ jsx4(
832
+ VoiceRecorder,
833
+ {
834
+ onSubmit: handleVoiceSubmit,
835
+ isSubmitting,
836
+ maxDuration: maxVoiceDuration,
837
+ accentColor
838
+ }
839
+ ) : /* @__PURE__ */ jsxs3("div", { className: "fw-text-input", children: [
840
+ /* @__PURE__ */ jsx4(
841
+ "textarea",
842
+ {
843
+ value: message,
844
+ onChange: (e) => setMessage(e.target.value),
845
+ placeholder: mergedLabels.placeholder,
846
+ className: "fw-textarea",
847
+ disabled: isSubmitting
848
+ }
849
+ ),
850
+ /* @__PURE__ */ jsx4(
851
+ "button",
852
+ {
853
+ onClick: handleTextSubmit,
854
+ disabled: !message.trim() || isSubmitting,
855
+ className: "fw-submit-btn",
856
+ children: isSubmitting ? /* @__PURE__ */ jsxs3(Fragment2, { children: [
857
+ /* @__PURE__ */ jsx4(LoaderIcon, { size: 16, className: "fw-icon-spin" }),
858
+ "Sending..."
859
+ ] }) : mergedLabels.submitButton
860
+ }
861
+ )
862
+ ] }) }),
863
+ allowScreenshots && /* @__PURE__ */ jsxs3("div", { className: "fw-screenshots", children: [
864
+ /* @__PURE__ */ jsxs3("div", { className: "fw-screenshots-header", children: [
865
+ /* @__PURE__ */ jsx4("span", { className: "fw-screenshots-label", children: "Screenshots" }),
866
+ /* @__PURE__ */ jsxs3(
867
+ "button",
868
+ {
869
+ onClick: () => fileInputRef.current?.click(),
870
+ disabled: screenshots.length >= 5 || isSubmitting,
871
+ className: "fw-screenshots-add",
872
+ children: [
873
+ /* @__PURE__ */ jsx4(ImagePlusIcon, { size: 16 }),
874
+ "Add"
875
+ ]
876
+ }
877
+ ),
878
+ /* @__PURE__ */ jsx4(
879
+ "input",
880
+ {
881
+ ref: fileInputRef,
882
+ type: "file",
883
+ accept: "image/*",
884
+ multiple: true,
885
+ onChange: handleScreenshotUpload,
886
+ style: { display: "none" }
887
+ }
888
+ )
889
+ ] }),
890
+ screenshots.length > 0 ? /* @__PURE__ */ jsx4("div", { className: "fw-screenshots-grid", children: screenshots.map((file, index) => /* @__PURE__ */ jsxs3("div", { className: "fw-screenshot", children: [
891
+ /* @__PURE__ */ jsx4(
892
+ "img",
893
+ {
894
+ src: URL.createObjectURL(file),
895
+ alt: `Screenshot ${index + 1}`
896
+ }
897
+ ),
898
+ /* @__PURE__ */ jsx4(
899
+ "button",
900
+ {
901
+ onClick: () => removeScreenshot(index),
902
+ className: "fw-screenshot-remove",
903
+ children: /* @__PURE__ */ jsx4(XIcon, { size: 12 })
904
+ }
905
+ )
906
+ ] }, index)) }) : /* @__PURE__ */ jsx4("p", { className: "fw-screenshots-hint", children: "Optional: Add screenshots to illustrate your feedback" })
907
+ ] })
908
+ ] }) })
909
+ ]
910
+ }
911
+ )
912
+ ] });
913
+ }
914
+
915
+ // src/components/FeedbackWidget.tsx
916
+ import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
917
+ function FeedbackWidget({
918
+ apiKey,
919
+ position = "bottom-right",
920
+ theme = "light",
921
+ accentColor = "#BDE0C2",
922
+ allowVoice = true,
923
+ allowText = true,
924
+ allowScreenshots = true,
925
+ maxVoiceDuration = 120,
926
+ user,
927
+ metadata,
928
+ onOpen,
929
+ onClose,
930
+ onSubmit,
931
+ onError,
932
+ triggerComponent,
933
+ labels
934
+ }) {
935
+ const [isOpen, setIsOpen] = useState4(false);
936
+ const [mounted, setMounted] = useState4(false);
937
+ useEffect4(() => {
938
+ setMounted(true);
939
+ }, []);
940
+ const handleOpen = () => {
941
+ setIsOpen(true);
942
+ onOpen?.();
943
+ };
944
+ const handleClose = () => {
945
+ setIsOpen(false);
946
+ onClose?.();
947
+ };
948
+ if (!mounted) return null;
949
+ const apiBaseUrl = typeof window !== "undefined" ? window.__FEEDBACK_API_URL__ : void 0;
950
+ return /* @__PURE__ */ jsxs4(Fragment3, { children: [
951
+ /* @__PURE__ */ jsx5(FeedbackStyles, { theme }),
952
+ triggerComponent ? /* @__PURE__ */ jsx5("div", { onClick: handleOpen, children: triggerComponent }) : /* @__PURE__ */ jsx5(
953
+ FeedbackTrigger,
954
+ {
955
+ onClick: handleOpen,
956
+ isOpen,
957
+ position,
958
+ accentColor,
959
+ tooltip: labels?.triggerTooltip
960
+ }
961
+ ),
962
+ /* @__PURE__ */ jsx5(
963
+ FeedbackPanel,
964
+ {
965
+ isOpen,
966
+ onClose: handleClose,
967
+ apiKey,
968
+ apiBaseUrl,
969
+ accentColor,
970
+ allowVoice,
971
+ allowText,
972
+ allowScreenshots,
973
+ maxVoiceDuration,
974
+ user,
975
+ metadata,
976
+ labels,
977
+ onSubmit,
978
+ onError
979
+ }
980
+ )
981
+ ] });
982
+ }
983
+ function FeedbackStyles({ theme }) {
984
+ const styles = `
985
+ @keyframes fw-spin {
986
+ from { transform: rotate(0deg); }
987
+ to { transform: rotate(360deg); }
988
+ }
989
+
990
+ .fw-trigger {
991
+ position: fixed;
992
+ z-index: 9998;
993
+ display: flex;
994
+ align-items: center;
995
+ justify-content: center;
996
+ border-radius: 50%;
997
+ border: none;
998
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
999
+ cursor: pointer;
1000
+ transition: transform 0.15s, box-shadow 0.15s;
1001
+ user-select: none;
1002
+ touch-action: none;
1003
+ color: #0a0a0a;
1004
+ }
1005
+ .fw-trigger:hover {
1006
+ transform: scale(1.05);
1007
+ box-shadow: 0 6px 20px rgba(0,0,0,0.2);
1008
+ }
1009
+ .fw-trigger:active {
1010
+ transform: scale(0.95);
1011
+ }
1012
+ .fw-trigger--dragging {
1013
+ transform: scale(1.1);
1014
+ box-shadow: 0 8px 24px rgba(0,0,0,0.25);
1015
+ cursor: grabbing;
1016
+ }
1017
+
1018
+ .fw-backdrop {
1019
+ position: fixed;
1020
+ inset: 0;
1021
+ background: rgba(0,0,0,0.3);
1022
+ z-index: 9998;
1023
+ }
1024
+
1025
+ .fw-panel {
1026
+ position: fixed;
1027
+ top: 0;
1028
+ right: 0;
1029
+ height: 100%;
1030
+ width: 100%;
1031
+ max-width: 400px;
1032
+ background: ${theme === "dark" ? "#1a1a1a" : "#ffffff"};
1033
+ z-index: 9999;
1034
+ box-shadow: -4px 0 24px rgba(0,0,0,0.15);
1035
+ border-left: 1px solid ${theme === "dark" ? "#333" : "#e5e5e5"};
1036
+ transform: translateX(100%);
1037
+ transition: transform 0.3s ease-out;
1038
+ }
1039
+ .fw-panel--open {
1040
+ transform: translateX(0);
1041
+ }
1042
+ .fw-panel--closed {
1043
+ transform: translateX(100%);
1044
+ }
1045
+
1046
+ .fw-panel-header {
1047
+ display: flex;
1048
+ align-items: center;
1049
+ justify-content: space-between;
1050
+ padding: 16px 20px;
1051
+ border-bottom: 1px solid ${theme === "dark" ? "#333" : "#f0f0f0"};
1052
+ }
1053
+ .fw-panel-title {
1054
+ margin: 0;
1055
+ font-size: 18px;
1056
+ font-weight: 600;
1057
+ color: ${theme === "dark" ? "#fff" : "#0a0a0a"};
1058
+ }
1059
+ .fw-panel-close {
1060
+ padding: 8px;
1061
+ background: none;
1062
+ border: none;
1063
+ cursor: pointer;
1064
+ border-radius: 8px;
1065
+ color: ${theme === "dark" ? "#999" : "#666"};
1066
+ transition: background 0.15s;
1067
+ }
1068
+ .fw-panel-close:hover {
1069
+ background: ${theme === "dark" ? "#333" : "#f5f5f5"};
1070
+ }
1071
+
1072
+ .fw-panel-content {
1073
+ display: flex;
1074
+ flex-direction: column;
1075
+ height: calc(100% - 65px);
1076
+ overflow-y: auto;
1077
+ }
1078
+
1079
+ .fw-context {
1080
+ display: flex;
1081
+ align-items: center;
1082
+ gap: 8px;
1083
+ padding: 12px 20px;
1084
+ background: ${theme === "dark" ? "#222" : "#f9f9f9"};
1085
+ border-bottom: 1px solid ${theme === "dark" ? "#333" : "#f0f0f0"};
1086
+ font-size: 14px;
1087
+ }
1088
+ .fw-context-label {
1089
+ font-weight: 500;
1090
+ color: ${theme === "dark" ? "#999" : "#666"};
1091
+ }
1092
+ .fw-context-value {
1093
+ color: ${theme === "dark" ? "#ccc" : "#333"};
1094
+ overflow: hidden;
1095
+ text-overflow: ellipsis;
1096
+ white-space: nowrap;
1097
+ }
1098
+
1099
+ .fw-mode-selector {
1100
+ display: flex;
1101
+ gap: 8px;
1102
+ padding: 16px 20px;
1103
+ border-bottom: 1px solid ${theme === "dark" ? "#333" : "#f0f0f0"};
1104
+ }
1105
+ .fw-mode-btn {
1106
+ flex: 1;
1107
+ display: flex;
1108
+ align-items: center;
1109
+ justify-content: center;
1110
+ gap: 8px;
1111
+ padding: 10px 16px;
1112
+ border-radius: 8px;
1113
+ border: none;
1114
+ font-size: 14px;
1115
+ font-weight: 500;
1116
+ cursor: pointer;
1117
+ transition: background 0.15s;
1118
+ background: ${theme === "dark" ? "#333" : "#f0f0f0"};
1119
+ color: ${theme === "dark" ? "#ccc" : "#666"};
1120
+ }
1121
+ .fw-mode-btn:hover:not(:disabled) {
1122
+ background: ${theme === "dark" ? "#444" : "#e5e5e5"};
1123
+ }
1124
+ .fw-mode-btn--active {
1125
+ color: #0a0a0a;
1126
+ }
1127
+ .fw-mode-btn:disabled {
1128
+ opacity: 0.5;
1129
+ cursor: not-allowed;
1130
+ }
1131
+
1132
+ .fw-input-area {
1133
+ flex: 1;
1134
+ padding: 20px;
1135
+ }
1136
+
1137
+ .fw-text-input {
1138
+ display: flex;
1139
+ flex-direction: column;
1140
+ gap: 16px;
1141
+ }
1142
+ .fw-textarea {
1143
+ width: 100%;
1144
+ min-height: 150px;
1145
+ padding: 12px;
1146
+ border: 1px solid ${theme === "dark" ? "#444" : "#e5e5e5"};
1147
+ border-radius: 8px;
1148
+ font-size: 14px;
1149
+ font-family: inherit;
1150
+ resize: none;
1151
+ background: ${theme === "dark" ? "#222" : "#fff"};
1152
+ color: ${theme === "dark" ? "#fff" : "#0a0a0a"};
1153
+ }
1154
+ .fw-textarea:focus {
1155
+ outline: none;
1156
+ border-color: #BDE0C2;
1157
+ }
1158
+ .fw-textarea::placeholder {
1159
+ color: ${theme === "dark" ? "#666" : "#999"};
1160
+ }
1161
+ .fw-submit-btn {
1162
+ display: flex;
1163
+ align-items: center;
1164
+ justify-content: center;
1165
+ gap: 8px;
1166
+ width: 100%;
1167
+ padding: 12px 16px;
1168
+ background: #0a0a0a;
1169
+ color: #fff;
1170
+ border: none;
1171
+ border-radius: 8px;
1172
+ font-size: 14px;
1173
+ font-weight: 500;
1174
+ cursor: pointer;
1175
+ transition: background 0.15s;
1176
+ }
1177
+ .fw-submit-btn:hover:not(:disabled) {
1178
+ background: #1a1a1a;
1179
+ }
1180
+ .fw-submit-btn:disabled {
1181
+ opacity: 0.5;
1182
+ cursor: not-allowed;
1183
+ }
1184
+
1185
+ .fw-screenshots {
1186
+ padding: 16px 20px;
1187
+ border-top: 1px solid ${theme === "dark" ? "#333" : "#f0f0f0"};
1188
+ }
1189
+ .fw-screenshots-header {
1190
+ display: flex;
1191
+ align-items: center;
1192
+ justify-content: space-between;
1193
+ margin-bottom: 12px;
1194
+ }
1195
+ .fw-screenshots-label {
1196
+ font-size: 14px;
1197
+ font-weight: 500;
1198
+ color: ${theme === "dark" ? "#ccc" : "#333"};
1199
+ }
1200
+ .fw-screenshots-add {
1201
+ display: flex;
1202
+ align-items: center;
1203
+ gap: 6px;
1204
+ padding: 6px 12px;
1205
+ background: ${theme === "dark" ? "#333" : "#f0f0f0"};
1206
+ border: none;
1207
+ border-radius: 6px;
1208
+ font-size: 13px;
1209
+ cursor: pointer;
1210
+ color: ${theme === "dark" ? "#ccc" : "#666"};
1211
+ transition: background 0.15s;
1212
+ }
1213
+ .fw-screenshots-add:hover:not(:disabled) {
1214
+ background: ${theme === "dark" ? "#444" : "#e5e5e5"};
1215
+ }
1216
+ .fw-screenshots-add:disabled {
1217
+ opacity: 0.5;
1218
+ cursor: not-allowed;
1219
+ }
1220
+ .fw-screenshots-grid {
1221
+ display: flex;
1222
+ flex-wrap: wrap;
1223
+ gap: 8px;
1224
+ }
1225
+ .fw-screenshot {
1226
+ position: relative;
1227
+ }
1228
+ .fw-screenshot img {
1229
+ width: 64px;
1230
+ height: 64px;
1231
+ object-fit: cover;
1232
+ border-radius: 8px;
1233
+ border: 1px solid ${theme === "dark" ? "#444" : "#e5e5e5"};
1234
+ }
1235
+ .fw-screenshot-remove {
1236
+ position: absolute;
1237
+ top: -6px;
1238
+ right: -6px;
1239
+ width: 20px;
1240
+ height: 20px;
1241
+ display: flex;
1242
+ align-items: center;
1243
+ justify-content: center;
1244
+ background: #ef4444;
1245
+ color: #fff;
1246
+ border: none;
1247
+ border-radius: 50%;
1248
+ cursor: pointer;
1249
+ opacity: 0;
1250
+ transition: opacity 0.15s;
1251
+ }
1252
+ .fw-screenshot:hover .fw-screenshot-remove {
1253
+ opacity: 1;
1254
+ }
1255
+ .fw-screenshots-hint {
1256
+ margin: 0;
1257
+ font-size: 13px;
1258
+ color: ${theme === "dark" ? "#666" : "#999"};
1259
+ }
1260
+
1261
+ .fw-success {
1262
+ flex: 1;
1263
+ display: flex;
1264
+ flex-direction: column;
1265
+ align-items: center;
1266
+ justify-content: center;
1267
+ padding: 32px;
1268
+ }
1269
+ .fw-success-icon {
1270
+ width: 80px;
1271
+ height: 80px;
1272
+ border-radius: 50%;
1273
+ display: flex;
1274
+ align-items: center;
1275
+ justify-content: center;
1276
+ margin-bottom: 24px;
1277
+ color: #0a0a0a;
1278
+ }
1279
+ .fw-success-title {
1280
+ margin: 0 0 8px 0;
1281
+ font-size: 20px;
1282
+ font-weight: 600;
1283
+ color: ${theme === "dark" ? "#fff" : "#0a0a0a"};
1284
+ }
1285
+ .fw-success-text {
1286
+ margin: 0;
1287
+ color: ${theme === "dark" ? "#999" : "#666"};
1288
+ }
1289
+
1290
+ /* Voice Recorder */
1291
+ .fw-voice-recorder {
1292
+ display: flex;
1293
+ flex-direction: column;
1294
+ align-items: center;
1295
+ justify-content: center;
1296
+ padding: 32px 0;
1297
+ }
1298
+ .fw-voice-btn {
1299
+ position: relative;
1300
+ width: 96px;
1301
+ height: 96px;
1302
+ border-radius: 50%;
1303
+ border: none;
1304
+ display: flex;
1305
+ align-items: center;
1306
+ justify-content: center;
1307
+ cursor: pointer;
1308
+ transition: transform 0.15s, box-shadow 0.15s;
1309
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
1310
+ color: #0a0a0a;
1311
+ }
1312
+ .fw-voice-btn:hover:not(:disabled) {
1313
+ transform: scale(1.05);
1314
+ box-shadow: 0 6px 20px rgba(0,0,0,0.2);
1315
+ }
1316
+ .fw-voice-btn:active:not(:disabled) {
1317
+ transform: scale(0.95);
1318
+ }
1319
+ .fw-voice-btn--recording {
1320
+ box-shadow: 0 4px 20px rgba(239,68,68,0.4);
1321
+ }
1322
+ .fw-voice-btn--recording:hover {
1323
+ box-shadow: 0 6px 24px rgba(239,68,68,0.5);
1324
+ }
1325
+ .fw-voice-btn--disabled {
1326
+ opacity: 0.5;
1327
+ cursor: not-allowed;
1328
+ }
1329
+ .fw-voice-pulse {
1330
+ position: absolute;
1331
+ inset: 0;
1332
+ border-radius: 50%;
1333
+ border: 4px solid rgba(239,68,68,0.3);
1334
+ animation: fw-pulse 1s infinite;
1335
+ }
1336
+ @keyframes fw-pulse {
1337
+ 0%, 100% { opacity: 0.5; }
1338
+ 50% { opacity: 1; }
1339
+ }
1340
+ .fw-voice-icon {
1341
+ color: #fff;
1342
+ }
1343
+ .fw-voice-icon--spin {
1344
+ animation: fw-spin 1s linear infinite;
1345
+ }
1346
+ .fw-voice-status {
1347
+ margin-top: 24px;
1348
+ text-align: center;
1349
+ }
1350
+ .fw-voice-timer {
1351
+ font-size: 32px;
1352
+ font-family: monospace;
1353
+ font-weight: 600;
1354
+ color: ${theme === "dark" ? "#fff" : "#0a0a0a"};
1355
+ margin: 0;
1356
+ }
1357
+ .fw-voice-cta {
1358
+ font-size: 16px;
1359
+ font-weight: 500;
1360
+ color: ${theme === "dark" ? "#ccc" : "#333"};
1361
+ margin: 0;
1362
+ }
1363
+ .fw-voice-hint {
1364
+ font-size: 14px;
1365
+ color: ${theme === "dark" ? "#666" : "#999"};
1366
+ margin: 4px 0 0 0;
1367
+ }
1368
+ .fw-voice-limit {
1369
+ font-size: 12px;
1370
+ color: ${theme === "dark" ? "#555" : "#bbb"};
1371
+ margin: 8px 0 0 0;
1372
+ }
1373
+ .fw-voice-text {
1374
+ color: ${theme === "dark" ? "#999" : "#666"};
1375
+ margin: 0;
1376
+ }
1377
+ .fw-voice-visualizer {
1378
+ display: flex;
1379
+ align-items: flex-end;
1380
+ justify-content: center;
1381
+ gap: 4px;
1382
+ height: 32px;
1383
+ margin-top: 24px;
1384
+ }
1385
+ .fw-voice-bar {
1386
+ width: 6px;
1387
+ border-radius: 3px;
1388
+ transition: height 0.075s;
1389
+ }
1390
+ .fw-voice-unsupported,
1391
+ .fw-voice-error {
1392
+ display: flex;
1393
+ flex-direction: column;
1394
+ align-items: center;
1395
+ justify-content: center;
1396
+ padding: 32px;
1397
+ text-align: center;
1398
+ }
1399
+ .fw-voice-error-icon {
1400
+ width: 64px;
1401
+ height: 64px;
1402
+ border-radius: 50%;
1403
+ background: ${theme === "dark" ? "#333" : "#f5f5f5"};
1404
+ display: flex;
1405
+ align-items: center;
1406
+ justify-content: center;
1407
+ margin-bottom: 16px;
1408
+ color: ${theme === "dark" ? "#666" : "#999"};
1409
+ }
1410
+ .fw-voice-error-icon--error {
1411
+ background: #fef2f2;
1412
+ color: #ef4444;
1413
+ }
1414
+ .fw-voice-unsupported p,
1415
+ .fw-voice-error p {
1416
+ margin: 0;
1417
+ font-size: 14px;
1418
+ color: ${theme === "dark" ? "#999" : "#666"};
1419
+ }
1420
+ .fw-voice-retry-btn {
1421
+ margin-top: 16px;
1422
+ padding: 8px 16px;
1423
+ background: ${theme === "dark" ? "#333" : "#f0f0f0"};
1424
+ border: none;
1425
+ border-radius: 6px;
1426
+ font-size: 14px;
1427
+ cursor: pointer;
1428
+ color: ${theme === "dark" ? "#ccc" : "#333"};
1429
+ }
1430
+ .fw-voice-retry-btn:hover {
1431
+ background: ${theme === "dark" ? "#444" : "#e5e5e5"};
1432
+ }
1433
+
1434
+ .fw-icon-spin {
1435
+ animation: fw-spin 1s linear infinite;
1436
+ }
1437
+ `;
1438
+ return /* @__PURE__ */ jsx5("style", { dangerouslySetInnerHTML: { __html: styles } });
1439
+ }
13
1440
 
14
1441
  // src/hooks/useFeedback.ts
15
- import { useState, useCallback, useMemo } from "react";
1442
+ import { useState as useState5, useCallback as useCallback3, useMemo as useMemo2 } from "react";
16
1443
  function useFeedback({
17
1444
  apiKey,
18
1445
  apiBaseUrl,
@@ -21,18 +1448,18 @@ function useFeedback({
21
1448
  onSubmit,
22
1449
  onError
23
1450
  }) {
24
- const [isOpen, setIsOpen] = useState(false);
25
- const [isSubmitting, setIsSubmitting] = useState(false);
26
- const [lastSubmission, setLastSubmission] = useState(null);
27
- const [error, setError] = useState(null);
28
- const apiClient = useMemo(
1451
+ const [isOpen, setIsOpen] = useState5(false);
1452
+ const [isSubmitting, setIsSubmitting] = useState5(false);
1453
+ const [lastSubmission, setLastSubmission] = useState5(null);
1454
+ const [error, setError] = useState5(null);
1455
+ const apiClient = useMemo2(
29
1456
  () => createApiClient(apiKey, apiBaseUrl),
30
1457
  [apiKey, apiBaseUrl]
31
1458
  );
32
- const open = useCallback(() => setIsOpen(true), []);
33
- const close = useCallback(() => setIsOpen(false), []);
34
- const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
35
- const submit = useCallback(
1459
+ const open = useCallback3(() => setIsOpen(true), []);
1460
+ const close = useCallback3(() => setIsOpen(false), []);
1461
+ const toggle = useCallback3(() => setIsOpen((prev) => !prev), []);
1462
+ const submit = useCallback3(
36
1463
  async (params) => {
37
1464
  setIsSubmitting(true);
38
1465
  setError(null);