import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useReport } from '../hooks/useReport'; import { useSettings } from '../hooks/useSettings'; import { apiFetch, isHomepageUrl, isPremium } from '../lib/api'; import ScanSlideIn from './ScanSlideIn'; import { ReportSkeleton } from './Skeleton'; import type { PageScanSummary } from '../lib/types'; import { labelsForRulesets } from '../lib/rulesets'; // ── Helpers ─────────────────────────────────────────────────── function scoreColor(s: number) { return s >= 90 ? '#22c55e' : s >= 75 ? '#84cc16' : s >= 55 ? '#f59e0b' : s >= 35 ? '#f97316' : '#ef4444'; } // "Issues found" everywhere in the app is the full total — matches the "All" // tab in the frontend overlay/ScanSlideIn (all severities, WCAG + best-practice). function totalCount(scan: PageScanSummary): number { return (scan.issues ?? []).length; } // The homepage's own is whatever the theme renders there — on some // sites that has nothing recognisable as "home" in it — so always prefer a // fixed "Homepage" label over the captured title/URL for that one page. function pageLabel(scan: PageScanSummary): string { if (isHomepageUrl(scan.url)) return 'Homepage'; if (scan.title) return scan.title; try { const u = new URL(scan.url); return (u.pathname + u.search.replace(/\/$/, '')) || '/'; } catch { return scan.url; } } function exportCsv(scans: PageScanSummary[]): void { const cols = ['Page URL', 'WCAG', 'Severity', 'Issue Title', 'Description', 'Selector', 'Elements']; // Scan data (titles/selectors/etc.) comes from the scanned page's own DOM, so a // value could start with =/+/-/@ and be interpreted as a formula by Excel/Sheets // when this CSV is opened (CSV/formula injection) — prefix those with a tab so // spreadsheet apps treat the cell as text instead of a formula. const esc = (v: unknown) => { let s = String(v ?? ''); if (/^[=+\-@\t\r]/.test(s)) s = '\t' + s; return `"${s.replace(/"/g, '""')}"`; }; const lines = [cols.map(esc).join(',')]; scans.forEach((scan) => { (scan.issues ?? []).forEach((iss) => { lines.push([ scan.url, iss.wcag ?? '', iss.severity, iss.title, iss.description, iss.selector ?? '', iss.count, ].map(esc).join(',')); }); }); const blob = new Blob([lines.join('\n')], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `accessmate-accessibility-audit-${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(a.href); } // ── Summary bar ─────────────────────────────────────────────── function SummaryBar({ scans }: { scans: PageScanSummary[] }) { // Include best-practice issues — they still carry a real severity and are // counted in scan.failed, so excluding them here would under-count // Warnings/Notices vs the "Issues" column shown in the table below. const all = scans.flatMap((s) => s.issues ?? []); const critical = all.filter((i) => i.severity === 'critical').length; const serious = all.filter((i) => i.severity === 'serious').length; const warning = all.filter((i) => i.severity === 'warning').length; const notice = all.filter((i) => i.severity === 'notice').length; const pages = scans.length; // "Issues found" is the full total — matches the "All" tab in the frontend // overlay/ScanSlideIn. const total = all.length; return ( <div className="aai-audit-summary"> <div className="aai-audit-summary-stat"> <span className="aai-audit-summary-val" style={{ color: 'var(--aai-purple)' }}>{total}</span> <span className="aai-audit-summary-lbl">Issues found</span> </div> <div className="aai-audit-summary-stat"> <span className="aai-audit-summary-val" style={{ color: '#ef4444' }}>{critical}</span> <span className="aai-audit-summary-lbl">Critical issues</span> </div> <div className="aai-audit-summary-stat"> <span className="aai-audit-summary-val" style={{ color: '#f97316' }}>{serious}</span> <span className="aai-audit-summary-lbl">Serious issues</span> </div> <div className="aai-audit-summary-stat"> <span className="aai-audit-summary-val" style={{ color: '#f59e0b' }}>{warning}</span> <span className="aai-audit-summary-lbl">Warnings</span> </div> <div className="aai-audit-summary-stat"> <span className="aai-audit-summary-val" style={{ color: '#3b82f6' }}>{notice}</span> <span className="aai-audit-summary-lbl">Notices</span> </div> <div className="aai-audit-summary-stat"> <span className="aai-audit-summary-val">{pages}</span> <span className="aai-audit-summary-lbl">Pages Scanned</span> </div> </div> ); } // ── Main report view ────────────────────────────────────────── export default function ReportView() { const { data: report } = useReport(); const { data: settings } = useSettings(); const rulesetLabel = labelsForRulesets(settings?.default_rulesets ?? []) || 'WCAG 2.2 AA'; const [searchInput, setSearchInput] = useState(''); const [search, setSearch] = useState(''); const [selectedScan, setSelectedScan] = useState<PageScanSummary | null>(null); const { data: pageScans = {}, isLoading: scansLoading, isError: scansError } = useQuery({ queryKey: ['page-scans', search], queryFn: () => apiFetch<Record<string, PageScanSummary>>(search ? `/page-scans?search=${encodeURIComponent(search)}` : '/page-scans'), }); if (scansLoading) return <ReportSkeleton />; if (scansError) return <div className="aai-error aai-card" style={{ margin: 24 }}>Failed to load report data. Please refresh the page.</div>; function commitSearch() { setSearch(searchInput.trim()); } function handleSearchKey(e: React.KeyboardEvent<HTMLInputElement>) { if (e.key === 'Enter') commitSearch(); } if (Object.keys(pageScans).length === 0 && !search) { return ( <div className="aai-empty"> <p className="aai-empty__primary">No pages scanned yet</p> <p className="aai-empty__sub">Scan pages from the Dashboard tab to see accessibility results here.</p> </div> ); } const allScans = Object.values(pageScans).sort((a, b) => totalCount(b) - totalCount(a)); const displayScans = allScans; return ( <div className="aai-report"> {/* Page issues slide-in */} <ScanSlideIn scan={selectedScan} onClose={() => setSelectedScan(null)} /> {/* ── Header ── */} <div className="aai-audit-header"> <div className="aai-audit-header-left"> <h2 className="aai-audit-title">AccessMate Accessibility Audit Report</h2> {report?.scanned_at && ( <span className="aai-audit-date"> {new Date(report.scanned_at).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })} </span> )} </div> </div> {/* ── Automated scan disclaimer ── */} <div className="aai-audit-notice"> This report highlights issues detected through automated scanning. Some accessibility issues may require manual testing for full {rulesetLabel} compliance. </div> {/* ── WCAG Standard notice ── */} <div className="aai-audit-standard"> <span>Standard: <strong>{rulesetLabel}</strong></span> <span>Engine: <strong>axe-core 4.x</strong></span> <span>Scope: <strong>Browser-based automated scan</strong></span> </div> {/* ── Summary bar ── */} <SummaryBar scans={displayScans} /> {/* ── Toolbar ── */} <div className="aai-audit-toolbar aai-print-hide"> <div className="aai-ps-search-wrap"> <svg className="aai-ps-search-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" strokeWidth="1.6"/><path d="M13 13l3.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg> <input type="search" placeholder="Search pages… (press Enter)" className="aai-ps-search" value={searchInput} onChange={(e) => setSearchInput(e.target.value)} onKeyDown={handleSearchKey} aria-label="Search pages" /> {searchInput && ( <button className="aai-ps-search-clear" onClick={() => { setSearchInput(''); setSearch(''); }} aria-label="Clear search">✕</button> )} </div> <div className="aai-audit-toolbar-right"> {isPremium && ( <> <button className="aai-btn aai-btn--ghost aai-btn--sm" onClick={() => exportCsv(displayScans)}> ↓ Export CSV </button> <button className="aai-btn aai-btn--ghost aai-btn--sm" onClick={() => window.print()}> ↓ Export PDF </button> </> )} </div> </div> {/* ── Pages table ── */} {displayScans.length === 0 ? ( <div className="aai-empty" style={{ marginTop: 16 }}> <p>No pages match your search.</p> </div> ) : ( <div className="aai-audit-table-wrap"> <table className="aai-audit-table"> <thead> <tr> <th className="aai-audit-th aai-audit-th--num">#</th> <th className="aai-audit-th">Page</th> <th className="aai-audit-th">Grade</th> <th className="aai-audit-th">Score</th> <th className="aai-audit-th">Issues</th> <th className="aai-audit-th">Last Scanned</th> <th className="aai-audit-th aai-print-hide"></th> </tr> </thead> <tbody> {displayScans.map((scan, i) => { const c = scoreColor(scan.score); const total = totalCount(scan); return ( <tr key={scan.url} className="aai-audit-row" style={{ borderLeft: `3px solid ${c}` }}> <td className="aai-audit-td aai-audit-td--num">{i + 1}</td> <td className="aai-audit-td"> <a href={scan.url + (scan.url.includes('?') ? '&' : '?') + 'accessmate_scan=1'} target="_blank" rel="noopener noreferrer" className="aai-audit-link" title={scan.url} > {pageLabel(scan)} </a> <span className="aai-audit-page-url">{scan.url}</span> </td> <td className="aai-audit-td"> <span className="aai-audit-badge" style={{ background: c + '22', color: c, border: `1px solid ${c}55` }}> {scan.grade} </span> </td> <td className="aai-audit-td" style={{ color: c, fontWeight: 600 }}>{scan.score}</td> <td className="aai-audit-td" style={{ color: total > 0 ? '#ef4444' : '#22c55e', fontWeight: 600 }}> {total} </td> <td className="aai-audit-td aai-muted" style={{ fontSize: 12, whiteSpace: 'nowrap' }}> {new Date(scan.scanned_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', })} </td> <td className="aai-audit-td aai-print-hide"> <button className="aai-btn aai-btn--sm aai-btn--ghost" onClick={() => setSelectedScan(scan)} > View </button> </td> </tr> ); })} </tbody> </table> </div> )} {/* ── Per-issue detail (print/PDF only) ── The PDF export is just this page printed, so on screen it only ever showed the page-level summary table above — while the CSV export carries the full per-issue detail (WCAG code, selector, etc). That made the two downloads look like different reports. This section is hidden on screen and only renders when printing, so the PDF now carries the same issue-level detail as the CSV. */} <div className="aai-print-only"> {displayScans.filter((s) => (s.issues ?? []).length > 0).map((scan) => ( <div key={scan.url} className="aai-audit-print-page"> <h3 className="aai-audit-print-page-title">{pageLabel(scan)}</h3> <div className="aai-audit-print-page-url">{scan.url}</div> <table className="aai-audit-table aai-audit-print-table"> <thead> <tr> <th className="aai-audit-th">WCAG</th> <th className="aai-audit-th">Severity</th> <th className="aai-audit-th">Issue</th> <th className="aai-audit-th">Description</th> <th className="aai-audit-th">Selector</th> <th className="aai-audit-th">Elements</th> </tr> </thead> <tbody> {(scan.issues ?? []).map((iss) => ( <tr key={iss.id} className="aai-audit-row"> <td className="aai-audit-td">{iss.wcag || '—'}</td> <td className="aai-audit-td" style={{ textTransform: 'capitalize' }}>{iss.severity}</td> <td className="aai-audit-td">{iss.title}</td> <td className="aai-audit-td">{iss.description}</td> <td className="aai-audit-td aai-muted">{iss.selector || '—'}</td> <td className="aai-audit-td">{iss.count}</td> </tr> ))} </tbody> </table> </div> ))} </div> <p className="aai-audit-footer-note"> AccessMate uses automated scanning to identify and help fix accessibility issues. Automated tools can detect only a subset of accessibility problems. For full {rulesetLabel} compliance, manual testing — including testing with assistive technologies such as screen readers — is recommended. </p> <p className="aai-audit-footer-note" style={{ marginTop: 6 }}> AccessMate helps you find and fix accessibility issues faster. It does not replace a full manual accessibility audit. </p> </div> ); }