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