import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { FaMagic } from 'react-icons/fa';
import EmptyState from '../components/agent-analytics/EmptyState';
import ErrorBanner from '../components/agent-analytics/ErrorBanner';
import InfoTooltip from '../components/agent-analytics/InfoTooltip';
import { Spinner } from '../components/ui/Spinner';
import CitedSourcesPanel from '../components/visibility/CitedSourcesPanel';
import CompetitorsTable from '../components/visibility/CompetitorsTable';
import FunnelCoveragePanel from '../components/visibility/FunnelCoveragePanel';
import GenerateIdeasModal from '../components/visibility/GenerateIdeasModal';
import InsufficientCreditsBanner from '../components/visibility/InsufficientCreditsBanner';
import { canSeeBetaVisibilityFeatures } from '../components/visibility/beta-access';
import {
contentTypeLabel,
getCurrentWeekIso,
isoWeekFromTimestamp,
PAGE_CONTENT_TYPES,
} from '../components/visibility/helpers';
import KPIRow from '../components/visibility/KPIRow';
import MentionedVsCitedCallout from '../components/visibility/MentionedVsCitedCallout';
import NewCompetitorsBanner from '../components/visibility/NewCompetitorsBanner';
import ProblemsList from '../components/visibility/ProblemsList';
import PublishedArticlesList from '../components/visibility/PublishedArticlesList';
import RecommendedArticlesList from '../components/visibility/RecommendedArticlesList';
import ScanHistoryChart from '../components/visibility/ScanHistoryChart';
import SettingsDrawer from '../components/visibility/SettingsDrawer';
import TopCompetitorsHighlight from '../components/visibility/TopCompetitorsHighlight';
import TopWinsLosses from '../components/visibility/TopWinsLosses';
import TrackedCompetitorsPanel from '../components/visibility/TrackedCompetitorsPanel';
import TrackedPromptsList from '../components/visibility/TrackedPromptsList';
import TrendingInYourChat from '../components/visibility/TrendingInYourChat';
import VisibilityHeader from '../components/visibility/VisibilityHeader';
import HowToReadReport from '../components/visibility/HowToReadReport';
import { useAppStateContext } from '../context/user.data.context';
import { recomaze_ai_personalization_env } from '../env';
import type {
ArticleCounts,
ArticleDetailResponse,
ArticleSummary,
BrandResponse,
CompetitorLeaderboardResponse,
ManualCompetitor,
PageContentType,
ProblemsResponse,
ScanHistoryResponse,
SuggestionEntry,
SuggestionListResponse,
VisibilitySettingsResponse,
WeeklyReportResponse,
} from '../service/visibility/visibility.interface';
import { InsufficientCreditsError } from '../service/visibility/visibility.interface';
import {
createBrand,
fetchSettings,
getBrand,
getCompetitors,
getWeeklyReport,
listArticles,
listBrands,
listProblems,
listScans,
listSuggestions,
listTrackedCompetitors,
startScan,
} from '../service/visibility/visibility.service';
import { getStoreLanguage } from './ai-readiness/constants';
const SECTION_HELP = {
competitors:
"Every brand AI mentioned alongside yours for the 16 monitored prompts this week. Your own brand is pinned at the top with a 'Your brand' badge so you can compare share of voice side-by-side; 'Δ vs last week' is the percentage-point change over the previous week. An empty list means AI didn't surface any brands at all this week, usually paired with a 'Problems detected' entry.",
problems:
"Issues the scanner found in this week's answers (AI citing competitors but not you, weak local presence, negative tone, etc.). Each problem links to the prompts that triggered it so you can decide whether to publish content, fix structured data, or tighten your tracked-prompts list.",
articles:
"Content ideas the agent thinks will move the needle next week, based on the prompts where AI ignored your brand. Click 'Generate' to draft the full post; the trash button next to each idea rejects it so it never comes back.",
};
const SCAN_POLL_INTERVAL_MS = 5_000;
const SCAN_POLL_MAX_MS = 15 * 60 * 1000;
const POST_HISTORY_GRACE_MS = 3 * 60 * 1000;
/** Tabs for the Articles section. */
const ARTICLES_TABS: ReadonlyArray<{
id: 'recommended' | 'published';
label: string;
}> = [
{ id: 'recommended', label: 'Recommended' },
{ id: 'published', label: 'Published' },
];
/** Top-level report tabs; ``?tab=`` deep-links a panel. */
type VisibilityTabId = 'overview' | 'citations' | 'content' | 'chat';
const ToastMessage = ({
content,
onDismiss,
duration = 3500,
}: {
content: string;
onDismiss: () => void;
duration?: number;
}): JSX.Element => {
useEffect(() => {
const t = window.setTimeout(onDismiss, duration);
return () => window.clearTimeout(t);
}, [content, duration, onDismiss]);
return (
{content}
);
};
const AiVisibilityPage = (): JSX.Element => {
const { token, clientId } = useAppStateContext();
const storeDomain = recomaze_ai_personalization_env?.domain || '';
const storeName = storeDomain;
const [allowed, setAllowed] = useState(null);
const [selectedWeek, setSelectedWeek] = useState(getCurrentWeekIso());
const [settings, setSettings] = useState(
null
);
const [brand, setBrand] = useState(null);
const [weeklyReport, setWeeklyReport] = useState(
null
);
const [scanHistory, setScanHistory] = useState(
null
);
const [competitors, setCompetitors] =
useState(null);
const [trackedCompetitors, setTrackedCompetitors] = useState<
ManualCompetitor[]
>([]);
const [problemsData, setProblemsData] = useState(
null
);
const [suggestions, setSuggestions] = useState(
null
);
const [articles, setArticles] = useState([]);
const [articleCounts, setArticleCounts] = useState({
recommended: 0,
published: 0,
});
const [articlesTab, setArticlesTab] = useState<'recommended' | 'published'>(
'recommended'
);
// Page-type filter applied to both article tabs. "all" shows every type;
// the counts on the tabs stay on the full library (they don't re-derive).
const [articleTypeFilter, setArticleTypeFilter] = useState<
PageContentType | 'all'
>('all');
const [activeTabId, setActiveTabId] = useState('overview');
// Weak prompt the ideas modal is focused on (red "Create content" CTA), or
// null for the plain "turn your ideas into articles" entry point.
const [boostSeedPrompt, setBoostSeedPrompt] = useState<{
promptText: string;
currentScore: number | null;
} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [scanning, setScanning] = useState(false);
const [outOfCredits, setOutOfCredits] = useState(false);
const [toast, setToast] = useState(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [ideasModalOpen, setIdeasModalOpen] = useState(false);
const pollRef = useRef | null>(null);
const pollStartedAt = useRef(0);
const baselineHistoryLen = useRef(0);
const baselineReportGeneratedAt = useRef(null);
const historyLandedAt = useRef(null);
const hasClientCreds = Boolean(token && clientId);
useEffect(() => {
if (hasClientCreds) setAllowed(true);
}, [hasClientCreds]);
const loadAll = useCallback(async () => {
if (!hasClientCreds || !allowed) return;
setLoading(true);
setError(null);
try {
const fetchedSettings = await fetchSettings(
clientId as string,
token as string
);
setSettings(fetchedSettings);
const brandList = await listBrands(clientId as string, token as string);
let firstBrand = brandList.brands[0];
if (!firstBrand) {
const primaryDomain = (storeDomain || '').trim().toLowerCase();
const looksLikeDomain =
/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(primaryDomain) &&
!/\s/.test(primaryDomain);
if (!looksLikeDomain) {
setSettingsOpen(true);
setLoading(false);
return;
}
try {
// Prefer the WordPress site locale (the storefront language
// shoppers and AI assistants actually see) over the
// merchant-wide ``preferred_language`` (which only tracks the
// Recomaze admin UI). ``getStoreLanguage`` reads the locale
// localized via ``wp_localize_script`` and normalizes it to a
// supported ISO 639-1 code.
const detectedStoreLanguage = getStoreLanguage();
firstBrand = await createBrand(clientId as string, token as string, {
domain: primaryDomain,
brand_name: storeName || undefined,
language:
detectedStoreLanguage || fetchedSettings.preferred_language,
country: fetchedSettings.default_country || undefined,
});
} catch (err: any) {
console.warn(
'[visibility] auto-create brand from domain failed:',
err
);
const backendDetail =
err?.response?.data?.detail ||
err?.response?.data?.message ||
(err instanceof Error ? err.message : null);
setError(
backendDetail ||
"Couldn't set up the brand automatically. Enter your domain in Settings."
);
setSettingsOpen(true);
setLoading(false);
return;
}
}
const [
fullBrand,
report,
scans,
leaderboard,
tracked,
problems,
sugg,
articleList,
] = await Promise.all([
getBrand(clientId as string, token as string, firstBrand.brand_id),
getWeeklyReport(
clientId as string,
token as string,
firstBrand.brand_id,
selectedWeek
).catch(() => null),
listScans(clientId as string, token as string, firstBrand.brand_id),
getCompetitors(
clientId as string,
token as string,
firstBrand.brand_id,
selectedWeek
),
listTrackedCompetitors(
clientId as string,
token as string,
firstBrand.brand_id
),
listProblems(clientId as string, token as string, firstBrand.brand_id),
listSuggestions(
clientId as string,
token as string,
firstBrand.brand_id
),
listArticles(clientId as string, token as string, {
brand_id: firstBrand.brand_id,
}),
]);
setBrand(fullBrand);
setWeeklyReport(report);
setScanHistory(scans);
setCompetitors(leaderboard);
setTrackedCompetitors(tracked.competitors);
setProblemsData(problems);
setSuggestions(sugg);
setArticles(articleList.articles);
setArticleCounts(articleList.counts);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data.');
} finally {
setLoading(false);
}
}, [
hasClientCreds,
allowed,
clientId,
token,
selectedWeek,
storeDomain,
storeName,
]);
useEffect(() => {
if (allowed) loadAll();
}, [loadAll, allowed]);
// The Reco copilot dispatches ``copilot:data-changed`` after a turn where a
// write tool ran (generate article, start scan, update settings, etc.). WordPress
// has no route loaders to revalidate, so this page re-fetches its data when the
// event fires - mirroring the Remix ``revalidator.revalidate()`` the copilot
// used in the Shopify plugin.
//
// A newly started article transitions pending -> generating -> ready
// asynchronously, so a single re-fetch right after the turn can run before the
// article row even exists. We poll ``loadAll`` a few times (~3s apart for
// ~30s) so the new generating row surfaces; once it exists, the dedicated
// generating-poll effect below takes over and flips it to ready. Timers are
// cleared on the next event and on unmount so intervals never stack.
const dataChangedPollRef = useRef | null>(
null
);
useEffect(() => {
const stopPolling = (): void => {
if (dataChangedPollRef.current) {
clearInterval(dataChangedPollRef.current);
dataChangedPollRef.current = null;
}
};
const onCopilotDataChanged = (): void => {
if (!allowed) return;
void loadAll();
stopPolling();
let elapsedMs = 0;
dataChangedPollRef.current = setInterval(() => {
elapsedMs += 3_000;
void loadAll();
if (elapsedMs >= 30_000) stopPolling();
}, 3_000);
};
window.addEventListener('copilot:data-changed', onCopilotDataChanged);
return () => {
window.removeEventListener('copilot:data-changed', onCopilotDataChanged);
stopPolling();
};
}, [loadAll, allowed]);
/**
* Refresh the articles list + the backend-derived tab counts. Called
* after every mutation that can flip a count (publish / unpublish /
* delete / suggestion reject). The backend is the single source of
* truth - the local list updates optimistically for snappy UX, the
* counts come from here.
*
* @returns {Promise} Resolves once articles + counts state has been refreshed.
*/
const refreshArticles = useCallback(async () => {
if (!brand) return;
try {
const articleList = await listArticles(
clientId as string,
token as string,
{ brand_id: brand.brand_id }
);
setArticles(articleList.articles);
setArticleCounts(articleList.counts);
} catch {
// Soft-fail: counts will catch up on the next loadAll. Better to
// leave the stale-by-1 badge than throw a banner over the page.
}
}, [brand, clientId, token]);
// While any article is still generating, poll the list so finished articles
// surface and the per-language version chips advance on their own. The agent
// invalidates the article cache on each completion, so a plain refetch sees
// fresh status. Capped so a stuck row can't poll forever.
const generatingPollStartedAt = useRef(0);
useEffect(() => {
const anyGenerating = articles.some(
article => article.status === 'generating' || article.status === 'pending'
);
if (!anyGenerating) {
generatingPollStartedAt.current = 0;
return;
}
if (generatingPollStartedAt.current === 0) {
generatingPollStartedAt.current = Date.now();
}
if (Date.now() - generatingPollStartedAt.current > 10 * 60 * 1000) return;
const handle = setTimeout(() => {
void refreshArticles();
}, 6000);
return () => clearTimeout(handle);
}, [articles, refreshArticles]);
const handleWeekChange = useCallback(
async (weekIso: string) => {
setSelectedWeek(weekIso);
if (!brand) return;
try {
const [report, leaderboard] = await Promise.all([
getWeeklyReport(
clientId as string,
token as string,
brand.brand_id,
weekIso
).catch(() => null),
getCompetitors(
clientId as string,
token as string,
brand.brand_id,
weekIso
),
]);
setWeeklyReport(report);
setCompetitors(leaderboard);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load week.');
}
},
[brand, clientId, token]
);
/**
* Re-fetch the competitor leaderboard for the pinned week. Adding or
* removing a tracked competitor changes which rows the board shows (a
* tracked rival is forced in with a "Manually tracked" pill), so the
* board has to refetch after a tracked-panel mutation.
*
* @returns {Promise} Resolves once the leaderboard state is refreshed.
*/
const refreshLeaderboard = useCallback(async () => {
if (!brand) return;
try {
const leaderboard = await getCompetitors(
clientId as string,
token as string,
brand.brand_id,
selectedWeek
);
setCompetitors(leaderboard);
} catch (err) {
setError(
err instanceof Error ? err.message : 'Failed to refresh competitors.'
);
}
}, [brand, clientId, token, selectedWeek]);
/**
* Re-fetch the manually tracked competitors. Excluding a rival from the
* leaderboard also drops it from the tracked list backend-side, so the
* panel above has to refetch after a leaderboard exclusion.
*
* @returns {Promise} Resolves once the tracked-competitor state is refreshed.
*/
const refreshTrackedCompetitors = useCallback(async () => {
if (!brand) return;
try {
const tracked = await listTrackedCompetitors(
clientId as string,
token as string,
brand.brand_id
);
setTrackedCompetitors(tracked.competitors);
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Failed to refresh tracked competitors.'
);
}
}, [brand, clientId, token]);
/**
* Apply the refreshed tracked list echoed by the panel's add/remove
* modals, then refresh the leaderboard so the same rival appears (or
* loses its "Manually tracked" pill) without a manual page refresh.
*
* @param next {ManualCompetitor[]} Refreshed tracked list echoed by the backend.
* @returns {void}
*/
const handleTrackedCompetitorsChange = useCallback(
(next: ManualCompetitor[]) => {
setTrackedCompetitors(next);
void refreshLeaderboard();
},
[refreshLeaderboard]
);
/**
* Refresh both competitor views after a rival is excluded from the
* leaderboard. The backend filters the domain from the board and removes
* it from the tracked list, so both panels have to refetch.
*
* @returns {void}
*/
const handleCompetitorExcluded = useCallback(() => {
void refreshLeaderboard();
void refreshTrackedCompetitors();
}, [refreshLeaderboard, refreshTrackedCompetitors]);
const handleForceScan = async () => {
if (!brand) return;
setScanning(true);
setOutOfCredits(false);
try {
baselineHistoryLen.current = scanHistory?.history?.length ?? 0;
baselineReportGeneratedAt.current = weeklyReport?.generated_at ?? null;
historyLandedAt.current = null;
await startScan(clientId as string, token as string, brand.brand_id);
setToast("Scan dispatched - we'll refresh this page once it lands.");
pollStartedAt.current = Date.now();
pollRef.current = setInterval(async () => {
try {
const [scans, report] = await Promise.all([
listScans(clientId as string, token as string, brand.brand_id),
getWeeklyReport(
clientId as string,
token as string,
brand.brand_id,
selectedWeek
).catch(() => null),
]);
setScanHistory(scans);
const historyGrew = scans.history.length > baselineHistoryLen.current;
const reportRefreshed =
report !== null &&
report.generated_at !== null &&
report.generated_at !== baselineReportGeneratedAt.current;
if (historyGrew && historyLandedAt.current === null) {
historyLandedAt.current = Date.now();
await loadAll();
setToast('Scan complete - waiting for the weekly report.');
}
if (reportRefreshed) {
if (pollRef.current) clearInterval(pollRef.current);
await loadAll();
setToast('All data refreshed.');
setScanning(false);
return;
}
const totalTimedOut =
Date.now() - pollStartedAt.current >= SCAN_POLL_MAX_MS;
const graceExpired =
historyLandedAt.current !== null &&
Date.now() - historyLandedAt.current >= POST_HISTORY_GRACE_MS;
if (totalTimedOut || graceExpired) {
if (pollRef.current) clearInterval(pollRef.current);
await loadAll();
setScanning(false);
setToast(
graceExpired
? 'Scan complete - some KPIs may need a moment to finalize.'
: 'Scan is taking longer than usual - reloaded with the latest data.'
);
}
} catch {
// Transient error - keep polling.
}
}, SCAN_POLL_INTERVAL_MS);
} catch (err) {
if (err instanceof InsufficientCreditsError) {
setOutOfCredits(true);
} else {
setError(err instanceof Error ? err.message : 'Failed to start scan.');
}
setScanning(false);
}
};
useEffect(
() => () => {
if (pollRef.current) clearInterval(pollRef.current);
},
[]
);
const brandNameLabel = useMemo(
() => brand?.brand_name || brand?.domain || 'AI Visibility',
[brand]
);
const floorWeekIso = useMemo(
() => isoWeekFromTimestamp(brand?.created_at),
[brand?.created_at]
);
const requiresSetup = Boolean(brand) && (!brand?.language || !brand?.country);
const autoOpenedRef = useRef(false);
useEffect(() => {
if (requiresSetup && !autoOpenedRef.current && !loading) {
autoOpenedRef.current = true;
setSettingsOpen(true);
}
}, [requiresSetup, loading]);
// Deep-link from the Dashboard ("Finish setup" CTA) lands here with
// ``?settings=open`` so the drawer pops open without waiting for the
// requiresSetup heuristic. HashRouter routes look like
// ``#/ai-visibility?settings=open`` so we read the query off the hash.
useEffect(() => {
if (typeof window === 'undefined') return;
const hash: string = window.location.hash || '';
const queryIndex: number = hash.indexOf('?');
if (queryIndex === -1) return;
const params: URLSearchParams = new URLSearchParams(
hash.slice(queryIndex + 1)
);
if (params.get('settings') === 'open') {
setSettingsOpen(true);
params.delete('settings');
const nextQuery: string = params.toString();
const nextHash: string =
hash.slice(0, queryIndex) + (nextQuery ? `?${nextQuery}` : '');
window.history.replaceState(
null,
'',
`${window.location.pathname}${nextHash}`
);
}
}, []);
// ``?tab=`` deep-links a specific panel (e.g. from the copilot or a
// dashboard link). Only known tab values are honored; anything else leaves
// the default "overview" in place. We don't write the tab back to the URL
// on click, so this stays a one-way deep link. HashRouter routes look like
// ``#/ai-visibility?tab=citations`` so we read the query off the hash.
useEffect(() => {
if (typeof window === 'undefined') return;
const hash: string = window.location.hash || '';
const queryIndex: number = hash.indexOf('?');
if (queryIndex === -1) return;
const requestedTab = new URLSearchParams(hash.slice(queryIndex + 1)).get(
'tab'
);
if (
requestedTab === 'overview' ||
requestedTab === 'citations' ||
requestedTab === 'content' ||
requestedTab === 'chat'
) {
setActiveTabId(requestedTab);
}
}, []);
if (allowed === null || !hasClientCreds) {
return (
);
}
/**
* Merge a freshly-ready article into the list so the "Ready" badge in
* Recommended flips without a full reload.
*
* @param article {ArticleDetailResponse} The article that just transitioned to ``ready``.
* @returns {void}
*/
const handleArticleReady = (article: ArticleDetailResponse) => {
setArticles(previousArticles => {
const others = previousArticles.filter(
existingArticle =>
existingArticle.article_id !== article.summary.article_id
);
return [article.summary, ...others];
});
void refreshArticles();
setToast('Content is ready.');
};
/**
* Drop a rejected suggestion from local state so the row disappears
* without waiting for a re-fetch of the suggestions list.
*
* @param rejectedSuggestionId {string} Identifier of the suggestion just rejected on the backend.
* @returns {void}
*/
const handleSuggestionRejected = (rejectedSuggestionId: string) => {
setSuggestions(previousSuggestions =>
previousSuggestions
? {
...previousSuggestions,
suggestions: previousSuggestions.suggestions.filter(
existingSuggestion =>
existingSuggestion.suggestion_id !== rejectedSuggestionId
),
}
: previousSuggestions
);
void refreshArticles();
setToast('Content idea removed.');
};
/**
* Merge ideas generated from a brief into the Recommended list so the new
* idea cards appear without a refetch. Already-present ideas (matched by
* ``suggestion_id``) are replaced, and the fresh ones are prepended so they
* surface at the top of the list.
*
* @param newIdeas {SuggestionEntry[]} Ideas the brief modal just generated.
* @returns {void}
*/
const handleIdeasGenerated = (newIdeas: SuggestionEntry[]) => {
if (newIdeas.length === 0) return;
const newIds = new Set(newIdeas.map(idea => idea.suggestion_id));
setSuggestions(previousSuggestions => {
const existing = (previousSuggestions?.suggestions ?? []).filter(
existingSuggestion => !newIds.has(existingSuggestion.suggestion_id)
);
return { suggestions: [...newIdeas, ...existing] };
});
};
/**
* Drop a hard-deleted article from local state so the row's "Open
* article" → "Generate article" flip happens without a re-fetch.
*
* @param deletedArticleId {string} Identifier of the article just hard-deleted on the backend.
* @returns {void}
*/
const handleArticleDeleted = (deletedArticleId: string) => {
// The backend cascades the rejection to the source suggestion, so
// mirror that locally - drop the idea card from the Recommended tab
// in the same gesture (the badge count is refreshed from the server
// below, so we only need the visible list to stay honest).
const removed = articles.find(
existingArticle => existingArticle.article_id === deletedArticleId
);
const suggestionIdToDrop = removed?.triggered_by_suggestion_id ?? null;
setArticles(previousArticles =>
previousArticles.filter(
existingArticle => existingArticle.article_id !== deletedArticleId
)
);
if (suggestionIdToDrop) {
setSuggestions(previousSuggestions =>
previousSuggestions
? {
...previousSuggestions,
suggestions: previousSuggestions.suggestions.filter(
existingSuggestion =>
existingSuggestion.suggestion_id !== suggestionIdToDrop
),
}
: previousSuggestions
);
}
void refreshArticles();
setToast('Content deleted.');
};
/**
* Patch a single article in local state when the merchant flips it
* between ``ready`` and ``published`` from inside the modal. Keeps the
* Recommended/Published tabs in sync without a full refetch.
*
* @param article {ArticleDetailResponse} The article whose status just toggled.
* @returns {void}
*/
const handleArticlePublishedChanged = (article: ArticleDetailResponse) => {
setArticles(previousArticles => {
const others = previousArticles.filter(
existingArticle =>
existingArticle.article_id !== article.summary.article_id
);
return [article.summary, ...others];
});
void refreshArticles();
setToast(
article.summary.status === 'published'
? 'Content marked as published.'
: 'Content moved back to Recommended.'
);
};
// Narrow the rendered list to the selected page type; both tabs read this so
// a "Service" filter scopes Recommended and Published alike. Rows written
// before the column existed read as "blog".
const typeFilteredArticles =
articleTypeFilter === 'all'
? articles
: articles.filter(
article => (article.content_type ?? 'blog') === articleTypeFilter
);
return (
<>