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.
This commit is contained in:
Usman Baig
2026-03-14 18:05:05 +01:00
parent af29bb77cd
commit 8f00193e0f
7 changed files with 392 additions and 3 deletions

View File

@@ -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

View File

@@ -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() {
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<Campaigns siteId={siteId} dateRange={dateRange} filters={filtersParam || undefined} onFilter={handleAddFilter} />
<SearchPerformance siteId={siteId} dateRange={dateRange} />
</div>
<div className="mb-8">
<PeakHours siteId={siteId} dateRange={dateRange} />
</div>

View File

@@ -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() {
/>
</div>
<ClicksImpressionsChart siteId={siteId} startDate={dateRange.start} endDate={dateRange.end} />
{/* Position tracker */}
{topQueries?.queries && topQueries.queries.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 mb-6">
{topQueries.queries.slice(0, 5).map((q) => (
<div key={q.query} className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate mb-1">{q.query}</p>
<div className="flex items-baseline gap-1.5">
<p className="text-lg font-semibold text-neutral-900 dark:text-white">{q.position.toFixed(1)}</p>
<p className="text-xs text-neutral-400">pos</p>
</div>
<p className="text-xs text-neutral-500 mt-0.5">{q.clicks} {q.clicks === 1 ? 'click' : 'clicks'}</p>
</div>
))}
</div>
)}
{/* New queries badge */}
{newQueries && newQueries.count > 0 && (
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-sm mb-4">
<span className="font-medium">{newQueries.count} new {newQueries.count === 1 ? 'query' : 'queries'}</span>
<span className="text-green-600 dark:text-green-400">appeared this period</span>
</div>
)}
{/* View toggle */}
<div className="mb-6">
<div className="inline-flex bg-neutral-100 dark:bg-neutral-800 rounded-lg p-1">

View File

@@ -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 ? (
<CaretUp className="w-3 h-3 text-emerald-500" weight="fill" />
) : (
<CaretDown className="w-3 h-3 text-red-500" weight="fill" />
)
}
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 (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<MagnifyingGlass className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Search
</h3>
</div>
<Link
href={`/sites/${siteId}/search`}
className="text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
View all &rarr;
</Link>
</div>
{isLoading ? (
/* Loading skeleton */
<div className="flex-1 space-y-4">
<div className="flex items-center gap-6">
<div className="h-4 w-20 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
<div className="h-4 w-24 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
<div className="h-4 w-20 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
</div>
<div className="space-y-2 mt-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-9 bg-neutral-100 dark:bg-neutral-800 rounded-lg animate-pulse" />
))}
</div>
</div>
) : (
<>
{/* Inline stats row */}
<div className="flex items-center gap-5 mb-4">
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-500 dark:text-neutral-400">Clicks</span>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{(overview?.total_clicks ?? 0).toLocaleString()}
</span>
<ChangeArrow current={overview?.total_clicks ?? 0} previous={overview?.prev_clicks ?? 0} />
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-500 dark:text-neutral-400">Impressions</span>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{(overview?.total_impressions ?? 0).toLocaleString()}
</span>
<ChangeArrow current={overview?.total_impressions ?? 0} previous={overview?.prev_impressions ?? 0} />
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-500 dark:text-neutral-400">Avg Position</span>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{(overview?.avg_position ?? 0).toFixed(1)}
</span>
<ChangeArrow current={overview?.avg_position ?? 0} previous={overview?.prev_avg_position ?? 0} invert />
</div>
</div>
{/* Top 5 queries list */}
<div className="space-y-1 flex-1">
{queries.length > 0 ? (
queries.map((q) => (
<div
key={q.query}
className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors"
>
<span className="text-sm text-neutral-900 dark:text-white truncate flex-1 min-w-0" title={q.query}>
{q.query}
</span>
<div className="flex items-center gap-3 ml-4 shrink-0">
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{q.clicks.toLocaleString()}
</span>
<span className="text-xs text-neutral-400 dark:text-neutral-500 bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded font-medium">
{q.position.toFixed(1)}
</span>
</div>
</div>
))
) : (
<div className="flex-1 flex items-center justify-center py-6">
<p className="text-sm text-neutral-400 dark:text-neutral-500">No search data yet</p>
</div>
)}
</div>
</>
)}
</div>
)
}

