import { useState, useCallback, useEffect } from 'react';
import {
ActionIcon,
Modal,
TextInput,
Textarea,
Select,
Button,
Group,
Stack,
Text,
Image,
Tooltip,
Affix,
Transition,
Paper,
Badge,
LoadingOverlay,
SegmentedControl,
FileButton,
Switch,
Box,
Alert,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import * as TablerIcons from '@tabler/icons-react';
const IconBug = TablerIcons.IconBug as React.FC<{ size?: number }>;
const IconCamera = TablerIcons.IconCamera as React.FC<{ size?: number }>;
const IconX = TablerIcons.IconX as React.FC<{ size?: number }>;
const IconCheck = TablerIcons.IconCheck as React.FC<{ size?: number }>;
const IconMessagePlus = TablerIcons.IconMessagePlus as React.FC<{ size?: number }>;
const IconUpload = TablerIcons.IconUpload as React.FC<{ size?: number }>;
const IconSparkles = TablerIcons.IconSparkles as React.FC<{ size?: number }>;
const IconAlertCircle = TablerIcons.IconAlertCircle as React.FC<{ size?: number }>;
import { useMutation, useAction } from 'convex/react';
import html2canvas from 'html2canvas';
import { useBugReportContext } from './BugReportContext';
import { InterviewChat, type InterviewMessage, type PendingRequest, type GeneratedReport } from './InterviewChat';
import type { FunctionReference } from 'convex/server';
import type { InterviewContext } from '../types';
/**
* Props for the BugReportButton component
*/
export interface BugReportButtonProps {
/** Reporter type: 'staff' for internal users, 'customer' for external users */
reporterType: 'staff' | 'customer';
/** Unique identifier for the reporter */
reporterId: string;
/** Reporter's email address */
reporterEmail: string;
/** Reporter's display name */
reporterName: string;
/** Convex API reference for bug reports */
bugReportApi: {
create: FunctionReference<'mutation'>;
generateUploadUrl: FunctionReference<'mutation'>;
};
/** Convex API reference for feedback */
feedbackApi: {
create: FunctionReference<'mutation'>;
generateUploadUrl: FunctionReference<'mutation'>;
};
/** Position of the floating button */
position?: { bottom?: number; right?: number; top?: number; left?: number };
/** Button color (default: red) */
buttonColor?: string;
/** Show feedback tab in addition to bug reports */
showFeedback?: boolean;
/** Callback when submission succeeds */
onSuccess?: (type: 'bug' | 'feedback') => void;
/** Callback when submission fails */
onError?: (error: Error, type: 'bug' | 'feedback') => void;
/** Enable AI interview mode (requires @convex-dev/agent) */
enableInterview?: boolean;
/** Default input mode when interview is enabled */
defaultMode?: 'form' | 'interview';
/** Context to help the AI interviewer ask better questions */
interviewContext?: InterviewContext;
/** Convex API reference for interview actions (required if enableInterview is true) */
interviewApi?: {
startBugInterview: FunctionReference<'action'>;
startFeedbackInterview: FunctionReference<'action'>;
continueInterview: FunctionReference<'action'>;
getPendingForThread: FunctionReference<'query'>;
getSessionByThread: FunctionReference<'query'>;
};
}
interface BrowserInfo {
userAgent: string;
platform: string;
language: string;
cookiesEnabled: boolean;
onLine: boolean;
screenWidth: number;
screenHeight: number;
colorDepth: number;
pixelRatio: number;
}
type FormMode = 'bug' | 'feedback';
/**
* Floating action button for submitting bug reports and feedback.
* Captures browser diagnostics, console errors, and optional screenshots.
*
* @example
* ```tsx
* import { BugReportButton } from '@convex-dev/feedback/react';
* import { api } from './convex/_generated/api';
*
* function App() {
* return (
*
* );
* }
* ```
*/
export function BugReportButton({
reporterType,
reporterId,
reporterEmail,
reporterName,
bugReportApi,
feedbackApi,
position = { bottom: 20, right: 20 },
buttonColor = 'red',
showFeedback = true,
onSuccess,
onError,
enableInterview = false,
defaultMode = 'form',
interviewContext,
interviewApi,
}: BugReportButtonProps) {
const [opened, { open, close }] = useDisclosure(false);
const [formMode, setFormMode] = useState('bug');
const [screenshot, setScreenshot] = useState(null);
const [screenshotBlob, setScreenshotBlob] = useState(null);
const [isCapturing, setIsCapturing] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Interview mode state
const [inputMode, setInputMode] = useState<'form' | 'interview'>(defaultMode);
const [interviewState, setInterviewState] = useState<{
threadId: string | null;
sessionId: string | null;
messages: InterviewMessage[];
isThinking: boolean;
waitingForInput: boolean;
pendingRequest: PendingRequest | null;
isComplete: boolean;
generatedReport: GeneratedReport | null;
error: string | null;
}>({
threadId: null,
sessionId: null,
messages: [],
isThinking: false,
waitingForInput: false,
pendingRequest: null,
isComplete: false,
generatedReport: null,
error: null,
});
// Interview actions - always call hooks unconditionally
// We use a dummy function reference if not provided to satisfy React's rules of hooks
const dummyAction = bugReportApi.create; // Use any existing action as fallback
const startBugInterviewAction = useAction(interviewApi?.startBugInterview ?? dummyAction as any);
const startFeedbackInterviewAction = useAction(interviewApi?.startFeedbackInterview ?? dummyAction as any);
const continueInterviewAction = useAction(interviewApi?.continueInterview ?? dummyAction as any);
// Only use the actions if interview is enabled and API is provided
const startBugInterview = enableInterview && interviewApi ? startBugInterviewAction : null;
const startFeedbackInterview = enableInterview && interviewApi ? startFeedbackInterviewAction : null;
const continueInterview = enableInterview && interviewApi ? continueInterviewAction : null;
const { getConsoleErrors, clearConsoleErrors } = useBugReportContext();
const createBugReport = useMutation(bugReportApi.create);
const createFeedback = useMutation(feedbackApi.create);
const generateBugUploadUrl = useMutation(bugReportApi.generateUploadUrl);
const generateFeedbackUploadUrl = useMutation(feedbackApi.generateUploadUrl);
// Bug report form
const bugForm = useForm({
initialValues: {
title: '',
description: '',
severity: 'medium' as 'low' | 'medium' | 'high' | 'critical',
},
validate: {
title: (value) =>
value.trim().length < 5 ? 'Title must be at least 5 characters' : null,
description: (value) =>
value.trim().length < 10
? 'Please provide more details (at least 10 characters)'
: null,
},
});
// Feedback form
const feedbackForm = useForm({
initialValues: {
title: '',
description: '',
type: 'feature_request' as 'feature_request' | 'change_request' | 'general',
priority: 'important' as 'nice_to_have' | 'important' | 'critical',
},
validate: {
title: (value) =>
value.trim().length < 5 ? 'Title must be at least 5 characters' : null,
description: (value) =>
value.trim().length < 10
? 'Please provide more details (at least 10 characters)'
: null,
},
});
const getBrowserInfo = useCallback((): BrowserInfo => {
return {
userAgent: navigator.userAgent,
platform: navigator.platform,
language: navigator.language,
cookiesEnabled: navigator.cookieEnabled,
onLine: navigator.onLine,
screenWidth: window.screen.width,
screenHeight: window.screen.height,
colorDepth: window.screen.colorDepth,
pixelRatio: window.devicePixelRatio,
};
}, []);
const captureScreenshot = useCallback(async () => {
setIsCapturing(true);
try {
// Hide the modal temporarily for screenshot
const modalElement = document.querySelector('[data-bug-report-modal]');
if (modalElement) {
(modalElement as HTMLElement).style.visibility = 'hidden';
}
// Small delay to ensure modal is hidden
await new Promise((resolve) => setTimeout(resolve, 100));
const canvas = await html2canvas(document.body, {
logging: false,
useCORS: true,
allowTaint: true,
scale: 1,
});
// Show modal again
if (modalElement) {
(modalElement as HTMLElement).style.visibility = 'visible';
}
const dataUrl = canvas.toDataURL('image/png');
setScreenshot(dataUrl);
// Convert to blob for upload
canvas.toBlob((blob) => {
if (blob) {
setScreenshotBlob(blob);
}
}, 'image/png');
notifications.show({
title: 'Screenshot captured',
message: 'Screenshot has been attached to your report',
color: 'green',
icon: ,
});
} catch (error) {
console.error('Failed to capture screenshot:', error);
notifications.show({
title: 'Screenshot failed',
message: 'Could not capture screenshot. You can still submit the report.',
color: 'yellow',
});
} finally {
setIsCapturing(false);
}
}, []);
const removeScreenshot = useCallback(() => {
setScreenshot(null);
setScreenshotBlob(null);
}, []);
const handleFileUpload = useCallback((file: File | null) => {
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
notifications.show({
title: 'Invalid file type',
message: 'Please upload an image file (PNG, JPG, GIF, etc.)',
color: 'red',
});
return;
}
// Validate file size (max 10MB)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
notifications.show({
title: 'File too large',
message: 'Please upload an image smaller than 10MB',
color: 'red',
});
return;
}
// Read the file and set as screenshot
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
setScreenshot(dataUrl);
setScreenshotBlob(file);
notifications.show({
title: 'Screenshot uploaded',
message: 'Screenshot has been attached to your report',
color: 'green',
icon: ,
});
};
reader.onerror = () => {
notifications.show({
title: 'Upload failed',
message: 'Could not read the image file. Please try again.',
color: 'red',
});
};
reader.readAsDataURL(file);
}, []);
const handleBugSubmit = async (values: typeof bugForm.values) => {
setIsSubmitting(true);
try {
// Upload screenshot if exists
let screenshotStorageId: string | undefined;
if (screenshotBlob) {
const uploadUrl = await generateBugUploadUrl({});
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': screenshotBlob.type || 'image/png' },
body: screenshotBlob,
});
const { storageId } = await response.json();
screenshotStorageId = storageId;
}
// Get console errors
const consoleErrors = getConsoleErrors();
// Create the report
await createBugReport({
title: values.title,
description: values.description,
severity: values.severity,
reporterType,
reporterId,
reporterEmail,
reporterName,
url: window.location.href,
route: window.location.pathname,
browserInfo: JSON.stringify(getBrowserInfo()),
consoleErrors:
consoleErrors.length > 0 ? JSON.stringify(consoleErrors) : undefined,
screenshotStorageId,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
networkState: navigator.onLine ? 'online' : 'offline',
});
// Clear console errors after successful submission
clearConsoleErrors();
notifications.show({
title: 'Bug report submitted',
message: 'Thank you for your feedback! We will look into this issue.',
color: 'green',
icon: ,
});
onSuccess?.('bug');
// Reset form and close modal
bugForm.reset();
setScreenshot(null);
setScreenshotBlob(null);
close();
} catch (error) {
console.error('Failed to submit bug report:', error);
notifications.show({
title: 'Submission failed',
message: 'Could not submit bug report. Please try again.',
color: 'red',
});
onError?.(error instanceof Error ? error : new Error(String(error)), 'bug');
} finally {
setIsSubmitting(false);
}
};
const handleFeedbackSubmit = async (values: typeof feedbackForm.values) => {
setIsSubmitting(true);
try {
// Upload screenshot if exists
let screenshotStorageId: string | undefined;
if (screenshotBlob) {
const uploadUrl = await generateFeedbackUploadUrl({});
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': screenshotBlob.type || 'image/png' },
body: screenshotBlob,
});
const { storageId } = await response.json();
screenshotStorageId = storageId;
}
// Get console errors
const consoleErrors = getConsoleErrors();
// Create the feedback
await createFeedback({
type: values.type,
title: values.title,
description: values.description,
priority: values.priority,
reporterType,
reporterId,
reporterEmail,
reporterName,
url: window.location.href,
route: window.location.pathname,
browserInfo: JSON.stringify(getBrowserInfo()),
consoleErrors:
consoleErrors.length > 0 ? JSON.stringify(consoleErrors) : undefined,
screenshotStorageId,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
networkState: navigator.onLine ? 'online' : 'offline',
});
// Clear console errors after successful submission
clearConsoleErrors();
notifications.show({
title: 'Feedback submitted',
message: 'Thank you for your feedback! We appreciate your input.',
color: 'green',
icon: ,
});
onSuccess?.('feedback');
// Reset form and close modal
feedbackForm.reset();
setScreenshot(null);
setScreenshotBlob(null);
close();
} catch (error) {
console.error('Failed to submit feedback:', error);
notifications.show({
title: 'Submission failed',
message: 'Could not submit feedback. Please try again.',
color: 'red',
});
onError?.(error instanceof Error ? error : new Error(String(error)), 'feedback');
} finally {
setIsSubmitting(false);
}
};
const handleOpen = () => {
bugForm.reset();
feedbackForm.reset();
setScreenshot(null);
setScreenshotBlob(null);
setFormMode('bug');
setInputMode(defaultMode);
setInterviewState({
threadId: null,
sessionId: null,
messages: [],
isThinking: false,
waitingForInput: false,
pendingRequest: null,
isComplete: false,
generatedReport: null,
error: null,
});
open();
};
// Start interview when switching to interview mode
const handleStartInterview = useCallback(async () => {
if (!enableInterview) return;
const startAction = formMode === 'bug' ? startBugInterview : startFeedbackInterview;
if (!startAction) {
setInterviewState(prev => ({ ...prev, error: 'Interview API not configured' }));
return;
}
setInterviewState(prev => ({ ...prev, isThinking: true, error: null }));
try {
const result = await startAction({
reporterType,
reporterId,
reporterEmail,
reporterName,
context: interviewContext,
});
const newMessage: InterviewMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: result.response,
timestamp: Date.now(),
};
setInterviewState(prev => ({
...prev,
threadId: result.threadId,
sessionId: result.sessionId,
messages: [...prev.messages, newMessage],
isThinking: false,
waitingForInput: result.waitingForInput,
pendingRequest: result.pendingRequest ? {
requestId: result.pendingRequest.requestId,
inputType: result.pendingRequest.inputType as 'text' | 'choice' | 'form',
prompt: result.pendingRequest.prompt,
config: result.pendingRequest.config,
} : null,
}));
} catch (error) {
console.error('Failed to start interview:', error);
setInterviewState(prev => ({
...prev,
isThinking: false,
error: error instanceof Error ? error.message : 'Failed to start interview',
}));
}
}, [enableInterview, formMode, startBugInterview, startFeedbackInterview, reporterType, reporterId, reporterEmail, reporterName, interviewContext]);
// Handle user input during interview
const handleInterviewInput = useCallback(async (response: string) => {
if (!continueInterview || !interviewState.threadId || !interviewState.sessionId || !interviewState.pendingRequest) {
return;
}
// Add user message
const userMessage: InterviewMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: response,
timestamp: Date.now(),
};
setInterviewState(prev => ({
...prev,
messages: [...prev.messages, userMessage],
isThinking: true,
waitingForInput: false,
pendingRequest: null,
}));
try {
const result = await continueInterview({
threadId: interviewState.threadId,
sessionId: interviewState.sessionId,
requestId: interviewState.pendingRequest.requestId as any,
response,
context: interviewContext,
reportType: formMode === 'bug' ? 'bug' : 'feedback',
});
const assistantMessage: InterviewMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: result.response,
timestamp: Date.now(),
};
setInterviewState(prev => ({
...prev,
messages: [...prev.messages, assistantMessage],
isThinking: false,
waitingForInput: result.waitingForInput,
isComplete: result.isComplete,
generatedReport: result.generatedReport ?? null,
pendingRequest: result.pendingRequest ? {
requestId: result.pendingRequest.requestId,
inputType: result.pendingRequest.inputType as 'text' | 'choice' | 'form',
prompt: result.pendingRequest.prompt,
config: result.pendingRequest.config,
} : null,
}));
} catch (error) {
console.error('Failed to continue interview:', error);
setInterviewState(prev => ({
...prev,
isThinking: false,
error: error instanceof Error ? error.message : 'Failed to continue interview',
}));
}
}, [continueInterview, interviewState.threadId, interviewState.sessionId, interviewState.pendingRequest, interviewContext, formMode]);
// Handle confirming the generated report from interview
const handleConfirmReport = useCallback(async (report: GeneratedReport) => {
setIsSubmitting(true);
try {
// Upload screenshot if exists
let screenshotStorageId: string | undefined;
if (screenshotBlob) {
const generateUploadUrl = formMode === 'bug' ? generateBugUploadUrl : generateFeedbackUploadUrl;
const uploadUrl = await generateUploadUrl({});
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': screenshotBlob.type || 'image/png' },
body: screenshotBlob,
});
const { storageId } = await response.json();
screenshotStorageId = storageId;
}
// Get console errors
const consoleErrors = getConsoleErrors();
if (formMode === 'bug') {
await createBugReport({
title: report.title,
description: report.description,
severity: report.severity || 'medium',
reporterType,
reporterId,
reporterEmail,
reporterName,
url: window.location.href,
route: window.location.pathname,
browserInfo: JSON.stringify(getBrowserInfo()),
consoleErrors: consoleErrors.length > 0 ? JSON.stringify(consoleErrors) : undefined,
screenshotStorageId,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
networkState: navigator.onLine ? 'online' : 'offline',
});
} else {
await createFeedback({
type: report.type || 'general',
title: report.title,
description: report.description,
priority: report.priority || 'important',
reporterType,
reporterId,
reporterEmail,
reporterName,
url: window.location.href,
route: window.location.pathname,
browserInfo: JSON.stringify(getBrowserInfo()),
consoleErrors: consoleErrors.length > 0 ? JSON.stringify(consoleErrors) : undefined,
screenshotStorageId,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
networkState: navigator.onLine ? 'online' : 'offline',
});
}
clearConsoleErrors();
notifications.show({
title: formMode === 'bug' ? 'Bug report submitted' : 'Feedback submitted',
message: 'Thank you for your feedback!',
color: 'green',
icon: ,
});
onSuccess?.(formMode);
close();
} catch (error) {
console.error('Failed to submit report:', error);
notifications.show({
title: 'Submission failed',
message: 'Could not submit your report. Please try again.',
color: 'red',
});
onError?.(error instanceof Error ? error : new Error(String(error)), formMode);
} finally {
setIsSubmitting(false);
}
}, [formMode, screenshotBlob, generateBugUploadUrl, generateFeedbackUploadUrl, createBugReport, createFeedback, getConsoleErrors, clearConsoleErrors, getBrowserInfo, reporterType, reporterId, reporterEmail, reporterName, onSuccess, onError, close]);
const handleModeChange = (mode: string) => {
setFormMode(mode as FormMode);
setScreenshot(null);
setScreenshotBlob(null);
// Reset interview state when switching report type
if (inputMode === 'interview') {
setInterviewState({
threadId: null,
sessionId: null,
messages: [],
isThinking: false,
waitingForInput: false,
pendingRequest: null,
isComplete: false,
generatedReport: null,
error: null,
});
}
};
// Handle input mode toggle
const handleInputModeChange = (checked: boolean) => {
const newMode = checked ? 'interview' : 'form';
setInputMode(newMode);
if (newMode === 'form') {
// Reset interview state when switching to form
setInterviewState({
threadId: null,
sessionId: null,
messages: [],
isThinking: false,
waitingForInput: false,
pendingRequest: null,
isComplete: false,
generatedReport: null,
error: null,
});
}
};
// Auto-start interview when interview mode is selected and no interview is active
// Important: Check for error state to prevent infinite retry loop on failure
useEffect(() => {
if (opened && inputMode === 'interview' && enableInterview && !interviewState.threadId && !interviewState.isThinking && !interviewState.error) {
handleStartInterview();
}
}, [opened, inputMode, enableInterview, interviewState.threadId, interviewState.isThinking, interviewState.error, handleStartInterview]);
return (
<>
{/* Floating Action Button */}
{(transitionStyles) => (
)}
{/* Bug Report / Feedback Modal */}
{formMode === 'bug' ? (
) : (
)}
{formMode === 'bug' ? 'Report a Bug' : 'Submit Feedback'}
}
size="md"
data-bug-report-modal
>
{/* Mode Switcher */}
{showFeedback && (
)}
{/* Interview Mode Toggle */}
{enableInterview && interviewApi && (
AI Interview Mode
handleInputModeChange(e.currentTarget.checked)}
label={inputMode === 'interview' ? 'On' : 'Off'}
labelPosition="left"
/>
{inputMode === 'interview'
? (interviewState.threadId
? 'Interview in progress - toggle off to switch to manual form'
: 'Chat with AI to create a detailed report')
: 'Fill out the form manually'}
)}
{/* Interview Error Alert */}
{interviewState.error && (
} color="red" title="Interview Error">
{interviewState.error}
)}
{/* Interview Chat UI */}
{inputMode === 'interview' && enableInterview ? (
{/* Cancel Interview Bar - always visible when interview is active or loading */}
{(interviewState.threadId || interviewState.isThinking) && (
{interviewState.isThinking && !interviewState.threadId
? 'Starting AI interview...'
: 'AI Interview in progress'}
}
onClick={() => handleInputModeChange(false)}
>
Cancel Interview
)}
{/* Screenshot Section for Interview Mode */}
{!interviewState.isComplete && (
Screenshot (optional)
{screenshot ? (
}
onClick={removeScreenshot}
>
Remove
) : (
}
onClick={captureScreenshot}
loading={isCapturing}
>
Capture
{(props) => (
}
>
Upload
)}
)}
{screenshot && (
)}
)}
) : formMode === 'bug' ? (
) : (
)}
>
);
}