From 34c705549b16d5403942c2896d9e78eff38eac13 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 15:36:37 +0100 Subject: [PATCH] feat: add Google Search Console integration UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search Console page with overview cards, top queries/pages tables, and query↔page drill-down. Integrations tab in Settings for connect/disconnect flow. New Search tab in site navigation. --- CHANGELOG.md | 5 + app/sites/[id]/search/page.tsx | 652 +++++++++++++++++++++++++++++++ app/sites/[id]/settings/page.tsx | 209 +++++++++- components/dashboard/SiteNav.tsx | 1 + lib/api/gsc.ts | 79 ++++ lib/swr/dashboard.ts | 46 +++ 6 files changed, 987 insertions(+), 5 deletions(-) create mode 100644 app/sites/[id]/search/page.tsx create mode 100644 lib/api/gsc.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aff8479..74b6bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- **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. + ### Improved - **Visit duration now works for single-page sessions.** Previously, if a visitor viewed only one page and left, the visit duration showed as "0s" because there was no second pageview to measure against. Pulse now tracks how long you actually spent on the page and reports real durations — even for single-page visits. This makes the Visit Duration metric, Journeys, and Top Paths much more accurate. diff --git a/app/sites/[id]/search/page.tsx b/app/sites/[id]/search/page.tsx new file mode 100644 index 0000000..01c55f5 --- /dev/null +++ b/app/sites/[id]/search/page.tsx @@ -0,0 +1,652 @@ +'use client' + +import { useEffect, useState } from 'react' +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 { getGSCQueryPages, getGSCPageQueries } from '@/lib/api/gsc' +import type { GSCDataRow } from '@/lib/api/gsc' +import { SkeletonLine, StatCardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' + +// ─── Helpers ──────────────────────────────────────────────────── + +function getThisWeekRange(): { start: string; end: string } { + const today = new Date() + const dayOfWeek = today.getDay() + const monday = new Date(today) + monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + return { start: formatDate(monday), end: formatDate(today) } +} + +function getThisMonthRange(): { start: string; end: string } { + const today = new Date() + const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1) + return { start: formatDate(firstOfMonth), end: formatDate(today) } +} + +const formatPosition = (pos: number) => pos.toFixed(1) +const formatCTR = (ctr: number) => (ctr * 100).toFixed(1) + '%' + +function formatChange(current: number, previous: number) { + if (previous === 0) return null + const change = ((current - previous) / previous) * 100 + return { value: change, label: (change >= 0 ? '+' : '') + change.toFixed(1) + '%' } +} + +function formatNumber(n: number) { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' + return n.toLocaleString() +} + +// ─── Page ─────────────────────────────────────────────────────── + +const PAGE_SIZE = 50 + +export default function SearchConsolePage() { + const params = useParams() + const siteId = params.id as string + + // Date range + const [period, setPeriod] = useState('28') + const [dateRange, setDateRange] = useState(() => getDateRange(28)) + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) + + // View toggle + const [activeView, setActiveView] = useState<'queries' | 'pages'>('queries') + + // Pagination + const [queryPage, setQueryPage] = useState(0) + const [pagePage, setPagePage] = useState(0) + + // Drill-down expansion + const [expandedQuery, setExpandedQuery] = useState(null) + const [expandedPage, setExpandedPage] = useState(null) + const [expandedData, setExpandedData] = useState([]) + const [expandedLoading, setExpandedLoading] = useState(false) + + // Data fetching + const { data: gscStatus } = useGSCStatus(siteId) + const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end) + 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 showSkeleton = useMinimumLoading(!gscStatus) + const fadeClass = useSkeletonFade(showSkeleton) + + // Document title + useEffect(() => { + const domain = dashboard?.site?.domain + document.title = domain ? `Search Console \u00b7 ${domain} | Pulse` : 'Search Console | Pulse' + }, [dashboard?.site?.domain]) + + // Reset pagination when date range changes + useEffect(() => { + setQueryPage(0) + setPagePage(0) + setExpandedQuery(null) + setExpandedPage(null) + setExpandedData([]) + }, [dateRange.start, dateRange.end]) + + // ─── Expand handlers ─────────────────────────────────────── + + async function handleExpandQuery(query: string) { + if (expandedQuery === query) { + setExpandedQuery(null) + setExpandedData([]) + return + } + setExpandedQuery(query) + setExpandedPage(null) + setExpandedLoading(true) + try { + const res = await getGSCQueryPages(siteId, query, dateRange.start, dateRange.end) + setExpandedData(res.pages) + } catch { + setExpandedData([]) + } finally { + setExpandedLoading(false) + } + } + + async function handleExpandPage(page: string) { + if (expandedPage === page) { + setExpandedPage(null) + setExpandedData([]) + return + } + setExpandedPage(page) + setExpandedQuery(null) + setExpandedLoading(true) + try { + const res = await getGSCPageQueries(siteId, page, dateRange.start, dateRange.end) + setExpandedData(res.queries) + } catch { + setExpandedData([]) + } finally { + setExpandedLoading(false) + } + } + + // ─── Loading skeleton ───────────────────────────────────── + + if (showSkeleton) { + return ( +
+
+
+ + +
+ +
+
+ + + + +
+
+ + {Array.from({ length: 10 }).map((_, i) => ( +
+ +
+ + + + +
+
+ ))} +
+
+ ) + } + + // ─── Not connected state ────────────────────────────────── + + if (gscStatus && !gscStatus.connected) { + return ( +
+
+
+ +
+

+ Connect Google Search Console +

+

+ See how your site performs in Google Search. View top queries, pages, click-through rates, and average position data. +

+ + Connect in Settings + + +
+
+ ) + } + + // ─── Connected — main view ──────────────────────────────── + + const clicksChange = overview ? formatChange(overview.total_clicks, overview.prev_clicks) : null + const impressionsChange = overview ? formatChange(overview.total_impressions, overview.prev_impressions) : null + const ctrChange = overview ? formatChange(overview.avg_ctr, overview.prev_avg_ctr) : null + // For position, lower is better — invert the direction + const positionChange = overview ? formatChange(overview.avg_position, overview.prev_avg_position) : null + + const queries = topQueries?.queries ?? [] + const queriesTotal = topQueries?.total ?? 0 + const pages = topPages?.pages ?? [] + const pagesTotal = topPages?.total ?? 0 + + return ( +
+ {/* Header */} +
+
+

+ Search Console +

+

+ Google Search performance, queries, and page rankings +

+
+