import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { Link } from 'react-router-dom'; import { HiSparkles as Sparkles } from 'react-icons/hi'; import { FaCheck as Check, FaEyeSlash as EyeOff, FaRegTrashAlt as Trash2, } from 'react-icons/fa'; import type { AgentGapCategory, AgentGapFixActionKind, AgentGapMutation, IAgentGap, IAgentGapFixAction, IAgentGapFixSuggestion, IAgentGapKbDraft, IAgentGapProductSuggestionResponse, IAgentGapsResponse, } from '../../service/agent-analytics/agent-analytics.interface'; import { fetchAgentGaps, prepareGapKbDraft, prepareProductSuggestionForGap, suggestAgentGapFix, updateAgentGapState, } from '../../service/agent-analytics/agent-analytics.service'; import GapKbDraftModal from './GapKbDraftModal'; import GapProductSuggestionModal from './GapProductSuggestionModal'; import JourneyDetailModal from './JourneyDetailModal'; const GAPS_PER_PAGE: number = 20; interface CategoryMeta { id: AgentGapCategory; label: string; iconBg: string; iconColor: string; border: string; } const CATEGORY_META: ReadonlyArray = [ { id: 'info_missing', label: 'Info missing', iconBg: 'bg-amber-50', iconColor: 'text-amber-700', border: 'border-amber-200', }, { id: 'feature_missing', label: 'Feature / product missing', iconBg: 'bg-rose-50', iconColor: 'text-rose-700', border: 'border-rose-200', }, { id: 'cannot_guide', label: 'Cannot guide', iconBg: 'bg-orange-50', iconColor: 'text-orange-700', border: 'border-orange-200', }, { id: 'out_of_scope', label: 'Out of scope', iconBg: 'bg-sky-50', iconColor: 'text-sky-700', border: 'border-sky-200', }, ]; const categoryMetaFor = (category: AgentGapCategory | string): CategoryMeta => CATEGORY_META.find(entry => entry.id === category) ?? CATEGORY_META[0]; const ACTION_META: Record< AgentGapFixActionKind, { cta: string; iconBg: string; iconColor: string } > = { kb_article: { cta: 'Open knowledge base', iconBg: 'bg-sky-50', iconColor: 'text-sky-700', }, ai_visibility_article: { cta: 'Open AI Visibility', iconBg: 'bg-[#ffeef8]', iconColor: 'text-[#B7007C]', }, product_rewrite: { cta: 'Open Content Generator', iconBg: 'bg-amber-50', iconColor: 'text-amber-700', }, policy_update: { cta: 'Open store policies', iconBg: 'bg-emerald-50', iconColor: 'text-emerald-700', }, manual: { cta: '', iconBg: 'bg-orange-50', iconColor: 'text-orange-700', }, }; /** * @param {IAgentGapFixAction} action * @return {string | null} */ function actionDestination(action: IAgentGapFixAction): string | null { switch (action.kind) { case 'kb_article': return '/knowledge-base'; case 'ai_visibility_article': { const topic: string = typeof action.payload?.topic === 'string' ? action.payload.topic : ''; const keywordsRaw: unknown = action.payload?.keywords; const keywords: string = Array.isArray(keywordsRaw) ? keywordsRaw .filter((kw): kw is string => typeof kw === 'string') .join(',') : ''; const search: URLSearchParams = new URLSearchParams(); search.set('action', 'new-article'); if (topic) search.set('topic', topic); if (keywords) search.set('keywords', keywords); return `/ai-visibility?${search.toString()}`; } case 'product_rewrite': { const focusRaw: unknown = action.payload?.focus_attributes; const focus: string = Array.isArray(focusRaw) ? focusRaw .filter((attr): attr is string => typeof attr === 'string') .join(',') : ''; const search: URLSearchParams = new URLSearchParams(); if (focus) search.set('focus', focus); const qs: string = search.toString(); return qs ? `/content-generator?${qs}` : '/content-generator'; } case 'policy_update': return '/knowledge-base'; case 'manual': return null; } } interface GapsTabProps { startDate: string; endDate: string; } const EMPTY_CATEGORY_COUNTS: Record = { info_missing: 0, feature_missing: 0, cannot_guide: 0, out_of_scope: 0, }; const GapsTab = ({ startDate, endDate }: GapsTabProps): JSX.Element => { const [activeCategory, setActiveCategory] = useState( null ); const [gaps, setGaps] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [categoryCounts, setCategoryCounts] = useState< Record >(EMPTY_CATEGORY_COUNTS); const [totalAll, setTotalAll] = useState(0); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [openFingerprint, setOpenFingerprint] = useState(null); const loadPage = useCallback( async (append: boolean, cursor: string | null): Promise => { append ? setLoadingMore(true) : setLoading(true); setError(null); try { const result: IAgentGapsResponse = await fetchAgentGaps( startDate, endDate, GAPS_PER_PAGE, cursor, activeCategory, 'active' ); setGaps(previous => append ? [...previous, ...result.gaps] : result.gaps ); setNextCursor(result.next_cursor); if (result.category_counts) setCategoryCounts(result.category_counts); if (typeof result.total_all_categories === 'number') { setTotalAll(result.total_all_categories); } } catch (loadError) { setError( loadError instanceof Error ? loadError.message : 'Failed to load agent gaps' ); } finally { append ? setLoadingMore(false) : setLoading(false); } }, [startDate, endDate, activeCategory] ); useEffect(() => { loadPage(false, null); }, [activeCategory, startDate, endDate]); const handleLoadMore = useCallback((): void => { if (!nextCursor) return; loadPage(true, nextCursor); }, [nextCursor, loadPage]); const handleMutateStatus = useCallback( async (gap: IAgentGap, status: AgentGapMutation): Promise => { const targetKey: string = `${gap.category}::${gap.target}`; setGaps(previous => previous.filter( entry => `${entry.category}::${entry.target}` !== targetKey ) ); try { await updateAgentGapState({ target: gap.target, category: gap.category, status, }); } catch (mutateError) { console.error('[gaps] updateAgentGapState failed', mutateError); } finally { // Counts come from the backend - skip FE-side maths and let the // refetch surface the authoritative totals. loadPage(false, null); } }, [loadPage] ); const visibleGaps: IAgentGap[] = useMemo(() => gaps, [gaps]); return (

Detected agent gaps in the selected period

{totalAll.toLocaleString()} gap{totalAll === 1 ? '' : 's'}

Each gap is one assistant turn where the agent said it does not know, does not carry the product, cannot guide the customer, or routed them to a human.

setActiveCategory(null)} accent="border-gray-200 text-gray-700" /> {CATEGORY_META.map(meta => ( setActiveCategory(activeCategory === meta.id ? null : meta.id) } accent={`${meta.border} ${meta.iconColor}`} /> ))}
{error && (
{error}
)} {loading ? (
) : visibleGaps.length === 0 ? (

{activeCategory ? 'No gaps in this bucket for the selected period.' : 'No agent gaps detected for this period. The assistant handled every conversation it received.'}

) : (
{visibleGaps.map((gap, index) => ( ))}
)} {nextCursor && visibleGaps.length > 0 && (
)} setOpenFingerprint(null)} />
); }; interface CategoryChipProps { label: string; count: number; active: boolean; onClick: () => void; accent: string; } function CategoryChip({ label, count, active, onClick, accent, }: CategoryChipProps): JSX.Element { return ( ); } interface GapRowProps { gap: IAgentGap; onOpenSample: (fingerprint: string) => void; onMutateStatus: (gap: IAgentGap, status: AgentGapMutation) => Promise; } function GapRow({ gap, onOpenSample, onMutateStatus, }: GapRowProps): JSX.Element { const meta: CategoryMeta = categoryMetaFor(gap.category); const [fixOpen, setFixOpen] = useState(false); const [pendingStatus, setPendingStatus] = useState( null ); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const handleStatusFlip = async (status: AgentGapMutation): Promise => { if (pendingStatus) return; setPendingStatus(status); try { await onMutateStatus(gap, status); } finally { setPendingStatus(null); } }; const handleConfirmDelete = async (): Promise => { setConfirmDeleteOpen(false); await handleStatusFlip('deleted'); }; const sampleCount: number = gap.samples.length; const onlySample = sampleCount > 0 ? gap.samples[0] : null; const countLabel: string = `${gap.count} conversation${gap.count === 1 ? '' : 's'}`; return (

{gap.target}

{meta.label}

{onlySample ? ( ) : ( {countLabel} )}
{fixOpen && } setConfirmDeleteOpen(false)} />
); } interface GapFixPanelProps { gap: IAgentGap; } function GapFixPanel({ gap }: GapFixPanelProps): JSX.Element { const [suggestion, setSuggestion] = useState( null ); const [loading, setLoading] = useState(true); const [panelError, setPanelError] = useState(null); useEffect(() => { let cancelled: boolean = false; setLoading(true); setPanelError(null); const firstSample = gap.samples[0]; suggestAgentGapFix({ target: gap.target, category: gap.category, sample_fingerprint: firstSample?.fingerprint ?? null, sample_message_index: firstSample?.message_index ?? null, }) .then(result => { if (!cancelled) setSuggestion(result); }) .catch(suggestError => { if (!cancelled) { setPanelError( suggestError instanceof Error ? suggestError.message : 'Failed to suggest a fix' ); } }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [gap.target, gap.category]); return (
Recommended fix {loading && (

Asking the assistant how to close this gap...

)} {panelError &&

{panelError}

} {suggestion && !loading && ( <> {suggestion.explanation && (

{suggestion.explanation}

)} {suggestion.actions.length === 0 ? (

No automated action fits this gap. Review the conversation samples above and decide on a manual next step.

) : (
{suggestion.actions.map((action, index) => ( ))}
)} )}
); } interface ActionCardProps { action: IAgentGapFixAction; gap: IAgentGap; } function ActionCard({ action, gap }: ActionCardProps): JSX.Element { const meta = ACTION_META[action.kind] ?? ACTION_META.manual; return (

{action.label}

{action.description && (

{action.description}

)}
); } interface ActionCtaProps { action: IAgentGapFixAction; gap: IAgentGap; } /** * Picks the right CTA for one action. ``kb_article`` runs the in-place * "Prepare fix" -> draft modal -> publish flow; ``product_rewrite`` * fires the agent's enrich-product job and shows an inline confirmation * pill. ``policy_update`` has no CTA - the recommendation text is the * call to action and the merchant resolves it inside the Knowledge Base * via the sibling ``kb_article`` action. Everything else falls back to * the static deep-link the original panel had. * * @param {ActionCtaProps} props - Component props. * @return {JSX.Element | null} CTA element, or null when none fits. */ function ActionCta({ action, gap }: ActionCtaProps): JSX.Element | null { if (action.kind === 'kb_article') { return ; } if (action.kind === 'product_rewrite') { return ; } if (action.kind === 'policy_update') return null; const meta = ACTION_META[action.kind] ?? ACTION_META.manual; const destination: string | null = actionDestination(action); if (!destination || !meta.cta) return null; return ( {meta.cta} → ); } interface AiActionButtonProps { gap: IAgentGap; } /** * "Prepare fix" CTA on a kb_article action. Asks the agent for a * ready-to-publish KB draft, opens :func:`GapKbDraftModal` so the * merchant can edit before publishing, then flips into a "Published" * pill once the publish round-trip succeeds. * * @param {AiActionButtonProps} props - Component props. * @return {JSX.Element} CTA + modal wrapper. */ function PrepareKbDraftButton({ gap }: AiActionButtonProps): JSX.Element { const [draft, setDraft] = useState(null); const [preparing, setPreparing] = useState(false); const [published, setPublished] = useState(false); const [error, setError] = useState(null); const [modalOpen, setModalOpen] = useState(false); const handleClick = async (): Promise => { if (preparing) return; setError(null); setPreparing(true); try { const firstSample = gap.samples[0]; const result: IAgentGapKbDraft | null = await prepareGapKbDraft({ target: gap.target, category: gap.category, sample_fingerprint: firstSample?.fingerprint ?? null, sample_message_index: firstSample?.message_index ?? null, }); if (!result) { setError('Failed to prepare a fix.'); return; } setDraft(result); setModalOpen(true); } catch (prepareError) { setError( prepareError instanceof Error ? prepareError.message : 'Failed to prepare a fix.' ); } finally { setPreparing(false); } }; if (published) { return ( Published ); } return (
{error &&

{error}

} {draft && modalOpen && ( setModalOpen(false)} onPublished={() => { setPublished(true); setModalOpen(false); }} /> )}
); } /** * "Suggest snippet" CTA on a product_rewrite action. Calls the agent's * product-suggestion endpoint, then opens * :func:`GapProductSuggestionModal` so the merchant can copy / edit / * regenerate the snippet inline. No CSV / no email - the whole loop * lives in the modal. * * @param {AiActionButtonProps} props - Component props. * @return {JSX.Element} CTA + modal wrapper. */ function EnrichProductButton({ gap }: AiActionButtonProps): JSX.Element { const [suggestion, setSuggestion] = useState(null); const [preparing, setPreparing] = useState(false); const [error, setError] = useState(null); const [modalOpen, setModalOpen] = useState(false); const handleClick = async (): Promise => { if (preparing) return; setError(null); setPreparing(true); try { const firstSample = gap.samples[0]; const result: IAgentGapProductSuggestionResponse | null = await prepareProductSuggestionForGap({ target: gap.target, category: gap.category, sample_fingerprint: firstSample?.fingerprint ?? null, sample_message_index: firstSample?.message_index ?? null, }); if (!result) { setError('Failed to prepare the snippet.'); return; } setSuggestion(result); setModalOpen(true); } catch (prepareError) { setError( prepareError instanceof Error ? prepareError.message : 'Failed to prepare the snippet.' ); } finally { setPreparing(false); } }; return (
{error &&

{error}

} {suggestion && modalOpen && ( setModalOpen(false)} /> )}
); } interface ConfirmDeleteGapModalProps { gap: IAgentGap; open: boolean; pending: boolean; onConfirm: () => void; onCancel: () => void; } /** * Confirmation overlay shown before a gap is hard-deleted. The trash * icon on the gap row opens this modal; the actual state flip to * ``deleted`` fires only after the merchant clicks the destructive * button inside. * * @param {ConfirmDeleteGapModalProps} props - Component props. * @return {JSX.Element | null} Overlay element or null when closed. */ function ConfirmDeleteGapModal({ gap, open, pending, onConfirm, onCancel, }: ConfirmDeleteGapModalProps): JSX.Element | null { useEffect(() => { if (!open) return undefined; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && !pending) onCancel(); }; document.addEventListener('keydown', handleEscape); return () => { document.removeEventListener('keydown', handleEscape); }; }, [open, pending, onCancel]); if (!open) return null; if (typeof document === 'undefined') return null; return createPortal(
!pending && onCancel()} />

Delete this gap?

This permanently removes{' '} {gap.target}{' '} from every Gaps tab. The action cannot be undone.

, document.body ); } export default GapsTab;