import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { HiSparkles as Sparkles } from 'react-icons/hi'; import { FaPlusCircle as PlusCircle, FaRegEnvelope as Mail, FaRegQuestionCircle as HelpCircle, } from 'react-icons/fa'; import { useAppStateContext } from '../context/user.data.context'; import { useCopilotDataChanged } from '../hooks/useCopilotDataChanged'; import { dispatchContentCheck, getContentCheckJob, getLatestContentCheck, listContentCheckProducts, rerunContentCheck, } from '../service/catalog-quality/catalog-quality.service'; import { getActiveContentGenJob } from '../service/content-generator/content-generator.service'; import BulkImproveModal from '../components/catalog-quality/BulkImproveModal'; import ProductActionModal from '../components/catalog-quality/ProductActionModal'; import type { ContentCheckSeverity, IContentCheckJob, IContentCheckProductRow, } from '../service/catalog-quality/catalog-quality.interface'; const SEVERITY_LABELS: Record = { critical: 'Critical', warning: 'Warnings', ok: 'OK', }; const SEVERITY_BADGE: Record< ContentCheckSeverity, { label: string; badge: string } > = { critical: { label: 'Critical', badge: 'bg-red-50 text-red-600' }, warning: { label: 'Warning', badge: 'bg-amber-50 text-amber-700' }, ok: { label: 'OK', badge: 'bg-emerald-50 text-emerald-700' }, }; const TAB_STYLES: Record< 'all' | ContentCheckSeverity, { active: string; inactive: string } > = { all: { active: 'bg-gray-100 text-gray-900', inactive: 'text-gray-500 hover:bg-gray-50 hover:text-gray-900', }, critical: { active: 'bg-red-50 text-red-700', inactive: 'text-red-600 hover:bg-red-50 hover:text-red-700', }, warning: { active: 'bg-amber-50 text-amber-700', inactive: 'text-amber-600 hover:bg-amber-50 hover:text-amber-700', }, ok: { active: 'bg-emerald-50 text-emerald-700', inactive: 'text-emerald-600 hover:bg-emerald-50 hover:text-emerald-700', }, }; const CatalogQuality = (): JSX.Element => { const { token, clientId } = useAppStateContext(); const [searchParams, setSearchParams] = useSearchParams(); const jobIdParam: string | null = searchParams.get('job'); const severityParam: ContentCheckSeverity | null = (() => { const raw = searchParams.get('severity'); if (raw === 'critical' || raw === 'warning' || raw === 'ok') return raw; return null; })(); const [job, setJob] = useState(null); const [products, setProducts] = useState([]); const [dispatching, setDispatching] = useState(false); const [rerunBusy, setRerunBusy] = useState(false); // True while a content-gen job (e.g. a bulk improve) runs. The backend // serialises these against the audit, so every catalog action is disabled // while one is in flight. const [bulkRunning, setBulkRunning] = useState(false); // bulkScope === null → closed; {} → full catalog; {sku, productName} → one row. const [bulkScope, setBulkScope] = useState<{ sku?: string; productName?: string; } | null>(null); const refreshJob = useCallback(async (): Promise => { if (!token) return; const fresh: IContentCheckJob | null = jobIdParam ? await getContentCheckJob(jobIdParam).catch(() => null) : await getLatestContentCheck().catch(() => null); setJob(fresh); if (fresh) { const page = await listContentCheckProducts(fresh.job_id, { limit: 50, severity: severityParam, }).catch(() => ({ products: [], next_cursor: null })); setProducts(page.products); } else { setProducts([]); } }, [token, jobIdParam, severityParam]); useEffect(() => { refreshJob(); }, [refreshJob]); // When the copilot runs a content check (or a bulk improve) it dispatches // ``copilot:data-changed``; re-pull the latest job + per-product results so // the audit score and rows reflect the new run without a manual reload. The // in-flight poll below then continues advancing a still-running job. useCopilotDataChanged(() => { void refreshJob(); }); // Lightweight poll for an in-flight content-gen job (e.g. a bulk improve). // Runs continuously while the page is open - decoupled from the audit poll // - so a bulk started from this page disables the buttons within one tick, // and re-enables them once it finishes. const refreshBulkRunning = useCallback(async (): Promise => { if (!clientId) return; const active = await getActiveContentGenJob(clientId).catch(() => null); setBulkRunning(!!active?.data?.active); }, [clientId]); useEffect(() => { refreshBulkRunning(); const handle = window.setInterval(refreshBulkRunning, 4000); return () => window.clearInterval(handle); }, [refreshBulkRunning]); const isRunning: boolean = !!job && (job.state === 'pending' || job.state === 'processing'); // Gate every action on the audit OR a bulk-improve job being in flight. const actionsBusy: boolean = isRunning || bulkRunning; useEffect(() => { if (!isRunning) return; const handle = window.setInterval(() => { refreshJob(); }, 4000); return () => window.clearInterval(handle); }, [isRunning, refreshJob]); const handleDispatch = async (): Promise => { if (!token || dispatching) return; setDispatching(true); try { const fresh = await dispatchContentCheck(); if (fresh) { const next = new URLSearchParams(); next.set('job', fresh.job_id); setSearchParams(next, { replace: true }); } } finally { setDispatching(false); } }; const handleRerun = async (): Promise => { if (!token || !job || rerunBusy) return; setRerunBusy(true); try { const fresh = await rerunContentCheck(job.job_id); if (fresh) { const next = new URLSearchParams(); next.set('job', fresh.job_id); setSearchParams(next, { replace: true }); } } finally { setRerunBusy(false); } }; if (!token) { return (
); } return (

Catalog Quality

Title and description AEO/GEO readiness, scored per SKU.

{job && ( )} {job && ( )}
{!job && (

No content check has been run yet

Click{' '} Run new check{' '} above to audit 50 random products from your catalog. Each title and description is scored individually against AEO and GEO criteria.

)} {job && } {job && (job.csv_url || job.pdf_url) && (
Download report: {job.csv_url && ( CSV )} {job.pdf_url && ( PDF )}
)} {job && ( setBulkScope({ sku, productName })} /> )} {job && ( !next && setBulkScope(null)} jobId={job.job_id} language={job.language_detected || 'en'} sku={bulkScope?.sku} productName={bulkScope?.productName} /> )}
); }; function JobOverview({ job }: { job: IContentCheckJob }): JSX.Element { const isCompleted: boolean = job.state === 'completed'; const isRunning: boolean = job.state === 'pending' || job.state === 'processing'; const isFailed: boolean = job.state === 'failed'; const scoreColor: string = job.overall_score >= 80 ? 'text-emerald-600' : job.overall_score >= 65 ? 'text-lime-600' : job.overall_score >= 50 ? 'text-amber-600' : job.overall_score >= 35 ? 'text-orange-600' : 'text-red-600'; const totalProducts: number = Math.max(1, job.total_products); const progressPercent: number = Math.max( 0, Math.min(100, Math.round((job.processed_products / totalProducts) * 100)) ); return (
{isRunning ? 'Audit in progress' : isFailed ? 'Audit failed' : 'Latest audit'} {job.language_detected && ( <> · {job.language_detected.toUpperCase()} )}
{isCompleted ? ( <>
{job.overall_score} /100

Audited {job.processed_products} of {job.total_products}{' '} products

) : isFailed ? (

{job.error ?? 'Something went wrong while running this audit.'}

) : (
{job.processed_products} / {job.total_products} products
{progressPercent}%
)}
); } function SeverityChip({ label, count, accent, dotClass, }: { label: string; count: number; accent: string; dotClass: string; }): JSX.Element { return (
{label}
{count}
); } function ProductsTable({ jobId, products, severity, language, disabled, onOpenBulk, }: { jobId: string; products: IContentCheckProductRow[]; severity: ContentCheckSeverity | null; language: string; disabled: boolean; onOpenBulk: (sku?: string, productName?: string) => void; }): JSX.Element { const [searchParams, setSearchParams] = useSearchParams(); const filterTabs = useMemo( () => [null, 'critical', 'warning', 'ok'] as Array, [] ); const handleSelectSeverity = (tab: ContentCheckSeverity | null): void => { const nextParams = new URLSearchParams(searchParams); nextParams.set('job', jobId); if (tab) nextParams.set('severity', tab); else nextParams.delete('severity'); setSearchParams(nextParams); }; return (

Per-product results

{products.length === 0 ? (
No products to show for this filter.
) : (
    {products.map(product => ( ))}
)}
); } function ProductRow({ jobId, product, language, disabled, onOpenBulk, }: { jobId: string; product: IContentCheckProductRow; language: string; disabled: boolean; onOpenBulk: (sku?: string, productName?: string) => void; }): JSX.Element { const [expanded, setExpanded] = useState(false); const [actionMode, setActionMode] = useState<'gap' | 'faq' | null>(null); const issueCount: number = (product.title_issues?.length ?? 0) + (product.description_issues?.length ?? 0); const severityBadge = SEVERITY_BADGE[product.severity]; return (
  • {severityBadge.label} {product.product_name || product.sku}

    SKU {product.sku} {product.detected_product_type && ` · ${product.detected_product_type}`}

    T{' '} {product.title_score} D{' '} {product.description_score} {issueCount > 0 && ( )}
    {expanded && issueCount > 0 && (
    )} !next && setActionMode(null)} mode={actionMode ?? 'gap'} jobId={jobId} sku={product.sku} productName={product.product_name} language={language} titleIssues={product.title_issues ?? []} descriptionIssues={product.description_issues ?? []} />
  • ); } function IssueList({ title, issues, }: { title: string; issues: string[]; }): JSX.Element { if (!issues || issues.length === 0) { return (

    {title}

    No issues found.

    ); } return (

    {title}

      {issues.map((issue, issueIndex) => (
    • · {issue}
    • ))}
    ); } export default CatalogQuality;