@stephenov/feedbackwidget 2.0.0 → 2.0.2

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