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

@@ -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'
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 { 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 { getGSCAuthURL, disconnectGSC } from '@/lib/api/gsc'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
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 { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
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 { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '@/lib/auth/context'
@@ -27,7 +28,7 @@ import {
AlertTriangleIcon,
ZapIcon,
} 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 = [
'UTC',
@@ -56,7 +57,8 @@ export default function SiteSettingsPage() {
const { data: site, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId)
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({
name: '',
@@ -93,6 +95,9 @@ export default function SiteSettingsPage() {
// Report schedules
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 [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null)
const [reportSaving, setReportSaving] = useState(false)
@@ -509,6 +514,29 @@ export default function SiteSettingsPage() {
if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
}, [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 fadeClass = useSkeletonFade(showSkeleton)
@@ -522,7 +550,7 @@ export default function SiteSettingsPage() {
</div>
<div className="flex flex-col md:flex-row gap-8">
<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" />
))}
</nav>
@@ -622,6 +650,19 @@ export default function SiteSettingsPage() {
<PaperPlaneTilt className="w-5 h-5" />
Reports
</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>
{/* Content Area */}
@@ -1403,6 +1444,164 @@ export default function SiteSettingsPage() {
)}
</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>
</div>
</div>