import React, { useEffect, useState } from 'react'; import { FiEdit3, FiFilter } from 'react-icons/fi'; import InfoTooltip from '../agent-analytics/InfoTooltip'; import type { ContentGap, FunnelCoverageResponse, FunnelStageCoverage, FunnelStageStatus, } from '../../service/visibility/visibility.interface'; import { generateArticle, getFunnelCoverage, } from '../../service/visibility/visibility.service'; import ArticleModal from './ArticleModal'; import ArticleProgress from './ArticleProgress'; import GenerateLanguagesDialog from './GenerateLanguagesDialog'; import { toneToBadgeClass } from './helpers'; interface FunnelCoveragePanelProps { clientId: string; token: string; brandId: string; language?: string | null; /** Fired when a gap's generated article finishes, so the parent can refresh. */ onArticleGenerated?: () => void; } const STAGE_COPY: Record = { research: { label: 'Research', hint: 'Top of funnel: what-is, best-for, use-case questions.', }, comparison: { label: 'Comparison', hint: 'Mid funnel: alternatives, X vs Y, weighing options.', }, evaluation: { label: 'Evaluation', hint: 'Bottom funnel: reviews, worth-it, reliability.', }, }; const STATUS_TONE: Record< FunnelStageStatus, 'success' | 'warning' | 'critical' | undefined > = { strong: 'success', weak: 'warning', absent: 'critical', untracked: undefined, }; const STATUS_LABEL: Record = { strong: 'Strong', weak: 'Weak', absent: 'Absent', untracked: 'Collecting', }; const STATUS_TEXT: Record = { strong: 'text-green-600', weak: 'text-yellow-600', absent: 'text-red-600', untracked: 'text-gray-400', }; const STATUS_BAR: Record = { strong: 'bg-green-500', weak: 'bg-yellow-500', absent: 'bg-red-500', untracked: 'bg-gray-300', }; /** One funnel stage as a compact tile: status, coverage %, and a progress bar. */ function StageTile({ stage }: { stage: FunnelStageCoverage }): JSX.Element { const copy = STAGE_COPY[stage.stage] ?? { label: stage.stage, hint: '' }; const status = stage.status; const tracked = status !== 'untracked'; return (
{copy.label} {STATUS_LABEL[status]}
{tracked ? `${Math.round(stage.coverage_pct)}%` : '—'} {stage.prompts_present}/{stage.prompts_total} prompts

{copy.hint}

); } /** * Funnel-coverage panel (Beta): where AI engines surface the brand across the * buyer journey (Research → Comparison → Evaluation) plus the content gaps to * write. Fetches client-side; clicking a gap dispatches article generation. */ const FunnelCoveragePanel = ({ clientId, token, brandId, language, onArticleGenerated, }: FunnelCoveragePanelProps): JSX.Element | null => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Gap generation, mirroring the TopWinsLosses flow. All keyed by gap prompt. const [langDialogGap, setLangDialogGap] = useState(null); const [pendingByGap, setPendingByGap] = useState>({}); const [errorByGap, setErrorByGap] = useState>({}); const [busyByGap, setBusyByGap] = useState>({}); const [dismissedGaps, setDismissedGaps] = useState>( () => new Set() ); const [openArticleId, setOpenArticleId] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); setError(null); getFunnelCoverage(clientId, token, brandId) .then(result => { if (!cancelled) setData(result); }) .catch(err => { if (!cancelled) setError( err instanceof Error ? err.message : 'Failed to load funnel coverage.' ); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [clientId, token, brandId]); const handleGenerate = async (gap: ContentGap, languages: string[]) => { setLangDialogGap(null); setBusyByGap(prev => ({ ...prev, [gap.prompt]: true })); setErrorByGap(prev => { const next = { ...prev }; delete next[gap.prompt]; return next; }); try { const dispatch = await generateArticle(clientId, token, { brand_id: brandId, topic: gap.prompt, target_prompts: [gap.prompt], language: language ?? undefined, languages: languages.length ? languages : undefined, triggered_by: 'manual', }); setPendingByGap(prev => ({ ...prev, [gap.prompt]: dispatch.article_id })); } catch (err) { setErrorByGap(prev => ({ ...prev, [gap.prompt]: err instanceof Error ? err.message : 'Failed to start generation.', })); } finally { setBusyByGap(prev => ({ ...prev, [gap.prompt]: false })); } }; // Nothing scanned yet: stay silent rather than render an empty card. if (!loading && !error && (!data || data.stages.length === 0)) { return null; } const gaps = (data?.content_gaps ?? []).filter( gap => !dismissedGaps.has(gap.prompt) ); return ( <>

Funnel coverage

Beta

Where AI engines surface your brand across the buyer journey. A weak or absent stage is a content gap you can close.

{loading && (
)} {error &&

{error}

} {!loading && !error && data && ( <>
{data.stages.map(stage => ( ))}
{gaps.length > 0 && (

Content gaps to write{' '} ({gaps.length})

Click a gap to generate an article that targets that prompt.

{gaps.map(gap => { const pendingId = pendingByGap[gap.prompt]; const isGenerating = Boolean(pendingId); const err = errorByGap[gap.prompt]; const busy = busyByGap[gap.prompt]; return (
{err && (

{err}

)} {isGenerating && pendingId && (
{ setPendingByGap(prev => { const next = { ...prev }; delete next[gap.prompt]; return next; }); setDismissedGaps(prev => { const next = new Set(prev); next.add(gap.prompt); return next; }); setOpenArticleId(article.summary.article_id); onArticleGenerated?.(); }} onFailed={message => setErrorByGap(prev => ({ ...prev, [gap.prompt]: message, })) } />
)}
); })}
)} )}
setOpenArticleId(null)} clientId={clientId} token={token} /> { if (!next) setLangDialogGap(null); }} primaryLanguage={language || 'en'} busy={langDialogGap ? Boolean(busyByGap[langDialogGap.prompt]) : false} onConfirm={languages => { if (langDialogGap) void handleGenerate(langDialogGap, languages); }} /> ); }; export default FunnelCoveragePanel;