import { useState, useEffect, useCallback } from 'react'; import { ScreenLoader } from './screen-loader'; import { ReportPdfDownloadButton } from './report-pdf-download-button'; import { createPagesReportPdf } from '@/lib/report-pdf'; import { getSiteNameForReports } from '@/lib/site-name'; import { PageDrawer } from './page-drawer'; import { Button } from '@/components/ui/button'; import { SegmentedControl } from '@/components/ui/segmented-control'; import { readPageStatsResponse } from '@/lib/utils'; import { trackPageDrawerOpened } from '@/lib/mixpanel'; import { __, sprintf, _n } from '../lib/i18n'; import { defaultBotLogo, getBotLogos } from '../lib/bot-logos'; interface PageStat { page_path: string; page_title: string; page_url: string; bot_visits: number; clicks: number; ctr: number; bots: string[]; post_id: number | null; visibility_score?: { score: number }; never_visited?: boolean; coverage_window_days?: number; } type CoverageFilter = 'all' | 'never_visited'; function toSafeNumber(value: unknown, fallback = 0): number { const num = Number(value); return Number.isFinite(num) ? num : fallback; } function normalizePageStat(raw: any): PageStat { const pagePath = typeof raw?.page_path === 'string' ? raw.page_path : ''; const pageUrl = typeof raw?.page_url === 'string' ? raw.page_url : ''; const pageTitleFromApi = typeof raw?.page_title === 'string' ? raw.page_title : ''; const pageTitle = pageTitleFromApi || pagePath || 'Untitled page'; const bots = Array.isArray(raw?.bots) ? raw.bots.filter((bot: unknown): bot is string => typeof bot === 'string' && bot.length > 0) : []; const visibilityScoreRaw = toSafeNumber(raw?.visibility_score?.score, NaN); return { page_path: pagePath, page_title: pageTitle, page_url: pageUrl, bot_visits: toSafeNumber(raw?.bot_visits), clicks: toSafeNumber(raw?.clicks), ctr: toSafeNumber(raw?.ctr), bots, post_id: raw?.post_id == null ? null : toSafeNumber(raw.post_id), visibility_score: Number.isFinite(visibilityScoreRaw) ? { score: visibilityScoreRaw } : undefined, never_visited: Boolean(raw?.never_visited), coverage_window_days: raw?.coverage_window_days == null ? undefined : toSafeNumber(raw.coverage_window_days), }; } interface PagesAnalyticsProps { apiRoot: string; apiNamespace: string; pluginUrl: string; } // Number of items to show per page const ITEMS_PER_PAGE = 30; // Visibility Score Circle Component function VisibilityScoreCircle({ score }: { score?: number }) { if (score === undefined || score === null) { return (
-
); } const getColor = (value: number) => { if (value >= 80) return { stroke: '#10b981', text: 'text-green-600', bg: 'bg-green-50' }; if (value >= 60) return { stroke: '#f59e0b', text: 'text-yellow-600', bg: 'bg-yellow-50' }; return { stroke: '#ef4444', text: 'text-red-600', bg: 'bg-red-50' }; }; const color = getColor(score); const circumference = 2 * Math.PI * 16; const offset = circumference - (score / 100) * circumference; return (
{score}
); } // eslint-disable-next-line @typescript-eslint/no-unused-vars export function PagesAnalytics({ apiRoot, apiNamespace, pluginUrl }: PagesAnalyticsProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [pageStats, setPageStats] = useState([]); const [displayCount, setDisplayCount] = useState(ITEMS_PER_PAGE); const [isLoadingMore, setIsLoadingMore] = useState(false); const [days, setDays] = useState(30); const [coverage, setCoverage] = useState('all'); const [totalCount, setTotalCount] = useState(null); const [scanCapped, setScanCapped] = useState(false); // Drawer state const [drawerPostId, setDrawerPostId] = useState(null); const [isDrawerOpen, setIsDrawerOpen] = useState(false); // Keep ?llmagnet_drawer= in sync so drawer views have shareable URLs const syncDrawerParam = (postId: number | null) => { const url = new URL(window.location.href); if (postId) { url.searchParams.set('llmagnet_drawer', String(postId)); } else { url.searchParams.delete('llmagnet_drawer'); } window.history.replaceState(window.history.state, '', url.toString()); }; // Drawer deep-linking: ?llmagnet_drawer= opens that post's drawer // on load (adoption plan Phase 0.3). The drawer fetches its own data by id, // so the row doesn't need to be in the visible table page. useEffect(() => { const param = new URLSearchParams(window.location.search).get('llmagnet_drawer'); const postId = param ? Number(param) : NaN; if (Number.isInteger(postId) && postId > 0) { setDrawerPostId(postId); setIsDrawerOpen(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const botLogos = getBotLogos(); const defaultLogo = defaultBotLogo(); useEffect(() => { setDisplayCount(ITEMS_PER_PAGE); fetchPageStats(true); }, [days, coverage]); const fetchPageStats = async (reset = true) => { try { setIsLoading(true); // Check if wpApiSettings is available if (!(window as any).wpApiSettings || !(window as any).wpApiSettings.root) { throw new Error('WordPress REST API settings not available. Please refresh the page.'); } const currentOffset = reset ? 0 : pageStats.length; const params = new URLSearchParams({ days: String(days), limit: String(ITEMS_PER_PAGE), offset: String(coverage === 'never_visited' ? currentOffset : 0), coverage, }); const apiUrl = `${apiRoot}${apiNamespace}/page-stats?${params.toString()}`; const response = await fetch(apiUrl, { headers: { 'X-WP-Nonce': (window as any).wpApiSettings?.nonce || '', }, credentials: 'same-origin', }); if (!response.ok) { let errorText = ''; try { errorText = await response.text(); } catch (e) { // Could not read error response } throw new Error(`HTTP ${response.status}: ${errorText || 'Unknown error'}`); } const data = await response.json(); // Handle error response from API if (data.error) { throw new Error(data.message || 'API returned an error'); } const envelope = readPageStatsResponse(data); const statsArray: PageStat[] = envelope.pages.map(normalizePageStat); if (coverage === 'never_visited') { setTotalCount(envelope.total); setScanCapped(Boolean((data as any)?.scan_capped)); setPageStats(reset ? statsArray : [...pageStats, ...statsArray]); } else { setTotalCount(null); setScanCapped(false); setPageStats(statsArray); } setError(null); } catch (err: any) { console.error('Error fetching page stats:', err); const errorMessage = err.message || 'Please try again later.'; setError(sprintf(__('Failed to load page statistics: %s', 'llmagnet-llm-txt-generator'), errorMessage)); } finally { setIsLoading(false); } }; const handlePageClick = (postId: number | null, pageTitle: string) => { if (!postId) return; setDrawerPostId(postId); setIsDrawerOpen(true); syncDrawerParam(postId); trackPageDrawerOpened(postId, pageTitle); }; const handleDrawerClose = () => { setIsDrawerOpen(false); syncDrawerParam(null); // Clear post ID after animation completes setTimeout(() => { setDrawerPostId(null); }, 300); }; const buildPagesPdf = useCallback(async () => { const rows = pageStats.map((p) => ({ page: (p.page_title || p.page_path).slice(0, 120), visits: toSafeNumber(p.bot_visits).toLocaleString(), clicks: toSafeNumber(p.clicks).toLocaleString(), ctr: `${toSafeNumber(p.ctr).toFixed(1)}%`, models: Array.isArray(p.bots) && p.bots.length ? p.bots.join(', ') : '—', })); return createPagesReportPdf({ pluginUrl, siteName: getSiteNameForReports(), reportTitle: 'Pages analytics', metaLines: [ `Total pages in report: ${pageStats.length}`, `Time range: Last ${days} days`, 'Includes all pages with recorded LLM bot traffic.', ], rows, }); }, [pluginUrl, pageStats, days]); const isNeverVisitedMode = coverage === 'never_visited'; if (isLoading) { return ; } if (error) { return (

