import React, { useEffect, useMemo, useRef, useState } from 'react'; import { LuCheck as Check, LuAlertCircle as AlertCircle, LuLoader2 as Loader2, LuPlus as Plus, LuScanLine as ScanLine, LuSearch as Search, LuX as X, } from 'react-icons/lu'; import { scanSelectedContentCheck, searchCatalogProducts, } from '../../service/catalog-quality/catalog-quality.service'; import { CREDITS_PER_PRODUCT_SCAN, MAX_SCAN_PRODUCTS, } from '../../service/catalog-quality/catalog-quality.constants'; import type { IProductSearchItem } from '../../service/catalog-quality/catalog-quality.interface'; /** Debounce window between keystrokes and the catalog search request. */ const SEARCH_DEBOUNCE_MS: number = 350; /** Minimum query length before a search request fires. */ const SEARCH_MIN_CHARS: number = 2; const INPUT_CLASS = 'block w-full rounded-md border border-gray-300 py-2 pl-10 pr-10 text-sm placeholder:text-gray-400 focus:border-gray-900 focus:outline-none focus:ring-1 focus:ring-gray-900'; /** * Price label for a search row; falls back to ``amount code`` when the * catalog carries a non-ISO currency string Intl cannot format. * * @param {number} price - Product price. * @param {string} currency - Currency code from the catalog feed. * @return {string} Display price, empty when the price is missing. */ const formatPriceLabel = (price: number, currency: string): string => { if (!price) return ''; try { return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'EUR', }).format(price); } catch { return `${price} ${currency}`.trim(); } }; /** Props for {@link ScanProductsModal}. */ interface ScanProductsModalProps { open: boolean; onOpenChange: (next: boolean) => void; /** Called with the new job id once the scan has been dispatched. */ onScanStarted: (jobId: string) => void; } /** * "Scan specific products" modal: search the catalog by name or SKU, queue * products one by one, then run a content-check on exactly that set. The * scan bills 1 credit per product; the running total is shown next to the * Run button. Searches fire client-side with a debounce and a stale-response * guard (answers for a query the merchant typed past are dropped). * * @param {ScanProductsModalProps} props - Open state + dispatch callback. * @return {JSX.Element | null} The modal, or null while closed. */ const ScanProductsModal = ({ open, onOpenChange, onScanStarted, }: ScanProductsModalProps): JSX.Element | null => { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [queue, setQueue] = useState([]); const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); // Latest trimmed query, read when a search response lands so answers for a // query the merchant has already typed past get dropped. const latestQueryRef = useRef(''); const trimmedQuery = query.trim(); const queuedSkus = useMemo( () => new Set(queue.map(item => item.sku)), [queue] ); const queueFull = queue.length >= MAX_SCAN_PRODUCTS; const creditsTotal = queue.length * CREDITS_PER_PRODUCT_SCAN; // Reset every time the modal closes so a fresh open starts empty. useEffect(() => { if (!open) { setQuery(''); setResults([]); setSearching(false); setQueue([]); setScanError(null); } }, [open]); useEffect(() => { if (!open) return undefined; const handleKey = (event: KeyboardEvent): void => { if (event.key === 'Escape' && !scanning) onOpenChange(false); }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); }, [open, scanning, onOpenChange]); useEffect(() => { latestQueryRef.current = trimmedQuery; if (!open || trimmedQuery.length < SEARCH_MIN_CHARS) { setResults([]); setSearching(false); return undefined; } setSearching(true); const handle = window.setTimeout(async () => { const found = await searchCatalogProducts(trimmedQuery).catch( () => [] as IProductSearchItem[] ); // Drop answers for a query the merchant has already typed past. if (latestQueryRef.current !== trimmedQuery) return; setResults(found); setSearching(false); }, SEARCH_DEBOUNCE_MS); return () => window.clearTimeout(handle); }, [open, trimmedQuery]); const addToQueue = (product: IProductSearchItem): void => { if (queuedSkus.has(product.sku) || queueFull) return; setQueue(previous => [...previous, product]); }; const removeFromQueue = (sku: string): void => { setQueue(previous => previous.filter(item => item.sku !== sku)); }; const runScan = async (): Promise => { if (queue.length === 0 || scanning) return; setScanning(true); setScanError(null); try { const result = await scanSelectedContentCheck( queue.map(item => item.sku) ); if (result.job) { onScanStarted(result.job.job_id); return; } setScanError( result.error ?? 'Failed to start the scan. Please try again.' ); } catch { setScanError('Failed to start the scan. Please try again.'); } finally { setScanning(false); } }; if (!open) return null; return (
{ if (!scanning) onOpenChange(false); }} />

Scan specific products

Search your catalog by name or SKU, add products to the queue, then run a check on exactly that set. Each product costs{' '} {CREDITS_PER_PRODUCT_SCAN} credit.

setQuery(event.target.value)} placeholder="Search products by name or SKU..." className={INPUT_CLASS} autoFocus /> {searching && ( )}
{trimmedQuery.length >= SEARCH_MIN_CHARS && (
{results.length === 0 && !searching ? (

No products match this search.

) : (
    {results.map(product => { const added = queuedSkus.has(product.sku); const priceLabel = formatPriceLabel( product.price, product.currency ); return (
  • {product.image_url ? ( ) : (
    )}

    {product.product_name || product.sku}

    SKU {product.sku} {priceLabel && ` · ${priceLabel}`} {!product.in_stock && ' · out of stock'}

  • ); })}
)}
)}

Scan queue · {queue.length}/{MAX_SCAN_PRODUCTS}

{queue.length > 0 && ( )}
{queue.length === 0 ? (

Nothing queued yet. Add products from the search results above.

) : (
    {queue.map(product => (
  • {product.product_name || product.sku}

    SKU {product.sku}

  • ))}
)}
{queueFull && (

Queue limit reached: a scan covers at most {MAX_SCAN_PRODUCTS}{' '} products per run.

)} {scanError && (

{scanError}

)}

{queue.length === 0 ? ( `${CREDITS_PER_PRODUCT_SCAN} credit per product` ) : ( <> {queue.length} {' '} product{queue.length !== 1 ? 's' : ''} ·{' '} {creditsTotal} {' '} credit{creditsTotal !== 1 ? 's' : ''} )}

); }; export default ScanProductsModal;