import { useCallback, useEffect, useRef, useState } from 'react'; import { ChevronDownIcon, ChevronRightIcon, CircleCheckIcon, CircleXIcon, ExternalLinkIcon, GaugeIcon, InfoIcon, RefreshCwIcon, SparklesIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, } from 'lucide-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { SeoMedicPanel } from '../../components/seo/seo-medic'; import { SeoPage, SeoPageHeader } from '../../components/seo/seo-page'; import { Badge } from '../../components/ui/badge'; import { Button } from '../../components/ui/button'; import { Callout } from '../../components/ui/callout'; import { Card, CardContent } from '../../components/ui/card'; import ConfirmDialog from '../../components/ui/confirm-dialog'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../../components/ui/table'; import { Tabs, TabsList, TabsTrigger } from '../../components/ui/tabs'; import { ScoreRunner } from '../../components/seo/score-runner'; import { useCopilot } from '../../context/copilot.context'; import { useSeoBadges } from '../../context/seo-badges.context'; import { recomaze_ai_personalization_env } from '../../env'; import { revertMessage } from '../../lib/seo/revert-message'; import { checkupGroup } from '../../lib/seo/checkup-groups'; import { changeExcerpt, fieldLabel, sourceLabel, valueLabel, type ChangePart, } from '../../lib/seo/change-labels'; import { SEVERITY_MEANING } from '../../lib/seo/checkup-severity'; import { classNames } from '../../lib/utils'; import type { ISeoChange, ISeoChangesPayload, ISeoCheck, ISeoCheckup, SeoCheckSeverity, } from '../../service/seo/seo.interface'; import { fetchSeoChanges, fetchSeoCheckup, fixSeoCheck, revertSeoChanges, } from '../../service/seo/seo.service'; import { PAGES } from './settings.copy'; const SEVERITY: Record< SeoCheckSeverity, { label: string; icon: typeof CircleXIcon; tone: string; badge: string } > = { error: { label: 'Problems', icon: CircleXIcon, tone: 'text-red-600 dark:text-red-400', badge: 'bg-red-500/10 text-red-700 dark:text-red-300', }, warning: { label: 'Warnings', icon: TriangleAlertIcon, tone: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/10 text-amber-700 dark:text-amber-300', }, info: { label: 'Suggestions', icon: InfoIcon, tone: 'text-sky-600 dark:text-sky-400', badge: 'bg-sky-500/10 text-sky-700 dark:text-sky-300', }, }; const ITEMS_IN_PROMPT = 10; /** * The Copilot and the automation work through the Recomaze account, which a * role given the SEO pages alone does not get. */ const WITH_ACCOUNT = !recomaze_ai_personalization_env?.seo_only; /** When a stored GMT time happened, in the reader's words. */ const when = (mysql: string): string => { const date = new Date(`${mysql.replace(' ', 'T')}Z`); return Number.isNaN(date.getTime()) ? mysql : date.toLocaleString(); }; const scoreTone = (score: number): string => score >= 80 ? 'text-emerald-600 dark:text-emerald-400' : score >= 50 ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400'; /** The Copilot's instruction for a check: what to do, then the items it found. */ const copilotPrompt = (check: ISeoCheck): string => { const lines = check.items .slice(0, ITEMS_IN_PROMPT) .map(item => `- ${item.title}${item.url ? ` (${item.url})` : ''}`); const more = check.count > ITEMS_IN_PROMPT ? `\n…and ${check.count - ITEMS_IN_PROMPT} more; ask the checkup for the full list.` : ''; return `${check.fix?.prompt ?? `Fix this SEO checkup item: ${check.title}.`}\n${lines.join('\n')}${more}`; }; const CheckCard = ({ check, busy, onFix, onCopilot, }: { check: ISeoCheck; busy: boolean; onFix: (check: ISeoCheck) => void; onCopilot: (check: ISeoCheck) => void; }): JSX.Element => { const navigate = useNavigate(); const [open, setOpen] = useState(false); const severity = SEVERITY[check.severity]; const Icon = check.passed ? CircleCheckIcon : severity.icon; const fix = check.fix; return (

{check.title}

{checkupGroup(check.group)?.label ?? check.group} {!check.passed && ( {check.count.toLocaleString()}{' '} {check.count === 1 ? 'item' : 'items'} )} {check.passed && Passed}

{check.description}

{!check.passed && fix && (
{(fix.type === 'local' || fix.type === 'score') && ( )} {(fix.type === 'copilot' || fix.prompt) && WITH_ACCOUNT && ( )} {fix.type === 'link' && fix.to && ( )} {fix.type === 'external' && fix.to && ( )}
)}
{!check.passed && check.items.length > 0 && (
{open && ( )}
)}
); }; const PART_TONE: Record = { same: 'text-muted-foreground', del: 'rounded-sm bg-red-500/10 text-red-700 line-through decoration-red-400', add: 'rounded-sm bg-emerald-500/10 text-emerald-700', }; /** * The part of a long text a change touched: on the before side what it * removed, on the after side what it wrote, or both where one column shows. */ const ChangeText = ({ parts, side, }: { parts: ChangePart[]; side: 'before' | 'after' | 'both'; }): JSX.Element => ( {parts .filter(part => side === 'before' ? part.type !== 'add' : side === 'after' ? part.type !== 'del' : true ) .map((part, index, shown) => ( {part.type === 'add' && shown[index - 1]?.type === 'del' ? ' ' : ''} {part.text} ))} ); const ChangesPanel = ({ onReverted, }: { onReverted: () => void; }): JSX.Element => { const [data, setData] = useState(null); const [error, setError] = useState(null); const [showReverted, setShowReverted] = useState(false); const [busy, setBusy] = useState(false); const [confirm, setConfirm] = useState< { kind: 'all' } | { kind: 'run'; runId: string } | null >(null); const load = useCallback(async (): Promise => { try { setData( await fetchSeoChanges({ per_page: 50, include_reverted: showReverted }) ); setError(null); } catch (reason) { setError((reason as Error).message); } }, [showReverted]); useEffect(() => { void load(); }, [load]); const revert = async (target: { ids?: number[]; run_id?: string; all?: boolean; }): Promise => { setBusy(true); try { const result = await revertSeoChanges(target); toast.success( revertMessage(result.reverted, result.skipped, result.gone) ); await load(); onReverted(); } catch (reason) { toast.error((reason as Error).message); } finally { setBusy(false); setConfirm(null); } }; const open = data?.runs.reduce( (sum, run) => sum + (run.changes - run.reverted - run.gone), 0 ); return (

{data ? open === 0 ? 'Nothing the automation wrote is left standing; every change was either never made or put back.' : `${open} ${open === 1 ? 'change written by the automation still stands' : 'changes written by the automation still stand'}. Put any of them back here.` : 'Loading…'}

{error && {error}} {data && data.runs.length > 0 && (
Run When Changes Put back {data.runs.map(run => ( {sourceLabel(run.source)} {run.changes} {run.changes === 1 ? 'change' : 'changes'} {when(run.ended_at)} {run.changes} {run.reverted} ))}
)} {data && data.items.length === 0 ? (
{showReverted ? 'The journal is empty.' : 'No change is waiting to be put back.'}
) : ( data && (
When By What Field Before After {data.items.map((change: ISeoChange) => { const excerpt = changeExcerpt(change.before, change.after); return ( {when(change.created_at)} {sourceLabel(change.source)} {change.edit_link ? ( {change.object || `${change.object_type} #${change.object_id}`} ) : ( change.object || `${change.object_type} #${change.object_id}` )} {fieldLabel(change.object_type, change.field)} {excerpt ? ( <> ) : ( <> {valueLabel(change.before)} {valueLabel(change.after)} )} {change.reverted_at ? ( Put back {when(change.reverted_at)} ) : change.gone ? ( Deleted since ) : ( )} ); })}
) )} { if (!next) setConfirm(null); }} title={ confirm?.kind === 'run' ? 'Undo this run?' : 'Undo everything the automation wrote?' } description={ confirm?.kind === 'run' ? 'Every SEO field, image text, redirect and setting this run changed goes back to what it was before. A field someone edited since keeps that edit. The journal keeps the rows, marked as put back.' : 'Every change the automation wrote and that still stands goes back to what it was before: SEO fields, image texts, redirects it created, settings the checkup switched. A field someone edited after the automation keeps that edit.' } confirmLabel={confirm?.kind === 'run' ? 'Undo the run' : 'Undo all'} tone="destructive" busy={busy} onConfirm={() => confirm?.kind === 'run' ? revert({ run_id: confirm.runId }) : revert({ all: true }) } />
); }; /** * The site-wide checkup: what the plugin found across the site, grouped by * how much it matters, with the way to fix each item; and the journal of * what the automation wrote, with the way back. */ /** The local fixes that ask before they run, and what they say. */ const FIXES_TO_CONFIRM: Record< string, { title: string; description: string; confirm: string } > = { blog_public: { title: 'Let search engines index the whole site?', description: 'This unticks "Discourage search engines" in Settings → Reading, so Google and the others may crawl and index every page. Do it on the live site only: a staging or test copy indexed next to it competes with it as duplicate content. The Changes tab can put it back.', confirm: 'Yes, this is the live site', }, }; const SeoCheckup = (): JSX.Element => { const [params, setParams] = useSearchParams(); const navigatePage = useNavigate(); const tabParam = params.get('tab'); const tab = tabParam === 'changes' || (tabParam === 'automation' && WITH_ACCOUNT) ? tabParam : 'checks'; const group = params.get('group') ?? ''; const [checkup, setCheckup] = useState(null); const [error, setError] = useState(null); const [running, setRunning] = useState(false); const [fixing, setFixing] = useState(null); const [asking, setAsking] = useState(null); const [scoring, setScoring] = useState(false); const [showPassed, setShowPassed] = useState(false); const copilot = useCopilot(); const badges = useSeoBadges(); const load = useCallback( async (refresh: boolean): Promise => { setRunning(true); try { setCheckup(await fetchSeoCheckup(refresh)); setError(null); if (refresh) badges.refresh(); } catch (reason) { setError((reason as Error).message); } finally { setRunning(false); } }, [badges] ); const loadRef = useRef(load); loadRef.current = load; useEffect(() => { void loadRef.current(false); }, []); // A notice after a Bulk Edit links here with ?score=1: open the scoring // of the pages whose score went out of date, once. useEffect(() => { if (params.get('score') === '1') { setScoring(true); params.delete('score'); setParams(params, { replace: true }); } }, [params, setParams]); const fix = async (check: ISeoCheck, confirmed = false): Promise => { // Scoring runs the editor's analysis here, page by page. if (check.fix?.type === 'score') { setScoring(true); return; } // A fix that opens the whole site to search engines is asked about // first: on a copy of the site it would get the copy indexed. if (!confirmed && FIXES_TO_CONFIRM[check.id]) { setAsking(check); return; } setFixing(check.id); try { const result = await fixSeoCheck(check.id); (result.fixed ? toast.success : toast.info)(result.message); setCheckup(result.checkup); badges.refresh(); } catch (reason) { toast.error((reason as Error).message); } finally { setFixing(null); } }; const setGroup = (next: string): void => { const query: Record = {}; if (tab === 'changes') query.tab = 'changes'; if (next) query.group = next; setParams(query, { replace: true }); }; const askCopilot = (check: ISeoCheck): void => { copilot.requestCopilotTurn(copilotPrompt(check)); toast.info('Handed to the Copilot; watch the panel on the right.'); }; const inGroup = (check: ISeoCheck): boolean => group === '' || check.group === group; const failed = (checkup?.checks ?? []).filter( check => !check.passed && inGroup(check) ); const passed = (checkup?.checks ?? []).filter( check => check.passed && inGroup(check) ); const bySeverity = (severity: SeoCheckSeverity): ISeoCheck[] => failed.filter(check => check.severity === severity); return ( } /> { const query: Record = {}; if (next === 'changes' || next === 'automation') query.tab = String(next); if (group) query.group = group; setParams(query, { replace: true }); }} > Checks {WITH_ACCOUNT && ( Automation )} Changes {error && {error}} {(checkup?.rivals ?? []).length > 0 && ( {checkup?.rivals?.join(', ')} prints this site's titles, descriptions, schema, sitemap and robots.txt. The checks read Recomaze's own fields, which stay empty until its data is imported: Import only copies it and keeps{' '} {checkup?.rivals?.join(', ')} in charge,{' '} Import & switch hands the site to Recomaze.{' '} )} {tab === 'automation' ? ( { void load(true); }} /> ) : tab === 'changes' ? ( void load(true)} /> ) : ( <> {checkup && (
Checkup score
{checkup.score} /100

Last run {new Date(checkup.generated_at).toLocaleString()} . Refreshed daily; the Recomaze agent reads it on every run.

{(['error', 'warning', 'info'] as SeoCheckSeverity[]).map( severity => { const Icon = SEVERITY[severity].icon; return (
{SEVERITY[severity].label}
{checkup.counts[severity]}

{SEVERITY_MEANING[severity]}

); } )}
)} {checkup && group && (

Showing the {checkupGroup(group)?.label ?? group} checks.{' '}

)} {checkup && failed.length === 0 && ( {group ? `Every ${checkupGroup(group)?.label ?? group} check passes.` : 'Every check passes. The checkup runs again daily and after anything the agent writes.'} )} {(['error', 'warning', 'info'] as SeoCheckSeverity[]).map(severity => bySeverity(severity).length === 0 ? null : (

{SEVERITY[severity].label}{' '} {bySeverity(severity).length}

{bySeverity(severity).map(check => ( ))}
) )} {passed.length > 0 && (
{showPassed && passed.map(check => ( ))}
)} {!checkup && !error && (

Running the checks…

)} )} setScoring(false)} onDone={() => void load(true)} /> { if (!next) setAsking(null); }} title={asking ? (FIXES_TO_CONFIRM[asking.id]?.title ?? '') : ''} description={ asking ? (FIXES_TO_CONFIRM[asking.id]?.description ?? '') : '' } confirmLabel={ asking ? (FIXES_TO_CONFIRM[asking.id]?.confirm ?? 'Go on') : 'Go on' } onConfirm={() => { const check = asking; setAsking(null); if (check) void fix(check, true); }} />
); }; export default SeoCheckup;