@stephenov/feedbackwidget 0.2.2 → 0.3.1

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,1455 +1,17 @@
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://feedbackwidget-api.vercel.app/api/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
- }
1
+ import {
2
+ FeedbackApiClient,
3
+ FeedbackPanel,
4
+ FeedbackTrigger,
5
+ FeedbackWidget,
6
+ VoiceRecorder,
7
+ cn,
8
+ createApiClient,
9
+ formatDuration,
10
+ isVoiceSupported
11
+ } from "./chunk-QQLLK6MC.mjs";
1450
12
 
1451
13
  // src/hooks/useFeedback.ts
1452
- import { useState as useState5, useCallback as useCallback3, useMemo as useMemo2 } from "react";
14
+ import { useState, useCallback, useMemo } from "react";
1453
15
  function useFeedback({
1454
16
  apiKey,
1455
17
  apiBaseUrl,
@@ -1458,18 +20,18 @@ function useFeedback({
1458
20
  onSubmit,
1459
21
  onError
1460
22
  }) {
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(
23
+ const [isOpen, setIsOpen] = useState(false);
24
+ const [isSubmitting, setIsSubmitting] = useState(false);
25
+ const [lastSubmission, setLastSubmission] = useState(null);
26
+ const [error, setError] = useState(null);
27
+ const apiClient = useMemo(
1466
28
  () => createApiClient(apiKey, apiBaseUrl),
1467
29
  [apiKey, apiBaseUrl]
1468
30
  );
1469
- const open = useCallback3(() => setIsOpen(true), []);
1470
- const close = useCallback3(() => setIsOpen(false), []);
1471
- const toggle = useCallback3(() => setIsOpen((prev) => !prev), []);
1472
- const submit = useCallback3(
31
+ const open = useCallback(() => setIsOpen(true), []);
32
+ const close = useCallback(() => setIsOpen(false), []);
33
+ const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
34
+ const submit = useCallback(
1473
35
  async (params) => {
1474
36
  setIsSubmitting(true);
1475
37
  setError(null);