{error}

); } const isEmpty = pageStats.length === 0; const displayedPages = isNeverVisitedMode ? pageStats : pageStats.slice(0, displayCount); const hasMorePages = !isEmpty && (isNeverVisitedMode ? pageStats.length < (totalCount ?? pageStats.length) : pageStats.length > displayCount); const handleLoadMore = () => { if (isNeverVisitedMode) { setIsLoadingMore(true); fetchPageStats(false).finally(() => setIsLoadingMore(false)); return; } setIsLoadingMore(true); setTimeout(() => { setDisplayCount(prev => prev + ITEMS_PER_PAGE); setIsLoadingMore(false); }, 300); }; return (
{/* Pages Table - Clean design matching pages-overview */}
{/* Header — title and description only; filters stay outside the frost zone */}

{__('Pages', 'llmagnet-llm-txt-generator')}

{isNeverVisitedMode ? sprintf( __('Published pages with no LLM bot visits in the last %d days.', 'llmagnet-llm-txt-generator'), days ) : __('View which pages on your site have received traffic from LLM bots.', 'llmagnet-llm-txt-generator')}

{isNeverVisitedMode && totalCount != null && totalCount > 0 && (

{sprintf( _n('%d page never visited', '%d pages never visited', totalCount, 'llmagnet-llm-txt-generator'), totalCount )} {scanCapped ? ` · ${__('Count may be incomplete on very large sites.', 'llmagnet-llm-txt-generator')}` : ''}

)}
setCoverage(value as CoverageFilter)} size="sm" /> {pageStats.length > 0 && ( )}
{isEmpty ? ( ) : displayedPages.map((page, index) => ( handlePageClick(page.post_id, page.page_title)} style={{ cursor: page.post_id ? 'pointer' : 'default' }} > ))}
{__('Pages', 'llmagnet-llm-txt-generator')} {__('Score', 'llmagnet-llm-txt-generator')} {__('Bot Visits', 'llmagnet-llm-txt-generator')} {__('Clicks', 'llmagnet-llm-txt-generator')} {__('CTR', 'llmagnet-llm-txt-generator')} {__('Models', 'llmagnet-llm-txt-generator')}