View File

@@ -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 (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-3 shadow-sm shadow-black/5 min-w-[140px]">
<div className="text-xs text-neutral-500 dark:text-neutral-400 mb-1.5">{label}</div>
{clicks && (
<div className="flex items-center gap-2 text-sm">
<div className="size-1.5 rounded-full" style={{ backgroundColor: '#FD5E0F' }} />
<span className="text-neutral-500 dark:text-neutral-400">Clicks:</span>
<span className="font-semibold text-neutral-900 dark:text-white">{clicks.value.toLocaleString()}</span>
</div>
)}
{impressions && (
<div className="flex items-center gap-2 text-sm mt-1">
<div className="size-1.5 rounded-full" style={{ backgroundColor: '#9CA3AF' }} />
<span className="text-neutral-500 dark:text-neutral-400">Impressions:</span>
<span className="font-semibold text-neutral-900 dark:text-white">{impressions.value.toLocaleString()}</span>
</div>
)}
</div>
)
}
// ─── 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 (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 mb-6">
<SkeletonLine className="h-4 w-36 mb-3" />
<SkeletonLine className="h-64 w-full rounded-lg" />
</div>
)
}
// No data — don't render anything
if (!chartData.length) return null
const gridStroke = resolvedTheme === 'dark' ? '#374151' : '#e5e7eb'
return (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 mb-6">
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">
Clicks &amp; Impressions
</p>
<ChartContainer
config={chartConfig}
className="h-64 w-full [&_.recharts-curve.recharts-tooltip-cursor]:stroke-[initial]"
>
<ComposedChart
data={chartData}
margin={{ top: 8, right: 8, left: 0, bottom: 8 }}
>
<defs>
<linearGradient id="clicksFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#FD5E0F" stopOpacity={0.15} />
<stop offset="100%" stopColor="#FD5E0F" stopOpacity={0.01} />
</linearGradient>
</defs>
<CartesianGrid
horizontal={true}
vertical={false}
stroke={gridStroke}
strokeOpacity={0.7}
/>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
tickMargin={8}
minTickGap={32}
/>
<YAxis
yAxisId="left"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
tickMargin={8}
tickCount={5}
tickFormatter={(v: number) => v.toLocaleString()}
/>
<YAxis
yAxisId="right"
orientation="right"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
tickMargin={8}
tickCount={5}
tickFormatter={(v: number) => v.toLocaleString()}
/>
<ChartTooltip content={<CustomTooltip />} cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} />
<Area
yAxisId="left"
type="bump"
dataKey="clicks"
fill="url(#clicksFill)"
stroke="none"
/>
<Line
yAxisId="left"
type="bump"
dataKey="clicks"
stroke="#FD5E0F"
strokeWidth={2}
dot={false}
activeDot={{
r: 5,
fill: '#FD5E0F',
stroke: 'white',
strokeWidth: 2,
}}
/>
<Line
yAxisId="right"
type="bump"
dataKey="impressions"
stroke="#9CA3AF"
strokeWidth={2}
dot={false}
strokeDasharray="4 3"
activeDot={{
r: 5,
fill: '#9CA3AF',
stroke: 'white',
strokeWidth: 2,
}}
/>
</ComposedChart>
</ChartContainer>
</div>
)
}

View File

@@ -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<GSCQueryResponse> {
return apiRequest<GSCQueryResponse>(`/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<GSCNewQueries> {
return apiRequest<GSCNewQueries>(`/sites/${siteId}/gsc/new-queries?start_date=${startDate}&end_date=${endDate}`)
}

View File

@@ -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<GSCNewQueries>(
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<SubscriptionDetails>(