From 8f00193e0f3e6fc9fb59f39a0bc85f295408e4a0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 18:05:05 +0100 Subject: [PATCH] feat: add Search panel to dashboard and enrich Search tab Dashboard: compact Search Performance panel showing top 5 queries, clicks, impressions, and avg position alongside Campaigns. Search tab: clicks/impressions trend chart, top query position tracker cards, and new queries badge. --- CHANGELOG.md | 4 + app/sites/[id]/page.tsx | 4 + app/sites/[id]/search/page.tsx | 30 ++- components/dashboard/SearchPerformance.tsx | 125 ++++++++++++ components/search/ClicksImpressionsChart.tsx | 189 +++++++++++++++++++ lib/api/gsc.ts | 19 ++ lib/swr/dashboard.ts | 24 ++- 7 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 components/dashboard/SearchPerformance.tsx create mode 100644 components/search/ClicksImpressionsChart.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6c0d0..6020623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Google Search Console integration.** Connect your Google Search Console account in Settings > Integrations to see which search queries bring visitors to your site. A new "Search" tab on your dashboard shows total clicks, impressions, average CTR, and average ranking position — with percentage changes compared to the previous period. Browse your top search queries and top pages in sortable, paginated tables. Click any query to see which pages rank for it, or click any page to see which queries drive traffic to it. Pulse only requests read-only access to your Search Console data, encrypts your Google credentials, and lets you disconnect and fully remove all search data at any time. - **Integrations tab in Settings.** A new "Integrations" section in your site settings is where you connect and manage external services. Google Search Console is the first integration available — more will follow. +- **Search performance on your dashboard.** When Google Search Console is connected, a new "Search" panel appears on your main dashboard alongside Campaigns — showing your total clicks, impressions, and average position at a glance, plus your top 5 search queries. Click "View all" to dive deeper. +- **Clicks & Impressions trend chart.** The Search tab now includes a chart showing how your clicks and impressions change over time, so you can spot trends and correlate them with content changes. +- **Top query position cards.** Five compact cards at the top of the Search tab show your best-performing queries with their average ranking position and click count. +- **New queries indicator.** A green badge on the Search tab tells you how many new search queries appeared this period compared to the last — so you can see your search footprint growing. ### Fixed diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index f7e4ff4..7440315 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -36,6 +36,7 @@ const PerformanceStats = dynamic(() => import('@/components/dashboard/Performanc const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats')) const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns')) const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours')) +const SearchPerformance = dynamic(() => import('@/components/dashboard/SearchPerformance')) const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties')) const ExportModal = dynamic(() => import('@/components/dashboard/ExportModal')) import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters' @@ -614,6 +615,9 @@ export default function SiteDashboardPage() {
+ +
+
diff --git a/app/sites/[id]/search/page.tsx b/app/sites/[id]/search/page.tsx index 01c55f5..050298c 100644 --- a/app/sites/[id]/search/page.tsx +++ b/app/sites/[id]/search/page.tsx @@ -5,10 +5,11 @@ import { useParams } from 'next/navigation' import Link from 'next/link' import { getDateRange, formatDate, Select, DatePicker } from '@ciphera-net/ui' import { CaretDown, CaretUp, MagnifyingGlass, ArrowSquareOut } from '@phosphor-icons/react' -import { useDashboard, useGSCStatus, useGSCOverview, useGSCTopQueries, useGSCTopPages } from '@/lib/swr/dashboard' +import { useDashboard, useGSCStatus, useGSCOverview, useGSCTopQueries, useGSCTopPages, useGSCNewQueries } from '@/lib/swr/dashboard' import { getGSCQueryPages, getGSCPageQueries } from '@/lib/api/gsc' import type { GSCDataRow } from '@/lib/api/gsc' import { SkeletonLine, StatCardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' +import ClicksImpressionsChart from '@/components/search/ClicksImpressionsChart' // ─── Helpers ──────────────────────────────────────────────────── @@ -73,6 +74,7 @@ export default function SearchConsolePage() { const { data: overview } = useGSCOverview(siteId, dateRange.start, dateRange.end) const { data: topQueries, isLoading: queriesLoading } = useGSCTopQueries(siteId, dateRange.start, dateRange.end, PAGE_SIZE, queryPage * PAGE_SIZE) const { data: topPages, isLoading: pagesLoading } = useGSCTopPages(siteId, dateRange.start, dateRange.end, PAGE_SIZE, pagePage * PAGE_SIZE) + const { data: newQueries } = useGSCNewQueries(siteId, dateRange.start, dateRange.end) const showSkeleton = useMinimumLoading(!gscStatus) const fadeClass = useSkeletonFade(showSkeleton) @@ -287,6 +289,32 @@ export default function SearchConsolePage() { /> + + + {/* Position tracker */} + {topQueries?.queries && topQueries.queries.length > 0 && ( +
+ {topQueries.queries.slice(0, 5).map((q) => ( +
+

{q.query}

+
+

{q.position.toFixed(1)}

+

pos

+
+

{q.clicks} {q.clicks === 1 ? 'click' : 'clicks'}

+
+ ))} +
+ )} + + {/* New queries badge */} + {newQueries && newQueries.count > 0 && ( +
+ {newQueries.count} new {newQueries.count === 1 ? 'query' : 'queries'} + appeared this period +
+ )} + {/* View toggle */}
diff --git a/components/dashboard/SearchPerformance.tsx b/components/dashboard/SearchPerformance.tsx new file mode 100644 index 0000000..056a210 --- /dev/null +++ b/components/dashboard/SearchPerformance.tsx @@ -0,0 +1,125 @@ +'use client' + +import Link from 'next/link' +import { MagnifyingGlass, CaretUp, CaretDown } from '@phosphor-icons/react' +import { useGSCStatus, useGSCOverview, useGSCTopQueries } from '@/lib/swr/dashboard' + +interface SearchPerformanceProps { + siteId: string + dateRange: { start: string; end: string } +} + +function ChangeArrow({ current, previous, invert = false }: { current: number; previous: number; invert?: boolean }) { + if (!previous || previous === 0) return null + const improved = invert ? current < previous : current > previous + const same = current === previous + if (same) return null + return improved ? ( + + ) : ( + + ) +} + +export default function SearchPerformance({ siteId, dateRange }: SearchPerformanceProps) { + const { data: gscStatus } = useGSCStatus(siteId) + const { data: overview, isLoading: overviewLoading } = useGSCOverview(siteId, dateRange.start, dateRange.end) + const { data: queriesData, isLoading: queriesLoading } = useGSCTopQueries(siteId, dateRange.start, dateRange.end, 5, 0) + + // Don't render if GSC is not connected + if (!gscStatus?.connected) return null + + const isLoading = overviewLoading || queriesLoading + const queries = queriesData?.queries ?? [] + + return ( +
+ {/* Header */} +
+
+ +

+ Search +

+
+ + View all → + +
+ + {isLoading ? ( + /* Loading skeleton */ +
+
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+ ) : ( + <> + {/* Inline stats row */} +
+
+ Clicks + + {(overview?.total_clicks ?? 0).toLocaleString()} + + +
+
+ Impressions + + {(overview?.total_impressions ?? 0).toLocaleString()} + + +
+
+ Avg Position + + {(overview?.avg_position ?? 0).toFixed(1)} + + +
+
+ + {/* Top 5 queries list */} +
+ {queries.length > 0 ? ( + queries.map((q) => ( +
+ + {q.query} + +
+ + {q.clicks.toLocaleString()} + + + {q.position.toFixed(1)} + +
+
+ )) + ) : ( +
+

No search data yet

+
+ )} +
+ + )} +
+ ) +} diff --git a/components/search/ClicksImpressionsChart.tsx b/components/search/ClicksImpressionsChart.tsx new file mode 100644 index 0000000..b855d53 --- /dev/null +++ b/components/search/ClicksImpressionsChart.tsx @@ -0,0 +1,189 @@ +'use client' + +import { useMemo } from 'react' +import { useTheme } from '@ciphera-net/ui' +import { Area, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts' +import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6' +import { useGSCDailyTotals } from '@/lib/swr/dashboard' +import { SkeletonLine } from '@/components/skeletons' +import { formatDateShort } from '@/lib/utils/formatDate' + +// ─── Config ───────────────────────────────────────────────────── + +const chartConfig = { + clicks: { label: 'Clicks', color: '#FD5E0F' }, + impressions: { label: 'Impressions', color: '#9CA3AF' }, +} satisfies ChartConfig + +// ─── Custom Tooltip ───────────────────────────────────────────── + +interface TooltipProps { + active?: boolean + payload?: Array<{ dataKey: string; value: number; color: string }> + label?: string +} + +function CustomTooltip({ active, payload, label }: TooltipProps) { + if (!active || !payload?.length) return null + + const clicks = payload.find((p) => p.dataKey === 'clicks') + const impressions = payload.find((p) => p.dataKey === 'impressions') + + return ( +
+
{label}
+ {clicks && ( +
+
+ Clicks: + {clicks.value.toLocaleString()} +
+ )} + {impressions && ( +
+
+ Impressions: + {impressions.value.toLocaleString()} +
+ )} +
+ ) +} + +// ─── Component ────────────────────────────────────────────────── + +interface ClicksImpressionsChartProps { + siteId: string + startDate: string + endDate: string +} + +export default function ClicksImpressionsChart({ siteId, startDate, endDate }: ClicksImpressionsChartProps) { + const { resolvedTheme } = useTheme() + const { data, isLoading } = useGSCDailyTotals(siteId, startDate, endDate) + + const chartData = useMemo(() => { + if (!data?.daily_totals?.length) return [] + return data.daily_totals.map((item) => ({ + date: formatDateShort(new Date(item.date + 'T00:00:00')), + clicks: item.clicks, + impressions: item.impressions, + })) + }, [data]) + + // Loading skeleton + if (isLoading) { + return ( +
+ + +
+ ) + } + + // No data — don't render anything + if (!chartData.length) return null + + const gridStroke = resolvedTheme === 'dark' ? '#374151' : '#e5e7eb' + + return ( +
+

