@fatagnus/convex-feedback 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +177 -0
  2. package/README.md +382 -0
  3. package/dist/convex/agents/bugReportAgent.d.ts +30 -0
  4. package/dist/convex/agents/bugReportAgent.d.ts.map +1 -0
  5. package/dist/convex/agents/bugReportAgent.js +243 -0
  6. package/dist/convex/agents/bugReportAgent.js.map +1 -0
  7. package/dist/convex/agents/feedbackAgent.d.ts +29 -0
  8. package/dist/convex/agents/feedbackAgent.d.ts.map +1 -0
  9. package/dist/convex/agents/feedbackAgent.js +232 -0
  10. package/dist/convex/agents/feedbackAgent.js.map +1 -0
  11. package/dist/convex/bugReports.d.ts +49 -0
  12. package/dist/convex/bugReports.d.ts.map +1 -0
  13. package/dist/convex/bugReports.js +321 -0
  14. package/dist/convex/bugReports.js.map +1 -0
  15. package/dist/convex/convex.config.d.ts +3 -0
  16. package/dist/convex/convex.config.d.ts.map +1 -0
  17. package/dist/convex/convex.config.js +6 -0
  18. package/dist/convex/convex.config.js.map +1 -0
  19. package/dist/convex/emails/bugReportEmails.d.ts +16 -0
  20. package/dist/convex/emails/bugReportEmails.d.ts.map +1 -0
  21. package/dist/convex/emails/bugReportEmails.js +403 -0
  22. package/dist/convex/emails/bugReportEmails.js.map +1 -0
  23. package/dist/convex/emails/feedbackEmails.d.ts +16 -0
  24. package/dist/convex/emails/feedbackEmails.d.ts.map +1 -0
  25. package/dist/convex/emails/feedbackEmails.js +389 -0
  26. package/dist/convex/emails/feedbackEmails.js.map +1 -0
  27. package/dist/convex/feedback.d.ts +49 -0
  28. package/dist/convex/feedback.d.ts.map +1 -0
  29. package/dist/convex/feedback.js +327 -0
  30. package/dist/convex/feedback.js.map +1 -0
  31. package/dist/convex/index.d.ts +10 -0
  32. package/dist/convex/index.d.ts.map +1 -0
  33. package/dist/convex/index.js +12 -0
  34. package/dist/convex/index.js.map +1 -0
  35. package/dist/convex/schema.d.ts +200 -0
  36. package/dist/convex/schema.d.ts.map +1 -0
  37. package/dist/convex/schema.js +150 -0
  38. package/dist/convex/schema.js.map +1 -0
  39. package/dist/convex/supportTeams.d.ts +29 -0
  40. package/dist/convex/supportTeams.d.ts.map +1 -0
  41. package/dist/convex/supportTeams.js +159 -0
  42. package/dist/convex/supportTeams.js.map +1 -0
  43. package/dist/index.d.ts +70 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +63 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/react/BugReportButton.d.ts +70 -0
  48. package/dist/react/BugReportButton.d.ts.map +1 -0
  49. package/dist/react/BugReportButton.js +371 -0
  50. package/dist/react/BugReportButton.js.map +1 -0
  51. package/dist/react/BugReportContext.d.ts +59 -0
  52. package/dist/react/BugReportContext.d.ts.map +1 -0
  53. package/dist/react/BugReportContext.js +107 -0
  54. package/dist/react/BugReportContext.js.map +1 -0
  55. package/dist/react/index.d.ts +36 -0
  56. package/dist/react/index.d.ts.map +1 -0
  57. package/dist/react/index.js +36 -0
  58. package/dist/react/index.js.map +1 -0
  59. package/dist/types.d.ts +89 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +5 -0
  62. package/dist/types.js.map +1 -0
  63. package/package.json +101 -0
  64. package/src/convex/agents/bugReportAgent.ts +277 -0
  65. package/src/convex/agents/feedbackAgent.ts +264 -0
  66. package/src/convex/bugReports.ts +350 -0
  67. package/src/convex/convex.config.ts +7 -0
  68. package/src/convex/emails/bugReportEmails.ts +479 -0
  69. package/src/convex/emails/feedbackEmails.ts +465 -0
  70. package/src/convex/feedback.ts +356 -0
  71. package/src/convex/index.ts +28 -0
  72. package/src/convex/schema.ts +207 -0
  73. package/src/convex/supportTeams.ts +179 -0
  74. package/src/index.ts +77 -0
  75. package/src/react/BugReportButton.tsx +755 -0
  76. package/src/react/BugReportContext.tsx +146 -0
  77. package/src/react/index.ts +46 -0
  78. package/src/types.ts +93 -0
