import React, { useEffect, useMemo, useState } from 'react'; import { CardSkeleton } from '../agent-analytics/CardSkeleton'; import EmptyState from '../agent-analytics/EmptyState'; import InfoTooltip from '../agent-analytics/InfoTooltip'; import type { PromptCategory, PromptMetricTrend, PromptResponse, } from '../../service/visibility/visibility.interface'; import { deletePrompt, getPromptMetrics, listPrompts, } from '../../service/visibility/visibility.service'; import ConfirmDialog from '../ui/ConfirmDialog'; import { toneToBadgeClass } from './helpers'; import EditPromptModal from './EditPromptModal'; import PromptResponseModal from './PromptResponseModal'; import { PromptTrendChip } from './PromptTrend'; import RegeneratePromptsModal from './RegeneratePromptsModal'; /** At or below this visibility score a prompt gets the red "Create content" CTA. */ const LOW_VISIBILITY_THRESHOLD = 20; interface TrackedPromptsListProps { clientId: string; token: string; brandId: string; brandDomain: string | null; /** * Fired from a weak prompt's red "Create content" button so the parent can * open the ideas modal focused on lifting that prompt. */ onBoostPrompt?: (prompt: { promptText: string; currentScore: number | null; }) => void; } const CATEGORY_META: Record< PromptCategory, { label: string; color: string; description: string } > = { product: { label: 'Product', color: '#2c6ecb', description: "Direct product-comparison prompts - 'best …', 'where can I buy …', 'which …'.", }, scenario: { label: 'Scenario', color: '#8a3ffc', description: "Situational prompts - 'I need … for …', 'what do I buy for a wedding'.", }, best_of: { label: 'Best of', color: '#3f7b00', description: "Listicle-style prompts - 'top N brands for …', 'best companies that …'.", }, geo: { label: 'Geo', color: '#e57200', description: 'Location-grounded prompts - city/country/market hints in the query.', }, }; const CATEGORY_ORDER: PromptCategory[] = [ 'product', 'scenario', 'best_of', 'geo', ]; /** * Section showing the 16 monitored prompts grouped by category. Clicking a * prompt opens {@link PromptResponseModal} with per-engine evidence. */ const TrackedPromptsList = ({ clientId, token, brandId, brandDomain, onBoostPrompt, }: TrackedPromptsListProps): JSX.Element => { const [prompts, setPrompts] = useState([]); const [metricsByPrompt, setMetricsByPrompt] = useState< Record >({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedPromptId, setSelectedPromptId] = useState(null); const [editingPromptId, setEditingPromptId] = useState(null); const [deletingPromptId, setDeletingPromptId] = useState(null); const [deleteBusy, setDeleteBusy] = useState(false); const [regenerateOpen, setRegenerateOpen] = useState(false); const refreshPrompts = (): void => { listPrompts(clientId, token, brandId) .then(res => { setPrompts(res.prompts || []); }) .catch(() => { // Silent: optimistic merge below keeps the list usable. }); }; const handlePromptSaved = (updated: PromptResponse): void => { setPrompts(prev => { const matched = prev.some( existing => existing.prompt_id === updated.prompt_id ); if (!matched) { return [...prev, updated]; } return prev.map(existing => existing.prompt_id === updated.prompt_id ? updated : existing ); }); refreshPrompts(); }; useEffect(() => { let cancelled = false; setLoading(true); setError(null); listPrompts(clientId, token, brandId) .then(res => { if (cancelled) return; setPrompts(res.prompts || []); }) .catch(err => { if (cancelled) return; setError( err instanceof Error ? err.message : 'Failed to load prompts.' ); }) .finally(() => { if (!cancelled) setLoading(false); }); // Per-prompt visibility trends drive the "Create content to rank" CTA on // weak prompts; a failure here only hides the CTA, never the list. getPromptMetrics(clientId, token, brandId) .then(res => { if (cancelled) return; const map: Record = {}; for (const trend of res.prompts || []) { map[trend.prompt_id] = trend; } setMetricsByPrompt(map); }) .catch(() => { // Silent: metrics are an enhancement on top of the prompt list. }); return () => { cancelled = true; }; }, [clientId, token, brandId]); useEffect(() => { // Trends load independently of the prompt list so a missing/empty rollup // (pre-deploy, or before the first scan) never blocks the prompts grid. let cancelled = false; getPromptMetrics(clientId, token, brandId) .then(res => { if (cancelled) return; const next: Record = {}; for (const trend of res.prompts || []) { next[trend.prompt_id] = trend; } setMetricsByPrompt(next); }) .catch(() => { // Silent: the cards just render without a trend chip. }); return () => { cancelled = true; }; }, [clientId, token, brandId]); const grouped = useMemo(() => { const buckets: Record = { product: [], scenario: [], best_of: [], geo: [], }; for (const p of prompts) { if (!p.enabled) continue; if (p.category in buckets) { buckets[p.category as PromptCategory].push(p); } } return buckets; }, [prompts]); const enabledCount = useMemo( () => prompts.filter(p => p.enabled).length, [prompts] ); const selectedPrompt = selectedPromptId != null ? (prompts.find(row => row.prompt_id === selectedPromptId) ?? null) : null; const editingPrompt = editingPromptId != null ? (prompts.find(row => row.prompt_id === editingPromptId) ?? null) : null; const deletingPrompt = deletingPromptId != null ? (prompts.find(row => row.prompt_id === deletingPromptId) ?? null) : null; const handleConfirmDelete = (): void => { if (deletingPromptId == null) return; setDeleteBusy(true); setError(null); deletePrompt(clientId, token, brandId, deletingPromptId) .then(res => { setPrompts(res.prompts || []); setDeletingPromptId(null); }) .catch(err => { setError( err instanceof Error ? err.message : 'Failed to delete prompt.' ); }) .finally(() => { setDeleteBusy(false); }); }; return ( <>

{`Tracked prompts${enabledCount ? ` (${enabledCount})` : ''}`}

Each card shows that prompt's AI-visibility score ( 0-100, how strongly AI engines surface you for it) and its change since tracking started. Higher and rising is better.

Not matching what you sell? Rebuild them from your live catalog.

{error && (

{error}

)} {loading ? (
) : enabledCount === 0 ? ( ) : (
{CATEGORY_ORDER.map(cat => { const items = grouped[cat]; const meta = CATEGORY_META[cat]; if (items.length === 0) return null; return (
{items.map(prompt => (
{onBoostPrompt && metricsByPrompt[prompt.prompt_id]?.current_score !== null && metricsByPrompt[prompt.prompt_id]?.current_score !== undefined && (metricsByPrompt[prompt.prompt_id] .current_score as number) <= LOW_VISIBILITY_THRESHOLD && (
)}
))}
); })}
)}
setSelectedPromptId(null)} clientId={clientId} token={token} brandId={brandId} brandDomain={brandDomain} /> setEditingPromptId(null)} onSaved={handlePromptSaved} clientId={clientId} token={token} brandId={brandId} /> setRegenerateOpen(false)} onRegenerated={next => setPrompts(next)} clientId={clientId} token={token} brandId={brandId} /> { if (!next && !deleteBusy) setDeletingPromptId(null); }} title="Delete this prompt?" description={ deletingPrompt ? `"${deletingPrompt.prompt_text}" will stop being tracked. This can't be undone.` : '' } confirmLabel="Delete" tone="destructive" busy={deleteBusy} busyLabel="Deleting…" onConfirm={handleConfirmDelete} /> ); }; export default TrackedPromptsList;