feat: add Google Search Console integration UI

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.
This commit is contained in:
Usman Baig
2026-03-14 15:36:37 +01:00
parent 9b7781115f
commit 34c705549b
6 changed files with 987 additions and 5 deletions

View File

@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased] ## [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 ### 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. - **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.

View File

@@ -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<string | null>(null)
const [expandedPage, setExpandedPage] = useState<string | null>(null)
const [expandedData, setExpandedData] = useState<GSCDataRow[]>([])
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 (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<SkeletonLine className="h-8 w-48 mb-2" />
<SkeletonLine className="h-4 w-64" />
</div>
<SkeletonLine className="h-9 w-36 rounded-lg" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
<SkeletonLine className="h-9 w-48 rounded-lg mb-6" />
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3">
<SkeletonLine className="h-4 w-1/3" />
<div className="flex gap-8">
<SkeletonLine className="h-4 w-16" />
<SkeletonLine className="h-4 w-16" />
<SkeletonLine className="h-4 w-12" />
<SkeletonLine className="h-4 w-12" />
</div>
</div>
))}
</div>
</div>
)
}
// ─── Not connected state ──────────────────────────────────
if (gscStatus && !gscStatus.connected) {
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6">
<MagnifyingGlass size={40} className="text-neutral-400 dark:text-neutral-500" />
</div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-2">
Connect Google Search Console
</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md mb-6">
See how your site performs in Google Search. View top queries, pages, click-through rates, and average position data.
</p>
<Link
href={`/sites/${siteId}/settings?tab=integrations`}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-orange hover:bg-brand-orange/90 text-white text-sm font-medium transition-colors"
>
Connect in Settings
<ArrowSquareOut size={16} weight="bold" />
</Link>
</div>
</div>
)
}
// ─── 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 (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
Search Console
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Google Search performance, queries, and page rankings
</p>
</div>
<Select
variant="input"
className="min-w-[140px]"
value={period}
onChange={(value) => {
if (value === 'today') {
const today = formatDate(new Date())
setDateRange({ start: today, end: today })
setPeriod('today')
} else if (value === '7') {
setDateRange(getDateRange(7))
setPeriod('7')
} else if (value === 'week') {
setDateRange(getThisWeekRange())
setPeriod('week')
} else if (value === '28') {
setDateRange(getDateRange(28))
setPeriod('28')
} else if (value === '30') {
setDateRange(getDateRange(30))
setPeriod('30')
} else if (value === 'month') {
setDateRange(getThisMonthRange())
setPeriod('month')
} else if (value === 'custom') {
setIsDatePickerOpen(true)
}
}}
options={[
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '28', label: 'Last 28 days' },
{ value: '30', label: 'Last 30 days' },
{ value: 'divider-1', label: '', divider: true },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: 'divider-2', label: '', divider: true },
{ value: 'custom', label: 'Custom' },
]}
/>
</div>
{/* Overview cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<OverviewCard
label="Total Clicks"
value={overview ? formatNumber(overview.total_clicks) : '-'}
change={clicksChange}
/>
<OverviewCard
label="Total Impressions"
value={overview ? formatNumber(overview.total_impressions) : '-'}
change={impressionsChange}
/>
<OverviewCard
label="Average CTR"
value={overview ? formatCTR(overview.avg_ctr) : '-'}
change={ctrChange}
/>
<OverviewCard
label="Average Position"
value={overview ? formatPosition(overview.avg_position) : '-'}
change={positionChange}
invertChange
/>
</div>
{/* View toggle */}
<div className="mb-6">
<div className="inline-flex bg-neutral-100 dark:bg-neutral-800 rounded-lg p-1">
<button
onClick={() => { setActiveView('queries'); setExpandedQuery(null); setExpandedData([]) }}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all cursor-pointer ${
activeView === 'queries'
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
Top Queries
</button>
<button
onClick={() => { setActiveView('pages'); setExpandedPage(null); setExpandedData([]) }}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all cursor-pointer ${
activeView === 'pages'
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
Top Pages
</button>
</div>
</div>
{/* Queries table */}
{activeView === 'queries' && (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 dark:border-neutral-800">
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400 w-8" />
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Query</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Clicks</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Impressions</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">CTR</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Position</th>
</tr>
</thead>
<tbody>
{queriesLoading && queries.length === 0 ? (
Array.from({ length: 10 }).map((_, i) => (
<tr key={i} className="border-b border-neutral-100 dark:border-neutral-800/50">
<td className="px-4 py-3" />
<td className="px-4 py-3"><SkeletonLine className="h-4 w-3/4" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-12 ml-auto" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-16 ml-auto" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-12 ml-auto" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-10 ml-auto" /></td>
</tr>
))
) : queries.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-neutral-500 dark:text-neutral-400">
No query data available for this period.
</td>
</tr>
) : (
queries.map((row) => (
<QueryRow
key={row.query}
row={row}
isExpanded={expandedQuery === row.query}
expandedData={expandedQuery === row.query ? expandedData : []}
expandedLoading={expandedQuery === row.query && expandedLoading}
onToggle={() => handleExpandQuery(row.query)}
/>
))
)}
</tbody>
</table>
{/* Pagination */}
{queriesTotal > PAGE_SIZE && (
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Showing {queryPage * PAGE_SIZE + 1}-{Math.min((queryPage + 1) * PAGE_SIZE, queriesTotal)} of {queriesTotal.toLocaleString()}
</p>
<div className="flex gap-2">
<button
disabled={queryPage === 0}
onClick={() => { setQueryPage((p) => p - 1); setExpandedQuery(null); setExpandedData([]) }}
className="px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
Previous
</button>
<button
disabled={(queryPage + 1) * PAGE_SIZE >= queriesTotal}
onClick={() => { setQueryPage((p) => p + 1); setExpandedQuery(null); setExpandedData([]) }}
className="px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
Next
</button>
</div>
</div>
)}
</div>
)}
{/* Pages table */}
{activeView === 'pages' && (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 dark:border-neutral-800">
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400 w-8" />
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Page</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Clicks</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Impressions</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">CTR</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Position</th>
</tr>
</thead>
<tbody>
{pagesLoading && pages.length === 0 ? (
Array.from({ length: 10 }).map((_, i) => (
<tr key={i} className="border-b border-neutral-100 dark:border-neutral-800/50">
<td className="px-4 py-3" />
<td className="px-4 py-3"><SkeletonLine className="h-4 w-3/4" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-12 ml-auto" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-16 ml-auto" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-12 ml-auto" /></td>
<td className="px-4 py-3"><SkeletonLine className="h-4 w-10 ml-auto" /></td>
</tr>
))
) : pages.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-neutral-500 dark:text-neutral-400">
No page data available for this period.
</td>
</tr>
) : (
pages.map((row) => (
<PageRow
key={row.page}
row={row}
isExpanded={expandedPage === row.page}
expandedData={expandedPage === row.page ? expandedData : []}
expandedLoading={expandedPage === row.page && expandedLoading}
onToggle={() => handleExpandPage(row.page)}
/>
))
)}
</tbody>
</table>
{/* Pagination */}
{pagesTotal > PAGE_SIZE && (
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Showing {pagePage * PAGE_SIZE + 1}-{Math.min((pagePage + 1) * PAGE_SIZE, pagesTotal)} of {pagesTotal.toLocaleString()}
</p>
<div className="flex gap-2">
<button
disabled={pagePage === 0}
onClick={() => { setPagePage((p) => p - 1); setExpandedPage(null); setExpandedData([]) }}
className="px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
Previous
</button>
<button
disabled={(pagePage + 1) * PAGE_SIZE >= pagesTotal}
onClick={() => { setPagePage((p) => p + 1); setExpandedPage(null); setExpandedData([]) }}
className="px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
Next
</button>
</div>
</div>
)}
</div>
)}
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
onApply={(range) => {
setDateRange(range)
setPeriod('custom')
setIsDatePickerOpen(false)
}}
initialRange={dateRange}
/>
</div>
)
}
// ─── Sub-components ─────────────────────────────────────────────
function OverviewCard({
label,
value,
change,
invertChange = false,
}: {
label: string
value: string
change: { value: number; label: string } | null
invertChange?: boolean
}) {
// For position, lower is better so a negative change is good
const isPositive = change ? (invertChange ? change.value < 0 : change.value > 0) : false
const isNegative = change ? (invertChange ? change.value > 0 : change.value < 0) : false
return (
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">{label}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{value}</p>
{change && (
<p className={`text-xs mt-1 font-medium ${
isPositive ? 'text-green-600 dark:text-green-400' :
isNegative ? 'text-red-600 dark:text-red-400' :
'text-neutral-500 dark:text-neutral-400'
}`}>
{change.label} vs previous period
</p>
)}
</div>
)
}
function QueryRow({
row,
isExpanded,
expandedData,
expandedLoading,
onToggle,
}: {
row: GSCDataRow
isExpanded: boolean
expandedData: GSCDataRow[]
expandedLoading: boolean
onToggle: () => void
}) {
const Caret = isExpanded ? CaretUp : CaretDown
return (
<>
<tr
onClick={onToggle}
className="border-b border-neutral-100 dark:border-neutral-800/50 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer transition-colors"
>
<td className="px-4 py-3 text-neutral-400 dark:text-neutral-500">
<Caret size={14} />
</td>
<td className="px-4 py-3 text-neutral-900 dark:text-white font-medium">{row.query}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.clicks.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.impressions.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatCTR(row.ctr)}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatPosition(row.position)}</td>
</tr>
{isExpanded && (
<tr className="bg-neutral-50 dark:bg-neutral-800/30">
<td colSpan={6} className="px-4 py-3">
{expandedLoading ? (
<div className="space-y-2 py-1">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonLine key={i} className="h-4 w-full" />
))}
</div>
) : expandedData.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-1">No pages found for this query.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr>
<th className="text-left px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Page</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Clicks</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Impressions</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">CTR</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Position</th>
</tr>
</thead>
<tbody>
{expandedData.map((sub) => (
<tr key={sub.page} className="border-t border-neutral-200/50 dark:border-neutral-700/50">
<td className="px-2 py-1.5 text-neutral-700 dark:text-neutral-300 max-w-md truncate" title={sub.page}>{sub.page}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{sub.clicks.toLocaleString()}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{sub.impressions.toLocaleString()}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{formatCTR(sub.ctr)}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{formatPosition(sub.position)}</td>
</tr>
))}
</tbody>
</table>
)}
</td>
</tr>
)}
</>
)
}
function PageRow({
row,
isExpanded,
expandedData,
expandedLoading,
onToggle,
}: {
row: GSCDataRow
isExpanded: boolean
expandedData: GSCDataRow[]
expandedLoading: boolean
onToggle: () => void
}) {
const Caret = isExpanded ? CaretUp : CaretDown
return (
<>
<tr
onClick={onToggle}
className="border-b border-neutral-100 dark:border-neutral-800/50 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer transition-colors"
>
<td className="px-4 py-3 text-neutral-400 dark:text-neutral-500">
<Caret size={14} />
</td>
<td className="px-4 py-3 text-neutral-900 dark:text-white font-medium max-w-md truncate" title={row.page}>{row.page}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.clicks.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{row.impressions.toLocaleString()}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatCTR(row.ctr)}</td>
<td className="px-4 py-3 text-right text-neutral-700 dark:text-neutral-300 tabular-nums">{formatPosition(row.position)}</td>
</tr>
{isExpanded && (
<tr className="bg-neutral-50 dark:bg-neutral-800/30">
<td colSpan={6} className="px-4 py-3">
{expandedLoading ? (
<div className="space-y-2 py-1">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonLine key={i} className="h-4 w-full" />
))}
</div>
) : expandedData.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-1">No queries found for this page.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr>
<th className="text-left px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Query</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Clicks</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Impressions</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">CTR</th>
<th className="text-right px-2 py-1.5 text-xs font-medium text-neutral-400 dark:text-neutral-500">Position</th>
</tr>
</thead>
<tbody>
{expandedData.map((sub) => (
<tr key={sub.query} className="border-t border-neutral-200/50 dark:border-neutral-700/50">
<td className="px-2 py-1.5 text-neutral-700 dark:text-neutral-300">{sub.query}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{sub.clicks.toLocaleString()}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{sub.impressions.toLocaleString()}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{formatCTR(sub.ctr)}</td>
<td className="px-2 py-1.5 text-right text-neutral-600 dark:text-neutral-400 tabular-nums">{formatPosition(sub.position)}</td>
</tr>
))}
</tbody>
</table>
)}
</td>
</tr>
)}
</>
)
}

