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:
@@ -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.
|
||||||
|
|||||||
652
app/sites/[id]/search/page.tsx
Normal file
652
app/sites/[id]/search/page.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
79
lib/api/gsc.ts
Normal 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}`)
|
||||||
|
}
|
||||||
@@ -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>(
|
||||||
|
|||||||
Reference in New Issue
Block a user