import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowDownIcon, ArrowUpIcon, ChartColumnIcon, ChevronLeftIcon, ChevronRightIcon, ExternalLinkIcon, LoaderCircleIcon, RefreshCwIcon, SearchIcon, UnplugIcon, } from 'lucide-react'; import { useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import ChartCanvas from '../../components/agent-analytics/ChartCanvas'; import { SeoPage, SeoPageHeader } from '../../components/seo/seo-page'; import { Button } from '../../components/ui/button'; import { Callout } from '../../components/ui/callout'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '../../components/ui/card'; import ConfirmDialog from '../../components/ui/confirm-dialog'; import EmptyState from '../../components/ui/empty-state'; import { Input } from '../../components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../../components/ui/table'; import { Tabs, TabsList, TabsTrigger } from '../../components/ui/tabs'; import { classNames } from '../../lib/utils'; import type { ISeoAnalyticsList, ISeoAnalyticsOverview, ISeoAnalyticsPeriod, ISeoAnalyticsRow, ISeoAnalyticsTotals, ISeoGoogleProperties, ISeoGoogleStatus, SeoAnalyticsSort, } from '../../service/seo/seo-analytics.interface'; import { chooseGoogleProperties, disconnectGoogle, fetchAnalyticsKeywords, fetchAnalyticsOverview, fetchAnalyticsPages, fetchGoogleConnectUrl, fetchGoogleProperties, fetchGoogleStatus, requestAnalyticsSync, } from '../../service/seo/seo-analytics.service'; import { PAGES } from './settings.copy'; const PERIODS = [ { key: '28d', label: 'Last 28 days', days: 28 }, { key: '3m', label: '3 months', days: 91 }, { key: '6m', label: '6 months', days: 182 }, { key: '12m', label: '12 months', days: 365 }, { key: '16m', label: '16 months', days: 487 }, ] as const; type PeriodKey = (typeof PERIODS)[number]['key']; // Search Console publishes a day two to three days after it ends. const LAG_DAYS = 3; const PER_PAGE = 25; const SYNC_POLL_MS = 10000; const NOT_CONNECTED: ISeoGoogleStatus = { connected: false, email: null, site_url: null, ga4_property: null, last_synced_at: null, syncing: false, rows: 0, error: null, }; const isoDay = (date: Date): string => date.toISOString().slice(0, 10); const periodOf = (key: PeriodKey): ISeoAnalyticsPeriod => { const days = PERIODS.find(period => period.key === key)?.days ?? 28; const to = new Date(); to.setUTCDate(to.getUTCDate() - LAG_DAYS); const from = new Date(to); from.setUTCDate(from.getUTCDate() - days + 1); return { from: isoDay(from), to: isoDay(to) }; }; const number = (value: number | null): string => Math.round(value ?? 0).toLocaleString(); const percent = (value: number | null): string => `${((value ?? 0) * 100).toFixed(1)}%`; const position = (value: number | null): string => value !== null && value > 0 ? value.toFixed(1) : '—'; const when = (iso: string | null): string => iso ? new Date(iso).toLocaleString() : 'never'; /** Change against the previous period, as a signed percentage; null without a base. */ const change = (now: number | null, before: number | null): number | null => now !== null && before !== null && before > 0 ? ((now - before) / before) * 100 : null; const Delta = ({ now, before, lowerIsBetter = false, }: { now: number | null; before: number | null; lowerIsBetter?: boolean; }): JSX.Element | null => { const value = change(now, before); if (value === null || Math.abs(value) < 0.05) { return —; } const good = lowerIsBetter ? value < 0 : value > 0; return ( {value > 0 ? ( ) : ( )} {Math.abs(value).toFixed(0)}% ); }; const METRICS: { key: SeoAnalyticsSort; label: string; format: (value: number | null) => string; lowerIsBetter?: boolean; hint: string; }[] = [ { key: 'clicks', label: 'Clicks', format: number, hint: 'Visits from Google search results.', }, { key: 'impressions', label: 'Impressions', format: number, hint: 'How often a page of the site appeared in results.', }, { key: 'ctr', label: 'CTR', format: percent, hint: 'Clicks divided by impressions.', }, { key: 'position', label: 'Position', format: position, lowerIsBetter: true, hint: 'Average rank in results, weighted by impressions; 1 is the top.', }, ]; const Tile = ({ metric, totals, previous, }: { metric: (typeof METRICS)[number]; totals: ISeoAnalyticsTotals; previous: ISeoAnalyticsTotals; }): JSX.Element => (
{metric.label}
{metric.format(totals[metric.key])}

{metric.hint}

); const chartData = (overview: ISeoAnalyticsOverview) => ({ labels: overview.daily.map(day => day.date.slice(5)), datasets: [ { label: 'Clicks', data: overview.daily.map(day => day.clicks), borderColor: '#b7007c', backgroundColor: 'rgba(183,0,124,0.08)', fill: true, tension: 0.35, pointRadius: 0, yAxisID: 'y', }, { label: 'Impressions', data: overview.daily.map(day => day.impressions), borderColor: '#60b5ff', backgroundColor: 'transparent', tension: 0.35, pointRadius: 0, yAxisID: 'y1', }, ], }); const CHART_OPTIONS = { interaction: { mode: 'index' as const, intersect: false }, plugins: { legend: { display: true, position: 'bottom' as const } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } }, y: { beginAtZero: true, position: 'left' as const }, y1: { beginAtZero: true, position: 'right' as const, grid: { drawOnChartArea: false }, }, }, }; const RowsTable = ({ title, keyLabel, period, fetchRows, linkKeys = false, }: { title: string; keyLabel: string; period: ISeoAnalyticsPeriod; fetchRows: ( query: Parameters[0] ) => Promise; linkKeys?: boolean; }): JSX.Element => { const [list, setList] = useState(null); const [sort, setSort] = useState('clicks'); const [order, setOrder] = useState<'asc' | 'desc'>('desc'); const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [typed, setTyped] = useState(''); const [error, setError] = useState(null); useEffect(() => { const handle = window.setTimeout(() => { setSearch(typed.trim()); setPage(1); }, 400); return () => window.clearTimeout(handle); }, [typed]); useEffect(() => { let live = true; fetchRows({ ...period, sort, order, page, per_page: PER_PAGE, search: search || undefined, }) .then(next => { if (live) { setList(next); setError(null); } }) .catch(reason => { if (live) setError((reason as Error).message); }); return () => { live = false; }; }, [fetchRows, period, sort, order, page, search]); const sortBy = (key: SeoAnalyticsSort): void => { if (key === sort) { setOrder(current => (current === 'desc' ? 'asc' : 'desc')); } else { setSort(key); setOrder(key === 'position' ? 'asc' : 'desc'); } setPage(1); }; const pages = list ? Math.max(1, list.total_pages) : 1; return ( {title} {list ? `${list.total.toLocaleString()} ${list.total === 1 ? 'row' : 'rows'} for ${period.from} to ${period.to}.` : 'Loading…'}
setTyped(event.target.value)} placeholder={`Search ${keyLabel.toLowerCase()}s`} className="pl-8" aria-label={`Search ${keyLabel.toLowerCase()}s`} />
{error && {error}}
{keyLabel} {METRICS.map(metric => ( ))} vs. before {(list?.rows ?? []).map((row: ISeoAnalyticsRow) => ( {linkKeys ? ( {row.key.replace(/^https?:\/\/[^/]+/, '') || row.key} ) : ( {row.key} )} {METRICS.map(metric => ( {metric.format(row[metric.key])} ))} {row.previous ? ( ) : ( new )} ))} {list && list.rows.length === 0 && ( Nothing for this period{search ? ' and search' : ''}. )}
{pages > 1 && (
Page {page} of {pages}
)}
); }; const ConnectCard = ({ busy, onConnect, }: { busy: boolean; onConnect: () => void; }): JSX.Element => ( Connect Google Search Console The queries people typed, the pages that answered, clicks, impressions and positions, kept by Recomaze and refreshed every day. Read-only: nothing in Search Console changes, and you can disconnect here at any time.

Google asks which account to use and shows the two read-only permissions before anything is shared.

); const PropertyPicker = ({ properties, busy, onPick, }: { properties: ISeoGoogleProperties | null; busy: boolean; onPick: (siteUrl: string) => void; }): JSX.Element => ( Which Search Console property is this site? The Google account is connected. Pick the property that matches this site; the first fetch brings the last 90 days. {!properties && (

Asking Google…

)} {properties && properties.sites.length === 0 && ( This Google account has no Search Console properties. Add the site in Search Console first, or connect another account. )} {(properties?.sites ?? []).map(site => ( ))}
); const SeoAnalytics = (): JSX.Element => { const [params, setParams] = useSearchParams(); const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [properties, setProperties] = useState( null ); const [periodKey, setPeriodKey] = useState('28d'); const [overview, setOverview] = useState(null); const [confirmDisconnect, setConfirmDisconnect] = useState(false); const poll = useRef(null); const period = useMemo(() => periodOf(periodKey), [periodKey]); const [notice, setNotice] = useState(null); const loadStatus = useCallback(async (): Promise => { try { const next = await fetchGoogleStatus(); setStatus(next); setError(null); setNotice(null); return next; } catch (reason) { const text = (reason as Error).message; // An API without the Google routes yet answers 404: show the page as // it will be, with a line saying why the button will not work today. if (/\b404\b/.test(text)) { setStatus(NOT_CONNECTED); setNotice( 'Google data is not switched on for this account on the Recomaze API yet.' ); return null; } setError(text); return null; } }, []); // Google sends the browser back here with ?google=connected (or error); // with a hash router it may sit before or after the hash. useEffect(() => { const outside = new URLSearchParams(window.location.search).get('google'); const inside = params.get('google'); const result = inside ?? outside; if (!result) return; if (result === 'connected') { toast.success('Google is connected.'); } else { toast.error( `Google did not connect${params.get('reason') ? `: ${params.get('reason')}` : '.'}` ); } if (inside) setParams({}, { replace: true }); if (outside) { const url = new URL(window.location.href); url.searchParams.delete('google'); url.searchParams.delete('reason'); window.history.replaceState(null, '', url.toString()); } }, [params, setParams]); useEffect(() => { void loadStatus(); }, [loadStatus]); // While the first fetch runs there is nothing to show; ask again until it lands. const syncing = Boolean( status?.connected && status.site_url && status.syncing ); useEffect(() => { if (!syncing) return undefined; poll.current = window.setInterval(() => void loadStatus(), SYNC_POLL_MS); return () => { if (poll.current) window.clearInterval(poll.current); }; }, [syncing, loadStatus]); useEffect(() => { if (status?.connected && !status.site_url && !properties) { fetchGoogleProperties() .then(setProperties) .catch(reason => setError((reason as Error).message)); } }, [status, properties]); const ready = Boolean( status?.connected && status.site_url && status.rows > 0 ); useEffect(() => { if (!ready) return undefined; let live = true; fetchAnalyticsOverview(period) .then(next => { if (live) setOverview(next); }) .catch(reason => { if (live) setError((reason as Error).message); }); return () => { live = false; }; }, [ready, period, status?.last_synced_at]); const connect = async (): Promise => { setBusy(true); try { const returnUrl = `${window.location.href.split('#')[0]}#/seo/analytics`; const { url } = await fetchGoogleConnectUrl(returnUrl); window.location.assign(url); } catch (reason) { toast.error((reason as Error).message); setBusy(false); } }; const pick = async (siteUrl: string): Promise => { setBusy(true); try { await chooseGoogleProperties({ site_url: siteUrl }); await loadStatus(); toast.success('Property saved; fetching the last 90 days.'); } catch (reason) { toast.error((reason as Error).message); } finally { setBusy(false); } }; const refresh = async (): Promise => { setBusy(true); try { await requestAnalyticsSync(); toast.info('Fetching fresh data from Google; a minute or two.'); await loadStatus(); } catch (reason) { toast.error((reason as Error).message); } finally { setBusy(false); } }; const disconnect = async (): Promise => { setBusy(true); try { await disconnectGoogle(); setOverview(null); setProperties(null); await loadStatus(); toast.success('Google disconnected; the cached data is gone.'); } catch (reason) { toast.error((reason as Error).message); } finally { setBusy(false); setConfirmDisconnect(false); } }; const data = useMemo( () => (overview ? chartData(overview) : null), [overview] ); return ( ) : undefined } /> {error && {error}} {notice && {notice}} {!status && !error && (

Checking the connection…

)} {status && !status.connected && ( )} {status?.connected && status.error === 'reconnect' && ( Google no longer accepts the saved access; connect again.{' '} )} {status?.connected && !status.site_url && ( )} {status?.connected && status.site_url && !ready && ( {syncing && } {syncing ? `Fetching the last 90 days for ${status.site_url} from Google; this page fills in by itself.` : status.error ? `The last fetch failed: ${status.error}` : `No data yet for ${status.site_url}.`} )} {ready && ( <>
setPeriodKey(next as PeriodKey)} > {PERIODS.map(item => ( {item.label} ))}

{status?.site_url} · {status?.email} · updated{' '} {when(overview?.last_synced_at ?? status?.last_synced_at ?? null)} {overview?.stale ? ' · refreshing' : ''}

{overview ? ( <>
{METRICS.map(metric => ( ))}
Clicks and impressions by day {period.from} to {period.to}, against the{' '} {overview.previous.from} {' – '} {overview.previous.to} before it. {data && overview.daily.length > 0 ? ( ) : ( )} ) : (

Loading…

)} )}
); }; export default SeoAnalytics;