+ Clicks & Impressions +

+ + + + + + + + + + + + + + v.toLocaleString()} + /> + + v.toLocaleString()} + /> + + } cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} /> + + + + + + + +
+ ) +} diff --git a/lib/api/gsc.ts b/lib/api/gsc.ts index af83512..771be7e 100644 --- a/lib/api/gsc.ts +++ b/lib/api/gsc.ts @@ -77,3 +77,22 @@ export async function getGSCQueryPages(siteId: string, query: string, startDate: export async function getGSCPageQueries(siteId: string, page: string, startDate: string, endDate: string): Promise { return apiRequest(`/sites/${siteId}/gsc/page-queries?page=${encodeURIComponent(page)}&start_date=${startDate}&end_date=${endDate}`) } + +export interface GSCDailyTotal { + date: string + clicks: number + impressions: number +} + +export interface GSCNewQueries { + count: number + queries: string[] +} + +export async function getGSCDailyTotals(siteId: string, startDate: string, endDate: string): Promise<{ daily_totals: GSCDailyTotal[] }> { + return apiRequest<{ daily_totals: GSCDailyTotal[] }>(`/sites/${siteId}/gsc/daily-totals?start_date=${startDate}&end_date=${endDate}`) +} + +export async function getGSCNewQueries(siteId: string, startDate: string, endDate: string): Promise { + return apiRequest(`/sites/${siteId}/gsc/new-queries?start_date=${startDate}&end_date=${endDate}`) +} diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index 5fd9a6e..1241f2d 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -33,8 +33,8 @@ import { listFunnels, type Funnel } from '@/lib/api/funnels' import { getUptimeStatus, type UptimeStatusResponse } from '@/lib/api/uptime' import { listGoals, type Goal } from '@/lib/api/goals' import { listReportSchedules, type ReportSchedule } from '@/lib/api/report-schedules' -import { getGSCStatus, getGSCOverview, getGSCTopQueries, getGSCTopPages } from '@/lib/api/gsc' -import type { GSCStatus, GSCOverview, GSCQueryResponse, GSCPageResponse } from '@/lib/api/gsc' +import { getGSCStatus, getGSCOverview, getGSCTopQueries, getGSCTopPages, getGSCDailyTotals, getGSCNewQueries } from '@/lib/api/gsc' +import type { GSCStatus, GSCOverview, GSCQueryResponse, GSCPageResponse, GSCDailyTotal, GSCNewQueries } from '@/lib/api/gsc' import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' import type { Stats, @@ -84,6 +84,8 @@ const fetchers = { gscOverview: (siteId: string, start: string, end: string) => getGSCOverview(siteId, start, end), gscTopQueries: (siteId: string, start: string, end: string, limit: number, offset: number) => getGSCTopQueries(siteId, start, end, limit, offset), gscTopPages: (siteId: string, start: string, end: string, limit: number, offset: number) => getGSCTopPages(siteId, start, end, limit, offset), + gscDailyTotals: (siteId: string, start: string, end: string) => getGSCDailyTotals(siteId, start, end), + gscNewQueries: (siteId: string, start: string, end: string) => getGSCNewQueries(siteId, start, end), subscription: () => getSubscription(), } @@ -449,6 +451,24 @@ export function useGSCTopPages(siteId: string, start: string, end: string, limit ) } +// * Hook for GSC daily totals (clicks & impressions per day) +export function useGSCDailyTotals(siteId: string, start: string, end: string) { + return useSWR<{ daily_totals: GSCDailyTotal[] }>( + siteId && start && end ? ['gscDailyTotals', siteId, start, end] : null, + () => fetchers.gscDailyTotals(siteId, start, end), + dashboardSWRConfig + ) +} + +// * Hook for GSC new queries (queries that appeared in the current period) +export function useGSCNewQueries(siteId: string, start: string, end: string) { + return useSWR( + siteId && start && end ? ['gscNewQueries', siteId, start, end] : null, + () => fetchers.gscNewQueries(siteId, start, end), + dashboardSWRConfig + ) +} + // * Hook for subscription details (changes rarely) export function useSubscription() { return useSWR(