View File

@@ -1,10 +1,11 @@
'use client' 'use client'
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites' import { updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
import { createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' import { createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
import { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules' import { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules'
import { getGSCAuthURL, disconnectGSC } from '@/lib/api/gsc'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatDateTime } from '@/lib/utils/formatDate' import { formatDateTime } from '@/lib/utils/formatDate'
@@ -16,7 +17,7 @@ import { Select, Modal, Button } from '@ciphera-net/ui'
import { APP_URL } from '@/lib/api/client' import { APP_URL } from '@/lib/api/client'
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges' import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges'
import { useSite, useGoals, useReportSchedules, useSubscription } from '@/lib/swr/dashboard' import { useSite, useGoals, useReportSchedules, useSubscription, useGSCStatus } from '@/lib/swr/dashboard'
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans' import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
@@ -27,7 +28,7 @@ import {
AlertTriangleIcon, AlertTriangleIcon,
ZapIcon, ZapIcon,
} from '@ciphera-net/ui' } from '@ciphera-net/ui'
import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play } from '@phosphor-icons/react' import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play, Plugs } from '@phosphor-icons/react'
const TIMEZONES = [ const TIMEZONES = [
'UTC', 'UTC',
@@ -56,7 +57,8 @@ export default function SiteSettingsPage() {
const { data: site, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId) const { data: site, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports'>('general') const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports' | 'integrations'>('general')
const searchParams = useSearchParams()
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@@ -93,6 +95,9 @@ export default function SiteSettingsPage() {
// Report schedules // Report schedules
const { data: reportSchedules = [], isLoading: reportLoading, mutate: mutateReportSchedules } = useReportSchedules(siteId) const { data: reportSchedules = [], isLoading: reportLoading, mutate: mutateReportSchedules } = useReportSchedules(siteId)
const { data: gscStatus, mutate: mutateGSCStatus } = useGSCStatus(siteId)
const [gscConnecting, setGscConnecting] = useState(false)
const [gscDisconnecting, setGscDisconnecting] = useState(false)
const [reportModalOpen, setReportModalOpen] = useState(false) const [reportModalOpen, setReportModalOpen] = useState(false)
const [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null) const [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null)
const [reportSaving, setReportSaving] = useState(false) const [reportSaving, setReportSaving] = useState(false)
@@ -509,6 +514,29 @@ export default function SiteSettingsPage() {
if (site?.domain) document.title = `Settings · ${site.domain} | Pulse` if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
}, [site?.domain]) }, [site?.domain])
// Handle GSC OAuth callback query params
useEffect(() => {
const gsc = searchParams.get('gsc')
if (!gsc) return
switch (gsc) {
case 'connected':
toast.success('Google Search Console connected successfully')
mutateGSCStatus()
break
case 'denied':
toast.error('Google authorization was denied')
break
case 'no_property':
toast.error('No matching Search Console property found for this site')
break
case 'error':
toast.error('Failed to connect Google Search Console')
break
}
setActiveTab('integrations')
window.history.replaceState({}, '', window.location.pathname)
}, [searchParams, mutateGSCStatus])
const showSkeleton = useMinimumLoading(siteLoading && !site) const showSkeleton = useMinimumLoading(siteLoading && !site)
const fadeClass = useSkeletonFade(showSkeleton) const fadeClass = useSkeletonFade(showSkeleton)
@@ -522,7 +550,7 @@ export default function SiteSettingsPage() {
</div> </div>
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col md:flex-row gap-8">
<nav className="w-full md:w-64 flex-shrink-0 space-y-1"> <nav className="w-full md:w-64 flex-shrink-0 space-y-1">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" /> <div key={i} className="h-12 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
))} ))}
</nav> </nav>
@@ -622,6 +650,19 @@ export default function SiteSettingsPage() {
<PaperPlaneTilt className="w-5 h-5" /> <PaperPlaneTilt className="w-5 h-5" />
Reports Reports
</button> </button>
<button
onClick={() => setActiveTab('integrations')}
role="tab"
aria-selected={activeTab === 'integrations'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'integrations'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
<Plugs className="w-5 h-5" />
Integrations
</button>
</nav> </nav>
{/* Content Area */} {/* Content Area */}
@@ -1403,6 +1444,164 @@ export default function SiteSettingsPage() {
)} )}
</div> </div>
)} )}
{activeTab === 'integrations' && (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Integrations</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Connect external services to enrich your analytics data.</p>
</div>
{/* Google Search Console */}
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 p-6">
{!gscStatus?.connected ? (
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="p-2.5 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex-shrink-0">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1Z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23Z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62Z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53Z" fill="#EA4335"/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Google Search Console</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
See which search queries bring visitors to your site, with impressions, clicks, CTR, and ranking position.
</p>
</div>
</div>
<div className="flex items-start gap-2 p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<svg className="w-4 h-4 text-neutral-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Pulse only requests read-only access. Your tokens are encrypted at rest and all data can be fully removed at any time.
</p>
</div>
{canEdit && (
<button
onClick={async () => {
setGscConnecting(true)
try {
const { auth_url } = await getGSCAuthURL(siteId)
window.location.href = auth_url
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to start Google authorization')
setGscConnecting(false)
}
}}
disabled={gscConnecting}
className="inline-flex items-center gap-2 px-4 py-2.5 bg-brand-orange text-white text-sm font-medium rounded-xl hover:bg-brand-orange/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 disabled:opacity-50"
>
{gscConnecting && <SpinnerGap className="w-4 h-4 animate-spin" />}
Connect Google Search Console
</button>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="p-2.5 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex-shrink-0">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1Z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23Z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62Z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53Z" fill="#EA4335"/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Google Search Console</h3>
<div className="flex items-center gap-2 mt-1.5">
<span className={`inline-flex items-center gap-1.5 text-xs font-medium ${
gscStatus.status === 'active'
? 'text-green-600 dark:text-green-400'
: gscStatus.status === 'syncing'
? 'text-amber-600 dark:text-amber-400'
: 'text-red-600 dark:text-red-400'
}`}>
<span className={`w-2 h-2 rounded-full ${
gscStatus.status === 'active'
? 'bg-green-500'
: gscStatus.status === 'syncing'
? 'bg-amber-500 animate-pulse'
: 'bg-red-500'
}`} />
{gscStatus.status === 'active' ? 'Connected' : gscStatus.status === 'syncing' ? 'Syncing...' : 'Error'}
</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{gscStatus.google_email && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Google Account</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{gscStatus.google_email}</p>
</div>
)}
{gscStatus.gsc_property && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Property</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5 truncate">{gscStatus.gsc_property}</p>
</div>
)}
{gscStatus.last_synced_at && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Last Synced</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
{new Date(gscStatus.last_synced_at).toLocaleString('en-GB')}
</p>
</div>
)}
{gscStatus.created_at && (
<div className="p-3 bg-white dark:bg-neutral-800/50 rounded-lg border border-neutral-200 dark:border-neutral-700">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Connected Since</p>
<p className="text-sm font-medium text-neutral-900 dark:text-white mt-0.5">
{new Date(gscStatus.created_at).toLocaleString('en-GB')}
</p>
</div>
)}
</div>
{gscStatus.status === 'error' && gscStatus.error_message && (
<div className="p-3 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/30">
<p className="text-sm text-red-700 dark:text-red-300">{gscStatus.error_message}</p>
</div>
)}
{canEdit && (
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<button
onClick={async () => {
if (!confirm('Disconnect Google Search Console? All search data will be removed from Pulse.')) return
setGscDisconnecting(true)
try {
await disconnectGSC(siteId)
mutateGSCStatus()
toast.success('Google Search Console disconnected')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to disconnect')
} finally {
setGscDisconnecting(false)
}
}}
disabled={gscDisconnecting}
className="inline-flex items-center gap-2 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors disabled:opacity-50"
>
{gscDisconnecting && <SpinnerGap className="w-4 h-4 animate-spin" />}
Disconnect
</button>
</div>
)}
</div>
)}
</div>
</div>
)}
</motion.div> </motion.div>
</div> </div>
</div> </div>

