import React, { useState } from 'react'; import { createPortal } from 'react-dom'; import { useEditor } from '@craftjs/core'; import { Modal } from '../../ui/Modal'; import { useEditorConfig } from '../../state/EditorConfigContext'; import { usePages } from '../../state/PageContext'; import { buildReportPayload, MAX_DESCRIPTION_CHARS, type ReportCategory } from '../../utils/report-payload'; import { getRecentConsoleErrors } from '../../utils/console-buffer'; import { editorBuild } from '../../utils/build-stamp'; export interface ReportIssueModalProps { open: boolean; onClose: () => void; device: string; } const CATEGORIES: { value: ReportCategory; label: string }[] = [ { value: 'bug', label: 'Something is broken' }, { value: 'confusing', label: 'Something is confusing' }, { value: 'feature', label: 'I wish it could…' }, ]; /** Only start showing the running character count once it's actually useful * -- i.e. once the user is close enough to MAX_DESCRIPTION_CHARS that * losing text is a real possibility, not on every keystroke from zero. */ const COUNTER_THRESHOLD = MAX_DESCRIPTION_CHARS - 500; export const ReportIssueModal: React.FC = ({ open, onClose, device }) => { const { whpConfig } = useEditorConfig(); const { activePageId, pages } = usePages(); // Guarded with optional chaining: some hosts around this component (e.g. // TopBar's own test harness) stub `useEditor` with a minimal collector // state that has no `events`/`nodes` at all -- this must degrade to "no // selection known" rather than throw and take the whole topbar down. const { query, selectedType } = useEditor((state: any) => { const sel = state?.events?.selected; const id = sel && sel.size > 0 ? (Array.from(sel)[0] as string) : null; return { selectedType: id ? (state?.nodes?.[id]?.data?.displayName ?? null) : null }; }); const [category, setCategory] = useState('bug'); const [description, setDescription] = useState(''); const [includeCanvas, setIncludeCanvas] = useState(true); const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); const [reference, setReference] = useState(''); const [error, setError] = useState(''); const activePage = pages.find((p) => p.id === activePageId); const reset = (): void => { setDescription(''); setStatus('idle'); setReference(''); setError(''); }; const handleSubmit = async (): Promise => { if (!description.trim() || !whpConfig) return; setStatus('sending'); setError(''); let canvasState: string | null = null; try { canvasState = query.serialize(); } catch { // A serialize failure must not block the report -- it is often the // very thing being reported. canvasState = null; } let payload; try { payload = buildReportPayload({ category, description, includeCanvas, siteId: whpConfig.siteId ?? null, siteDomain: whpConfig.siteDomain ?? '', pageId: activePageId, pageSlug: activePage?.slug ?? '', editorVersion: editorBuild(), userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '', viewport: typeof window !== 'undefined' ? `${window.innerWidth}x${window.innerHeight}` : '', deviceMode: device, selectedType, consoleErrors: getRecentConsoleErrors(), canvasState, }); } catch { // buildReportPayload throws (rather than returning an oversized body) // when the payload is still over the cap even after dropping // canvas_state -- e.g. an enormous description. The thrown value is a // plain Error with no discriminator, so treat ANY throw here as "too // large" rather than string-matching the message. The user's text is // left untouched in the textarea (state.description is never reset on // this path) so nothing is lost -- they just need to shorten it or // untick "include this page's contents". setError( 'This report is too large to send, even without the page contents. ' + 'Try unchecking "Include this page\'s contents" below, or shortening your description.', ); setStatus('error'); return; } try { const resp = await fetch(`${whpConfig.apiUrl}?action=report_issue`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken, }, body: JSON.stringify(payload), }); const data = await resp.json(); if (!resp.ok || !data.success) { setError(data.error || 'Could not send the report. Please try again.'); setStatus('error'); return; } setReference(data.reference || `SB-${data.id}`); setStatus('sent'); } catch (e) { setError('Could not reach the server. Your text is still here — try again.'); setStatus('error'); } }; const handleClose = (): void => { if (status === 'sent') reset(); onClose(); }; return createPortal(
e.stopPropagation()} >
Report an issue
{status === 'sent' ? (