import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { apiFetch } from '../lib/api'; import OnboardingHero from './OnboardingHero'; import PageScanner from './PageScanner'; import ScanSlideIn from './ScanSlideIn'; import { DashboardSkeleton } from './Skeleton'; import type { PageScanSummary, ScannableUrl } from '../lib/types'; export default function Dashboard() { const [selectedScan, setSelectedScan] = useState(null); const { data: pageScans = {}, isLoading, isError } = useQuery({ queryKey: ['page-scans'], queryFn: () => apiFetch>('/page-scans'), staleTime: 0, }); // Same query key/shape as PageScanner's default (no search) query, so React // Query dedupes the request and both components share one cached result. const { data: scannableUrls = [] } = useQuery({ queryKey: ['scannable-urls', ''], queryFn: () => apiFetch('/scannable-urls'), }); if (isLoading) return ; if (isError) return
Failed to load scan data. Please refresh the page.
; // Aggregate issue counts across all scanned pages, including best-practice // items — they still carry a real severity and are counted in scan.failed, // so excluding them here would under-count Warnings/Notices vs the totals // shown elsewhere (e.g. the "Issues by Page" bar and ScanSlideIn's modal). const allIssues = Object.values(pageScans).flatMap((s) => s.issues ?? []); const critical = allIssues.filter((i) => i.severity === 'critical').length; const serious = allIssues.filter((i) => i.severity === 'serious').length; const warning = allIssues.filter((i) => i.severity === 'warning').length; const notice = allIssues.filter((i) => i.severity === 'notice').length; // "Issues found" is the full total — matches the "All" tab in the frontend // overlay/ScanSlideIn (critical + serious + warning + notice, WCAG + best-practice). const total = allIssues.length; const passed = Object.values(pageScans).reduce((sum, s) => sum + (s.passed ?? 0), 0); const hasData = Object.keys(pageScans).length > 0; return (
{/* First-run onboarding — replaced by the stats/chart once a scan exists */} {!hasData && } {/* Stats row */} {hasData && ( <>

Automated accessibility score based on detected issues.{' '} Some issues may require manual testing. ⓘ

💡 Tip: Fix critical issues first for the biggest accessibility impact.

)} {/* Issues by page chart */} {hasData && (

Issues by Page

Top pages ranked by number of accessibility issues

)} {/* Pages & Posts scanner (main dashboard view) */} setSelectedScan(null)} />
); } // ── Issues by page bar chart ─────────────────────────────────── function IssuesByPage({ pageScans, scannableUrls, onOpenScan }: { pageScans: Record; scannableUrls: ScannableUrl[]; onOpenScan: (scan: PageScanSummary) => void }) { // Discovery labels (e.g. "Homepage") are authoritative — they're what the // Pages & Posts table shows, so use them here too instead of the scanned // page's raw tag, which for the homepage is just the site name. const urlLabels = new Map(scannableUrls.map((u) => [u.url, u.label])); // Ranked and counted by the full issues array (matches the frontend "All" tab), // not the separately-stored scan.failed field, so this can't drift out of sync. const pages = Object.values(pageScans) .filter((s) => (s.issues ?? []).length > 0) .sort((a, b) => (b.issues ?? []).length - (a.issues ?? []).length) .slice(0, 7); if (pages.length === 0) return ( <p className="aai-chart-empty">No issues detected across scanned pages.</p> ); const maxTotal = pages[0].issues?.length || 1; return ( <div className="aai-ibc"> {pages.map((scan) => { const scanIssues = scan.issues ?? []; const crit = scanIssues.filter((i) => i.severity === 'critical').length; const ser = scanIssues.filter((i) => i.severity === 'serious').length; const warn = scanIssues.filter((i) => i.severity === 'warning').length; const note = scanIssues.filter((i) => i.severity === 'notice').length; const total = scanIssues.length; const pathname = (() => { try { const u = new URL(scan.url); // Strip a trailing slash that trailingslashit() incorrectly appended after query params // (e.g. http://example.com/?cat=1/ → /?cat=1, not /?cat=1/) const search = u.search.replace(/\/$/, ''); return (u.pathname + search) || '/'; } catch { return scan.url; } })(); const label = urlLabels.get(scan.url) || scan.title || pathname; const barPct = (total / maxTotal) * 100; return ( <div key={scan.url} className="aai-ibc__row"> <button className="aai-ibc__label aai-ibc__label--link" title={scan.url} onClick={() => onOpenScan(scan)} >{label}</button> <div className="aai-ibc__track"> <div className="aai-ibc__bar" style={{ width: `${barPct}%` }}> {crit > 0 && <span className="aai-ibc__seg aai-ibc__seg--critical" style={{ flex: crit }} />} {ser > 0 && <span className="aai-ibc__seg aai-ibc__seg--serious" style={{ flex: ser }} />} {warn > 0 && <span className="aai-ibc__seg aai-ibc__seg--warning" style={{ flex: warn }} />} {note > 0 && <span className="aai-ibc__seg aai-ibc__seg--notice" style={{ flex: note }} />} </div> <span className="aai-ibc__count">{total}</span> </div> </div> ); })} <div className="aai-ibc__legend"> <span><em className="aai-ibc__dot aai-ibc__dot--critical" />Critical</span> <span><em className="aai-ibc__dot aai-ibc__dot--serious" />Serious</span> <span><em className="aai-ibc__dot aai-ibc__dot--warning" />Warning</span> <span><em className="aai-ibc__dot aai-ibc__dot--notice" />Notice</span> </div> </div> ); } // ── Stat card ────────────────────────────────────────────────── function StatCard({ label, value, color }: { label: string; value: number; color: string }) { return ( <div className="aai-audit-summary-stat"> <span className="aai-audit-summary-val" style={{ color }}>{value}</span> <span className="aai-audit-summary-lbl">{label}</span> </div> ); }