import React, { useCallback, useState } from 'react'; import type { IconType } from 'react-icons'; import { LuAlertCircle, LuBot, LuCheckCircle, LuFileText, LuHelpCircle, LuLightbulb, LuListChecks, LuMessageSquare, LuPackage, LuSend, LuType, LuWand2, LuXCircle, LuZap, } from 'react-icons/lu'; import CheckerFixCard from '../components/content-generator/CheckerFixCard'; import SearchableSelect from '../components/widgets/SearchableSelect'; import { Spinner } from '../components/ui/Spinner'; import { LANGUAGE_OPTIONS } from '../data/locales'; import { evaluateBlogContent, evaluateProductContent, evaluateQueryLab, fixProductContent, } from '../service/content-generator/content-generator.service'; import type { BaseAgentResponse, BlogEvaluation, ContentCheckType, ContentCheckerEvaluateResponse, ContentCheckerFixResult, PreAnsweredQuestion, QueryLabResult, TitleDescriptionEvaluation, } from '../service/content-generator/content-generator.interface'; /** Check mode: the three product modes plus the standalone blog mode. */ type CheckType = ContentCheckType | 'blog'; /** Dropdown labels for every check mode. */ const CHECK_TYPE_LABELS: Record = { both: 'Both', title: 'Title', description: 'Description', blog: 'Blog', }; /** Credits billed per fixed topic - title or description - so fixing both * costs double (agent: CREDITS_INLINE_SINGLE_CALL per topic). */ const FIX_CREDITS_PER_TOPIC = 10; /** Fix is offered under this score even when the issue lists come back empty * (mirrors the catalog audit's "ok" band: avg >= 80). */ const FIX_SCORE_THRESHOLD = 80; /** Query Lab questions allowed per evaluation. */ const QUERY_LAB_LIMIT = 5; const TITLE_MAX_LENGTH = 500; const DESCRIPTION_MAX_LENGTH = 5000; const BLOG_MAX_LENGTH = 50000; const KEYWORDS_MAX_LENGTH = 500; const SCORE_EXCELLENT = 90; const SCORE_GOOD = 70; const SCORE_ACCEPTABLE = 50; const SCORE_POOR = 30; const CONFIDENCE_HIGH = 80; const CONFIDENCE_MEDIUM = 50; /** Tailwind classes describing one score band. */ interface ScoreGrade { label: string; color: string; border: string; bg: string; } /** * Map a 0-100 score to its band label and colours. * * @param {number} score - The score to grade. * @return {ScoreGrade} Label plus text / border / background classes. */ const scoreGrade = (score: number): ScoreGrade => { if (score >= SCORE_EXCELLENT) return { label: 'Excellent', color: 'text-emerald-600', border: 'border-emerald-200', bg: 'bg-emerald-50', }; if (score >= SCORE_GOOD) return { label: 'Good', color: 'text-blue-600', border: 'border-blue-200', bg: 'bg-blue-50', }; if (score >= SCORE_ACCEPTABLE) return { label: 'Acceptable', color: 'text-amber-600', border: 'border-amber-200', bg: 'bg-amber-50', }; if (score >= SCORE_POOR) return { label: 'Poor', color: 'text-orange-600', border: 'border-orange-200', bg: 'bg-orange-50', }; return { label: 'Bad', color: 'text-red-600', border: 'border-red-200', bg: 'bg-red-50', }; }; /** * Map a Query Lab confidence score to its badge colours. * * @param {number} score - Confidence percentage. * @return {{ bg: string; border: string; text: string }} Badge classes. */ const confidenceColor = ( score: number ): { bg: string; border: string; text: string } => { if (score >= CONFIDENCE_HIGH) return { bg: 'bg-emerald-50', border: 'border-emerald-200', text: 'text-emerald-700', }; if (score >= CONFIDENCE_MEDIUM) return { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-700', }; return { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700' }; }; /** * Read a checker envelope, collapsing every failure shape into one message. * * @param {BaseAgentResponse} response - Agent envelope (or service error). * @param {string} fallbackMessage - Message shown when the call did not succeed. * @return {{ data?: T; error?: string }} Payload on success, message on failure. */ const readEnvelope = ( response: BaseAgentResponse, fallbackMessage: string ): { data?: T; error?: string } => { if (response && !response.errors && response.data) { return { data: response.data }; } return { error: fallbackMessage }; }; /** Props for {@link CheckItem}. */ interface CheckItemProps { checked: boolean; label: string; } /** * One checklist row: a tick or a cross plus its label. * * @param {CheckItemProps} props - Checked flag and label. * @return {JSX.Element} The checklist row. */ const CheckItem = ({ checked, label }: CheckItemProps): JSX.Element => (
{checked ? ( ) : ( )} {label}
); /** Props for {@link ScoreCard}. */ interface ScoreCardProps { label: string; score: number; Icon: IconType; } /** * A single graded score tile. * * @param {ScoreCardProps} props - Label, score and leading icon. * @return {JSX.Element} The score tile. */ const ScoreCard = ({ label, score, Icon }: ScoreCardProps): JSX.Element => { const grade = scoreGrade(score); return (

{label}

{score}

{grade.label}
); }; const inputClass = 'block w-full rounded-md border-gray-300 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-gray-900 focus:outline-none focus:ring-1 focus:ring-gray-900'; const errorInputClass = 'border-red-500 focus:border-red-500 focus:ring-red-500'; const sectionClass = 'rounded-xl border border-gray-200 bg-white p-5'; const primaryButtonClass = 'inline-flex items-center justify-center gap-2 rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50'; /** * Content Checker page. Scores pasted product copy or a blog post for AI * search readiness, offers a one-click "Fix with AI" pass through the Product * Content pipeline, and lets the merchant interrogate a description in the * Query Lab. * * Client-only: every call goes straight to the agent backend with the * merchant's recomaze JWT, so there is no loader / action round trip. * * @return {JSX.Element} The Content Checker page. */ const ContentChecker = (): JSX.Element => { const [checkType, setCheckType] = useState('both'); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [blogContent, setBlogContent] = useState(''); const [targetKeywords, setTargetKeywords] = useState(''); const [language, setLanguage] = useState('en'); const [fieldErrors, setFieldErrors] = useState<{ title?: string; description?: string; blogContent?: string; }>({}); const [result, setResult] = useState(null); const [preAnswered, setPreAnswered] = useState([]); const [blogResult, setBlogResult] = useState(null); const [apiError, setApiError] = useState(null); const [submitting, setSubmitting] = useState(false); const [fixResult, setFixResult] = useState( null ); const [fixError, setFixError] = useState(null); const [fixing, setFixing] = useState(false); const [queryQuestion, setQueryQuestion] = useState(''); const [queryHistory, setQueryHistory] = useState< Array<{ question: string; result: QueryLabResult }> >([]); const [queryRemaining, setQueryRemaining] = useState(QUERY_LAB_LIMIT); const [productType, setProductType] = useState(''); const [querying, setQuerying] = useState(false); const handleCheckTypeChange = (next: CheckType): void => { setCheckType(next); setFieldErrors({}); }; const onSubmit = useCallback(async (): Promise => { const errors: { title?: string; description?: string; blogContent?: string; } = {}; if (checkType === 'blog') { if (!blogContent.trim()) errors.blogContent = 'Blog content is required.'; } else { if ((checkType === 'title' || checkType === 'both') && !title.trim()) { errors.title = 'Title is required.'; } if ( (checkType === 'description' || checkType === 'both') && !description.trim() ) { errors.description = 'Description is required.'; } } setFieldErrors(errors); if (Object.keys(errors).length > 0) return; setApiError(null); setSubmitting(true); try { if (checkType === 'blog') { const response = await evaluateBlogContent({ blog_content: blogContent.trim(), target_keywords: targetKeywords.trim(), language, }); const { data, error } = readEnvelope( response, 'Blog evaluation failed.' ); if (error) { setApiError(error); } else if (data) { setBlogResult(data); } } else { const response = await evaluateProductContent({ check_type: checkType, title: title.trim(), description: description.trim(), language, }); const { data, error } = readEnvelope( response, 'Evaluation failed.' ); if (error) { setApiError(error); } else if (data) { setResult(data.result); setPreAnswered(data.pre_answered_questions ?? []); setProductType(data.result.detected_product_type); setQueryHistory([]); setQueryRemaining(QUERY_LAB_LIMIT); setFixResult(null); setFixError(null); } } } catch { setApiError( checkType === 'blog' ? 'Blog evaluation failed.' : 'Evaluation failed.' ); } finally { setSubmitting(false); } }, [checkType, title, description, blogContent, targetKeywords, language]); const handleQuerySubmit = useCallback(async (): Promise => { if (!queryQuestion.trim() || !description.trim() || queryRemaining <= 0) { return; } const askedQuestion = queryQuestion.trim(); setQuerying(true); try { const response = await evaluateQueryLab({ description: description.trim(), question: askedQuestion, product_type: productType, language, }); const { data, error } = readEnvelope(response, 'Query lab failed.'); if (error) { setApiError(error); } else if (data) { setQueryHistory(prev => [ ...prev, { question: askedQuestion, result: data }, ]); setQueryQuestion(''); setQueryRemaining(prev => Math.max(0, prev - 1)); } } catch { setApiError('Query lab failed.'); } finally { setQuerying(false); } }, [queryQuestion, description, queryRemaining, productType, language]); const handleFix = useCallback(async (): Promise => { if (!result) return; setFixError(null); setFixing(true); // Only the checked topics are sent (and billed): a leftover description // in form state must not turn a title-only fix into a 2-topic charge. const includeTitle = checkType === 'title' || checkType === 'both'; const includeDescription = checkType === 'description' || checkType === 'both'; try { const response = await fixProductContent({ title: includeTitle ? title.trim() : '', description: includeDescription ? description.trim() : '', language, detected_product_type: result.detected_product_type ?? '', title_issues: result.title_issues ?? [], description_issues: result.description_issues ?? [], }); const { data, error } = readEnvelope(response, 'Fix failed.'); if (error) { setFixError(error); } else if (data) { setFixResult(data); } } catch { setFixError('Fix failed.'); } finally { setFixing(false); } }, [result, checkType, title, description, language]); const fixTopicCount = checkType === 'both' ? 2 : 1; const fixCost = FIX_CREDITS_PER_TOPIC * fixTopicCount; const hasIssues = !!result && checkType !== 'blog' && (((checkType === 'title' || checkType === 'both') && result.title_issues.length > 0) || ((checkType === 'description' || checkType === 'both') && result.description_issues.length > 0)); const hasSuggestions = !!result && checkType !== 'blog' && (((checkType === 'title' || checkType === 'both') && result.title_suggestions.length > 0) || ((checkType === 'description' || checkType === 'both') && result.description_suggestions.length > 0)); // The evaluator sometimes returns a low score with empty issue lists, so // gating Fix on issues alone made it appear "only sometimes". Offer it // whenever anything scored under the ok band. const hasLowScore = !!result && checkType !== 'blog' && (((checkType === 'title' || checkType === 'both') && result.title_score < FIX_SCORE_THRESHOLD) || ((checkType === 'description' || checkType === 'both') && result.description_score < FIX_SCORE_THRESHOLD)); const canFix = hasIssues || hasLowScore; return (

Content Checker

Evaluate product titles, descriptions, and blog posts for AI search engine readiness.

{/* Input panel */}

Check type

{(checkType === 'title' || checkType === 'both') && (
setTitle(event.target.value)} placeholder="e.g. Samsung Galaxy S24 Ultra 256GB Black" maxLength={TITLE_MAX_LENGTH} className={`${inputClass} ${fieldErrors.title ? errorInputClass : ''}`} /> {fieldErrors.title && (

{fieldErrors.title}

)}
)} {(checkType === 'description' || checkType === 'both') && (