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 ( { if (!busy) onOpenChange(next); }} > Fix title & description {stage === 'ask' && 'Recomaze checks the product and writes it again the way the Catalog Quality page does: a title with the brand, model and type, a description grounded in the product data, plus a short description, tags and SEO fields. Nothing is saved until you apply it.'} {stage === 'loading' && 'Checking the product and writing the new copy. This usually takes under a minute.'} {stage === 'preview' && 'What would change. Untick a field to keep what the product has now.'} {stage === 'applying' && 'Writing the product…'} {stage === 'applied' && 'Saved on the product and journaled under SEO → Checkup → Changes, where it can be put back later too.'} {stage === 'undoing' && 'Putting the product back…'} {stage === 'undone' && 'The product is back to what it was.'} {error && } {stage === 'ask' && !ai.allowed && } {stage === 'ask' && (
{( [ { key: 'title', label: 'Title', help: 'Brand, model and product type first, the attributes after; no marketing words.', }, { key: 'description', label: 'Description', help: 'Rewritten from the current text and the product data. Brings the short description, tags, SEO title, meta description and a FAQ with it.', }, ] as { key: Topic; label: string; help: string }[] ).map(topic => ( ))}
)} {repeat.asking && ( {again} )} {stage === 'loading' && (
Working on the product…
)} {preview && (stage === 'preview' || stage === 'applying') && (
{preview.topics.includes('title') && (

Title now:{' '} {preview.check.title_score}/100

{preview.check.title_issues.length > 0 ? (
    {preview.check.title_issues.map(issue => (
  • {issue}
  • ))}
) : (

Nothing wrong found.

)}
)} {preview.topics.includes('description') && (

Description now:{' '} {preview.check.description_score}/100 {preview.check.product_type && ( read as a {preview.check.product_type} )}

{preview.check.description_issues.length > 0 ? (
    {preview.check.description_issues.map(issue => (
  • {issue}
  • ))}
) : (

Nothing wrong found.

)}
)}
{rows.length === 0 && ( Recomaze would keep the product as it is: nothing differs from what it has now. )} {rows.map(row => ( setKept(current => ({ ...current, [row.key]: checked })) } > {row.key === 'description' && preview.proposal.faq.length > 0 && ( )} ))}
)} {applied && (stage === 'applied' || stage === 'undoing') && (
    {(applied.changed ?? []).map(name => (
  • {ROWS.find(row => row.key === name)?.label ?? name}
  • ))} {(applied.changed ?? []).length === 0 && (
  • Nothing was different from what the product already had.
  • )}
)} {stage === 'ask' && (topics.length > 0 ? `${cost} credits, charged when the copy is written.${balanceNote(ai.balance, cost)}` : 'Tick at least one.')} {(stage === 'preview' || stage === 'applying') && preview && `${preview.credits} credits used. ${keptCount} of ${rows.length} fields ticked.`}
{stage === 'ask' && ( <> )} {(stage === 'preview' || stage === 'applying') && ( <> )} {(stage === 'applied' || stage === 'undoing') && ( <> {applied?.run_id && (applied.changed ?? []).length > 0 && ( )} )} {stage === 'undone' && ( )}
); }; /** The button in the score card that opens the fix. */ export const ProductFixButton = ({ onClick, }: { onClick: () => void; }): JSX.Element => ( );