import { useEffect, useMemo, useState } from 'react';
import apiFetch from '@wordpress/api-fetch';
import {
CheckIcon,
Loader2Icon,
SparklesIcon,
Undo2Icon,
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 { htmlToText } from '../lib/diff';
import {
Diff,
errorOf,
FixErrorCallout,
NoAiAccess,
useRepeatGuard,
type FixError,
} from './fix';
/** The fields the fix can replace, as the product has them and as proposed. */
export interface ProductFixValues {
title: string;
description: string;
short_description: string;
tags: string[];
seo_title: string;
seo_description: string;
}
export interface ProductFixPreview {
post_id: number;
topics: string[];
credits: number;
check: {
product_type: string;
title_score: number;
title_issues: string[];
description_score: number;
description_issues: string[];
description_suggestions: string[];
};
current: ProductFixValues;
proposal: ProductFixValues & { faq: { q: string; a: string }[] };
}
export interface ProductFixTag {
id: number;
name: string;
}
/** What apply and undo answer: the run, and the product as it stands now. */
export interface ProductFixResult {
run_id?: string;
changed?: string[];
reverted?: number;
tags: ProductFixTag[];
current: ProductFixValues;
}
type FieldKey = keyof ProductFixValues;
type Topic = 'title' | 'description';
interface FieldRow {
key: FieldKey;
label: string;
kind: 'text' | 'html' | 'tags';
}
const ROWS: FieldRow[] = [
{ key: 'title', label: 'Product title', kind: 'text' },
{ key: 'description', label: 'Description', kind: 'html' },
{ key: 'short_description', label: 'Short description', kind: 'html' },
{ key: 'tags', label: 'Tags', kind: 'tags' },
{ key: 'seo_title', label: 'SEO title', kind: 'text' },
{ key: 'seo_description', label: 'Meta description', kind: 'text' },
];
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 [&_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';
const escapeHtml = (value: string): string =>
value.replace(/&/g, '&').replace(//g, '>');
/** The questions and answers as the description carries them on the store. */
const faqHtml = (faq: { q: string; a: string }[]): string =>
`
FAQ
${faq
.map(
pair =>
`
${escapeHtml(pair.q)}
${escapeHtml(pair.a)}
`
)
.join('')}
`;
const scoreClass = (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';
/** One field of the proposal: kept or not, what changes, and the new text. */
const Row = ({
row,
current,
proposed,
checked,
onChange,
children,
}: {
row: FieldRow;
current: string;
proposed: 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 (
{row.kind === 'html' && (
{(['changes', 'rendered'] as const).map(option => (
))}
)}
{row.kind === 'html' && view === 'rendered' ? (
) : (
)}
{children}
);
};
/**
* The catalog fix of the product being edited: the agent rewrites the
* title and the description the way the Catalog Quality page does in
* bulk, the dialog shows what would change, and only the ticked fields
* are written, in one journaled run that Undo gives back.
*/
export const ProductFixDialog = ({
postId,
draft,
creditsPerTopic,
pricingUrl,
ai,
open,
onOpenChange,
onApplied,
onUndone,
}: {
postId: number;
/** The product as typed in the editor, saved or not: the fix starts from it. */
draft?: { title: string; content: string; excerpt: string; keyword: string };
creditsPerTopic: number;
pricingUrl: string;
ai: AiGate;
open: boolean;
onOpenChange: (open: boolean) => void;
/** The product as it stands after the write, and which fields were written. */
onApplied: (result: ProductFixResult, fields: FieldKey[]) => void;
onUndone: (result: ProductFixResult) => void;
}): JSX.Element => {
const [topics, setTopics] = useState(['title', 'description']);
const [stage, setStage] = useState<
| 'ask'
| 'loading'
| 'preview'
| 'applying'
| 'applied'
| 'undoing'
| 'undone'
>('ask');
const [error, setError] = useState(null);
const [preview, setPreview] = useState(null);
const [kept, setKept] = useState>({});
// The rewritten description carries no questions of its own, and the
// panel counts a product without them as unfinished.
const [withFaq, setWithFaq] = useState(true);
const [applied, setApplied] = useState(null);
useEffect(() => {
if (open) {
setStage('ask');
setError(null);
setPreview(null);
setApplied(null);
setWithFaq(true);
}
}, [open]);
const cost = creditsPerTopic * topics.length;
const again =
open && stage === 'ask' && topics.length > 0
? repeatQuestion('catalog', ai.ranToday, cost, 'product')
: null;
const repeat = useRepeatGuard(again);
const rows = useMemo(() => {
if (!preview) return [];
return ROWS.filter(row => {
const proposed = preview.proposal[row.key];
const current = preview.current[row.key];
if (row.kind === 'tags') {
const next = proposed as string[];
return (
next.length > 0 && next.join('|') !== (current as string[]).join('|')
);
}
return (
String(proposed).trim() !== '' &&
String(proposed).trim() !== String(current).trim()
);
});
}, [preview]);
const generate = async (): Promise => {
setStage('loading');
setError(null);
try {
const answer = await apiFetch({
path: '/recomaze/v1/seo/product-fix/preview',
method: 'POST',
data: { post_id: postId, topics, draft },
});
setPreview(answer);
ai.onRan('catalog');
setKept(Object.fromEntries(ROWS.map(row => [row.key, true])));
setStage('preview');
} catch (failure) {
setError(errorOf(failure));
setStage('ask');
}
};
const apply = async (): Promise => {
if (!preview) return;
const fields: Partial = {};
for (const row of rows) {
if (!kept[row.key]) continue;
if (row.key === 'description') {
fields.description =
withFaq && preview.proposal.faq.length > 0
? `${preview.proposal.description}\n${faqHtml(preview.proposal.faq)}`
: preview.proposal.description;
} else if (row.key === 'tags') {
fields.tags = preview.proposal.tags;
} else {
fields[row.key] = preview.proposal[row.key];
}
}
if (Object.keys(fields).length === 0) return;
setStage('applying');
setError(null);
try {
const answer = await apiFetch({
path: '/recomaze/v1/seo/product-fix/apply',
method: 'POST',
data: { post_id: postId, fields },
});
setApplied(answer);
onApplied(answer, Object.keys(fields) as FieldKey[]);
setStage('applied');
} catch (failure) {
setError(errorOf(failure));
setStage('preview');
}
};
const undo = async (): Promise => {
if (!applied?.run_id) return;
setStage('undoing');
setError(null);
try {
const answer = await apiFetch({
path: '/recomaze/v1/seo/product-fix/undo',
method: 'POST',
data: { post_id: postId, run_id: applied.run_id },
});
onUndone(answer);
setStage('undone');
} catch (failure) {
setError(errorOf(failure));
setStage('applied');
}
};
const keptCount = rows.filter(row => kept[row.key]).length;
const busy =
stage === 'loading' || stage === 'applying' || stage === 'undoing';
return (
);
};
/** The button in the score card that opens the fix. */
export const ProductFixButton = ({
onClick,
}: {
onClick: () => void;
}): JSX.Element => (
);