View File

@@ -21,6 +21,7 @@ export default function SiteNav({ siteId }: SiteNavProps) {
{ label: 'Journeys', href: `/sites/${siteId}/journeys` }, { label: 'Journeys', href: `/sites/${siteId}/journeys` },
{ label: 'Funnels', href: `/sites/${siteId}/funnels` }, { label: 'Funnels', href: `/sites/${siteId}/funnels` },
{ label: 'Behavior', href: `/sites/${siteId}/behavior` }, { label: 'Behavior', href: `/sites/${siteId}/behavior` },
{ label: 'Search', href: `/sites/${siteId}/search` },
{ label: 'Uptime', href: `/sites/${siteId}/uptime` }, { label: 'Uptime', href: `/sites/${siteId}/uptime` },
...(canEdit ? [{ label: 'Settings', href: `/sites/${siteId}/settings` }] : []), ...(canEdit ? [{ label: 'Settings', href: `/sites/${siteId}/settings` }] : []),
] ]

79
lib/api/gsc.ts Normal file
View File

@@ -0,0 +1,79 @@
import apiRequest from './client'
// ─── Types ──────────────────────────────────────────────────────────
export interface GSCStatus {
connected: boolean
google_email?: string
gsc_property?: string
status?: 'active' | 'syncing' | 'error'
error_message?: string | null
last_synced_at?: string | null
created_at?: string
}
export interface GSCOverview {
total_clicks: number
total_impressions: number
avg_ctr: number
avg_position: number
prev_clicks: number
prev_impressions: number
prev_avg_ctr: number
prev_avg_position: number
}
export interface GSCDataRow {
query: string
page: string
impressions: number
clicks: number
ctr: number
position: number
}
export interface GSCQueryResponse {
queries: GSCDataRow[]
total: number
}
export interface GSCPageResponse {
pages: GSCDataRow[]
total: number
}
// ─── API Functions ──────────────────────────────────────────────────
export async function getGSCAuthURL(siteId: string): Promise<{ auth_url: string }> {
return apiRequest<{ auth_url: string }>(`/sites/${siteId}/integrations/gsc/auth-url`)
}
export async function getGSCStatus(siteId: string): Promise<GSCStatus> {
return apiRequest<GSCStatus>(`/sites/${siteId}/integrations/gsc/status`)
}
export async function disconnectGSC(siteId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/integrations/gsc`, {
method: 'DELETE',
})
}
export async function getGSCOverview(siteId: string, startDate: string, endDate: string): Promise<GSCOverview> {
return apiRequest<GSCOverview>(`/sites/${siteId}/gsc/overview?start_date=${startDate}&end_date=${endDate}`)
}
export async function getGSCTopQueries(siteId: string, startDate: string, endDate: string, limit = 50, offset = 0): Promise<GSCQueryResponse> {
return apiRequest<GSCQueryResponse>(`/sites/${siteId}/gsc/top-queries?start_date=${startDate}&end_date=${endDate}&limit=${limit}&offset=${offset}`)
}
export async function getGSCTopPages(siteId: string, startDate: string, endDate: string, limit = 50, offset = 0): Promise<GSCPageResponse> {
return apiRequest<GSCPageResponse>(`/sites/${siteId}/gsc/top-pages?start_date=${startDate}&end_date=${endDate}&limit=${limit}&offset=${offset}`)
}
export async function getGSCQueryPages(siteId: string, query: string, startDate: string, endDate: string): Promise<GSCPageResponse> {
return apiRequest<GSCPageResponse>(`/sites/${siteId}/gsc/query-pages?query=${encodeURIComponent(query)}&start_date=${startDate}&end_date=${endDate}`)
}
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}`)
}