{isNeverVisitedMode ? sprintf( __('Every tracked page received at least one LLM bot visit in the last %d days.', 'llmagnet-llm-txt-generator'), days ) : __('No bot visits', 'llmagnet-llm-txt-generator')}

{!isNeverVisitedMode && (

{__('Bot visits will be automatically logged when LLM crawlers access your pages.', 'llmagnet-llm-txt-generator')}

)}
{page.page_title} {page.never_visited && ( {sprintf(__('No visits · %dd', 'llmagnet-llm-txt-generator'), page.coverage_window_days ?? days)} )}
{page.page_path === '/' ? ( {__('Site Homepage', 'llmagnet-llm-txt-generator')} ) : ( {page.page_path} )} e.stopPropagation()} >
{toSafeNumber(page.bot_visits).toLocaleString()} {toSafeNumber(page.clicks).toLocaleString()} {toSafeNumber(page.ctr).toFixed(1)}%
{(Array.isArray(page.bots) ? page.bots : []).slice(0, 5).map((botName, botIndex) => { const logoUrl = botLogos[botName] || defaultLogo; return ( {botName} { (e.currentTarget as HTMLElement).style.transform = 'translateY(-4px)'; (e.currentTarget as HTMLElement).style.zIndex = '10'; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.transform = 'translateY(0)'; (e.currentTarget as HTMLElement).style.zIndex = String(botIndex); }} onError={(e) => { const target = e.target as HTMLImageElement; if (target.src !== defaultLogo) { target.src = defaultLogo; } }} /> ); })} {(Array.isArray(page.bots) ? page.bots.length : 0) > 5 && (
{ (e.currentTarget as HTMLElement).style.transform = 'translateY(-4px)'; (e.currentTarget as HTMLElement).style.zIndex = '10'; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.transform = 'translateY(0)'; (e.currentTarget as HTMLElement).style.zIndex = '5'; }} > +{(Array.isArray(page.bots) ? page.bots.length : 0) - 5}
)}
{/* Load More Button */} {hasMorePages && (
)} {/* Show count info when all loaded */} {!hasMorePages && pageStats.length > ITEMS_PER_PAGE && (
{sprintf(__('Showing all %d pages', 'llmagnet-llm-txt-generator'), pageStats.length)}
)}
{/* Page Details Drawer */} fetchPageStats(true)} />
); }