@@ -0,0 +1,755 @@
1
+ import { useState, useCallback } from 'react';
2
+ import {
3
+ ActionIcon,
4
+ Modal,
5
+ TextInput,
6
+ Textarea,
7
+ Select,
8
+ Button,
9
+ Group,
10
+ Stack,
11
+ Text,
12
+ Image,
13
+ Tooltip,
14
+ Affix,
15
+ Transition,
16
+ Paper,
17
+ Badge,
18
+ LoadingOverlay,
19
+ SegmentedControl,
20
+ FileButton,
21
+ } from '@mantine/core';
22
+ import { useForm } from '@mantine/form';
23
+ import { useDisclosure } from '@mantine/hooks';
24
+ import { notifications } from '@mantine/notifications';
25
+ import * as TablerIcons from '@tabler/icons-react';
26
+
27
+ const IconBug = TablerIcons.IconBug as React.FC<{ size?: number }>;
28
+ const IconCamera = TablerIcons.IconCamera as React.FC<{ size?: number }>;
29
+ const IconX = TablerIcons.IconX as React.FC<{ size?: number }>;
30
+ const IconCheck = TablerIcons.IconCheck as React.FC<{ size?: number }>;
31
+ const IconMessagePlus = TablerIcons.IconMessagePlus as React.FC<{ size?: number }>;
32
+ const IconUpload = TablerIcons.IconUpload as React.FC<{ size?: number }>;
33
+ import { useMutation } from 'convex/react';
34
+ import html2canvas from 'html2canvas';
35
+ import { useBugReportContext } from './BugReportContext';
36
+ import type { FunctionReference } from 'convex/server';
37
+
38
+ /**
39
+ * Props for the BugReportButton component
40
+ */
41
+ export interface BugReportButtonProps {
42
+ /** Reporter type: 'staff' for internal users, 'customer' for external users */
43
+ reporterType: 'staff' | 'customer';
44
+ /** Unique identifier for the reporter */
45
+ reporterId: string;
46
+ /** Reporter's email address */
47
+ reporterEmail: string;
48
+ /** Reporter's display name */
49
+ reporterName: string;
50
+ /** Convex API reference for bug reports */
51
+ bugReportApi: {
52
+ create: FunctionReference<'mutation'>;
53
+ generateUploadUrl: FunctionReference<'mutation'>;
54
+ };
55
+ /** Convex API reference for feedback */
56
+ feedbackApi: {
57
+ create: FunctionReference<'mutation'>;
58
+ generateUploadUrl: FunctionReference<'mutation'>;
59
+ };
60
+ /** Position of the floating button */
61
+ position?: { bottom?: number; right?: number; top?: number; left?: number };
62
+ /** Button color (default: red) */
63
+ buttonColor?: string;
64
+ /** Show feedback tab in addition to bug reports */
65
+ showFeedback?: boolean;
66
+ /** Callback when submission succeeds */
67
+ onSuccess?: (type: 'bug' | 'feedback') => void;
68
+ /** Callback when submission fails */
69
+ onError?: (error: Error, type: 'bug' | 'feedback') => void;
70
+ }
71
+
72
+ interface BrowserInfo {
73
+ userAgent: string;
74
+ platform: string;
75
+ language: string;
76
+ cookiesEnabled: boolean;
77
+ onLine: boolean;
78
+ screenWidth: number;
79
+ screenHeight: number;
80
+ colorDepth: number;
81
+ pixelRatio: number;
82
+ }
83
+
84
+ type FormMode = 'bug' | 'feedback';
85
+
86
+ /**
87
+ * Floating action button for submitting bug reports and feedback.
88
+ * Captures browser diagnostics, console errors, and optional screenshots.
89
+ *
90
+ * @example
91
+ * ```tsx
92
+ * import { BugReportButton } from '@convex-dev/feedback/react';
93
+ * import { api } from './convex/_generated/api';
94
+ *
95
+ * function App() {
96
+ * return (
97
+ * <BugReportButton
98
+ * reporterType="staff"
99
+ * reporterId={user.id}
100
+ * reporterEmail={user.email}
101
+ * reporterName={user.name}
102
+ * bugReportApi={{
103
+ * create: api.feedback.bugReports.create,
104
+ * generateUploadUrl: api.feedback.bugReports.generateUploadUrl,
105
+ * }}
106
+ * feedbackApi={{
107
+ * create: api.feedback.feedback.create,
108
+ * generateUploadUrl: api.feedback.feedback.generateUploadUrl,
109
+ * }}
110
+ * />
111
+ * );
112
+ * }
113
+ * ```
114
+ */
115
+ export function BugReportButton({
116
+ reporterType,
117
+ reporterId,
118
+ reporterEmail,
119
+ reporterName,
120
+ bugReportApi,
121
+ feedbackApi,
122
+ position = { bottom: 20, right: 20 },
123
+ buttonColor = 'red',
124
+ showFeedback = true,
125
+ onSuccess,
126
+ onError,
127
+ }: BugReportButtonProps) {
128
+ const [opened, { open, close }] = useDisclosure(false);
129
+ const [formMode, setFormMode] = useState<FormMode>('bug');
130
+ const [screenshot, setScreenshot] = useState<string | null>(null);
131
+ const [screenshotBlob, setScreenshotBlob] = useState<Blob | null>(null);
132
+ const [isCapturing, setIsCapturing] = useState(false);
133
+ const [isSubmitting, setIsSubmitting] = useState(false);
134
+
135
+ const { getConsoleErrors, clearConsoleErrors } = useBugReportContext();
136
+ const createBugReport = useMutation(bugReportApi.create);
137
+ const createFeedback = useMutation(feedbackApi.create);
138
+ const generateBugUploadUrl = useMutation(bugReportApi.generateUploadUrl);
139
+ const generateFeedbackUploadUrl = useMutation(feedbackApi.generateUploadUrl);
140
+
141
+ // Bug report form
142
+ const bugForm = useForm({
143
+ initialValues: {
144
+ title: '',
145
+ description: '',
146
+ severity: 'medium' as 'low' | 'medium' | 'high' | 'critical',
147
+ },
148
+ validate: {
149
+ title: (value) =>
150
+ value.trim().length < 5 ? 'Title must be at least 5 characters' : null,
151
+ description: (value) =>
152
+ value.trim().length < 10
153
+ ? 'Please provide more details (at least 10 characters)'
154
+ : null,
155
+ },
156
+ });
157
+
158
+ // Feedback form
159
+ const feedbackForm = useForm({
160
+ initialValues: {
161
+ title: '',
162
+ description: '',
163
+ type: 'feature_request' as 'feature_request' | 'change_request' | 'general',
164
+ priority: 'important' as 'nice_to_have' | 'important' | 'critical',
165
+ },
166
+ validate: {
167
+ title: (value) =>
168
+ value.trim().length < 5 ? 'Title must be at least 5 characters' : null,
169
+ description: (value) =>
170
+ value.trim().length < 10
171
+ ? 'Please provide more details (at least 10 characters)'
172
+ : null,
173
+ },
174
+ });
175
+
176
+ const getBrowserInfo = useCallback((): BrowserInfo => {
177
+ return {
178
+ userAgent: navigator.userAgent,
179
+ platform: navigator.platform,
180
+ language: navigator.language,
181
+ cookiesEnabled: navigator.cookieEnabled,
182
+ onLine: navigator.onLine,
183
+ screenWidth: window.screen.width,
184
+ screenHeight: window.screen.height,
185
+ colorDepth: window.screen.colorDepth,
186
+ pixelRatio: window.devicePixelRatio,
187
+ };
188
+ }, []);
189
+
190
+ const captureScreenshot = useCallback(async () => {
191
+ setIsCapturing(true);
192
+ try {
193
+ // Hide the modal temporarily for screenshot
194
+ const modalElement = document.querySelector('[data-bug-report-modal]');
195
+ if (modalElement) {
196
+ (modalElement as HTMLElement).style.visibility = 'hidden';
197
+ }
198
+
199
+ // Small delay to ensure modal is hidden
200
+ await new Promise((resolve) => setTimeout(resolve, 100));
201
+
202
+ const canvas = await html2canvas(document.body, {
203
+ logging: false,
204
+ useCORS: true,
205
+ allowTaint: true,
206
+ scale: 1,
207
+ });
208
+
209
+ // Show modal again
210
+ if (modalElement) {
211
+ (modalElement as HTMLElement).style.visibility = 'visible';
212
+ }
213
+
214
+ const dataUrl = canvas.toDataURL('image/png');
215
+ setScreenshot(dataUrl);
216
+
217
+ // Convert to blob for upload
218
+ canvas.toBlob((blob) => {
219
+ if (blob) {
220
+ setScreenshotBlob(blob);
221
+ }
222
+ }, 'image/png');
223
+
224
+ notifications.show({
225
+ title: 'Screenshot captured',
226
+ message: 'Screenshot has been attached to your report',
227
+ color: 'green',
228
+ icon: <IconCheck size={16} />,
229
+ });
230
+ } catch (error) {
231
+ console.error('Failed to capture screenshot:', error);
232
+ notifications.show({
233
+ title: 'Screenshot failed',
234
+ message: 'Could not capture screenshot. You can still submit the report.',
235
+ color: 'yellow',
236
+ });
237
+ } finally {
238
+ setIsCapturing(false);
239
+ }
240
+ }, []);
241
+
242
+ const removeScreenshot = useCallback(() => {
243
+ setScreenshot(null);
244
+ setScreenshotBlob(null);
245
+ }, []);
246
+
247
+ const handleFileUpload = useCallback((file: File | null) => {
248
+ if (!file) return;
249
+
250
+ // Validate file type
251
+ if (!file.type.startsWith('image/')) {
252
+ notifications.show({
253
+ title: 'Invalid file type',
254
+ message: 'Please upload an image file (PNG, JPG, GIF, etc.)',
255
+ color: 'red',
256
+ });
257
+ return;
258
+ }
259
+
260
+ // Validate file size (max 10MB)
261
+ const maxSize = 10 * 1024 * 1024;
262
+ if (file.size > maxSize) {
263
+ notifications.show({
264
+ title: 'File too large',
265
+ message: 'Please upload an image smaller than 10MB',
266
+ color: 'red',
267
+ });
268
+ return;
269
+ }
270
+
271
+ // Read the file and set as screenshot
272
+ const reader = new FileReader();
273
+ reader.onload = (e) => {
274
+ const dataUrl = e.target?.result as string;
275
+ setScreenshot(dataUrl);
276
+ setScreenshotBlob(file);
277
+ notifications.show({
278
+ title: 'Screenshot uploaded',
279
+ message: 'Screenshot has been attached to your report',
280
+ color: 'green',
281
+ icon: <IconCheck size={16} />,
282
+ });
283
+ };
284
+ reader.onerror = () => {
285
+ notifications.show({
286
+ title: 'Upload failed',
287
+ message: 'Could not read the image file. Please try again.',
288
+ color: 'red',
289
+ });
290
+ };
291
+ reader.readAsDataURL(file);
292
+ }, []);
293
+
294
+ const handleBugSubmit = async (values: typeof bugForm.values) => {
295
+ setIsSubmitting(true);
296
+ try {
297
+ // Upload screenshot if exists
298
+ let screenshotStorageId: string | undefined;
299
+ if (screenshotBlob) {
300
+ const uploadUrl = await generateBugUploadUrl({});
301
+ const response = await fetch(uploadUrl, {
302
+ method: 'POST',
303
+ headers: { 'Content-Type': screenshotBlob.type || 'image/png' },
304
+ body: screenshotBlob,
305
+ });
306
+ const { storageId } = await response.json();
307
+ screenshotStorageId = storageId;
308
+ }
309
+
310
+ // Get console errors
311
+ const consoleErrors = getConsoleErrors();
312
+
313
+ // Create the report
314
+ await createBugReport({
315
+ title: values.title,
316
+ description: values.description,
317
+ severity: values.severity,
318
+ reporterType,
319
+ reporterId,
320
+ reporterEmail,
321
+ reporterName,
322
+ url: window.location.href,
323
+ route: window.location.pathname,
324
+ browserInfo: JSON.stringify(getBrowserInfo()),
325
+ consoleErrors:
326
+ consoleErrors.length > 0 ? JSON.stringify(consoleErrors) : undefined,
327
+ screenshotStorageId,
328
+ viewportWidth: window.innerWidth,
329
+ viewportHeight: window.innerHeight,
330
+ networkState: navigator.onLine ? 'online' : 'offline',
331
+ });
332
+
333
+ // Clear console errors after successful submission
334
+ clearConsoleErrors();
335
+
336
+ notifications.show({
337
+ title: 'Bug report submitted',
338
+ message: 'Thank you for your feedback! We will look into this issue.',
339
+ color: 'green',
340
+ icon: <IconCheck size={16} />,
341
+ });
342
+
343
+ onSuccess?.('bug');
344
+
345
+ // Reset form and close modal
346
+ bugForm.reset();
347
+ setScreenshot(null);
348
+ setScreenshotBlob(null);
349
+ close();
350
+ } catch (error) {
351
+ console.error('Failed to submit bug report:', error);
352
+ notifications.show({
353
+ title: 'Submission failed',
354
+ message: 'Could not submit bug report. Please try again.',
355
+ color: 'red',
356
+ });
357
+ onError?.(error instanceof Error ? error : new Error(String(error)), 'bug');
358
+ } finally {
359
+ setIsSubmitting(false);
360
+ }
361
+ };
362
+
363
+ const handleFeedbackSubmit = async (values: typeof feedbackForm.values) => {
364
+ setIsSubmitting(true);
365
+ try {
366
+ // Upload screenshot if exists
367
+ let screenshotStorageId: string | undefined;
368
+ if (screenshotBlob) {
369
+ const uploadUrl = await generateFeedbackUploadUrl({});
370
+ const response = await fetch(uploadUrl, {
371
+ method: 'POST',
372
+ headers: { 'Content-Type': screenshotBlob.type || 'image/png' },
373
+ body: screenshotBlob,
374
+ });
375
+ const { storageId } = await response.json();
376
+ screenshotStorageId = storageId;
377
+ }
378
+
379
+ // Get console errors
380
+ const consoleErrors = getConsoleErrors();
381
+
382
+ // Create the feedback
383
+ await createFeedback({
384
+ type: values.type,
385
+ title: values.title,
386
+ description: values.description,
387
+ priority: values.priority,
388
+ reporterType,
389
+ reporterId,
390
+ reporterEmail,
391
+ reporterName,
392
+ url: window.location.href,
393
+ route: window.location.pathname,
394
+ browserInfo: JSON.stringify(getBrowserInfo()),
395
+ consoleErrors:
396
+ consoleErrors.length > 0 ? JSON.stringify(consoleErrors) : undefined,
397
+ screenshotStorageId,
398
+ viewportWidth: window.innerWidth,
399
+ viewportHeight: window.innerHeight,
400
+ networkState: navigator.onLine ? 'online' : 'offline',
401
+ });
402
+
403
+ // Clear console errors after successful submission
404
+ clearConsoleErrors();
405
+
406
+ notifications.show({
407
+ title: 'Feedback submitted',
408
+ message: 'Thank you for your feedback! We appreciate your input.',
409
+ color: 'green',
410
+ icon: <IconCheck size={16} />,
411
+ });
412
+
413
+ onSuccess?.('feedback');
414
+
415
+ // Reset form and close modal
416
+ feedbackForm.reset();
417
+ setScreenshot(null);
418
+ setScreenshotBlob(null);
419
+ close();
420
+ } catch (error) {
421
+ console.error('Failed to submit feedback:', error);
422
+ notifications.show({
423
+ title: 'Submission failed',
424
+ message: 'Could not submit feedback. Please try again.',
425
+ color: 'red',
426
+ });
427
+ onError?.(error instanceof Error ? error : new Error(String(error)), 'feedback');
428
+ } finally {
429
+ setIsSubmitting(false);
430
+ }
431
+ };
432
+
433
+ const handleOpen = () => {
434
+ bugForm.reset();
435
+ feedbackForm.reset();
436
+ setScreenshot(null);
437
+ setScreenshotBlob(null);
438
+ setFormMode('bug');
439
+ open();
440
+ };
441
+
442
+ const handleModeChange = (mode: string) => {
443
+ setFormMode(mode as FormMode);
444
+ setScreenshot(null);
445
+ setScreenshotBlob(null);
446
+ };
447
+
448
+ return (
449
+ <>
450
+ {/* Floating Action Button */}
451
+ <Affix position={position}>
452
+ <Transition transition="slide-up" mounted={true}>
453
+ {(transitionStyles) => (
454
+ <Tooltip label="Report a bug" position="left" withArrow>
455
+ <ActionIcon
456
+ style={transitionStyles}
457
+ variant="filled"
458
+ color={buttonColor}
459
+ size="xl"
460
+ radius="xl"
461
+ onClick={handleOpen}
462
+ aria-label="Report a bug"
463
+ >
464
+ <IconBug size={24} />
465
+ </ActionIcon>
466
+ </Tooltip>
467
+ )}
468
+ </Transition>
469
+ </Affix>
470
+
471
+ {/* Bug Report / Feedback Modal */}
472
+ <Modal
473
+ opened={opened}
474
+ onClose={close}
475
+ title={
476
+ <Group gap="xs">
477
+ {formMode === 'bug' ? (
478
+ <IconBug size={20} />
479
+ ) : (
480
+ <IconMessagePlus size={20} />
481
+ )}
482
+ <Text fw={600}>
483
+ {formMode === 'bug' ? 'Report a Bug' : 'Submit Feedback'}
484
+ </Text>
485
+ </Group>
486
+ }
487
+ size="md"
488
+ data-bug-report-modal
489
+ >
490
+ <LoadingOverlay visible={isSubmitting} />
491
+ <Stack gap="md">
492
+ {/* Mode Switcher */}
493
+ {showFeedback && (
494
+ <SegmentedControl
495
+ value={formMode}
496
+ onChange={handleModeChange}
497
+ data={[
498
+ { value: 'bug', label: 'Bug Report' },
499
+ { value: 'feedback', label: 'Feedback' },
500
+ ]}
501
+ fullWidth
502
+ />
503
+ )}
504
+
505
+ {formMode === 'bug' ? (
506
+ <form onSubmit={bugForm.onSubmit(handleBugSubmit)}>
507
+ <Stack gap="md">
508
+ <TextInput
509
+ label="Title"
510
+ placeholder="Brief summary of the issue"
511
+ required
512
+ {...bugForm.getInputProps('title')}
513
+ />
514
+
515
+ <Textarea
516
+ label="Description"
517
+ placeholder="What happened? What were you trying to do? What did you expect to happen?"
518
+ required
519
+ minRows={4}
520
+ {...bugForm.getInputProps('description')}
521
+ />
522
+
523
+ <Select
524
+ label="Severity"
525
+ placeholder="How severe is this issue?"
526
+ data={[
527
+ { value: 'low', label: 'Low - Minor inconvenience' },
528
+ { value: 'medium', label: 'Medium - Affects workflow' },
529
+ { value: 'high', label: 'High - Major functionality broken' },
530
+ { value: 'critical', label: 'Critical - System unusable' },
531
+ ]}
532
+ {...bugForm.getInputProps('severity')}
533
+ />
534
+
535
+ {/* Screenshot Section */}
536
+ <Stack gap="xs">
537
+ <Group justify="space-between">
538
+ <Text size="sm" fw={500}>
539
+ Screenshot
540
+ </Text>
541
+ {screenshot ? (
542
+ <Button
543
+ variant="subtle"
544
+ color="red"
545
+ size="xs"
546
+ leftSection={<IconX size={14} />}
547
+ onClick={removeScreenshot}
548
+ >
549
+ Remove
550
+ </Button>
551
+ ) : (
552
+ <Group gap="xs">
553
+ <Button
554
+ variant="light"
555
+ size="xs"
556
+ leftSection={<IconCamera size={14} />}
557
+ onClick={captureScreenshot}
558
+ loading={isCapturing}
559
+ >
560
+ Capture
561
+ </Button>
562
+ <FileButton onChange={handleFileUpload} accept="image/*">
563
+ {(props) => (
564
+ <Button
565
+ {...props}
566
+ variant="light"
567
+ size="xs"
568
+ leftSection={<IconUpload size={14} />}
569
+ >
570
+ Upload
571
+ </Button>
572
+ )}
573
+ </FileButton>
574
+ </Group>
575
+ )}
576
+ </Group>
577
+ {screenshot && (
578
+ <Paper withBorder p="xs" radius="md">
579
+ <Image
580
+ src={screenshot}
581
+ alt="Bug screenshot"
582
+ radius="md"
583
+ fit="contain"
584
+ h={150}
585
+ />
586
+ </Paper>
587
+ )}
588
+ </Stack>
589
+
590
+ {/* Auto-captured Info Badge */}
591
+ <Paper withBorder p="xs" radius="md">
592
+ <Group gap="xs">
593
+ <Badge size="xs" variant="outline" color="gray">
594
+ Auto-captured
595
+ </Badge>
596
+ <Text size="xs" c="dimmed">
597
+ Browser info, URL, viewport size, and console errors will be
598
+ included automatically.
599
+ </Text>
600
+ </Group>
601
+ </Paper>
602
+
603
+ {/* Submit Buttons */}
604
+ <Group justify="flex-end" mt="md">
605
+ <Button variant="subtle" onClick={close} disabled={isSubmitting}>
606
+ Cancel
607
+ </Button>
608
+ <Button type="submit" loading={isSubmitting}>
609
+ Submit Report
610
+ </Button>
611
+ </Group>
612
+ </Stack>
613
+ </form>
614
+ ) : (
615
+ <form onSubmit={feedbackForm.onSubmit(handleFeedbackSubmit)}>
616
+ <Stack gap="md">
617
+ <Select
618
+ label="Feedback Type"
619
+ placeholder="What kind of feedback is this?"
620
+ data={[
621
+ {
622
+ value: 'feature_request',
623
+ label: 'Feature Request - Idea for new functionality',
624
+ },
625
+ {
626
+ value: 'change_request',
627
+ label: 'Change Request - Modify existing feature',
628
+ },
629
+ {
630
+ value: 'general',
631
+ label: 'General Feedback - Comments or suggestions',
632
+ },
633
+ ]}
634
+ {...feedbackForm.getInputProps('type')}
635
+ />
636
+
637
+ <TextInput
638
+ label="Title"
639
+ placeholder="Brief summary of your feedback"
640
+ required
641
+ {...feedbackForm.getInputProps('title')}
642
+ />
643
+
644
+ <Textarea
645
+ label="Description"
646
+ placeholder="Describe your idea or suggestion in detail. What problem does it solve? How would it improve your workflow?"
647
+ required
648
+ minRows={4}
649
+ {...feedbackForm.getInputProps('description')}
650
+ />
651
+
652
+ <Select
653
+ label="Priority"
654
+ placeholder="How important is this to you?"
655
+ data={[
656
+ {
657
+ value: 'nice_to_have',
658
+ label: 'Nice to Have - Would be a welcome addition',
659
+ },
660
+ {
661
+ value: 'important',
662
+ label: 'Important - Would significantly improve my work',
663
+ },
664
+ {
665
+ value: 'critical',
666
+ label: 'Critical - Blocking my workflow',
667
+ },
668
+ ]}
669
+ {...feedbackForm.getInputProps('priority')}
670
+ />
671
+
672
+ {/* Screenshot Section */}
673
+ <Stack gap="xs">
674
+ <Group justify="space-between">
675
+ <Text size="sm" fw={500}>
676
+ Screenshot (optional)
677
+ </Text>
678
+ {screenshot ? (
679
+ <Button
680
+ variant="subtle"
681
+ color="red"
682
+ size="xs"
683
+ leftSection={<IconX size={14} />}
684
+ onClick={removeScreenshot}
685
+ >
686
+ Remove
687
+ </Button>
688
+ ) : (
689
+ <Group gap="xs">
690
+ <Button
691
+ variant="light"
692
+ size="xs"
693
+ leftSection={<IconCamera size={14} />}
694
+ onClick={captureScreenshot}
695
+ loading={isCapturing}
696
+ >
697
+ Capture
698
+ </Button>
699
+ <FileButton onChange={handleFileUpload} accept="image/*">
700
+ {(props) => (
701
+ <Button
702
+ {...props}
703
+ variant="light"
704
+ size="xs"
705
+ leftSection={<IconUpload size={14} />}
706
+ >
707
+ Upload
708
+ </Button>
709
+ )}
710
+ </FileButton>
711
+ </Group>
712
+ )}
713
+ </Group>
714
+ {screenshot && (
715
+ <Paper withBorder p="xs" radius="md">
716
+ <Image
717
+ src={screenshot}
718
+ alt="Feedback screenshot"
719
+ radius="md"
720
+ fit="contain"
721
+ h={150}
722
+ />
723
+ </Paper>
724
+ )}
725
+ </Stack>
726
+
727
+ {/* Auto-captured Info Badge */}
728
+ <Paper withBorder p="xs" radius="md">
729
+ <Group gap="xs">
730
+ <Badge size="xs" variant="outline" color="gray">
731
+ Auto-captured
732
+ </Badge>
733
+ <Text size="xs" c="dimmed">
734
+ Browser info and page context will be included automatically.
735
+ </Text>
736
+ </Group>
737
+ </Paper>
738
+
739
+ {/* Submit Buttons */}
740
+ <Group justify="flex-end" mt="md">
741
+ <Button variant="subtle" onClick={close} disabled={isSubmitting}>
742
+ Cancel
743
+ </Button>
744
+ <Button type="submit" loading={isSubmitting} color="teal">
745
+ Submit Feedback
746
+ </Button>
747
+ </Group>
748
+ </Stack>
749
+ </form>
750
+ )}
751
+ </Stack>
752
+ </Modal>
753
+ </>
754
+ );
755
+ }