import React, { useState, useCallback, useMemo, useEffect } from "react"; import { Stack, Text, Box } from "@sanity/ui"; import { RefreshIcon, SearchIcon, DownloadIcon } from "@sanity/icons"; import { useClient } from "sanity"; import useProEnabled from "../../hooks/useProEnabled"; import { computeSEOScore } from "../../utils/seoScore"; import ProGate from "./ProGate"; import Pagination from "./Pagination"; import BulkStatCards from "./bulk/BulkStatCards"; import BulkActions from "./bulk/BulkActions"; import { BulkDoc, RowEdit, BulkTab, getIssues } from "./bulk/types"; import BulkDocRow from "./bulk/BulkDocRow"; /* eslint-disable @typescript-eslint/no-explicit-any */ interface BulkScanCache { docs: BulkDoc[]; rowEdits: Record; timestamp: number; } const BULK_CACHE_TTL_MS = 10 * 60 * 1000; const BULK_LS_KEY = "seo-plugin__bulk-scan"; let bulkScanCache: BulkScanCache | null = null; function readBulkLS(): BulkScanCache | null { try { const raw = localStorage.getItem(BULK_LS_KEY); if (!raw) return null; const parsed: BulkScanCache = JSON.parse(raw); if (Date.now() - parsed.timestamp > BULK_CACHE_TTL_MS) { localStorage.removeItem(BULK_LS_KEY); return null; } return parsed; } catch { return null; } } function getBulkCached(): Pick | null { const hit = bulkScanCache ?? readBulkLS(); if (!hit) return null; if (Date.now() - hit.timestamp > BULK_CACHE_TTL_MS) { bulkScanCache = null; localStorage.removeItem(BULK_LS_KEY); return null; } if (!bulkScanCache) bulkScanCache = hit; const docs = hit.docs.map((d) => ({ ...d, selected: false })); const rowEdits = Object.fromEntries( Object.entries(hit.rowEdits).map(([id, edit]) => [ id, { ...edit, saving: false, saved: false }, ]), ); return { docs, rowEdits }; } function setBulkCache(docs: BulkDoc[], rowEdits: Record): void { const data: BulkScanCache = { docs, rowEdits, timestamp: Date.now() }; bulkScanCache = data; try { localStorage.setItem(BULK_LS_KEY, JSON.stringify(data)); } catch { // localStorage full or unavailable — in-memory layer still works } } function clearBulkCache(): void { bulkScanCache = null; try { localStorage.removeItem(BULK_LS_KEY); } catch { // ignore } } function getBulkCacheAge(): string | null { const hit = bulkScanCache ?? readBulkLS(); 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`; } interface CsvRow { _id: string; title: string; metaTitle: string; metaDescription: string; focusKeyword: string; ogTitle: string; ogDescription: string; found: boolean; } export default function BulkSEOPanel() { const client = useClient({ apiVersion: "2024-01-01" }); const [docs, setDocs] = useState(() => getBulkCached()?.docs ?? []); const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState(() => getBulkCached() !== null); const [cacheAge, setCacheAge] = useState(() => getBulkCacheAge()); const [expandedId, setExpandedId] = useState(null); const [rowEdits, setRowEdits] = useState>( () => getBulkCached()?.rowEdits ?? {}, ); const [bulkTab, setBulkTab] = useState("og"); const [bulkProcessing, setBulkProcessing] = useState(false); const [csvPreview, setCsvPreview] = useState([]); const [csvError, setCsvError] = useState(null); const [csvApplying, setCsvApplying] = useState(false); const [log, setLog] = useState<{ msg: string; ok: boolean }[]>([]); const [scanHovered, setScanHovered] = useState(false); const [exportHovered, setExportHovered] = useState(false); const fetchDocs = useCallback( async (bust = false) => { if (bust) clearBulkCache(); setLoading(true); setLog([]); setExpandedId(null); setCsvPreview([]); setCsvError(null); try { const results: any[] = await client.fetch( `*[!(_id in path("drafts.**")) && defined(seo)] | order(_updatedAt desc) [0...200] { _id, _type, "docTitle": coalesce(title, name, slug.current, "Untitled"), "docSlug": slug.current, "seo": seo }`, { _ts: Date.now() }, ); const scored = results.map((d) => ({ ...d, score: computeSEOScore(d.seo || undefined).score, issues: getIssues(d.seo), selected: false, })); const withIssues = scored.filter((d) => d.issues.length > 0); setDocs(withIssues); const edits: Record = {}; withIssues.forEach((d) => { edits[d._id] = { metaTitle: d.seo?.metaTitle || "", metaDescription: d.seo?.metaDescription || "", focusKeyword: d.seo?.focusKeyword || "", ogTitle: d.seo?.openGraph?.title || "", ogDescription: d.seo?.openGraph?.description || "", seoStatus: d.seo?.seoStatus || "draft", saving: false, saved: false, }; }); setRowEdits(edits); setBulkCache(withIssues, edits); setCacheAge(getBulkCacheAge()); setLoaded(true); } finally { setLoading(false); } }, [client], ); const toggleAll = () => { const visibleIds = new Set(visibleDocs.map((d) => d._id)); const allSelected = visibleDocs.every((d) => d.selected); setDocs((prev) => prev.map((d) => (visibleIds.has(d._id) ? { ...d, selected: !allSelected } : d)), ); }; const toggleSelect = (id: string, e: React.MouseEvent | React.KeyboardEvent) => { e.stopPropagation(); setDocs((prev) => prev.map((d) => (d._id === id ? { ...d, selected: !d.selected } : d))); }; const toggleExpand = (id: string) => { setExpandedId((prev) => (prev === id ? null : id)); }; const onFieldChange = ( id: string, field: keyof Omit, val: string, ) => { setRowEdits((prev) => ({ ...prev, [id]: { ...prev[id], [field]: val, saved: false } })); }; const saveRow = useCallback( async (doc: BulkDoc) => { const edit = rowEdits[doc._id]; if (!edit) return; setRowEdits((prev) => ({ ...prev, [doc._id]: { ...prev[doc._id], saving: true } })); try { const patch: Record = {}; if (edit.metaTitle !== undefined) patch["seo.metaTitle"] = edit.metaTitle; if (edit.metaDescription !== undefined) patch["seo.metaDescription"] = edit.metaDescription; if (edit.focusKeyword !== undefined) patch["seo.focusKeyword"] = edit.focusKeyword; if (edit.ogTitle !== undefined) patch["seo.openGraph.title"] = edit.ogTitle; if (edit.ogDescription !== undefined) patch["seo.openGraph.description"] = edit.ogDescription; await client.patch(doc._id).set(patch).commit(); setRowEdits((prev) => ({ ...prev, [doc._id]: { ...prev[doc._id], saving: false, saved: true }, })); setTimeout(fetchDocs, 900); } catch (err) { setRowEdits((prev) => ({ ...prev, [doc._id]: { ...prev[doc._id], saving: false } })); setLog([{ msg: `✗ Failed to save "${doc.docTitle}": ${String(err)}`, ok: false }]); } }, [client, rowEdits, fetchDocs], ); const runBulk = useCallback(async () => { const selected = docs.filter((d) => d.selected); if (!selected.length) return; setBulkProcessing(true); setLog([{ msg: `Applying Open Graph sync to ${selected.length} page(s)…`, ok: true }]); const results = await Promise.allSettled( selected.map(async (doc) => { const metaTitle = doc.seo?.metaTitle; const metaDesc = doc.seo?.metaDescription; if (!metaTitle) { return { docTitle: doc.docTitle, detail: "skipped — no meta title set" }; } const ogValue: Record = { _type: "openGraph", title: metaTitle }; if (metaDesc) ogValue.description = metaDesc; if (doc.seo?.openGraph?.image) ogValue.image = doc.seo.openGraph.image; await client.patch(doc._id).set({ "seo.openGraph": ogValue }).commit(); return { docTitle: doc.docTitle, detail: `OG title → "${metaTitle}"` }; }), ); const newLog: { msg: string; ok: boolean }[] = []; results.forEach((r) => { if (r.status === "fulfilled") { newLog.push({ msg: `✓ ${r.value.docTitle} → ${r.value.detail}`, ok: true }); } else { newLog.push({ msg: `✗ Failed: ${String(r.reason)}`, ok: false }); } }); const ok = results.filter((r) => r.status === "fulfilled").length; newLog.push({ msg: `Done — ${ok} of ${selected.length} updated.`, ok: true }); setLog(newLog); setBulkProcessing(false); setDocs((prev) => prev.map((d) => (d.selected ? { ...d, selected: false } : d))); fetchDocs(); }, [docs, client, fetchDocs]); const exportCSV = useCallback(() => { const header = "_id,title,type,metaTitle,metaDescription,focusKeyword,ogTitle,ogDescription"; const q = (v: string) => `"${(v || "").replace(/"/g, '""')}"`; const rows = docs.map((d) => [ d._id, q(d.docTitle), d._type, q(d.seo?.metaTitle || ""), q(d.seo?.metaDescription || ""), q(d.seo?.focusKeyword || ""), q(d.seo?.openGraph?.title || ""), q(d.seo?.openGraph?.description || ""), ].join(","), ); const csv = [header, ...rows].join("\n"); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "seo-bulk-export.csv"; a.click(); URL.revokeObjectURL(url); }, [docs]); const parseCSVLine = (line: string): string[] => { const result: string[] = []; let current = ""; let inQuotes = false; for (let i = 0; i < line.length; i += 1) { const ch = line[i]; if (ch === '"') { if (inQuotes && line[i + 1] === '"') { current += '"'; i += 1; } else { inQuotes = !inQuotes; } } else if (ch === "," && !inQuotes) { result.push(current); current = ""; } else { current += ch; } } result.push(current); return result; }; const handleCSVFile = useCallback( (fileOrEvent: File | React.ChangeEvent) => { setCsvError(null); setCsvPreview([]); const file = fileOrEvent instanceof File ? fileOrEvent : fileOrEvent.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (evt) => { const text = evt.target?.result as string; const lines = text.trim().split(/\r?\n/); if (lines.length < 2) { setCsvError("CSV must have a header row and at least one data row."); return; } const headers = parseCSVLine(lines[0]).map((h) => h.trim().toLowerCase()); const idIdx = headers.indexOf("_id"); const mtIdx = headers.indexOf("metatitle"); const mdIdx = headers.indexOf("metadescription"); const kwIdx = headers.indexOf("focuskeyword"); const ogTIdx = headers.indexOf("ogtitle"); const ogDIdx = headers.indexOf("ogdescription"); if (idIdx === -1) { setCsvError('CSV must include an "_id" column.'); return; } const col = (cols: string[], idx: number) => (idx >= 0 ? cols[idx]?.trim() || "" : ""); const docMap = Object.fromEntries(docs.map((d) => [d._id, d])); const preview: CsvRow[] = lines.slice(1).map((line) => { const cols = parseCSVLine(line); const id = cols[idIdx]?.trim() || ""; return { _id: id, title: docMap[id]?.docTitle ?? id, metaTitle: col(cols, mtIdx), metaDescription: col(cols, mdIdx), focusKeyword: col(cols, kwIdx), ogTitle: col(cols, ogTIdx), ogDescription: col(cols, ogDIdx), found: Boolean(docMap[id]), }; }); setCsvPreview(preview); }; reader.readAsText(file); if (!(fileOrEvent instanceof File)) { const inputElement = fileOrEvent.target; inputElement.value = ""; } }, [docs], ); const applyCSV = useCallback(async () => { const toApply = csvPreview.filter((r) => r.found); if (!toApply.length) return; setCsvApplying(true); setLog([{ msg: `Applying CSV to ${toApply.length} page(s)…`, ok: true }]); const results = await Promise.allSettled( toApply.map(async (row) => { const patch: Record = {}; if (row.metaTitle !== undefined) patch["seo.metaTitle"] = row.metaTitle; if (row.metaDescription !== undefined) patch["seo.metaDescription"] = row.metaDescription; if (row.focusKeyword !== undefined) patch["seo.focusKeyword"] = row.focusKeyword; if (row.ogTitle !== undefined) patch["seo.openGraph.title"] = row.ogTitle; if (row.ogDescription !== undefined) patch["seo.openGraph.description"] = row.ogDescription; await client.patch(row._id).set(patch).commit(); return { title: row.title }; }), ); const newLog: { msg: string; ok: boolean }[] = []; results.forEach((r) => { if (r.status === "fulfilled") { newLog.push({ msg: `✓ ${r.value.title}`, ok: true }); } else { newLog.push({ msg: `✗ Failed: ${String(r.reason)}`, ok: false }); } }); const ok = results.filter((r) => r.status === "fulfilled").length; newLog.push({ msg: `Done — ${ok} of ${toApply.length} updated.`, ok: true }); setLog(newLog); setCsvApplying(false); setCsvPreview([]); fetchDocs(); }, [csvPreview, client, fetchDocs]); const [typeFilter, setTypeFilter] = useState("all"); const docTypes = useMemo(() => Array.from(new Set(docs.map((d) => d._type))).sort(), [docs]); const visibleDocs = useMemo( () => (typeFilter === "all" ? docs : docs.filter((d) => d._type === typeFilter)), [docs, typeFilter], ); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(5); useEffect(() => { setPage(0); }, [typeFilter]); const paginatedDocs = useMemo(() => { return visibleDocs.slice(page * pageSize, (page + 1) * pageSize); }, [visibleDocs, page, pageSize]); const selectedCount = docs.filter((d) => d.selected).length; const totalIssues = docs.reduce((sum, d) => sum + d.issues.length, 0); const missingKeywords = docs.filter((d) => d.issues.includes("No focus keyword")).length; const noMetaImage = docs.filter((d) => d.issues.includes("No meta image")).length; const avgScore = docs.length > 0 ? Math.round(docs.reduce((s, d) => s + d.score, 0) / docs.length) : 0; const { isPro } = useProEnabled(); const scanLabel = loaded ? "Re-scan Pages" : "Scan Pages"; return (
Fix Queue
SEO Optimizer
{loaded ? `${docs.length} page${ docs.length !== 1 ? "s" : "" } with issues — click a row to edit, select multiple for bulk fixes` : "Scan your content to find SEO issues and fix them inline or in bulk"} {loaded && cacheAge && (
Cached · Last scanned {cacheAge}
)}
{loaded && docs.length === 0 && (
🎉
All pages look great!
No SEO issues found across your content.
)} {docs.length > 0 && (
{docTypes.length > 1 && (
Filter by type:
)}
0 && visibleDocs.every((d) => d.selected)} onChange={toggleAll} style={{ cursor: "pointer", accentColor: "var(--card-link-color)" }} />
{["Score", "Page"].map((h) => (
{h}
))}
{paginatedDocs.map((doc) => ( ))}
{visibleDocs.length === docs.length ? `${docs.length} pages with SEO issues` : `${visibleDocs.length} of ${docs.length} pages`}{" "} · click a row to edit
)} {selectedCount > 0 && ( d.selected).filter((d) => !d.seo?.metaTitle).length } /> )} {!loaded && !loading && (
Scan your content for SEO issues
Click a page row to expand and edit all its SEO fields individually, or select multiple pages for bulk changes
)}
); }