import { useEffect, useMemo, useRef, useState } from 'react'; import apiFetch from '@wordpress/api-fetch'; import { CheckIcon, ExternalLinkIcon, Loader2Icon, SparklesIcon, XIcon, } from 'lucide-react'; import { Button } from '../../components/ui/button'; import { Callout } from '../../components/ui/callout'; import { Checkbox } from '../../components/ui/checkbox'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '../../components/ui/dialog'; import { classNames } from '../../lib/utils'; import type { AiGate } from '../hooks/use-ai-gate'; import { balanceNote, repeatQuestion } from '../lib/ai-runs'; import { bandLabel, measure, outsideBand, type Band } from '../lib/bands'; import { htmlToText } from '../lib/diff'; import { waitForJob } from '../lib/fix-job'; import { Diff, errorOf, FixErrorCallout, NoAiAccess, useRepeatGuard, type FixError, } from './fix'; /** The page as the editor holds it, unsaved edits included, sent for the fix. */ export interface PostFixPage { title: string; content: string; excerpt: string; keyword: string; slug: string; /** The saved status; a published post keeps its address unless the old one will redirect. */ status: string; /** Whether a slug change on a published post redirects the old address. */ autoRedirects: boolean; /** Another post already targets the keyword, so the fix picks a new one. */ keywordUsedElsewhere: boolean; /** The failing tests of the content analysis, as the panel words them. */ tests: string[]; /** The same tests by id, which is what the agent answers one by one. */ testIds: string[]; } /** The values the fix writes, as the page has them now and as proposed. */ export interface PostFixValues { title: string; description: string; keyword: string; slug: string; /** The content as HTML. */ content: string; } export interface PostFixPreview { post_id: number; run_id: string; credits: number; proposal: PostFixValues; word_count: number; /** What changed in the content, one line each. */ content_changes: string[]; /** What the content still needs; nothing is written for these. */ notes: string[]; } type FieldKey = keyof PostFixValues; interface FieldRow { key: FieldKey; label: string; /** The length the agent aims for; the content has none. */ band?: Band; kind: 'text' | 'html'; } const ROWS: FieldRow[] = [ { key: 'title', label: 'SEO title', band: { min: 50, max: 60, unit: 'characters' }, kind: 'text', }, { key: 'description', label: 'Meta description', band: { min: 120, max: 155, unit: 'characters' }, kind: 'text', }, { key: 'keyword', label: 'Focus keyword', band: { min: 2, max: 4, unit: 'words' }, kind: 'text', }, { key: 'slug', label: 'URL slug', band: { max: 75, unit: 'characters' }, kind: 'text', }, { key: 'content', label: 'Content', kind: 'html' }, ]; const SHOWN_TESTS = 6; const RENDERED_CLASS = 'text-sm leading-relaxed text-foreground [&_h2]:mt-3 [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:text-sm [&_h3]:font-semibold [&_p]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5 [&_strong]:font-semibold [&_a]:underline [&_table]:my-2 [&_table]:w-full [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_img]:my-2 [&_img]:max-w-full'; /** One field of the proposal: kept or not, what changes, and the new text. */ const Row = ({ row, current, proposed, measure, hint, checked, onChange, children, }: { row: FieldRow; current: string; proposed: string; /** What the counter says of the proposal: "58 characters", "640 words". */ measure: string; /** A line under the field, when something is worth knowing before ticking it. */ hint?: string; checked: boolean; onChange: (checked: boolean) => void; children?: React.ReactNode; }): JSX.Element => { const [view, setView] = useState<'changes' | 'rendered'>('changes'); const beforeText = row.kind === 'html' ? htmlToText(current) : current; const afterText = row.kind === 'html' ? htmlToText(proposed) : proposed; return (
{measure} {row.band ? ` · ${bandLabel(row.band)}` : ''} {row.kind === 'html' && (
{(['changes', 'rendered'] as const).map(option => ( ))}
)}
{row.kind === 'html' && view === 'rendered' ? (
) : (
)}
{hint &&

{hint}

} {children}
); }; /** * "Fix with AI" on a post: the agent writes the snippet (the SEO title, the * meta description, the focus keyword, the slug), the content itself, or * both, as ticked, for the page as it stands in the editor; the dialog * shows each word by word against what the page has now, and Apply puts * the ticked ones into the panel's fields and the editor, to be saved with * the post. What no rewrite can do (an image, say) comes back as notes, * with the Copilot a link away. It runs as a job the dialog asks about * (fix-job), so no request waits minutes on the agent. */ export const PostFixDialog = ({ postId, typeLabel, credits, contentCredits, pricingUrl, copilotUrl, page, current, ai, open, withContent = false, withSnippet = true, onOpenChange, onApply, }: { postId: number; typeLabel: string; /** What the snippet costs, and what the content rewrite adds. */ credits: number; contentCredits: number; pricingUrl: string; copilotUrl?: string; page: PostFixPage; /** What the page shows now: the rendered title and description, the first keyword, the slug, the content. */ current: PostFixValues; ai: AiGate; open: boolean; /** Whether the content rewrite comes ticked: opened from a content test. */ withContent?: boolean; /** Whether the snippet comes ticked: unticked when only the content fails. */ withSnippet?: boolean; onOpenChange: (open: boolean) => void; /** The ticked values, to go into the panel's fields and the editor. */ onApply: (values: Partial) => void; }): JSX.Element => { const [stage, setStage] = useState<'ask' | 'loading' | 'preview'>('ask'); // The snippet alone by default: the content rewrite costs five times as // much and replaces the blocks, so it is a choice made on purpose. const [snippet, setSnippet] = useState(true); const [rewrite, setRewrite] = useState(false); const [error, setError] = useState(null); const [preview, setPreview] = useState(null); const [kept, setKept] = useState>({}); // Whether anyone still waits for the job's answer. const waiting = useRef(open); waiting.current = open; useEffect( () => () => { waiting.current = false; }, [] ); useEffect(() => { if (open) { setStage('ask'); setError(null); setPreview(null); setRewrite(withContent); setSnippet(withSnippet); } }, [open, withContent, withSnippet]); const rows = useMemo(() => { if (!preview) return []; return ROWS.filter(row => { const proposed = preview.proposal[row.key].trim(); return proposed !== '' && proposed !== current[row.key].trim(); }); }, [preview, current]); const generate = async (): Promise => { setStage('loading'); setError(null); try { const { job } = await apiFetch<{ job: string }>({ path: '/recomaze/v1/seo/post-fix/start', method: 'POST', data: { post_id: postId, title: page.title, content: page.content, excerpt: page.excerpt, keyword: page.keyword, slug: page.slug, tests: page.tests, test_ids: page.testIds, snippet, rewrite, keyword_used_elsewhere: page.keywordUsedElsewhere, }, }); const answer = await waitForJob(job, { cancelled: () => !waiting.current, }); setPreview(answer); ai.onRan(rewrite ? 'rewrite' : 'fix'); // A published post keeps its address unless the old one will redirect. setKept( Object.fromEntries( ROWS.map(row => [ row.key, row.key !== 'slug' || page.status !== 'publish' || page.autoRedirects, ]) ) ); setStage('preview'); } catch (failure) { // Closed while it ran: nobody is there to read why. if ((failure as { code?: string }).code === 'cancelled') return; setError(errorOf(failure)); setStage('ask'); } }; const apply = (): void => { if (!preview) return; const values: Partial = {}; for (const row of rows) { if (kept[row.key]) values[row.key] = preview.proposal[row.key].trim(); } if (Object.keys(values).length === 0) return; onApply(values); onOpenChange(false); }; const keptCount = rows.filter(row => kept[row.key]).length; const busy = stage === 'loading'; const shownTests = page.tests.slice(0, SHOWN_TESTS); const kind = typeLabel.toLowerCase(); const cost = (snippet ? credits : 0) + (rewrite ? contentCredits : 0); const asked = snippet || rewrite; const again = open && stage === 'ask' ? repeatQuestion( rewrite ? 'rewrite' : 'fix', ai.ranToday, cost, typeLabel ) : null; const repeat = useRepeatGuard(again); const measureOf = (row: FieldRow, proposed: string): string => row.band ? measure(row.band, proposed) : `${preview?.word_count || htmlToText(proposed).split(/\s+/).filter(Boolean).length} words`; return ( { if (!busy) onOpenChange(next); }} > Fix with AI {stage === 'ask' && `Recomaze reads the ${kind} as it is now, unsaved changes included, and writes what the failing tests ask for. Nothing is saved until you apply it.`} {stage === 'loading' && (rewrite ? `Reading the ${kind}, rewriting the content${snippet ? ' and writing the snippet' : ''}. This usually takes a minute or two.` : `Reading the ${kind} and writing the snippet. This usually takes a few seconds.`)} {stage === 'preview' && 'What would change. Untick a field to keep what you have now.'} {error && } {stage === 'ask' && !ai.allowed && } {stage === 'ask' && (

{page.tests.length} failing test {page.tests.length === 1 ? '' : 's'} go with the {kind}

    {shownTests.map(test => (
  • {test}
  • ))} {page.tests.length > shownTests.length && (
  • and {page.tests.length - shownTests.length} more
  • )}
)} {repeat.asking && ( {again} )} {stage === 'loading' && (
{' '} {rewrite ? 'Rewriting the content…' : 'Writing the snippet…'}
)} {preview && stage === 'preview' && (
{rows.length === 0 && ( Recomaze would keep the {kind} as it is: nothing differs from what you have now. )} {rows.map(row => ( setKept(state => ({ ...state, [row.key]: checked })) } > {row.key === 'content' && preview.content_changes.length > 0 && (
    {preview.content_changes.map(change => (
  • {change}
  • ))}
)}
))} {preview.notes.length > 0 && (

Still to do by hand

    {preview.notes.map(note => (
  • {note}
  • ))}

Nothing is written for these here. {copilotUrl && ( <> {' '} Open in Copilot {' '} to work through them in the chat. )}

)}
)} {stage === 'ask' && (asked ? `${cost} credits, charged when the answer is written.${balanceNote(ai.balance, cost)}` : 'Tick the snippet, the content or both.')} {stage === 'preview' && preview && `${preview.credits} credits used. ${keptCount} of ${rows.length} field${rows.length === 1 ? '' : 's'} ticked.`}
{stage === 'ask' && ( <> )} {stage === 'preview' && ( <> )}
); };