import React, { useEffect, useState, useCallback } from "react"; import { useClient } from "sanity"; import { IntentLink } from "sanity/router"; import { Stack, Text, Flex, Spinner, Box } from "@sanity/ui"; import { RefreshIcon, ActivityIcon } from "@sanity/icons"; import useProEnabled from "../../hooks/useProEnabled"; import { computeSEOScore } from "../../utils/seoScore"; import ProGate from "./ProGate"; import Pagination from "./Pagination"; /* eslint-disable @typescript-eslint/no-explicit-any */ interface DocSEO { _id: string; _type: string; _updatedAt: string; docTitle: string; seo: Record | null; } interface ScoredDoc extends DocSEO { score: number; color: string; issues: string[]; } interface ScanCache { docs: ScoredDoc[]; timestamp: number; } const CACHE_TTL_MS = 5 * 60 * 1000; const DASHBOARD_LS_KEY = "seo-plugin__dashboard-scan"; let scanCache: ScanCache | null = null; function readDashboardLS(): ScanCache | null { try { const raw = localStorage.getItem(DASHBOARD_LS_KEY); if (!raw) return null; const parsed: ScanCache = JSON.parse(raw); if (Date.now() - parsed.timestamp > CACHE_TTL_MS) { localStorage.removeItem(DASHBOARD_LS_KEY); return null; } return parsed; } catch { return null; } } function getCached(): ScoredDoc[] | null { const hit = scanCache ?? readDashboardLS(); if (!hit) return null; if (Date.now() - hit.timestamp > CACHE_TTL_MS) { scanCache = null; localStorage.removeItem(DASHBOARD_LS_KEY); return null; } if (!scanCache) scanCache = hit; return hit.docs; } function setCache(docs: ScoredDoc[]): void { const data: ScanCache = { docs, timestamp: Date.now() }; scanCache = data; try { localStorage.setItem(DASHBOARD_LS_KEY, JSON.stringify(data)); } catch { // localStorage full or unavailable — in-memory layer still works } } function clearDashboardCache(): void { scanCache = null; try { localStorage.removeItem(DASHBOARD_LS_KEY); } catch { // ignore } } function getCacheAge(): string | null { const hit = scanCache ?? readDashboardLS(); if (!hit) return null; const secs = Math.floor((Date.now() - hit.timestamp) / 1000); if (secs < 60) return "just now"; const mins = Math.floor(secs / 60); return `${mins} minute${mins !== 1 ? "s" : ""} ago`; } function getIssues(seo: Record | null): string[] { if (!seo) return ["No SEO data — open this document and fill in SEO fields"]; const issues: string[] = []; if (!seo.metaTitle) issues.push("Missing meta title"); else if (seo.metaTitle.length < 50 || seo.metaTitle.length > 60) issues.push("Title length out of range"); if (!seo.metaDescription) issues.push("Missing meta description"); else if (seo.metaDescription.length < 100 || seo.metaDescription.length > 160) issues.push("Description length out of range"); if (!seo.openGraph?.image?.asset) issues.push("Missing OG image"); if (!seo.focusKeyword) issues.push("No focus keyword"); if (!seo.openGraph?.title) issues.push("Open Graph not configured"); return issues; } function scoreColorHex(score: number): string { if (score >= 80) return "#22c55e"; if (score >= 50) return "#f59e0b"; return "#ef4444"; } function scoreLabel(score: number): string { if (score >= 80) return "Good"; if (score >= 50) return "Needs Work"; return "Poor"; } function getDocColor(score: number): "green" | "orange" | "red" { if (score >= 80) return "green"; if (score >= 50) return "orange"; return "red"; } function ScoreBar({ score }: { score: number }) { const color = scoreColorHex(score); return (
{score}
{scoreLabel(score)}
); } function StatCard({ value, label, sub, accent, }: { value: number | string; label: string; sub: string; accent: string; }) { return (
{value}
{label}
{sub}
); } function DistributionStrip({ good, ok, poor, total, }: { good: number; ok: number; poor: number; total: number; }) { if (total === 0) return null; const pct = (n: number) => `${Math.round((n / total) * 100)}%`; return (
{good > 0 &&
} {ok > 0 &&
} {poor > 0 &&
}
{[ { count: good, color: "#22c55e", label: "Good" }, { count: ok, color: "#f59e0b", label: "Needs Work" }, { count: poor, color: "#ef4444", label: "Poor" }, ].map(({ count, color, label }) => (
{label}: {count} ({pct(count)})
))}
); } function DashboardRow({ doc }: { doc: ScoredDoc }) { const [hovered, setHovered] = useState(false); return (
setHovered(true)} onMouseLeave={() => setHovered(false)} >
{doc.docTitle}
Updated {new Date(doc._updatedAt).toLocaleDateString()}
{doc._type}
{doc.issues.length === 0 ? (
All checks passed
) : ( doc.issues.map((issue) => (
{issue}
)) )}
Open →
); } const FILTER_TABS = [ { key: "all", label: "All" }, { key: "good", label: "Good" }, { key: "ok", label: "Needs Work" }, { key: "poor", label: "Poor" }, ] as const; export default function SEODashboardPane() { const client = useClient({ apiVersion: "2024-01-01" }); const [docs, setDocs] = useState(() => getCached() ?? []); const [loading, setLoading] = useState(() => getCached() === null); const [cacheAge, setCacheAge] = useState(() => getCacheAge()); const [filter, setFilter] = useState<"all" | "poor" | "ok" | "good">("all"); const [issueFilter, setIssueFilter] = useState("all"); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(5); const { isPro } = useProEnabled(); const [refreshHovered, setRefreshHovered] = useState(false); const fetchDocs = useCallback( async (bust = false) => { if (bust) clearDashboardCache(); const cached = getCached(); if (cached) { setDocs(cached); setCacheAge(getCacheAge()); setLoading(false); return; } setLoading(true); try { const results: DocSEO[] = await client.fetch( `*[!(_id in path("drafts.**")) && defined(seo)] | order(_updatedAt desc) [0...200] { _id, _type, _updatedAt, "docTitle": coalesce(title, name, slug.current, _type, "Untitled"), "seo": seo }`, { _ts: Date.now() }, ); const initial = results.map((doc) => { const result = computeSEOScore(doc.seo || undefined); let color: "green" | "orange" | "red" = "red"; if (result.score >= 80) { color = "green"; } else if (result.score >= 50) { color = "orange"; } return { ...doc, score: result.score, color, issues: getIssues(doc.seo) }; }); const titleCounts: Record = {}; results.forEach((doc) => { if (doc.seo?.metaTitle) { titleCounts[doc.seo.metaTitle] = (titleCounts[doc.seo.metaTitle] || 0) + 1; } }); const withDupes = initial.map((doc) => doc.seo?.metaTitle && titleCounts[doc.seo.metaTitle] > 1 ? { ...doc, issues: [...doc.issues, "Duplicate meta title"] } : doc, ); setCache(withDupes); setDocs(withDupes); setCacheAge(getCacheAge()); } finally { setLoading(false); } }, [client], ); useEffect(() => { fetchDocs(); }, [fetchDocs]); const allIssueTypes = Array.from(new Set(docs.flatMap((d) => d.issues))).sort(); const filtered = docs.filter((d) => { const docColor = getDocColor(d.score); if (filter === "poor" && docColor !== "red") return false; if (filter === "ok" && docColor !== "orange") return false; if (filter === "good" && docColor !== "green") return false; if (issueFilter !== "all" && !d.issues.includes(issueFilter)) return false; return true; }); const totalDocs = docs.length; const goodCount = docs.filter((d) => getDocColor(d.score) === "green").length; const okCount = docs.filter((d) => getDocColor(d.score) === "orange").length; const poorCount = docs.filter((d) => getDocColor(d.score) === "red").length; const avgScore = totalDocs > 0 ? Math.round(docs.reduce((s, d) => s + d.score, 0) / totalDocs) : 0; const duplicateTitles = docs.filter((d) => d.issues.includes("Duplicate meta title")).length; const noOpenGraph = docs.filter((d) => d.issues.includes("Open Graph not configured")).length; if (!isPro) { return ( {null} ); } return (
SEO Analytics
SEO Health Dashboard
{loading ? "Loading SEO health data…" : `Monitoring ${totalDocs} page${ totalDocs !== 1 ? "s" : "" } across your content`} {!loading && cacheAge && (
Last scanned {cacheAge} · cached
)}
{!loading && totalDocs > 0 && ( )}
{FILTER_TABS.map((f) => ( ))}
{loading ? ( ) : (
{["Score", "Page", "Type", "Issues", ""].map((h) => (
{h}
))}
{filtered.length === 0 && (
No pages match this filter
)} {filtered.slice(page * pageSize, (page + 1) * pageSize).map((doc) => ( ))}
)}
); }