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'} )} {/* Screenshot Section for Interview Mode */} {!interviewState.isComplete && ( Screenshot (optional) {screenshot ? ( ) : ( {(props) => ( )} )} {screenshot && ( Screenshot )} )} ) : formMode === 'bug' ? (