);
};
/**
* "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 (
);
};