View File

@@ -33,6 +33,8 @@ import { listFunnels, type Funnel } from '@/lib/api/funnels'
import { getUptimeStatus, type UptimeStatusResponse } from '@/lib/api/uptime' import { getUptimeStatus, type UptimeStatusResponse } from '@/lib/api/uptime'
import { listGoals, type Goal } from '@/lib/api/goals' import { listGoals, type Goal } from '@/lib/api/goals'
import { listReportSchedules, type ReportSchedule } from '@/lib/api/report-schedules' 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 { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
import type { import type {
Stats, Stats,
@@ -78,6 +80,10 @@ const fetchers = {
uptimeStatus: (siteId: string) => getUptimeStatus(siteId), uptimeStatus: (siteId: string) => getUptimeStatus(siteId),
goals: (siteId: string) => listGoals(siteId), goals: (siteId: string) => listGoals(siteId),
reportSchedules: (siteId: string) => listReportSchedules(siteId), reportSchedules: (siteId: string) => listReportSchedules(siteId),
gscStatus: (siteId: string) => getGSCStatus(siteId),
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),
subscription: () => getSubscription(), subscription: () => getSubscription(),
} }
@@ -397,6 +403,46 @@ export function useReportSchedules(siteId: string) {
) )
} }
// * Hook for GSC connection status
export function useGSCStatus(siteId: string) {
return useSWR<GSCStatus>(
siteId ? ['gscStatus', siteId] : null,
() => fetchers.gscStatus(siteId),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 30 * 1000,
}
)
}
// * Hook for GSC overview metrics (clicks, impressions, CTR, position)
export function useGSCOverview(siteId: string, start: string, end: string) {
return useSWR<GSCOverview>(
siteId && start && end ? ['gscOverview', siteId, start, end] : null,
() => fetchers.gscOverview(siteId, start, end),
dashboardSWRConfig
)
}
// * Hook for GSC top queries
export function useGSCTopQueries(siteId: string, start: string, end: string, limit = 50, offset = 0) {
return useSWR<GSCQueryResponse>(
siteId && start && end ? ['gscTopQueries', siteId, start, end, limit, offset] : null,
() => fetchers.gscTopQueries(siteId, start, end, limit, offset),
dashboardSWRConfig
)
}
// * Hook for GSC top pages
export function useGSCTopPages(siteId: string, start: string, end: string, limit = 50, offset = 0) {
return useSWR<GSCPageResponse>(
siteId && start && end ? ['gscTopPages', siteId, start, end, limit, offset] : null,
() => fetchers.gscTopPages(siteId, start, end, limit, offset),
dashboardSWRConfig
)
}
// * Hook for subscription details (changes rarely) // * Hook for subscription details (changes rarely)
export function useSubscription() { export function useSubscription() {
return useSWR<SubscriptionDetails>( return useSWR<SubscriptionDetails>(