- |
+ |
{org.business_name || 'N/A'}
|
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index e4aa99d..05bb448 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -9,9 +9,9 @@ export default function AdminDashboard() {
href="/admin/orgs"
className="block transition-transform hover:scale-[1.02] rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm"
>
- Organizations
- Manage organization plans and limits
-
+ Organizations
+ Manage organization plans and limits
+
View all organizations, check billing status, and manually grant plans.
@@ -19,9 +19,9 @@ export default function AdminDashboard() {
href="/admin/filtered-traffic"
className="block transition-transform hover:scale-[1.02] rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm"
>
- Filtered Traffic
- Monitor blocked referrer spam
-
+ Filtered Traffic
+ Monitor blocked referrer spam
+
View domains blocked by the spam filter and check for false positives.
diff --git a/app/changelog/page.tsx b/app/changelog/page.tsx
index c0b9c84..39d8366 100644
--- a/app/changelog/page.tsx
+++ b/app/changelog/page.tsx
@@ -18,7 +18,7 @@ export default function ChangelogPage() {
return (
-
+
Changelog
diff --git a/app/not-found.tsx b/app/not-found.tsx
index 924cc99..90efa7c 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -14,9 +14,11 @@ export default function NotFound() {
-
- 404
-
+
Page not found
diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx
index 1e7ea60..eb31ee3 100644
--- a/app/notifications/page.tsx
+++ b/app/notifications/page.tsx
@@ -122,8 +122,8 @@ export default function NotificationsPage() {
)}
- Notifications
-
+ Notifications
+
Manage which notifications you receive in{' '}
Organization Settings → Notifications
@@ -137,7 +137,7 @@ export default function NotificationsPage() {
{error}
) : notifications.length === 0 ? (
-
+
No notifications yet
Manage which notifications you receive in{' '}
@@ -159,11 +159,11 @@ export default function NotificationsPage() {
{getTypeIcon(n.type)}
-
+
{n.title}
{n.body && (
- {n.body}
+ {n.body}
)}
{formatTimeAgo(n.created_at)}
@@ -182,11 +182,11 @@ export default function NotificationsPage() {
{getTypeIcon(n.type)}
-
+
{n.title}
{n.body && (
- {n.body}
+ {n.body}
)}
{formatTimeAgo(n.created_at)}
diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx
index 72b5307..dcbd41f 100644
--- a/app/onboarding/page.tsx
+++ b/app/onboarding/page.tsx
@@ -47,7 +47,7 @@ export default function OnboardingPage() {
-
+
Welcome to Pulse
diff --git a/app/page.tsx b/app/page.tsx
index 851f089..fda9c5f 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -13,7 +13,7 @@ import { LoadingOverlay } from '@ciphera-net/ui'
import SiteList from '@/components/sites/SiteList'
import DeleteSiteModal from '@/components/sites/DeleteSiteModal'
import { Button } from '@ciphera-net/ui'
-import { XIcon, GlobeIcon } from '@ciphera-net/ui'
+import { XIcon } from '@ciphera-net/ui'
import { Cookie, ShieldCheck, Code, Lightning, ArrowRight, GithubLogo } from '@phosphor-icons/react'
import DashboardDemo from '@/components/marketing/DashboardDemo'
import FeatureSections from '@/components/marketing/FeatureSections'
@@ -334,7 +334,7 @@ export default function HomePage() {
return `${label} Plan`
})()}
- {(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
+ {(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (!subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
{typeof subscription.sites_count === 'number' && (
Sites: {(() => {
@@ -346,20 +346,9 @@ export default function HomePage() {
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
Pageviews: {subscription.pageview_usage.toLocaleString()}/{subscription.pageview_limit.toLocaleString()}
)}
- {subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && (
+ {!subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && subscription.current_period_end && (
- Renews {(() => {
- const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
- const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
- const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
- ? formatDate(d)
- : null
- const amount = (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
- style: 'currency',
- currency: subscription.next_invoice_currency.toUpperCase(),
- })
- return dateStr ? `${dateStr} for ${amount}` : amount
- })()}
+ Renews {formatDate(new Date(subscription.current_period_end))}
)}
@@ -383,10 +372,12 @@ export default function HomePage() {
{!sitesLoading && sites.length === 0 && (
-
-
-
-
+
+
Add your first site
Connect a domain to start collecting privacy-friendly analytics. You can add more sites later from the dashboard.
diff --git a/app/share/[id]/layout.tsx b/app/share/[id]/layout.tsx
index 60de8ee..7a0189e 100644
--- a/app/share/[id]/layout.tsx
+++ b/app/share/[id]/layout.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from 'next'
-import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
+import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx
index 780c17f..664999e 100644
--- a/app/share/[id]/page.tsx
+++ b/app/share/[id]/page.tsx
@@ -16,7 +16,7 @@ import TechSpecs from '@/components/dashboard/TechSpecs'
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal'
-import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
+import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon'
// Helper to get date ranges
const getDateRange = (days: number) => {
@@ -195,7 +195,7 @@ export default function PublicDashboardPage() {
-
+
Protected Dashboard
@@ -210,7 +210,7 @@ export default function PublicDashboardPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
- className="w-full px-4 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange focus:border-transparent"
+ className="w-full px-4 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-white focus:ring-2 focus:ring-brand-orange focus:border-transparent"
autoFocus
/>
@@ -270,7 +270,7 @@ export default function PublicDashboardPage() {
Public Dashboard
-
+
void }) {
+ return (
+
+ )
+}
diff --git a/app/sites/[id]/behavior/page.tsx b/app/sites/[id]/behavior/page.tsx
index 8e0a2c4..050b635 100644
--- a/app/sites/[id]/behavior/page.tsx
+++ b/app/sites/[id]/behavior/page.tsx
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
-import { getDateRange, formatDate } from '@ciphera-net/ui'
+import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
import { Select, DatePicker } from '@ciphera-net/ui'
import dynamic from 'next/dynamic'
import { getRageClicks, getDeadClicks } from '@/lib/api/stats'
@@ -15,20 +15,6 @@ import { BehaviorSkeleton, useMinimumLoading, useSkeletonFade } from '@/componen
const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth'))
-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) }
-}
-
export default function BehaviorPage() {
const params = useParams()
const siteId = params.id as string
@@ -74,10 +60,10 @@ export default function BehaviorPage() {
{/* Header */}
-
+
Behavior
-
+
Frustration signals and user engagement patterns
diff --git a/app/sites/[id]/cdn/error.tsx b/app/sites/[id]/cdn/error.tsx
new file mode 100644
index 0000000..e11400b
--- /dev/null
+++ b/app/sites/[id]/cdn/error.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import ErrorDisplay from '@/components/ErrorDisplay'
+
+export default function CDNError({ reset }: { error: Error; reset: () => void }) {
+ return (
+
+ )
+}
diff --git a/app/sites/[id]/cdn/page.tsx b/app/sites/[id]/cdn/page.tsx
index a244b55..b8872d0 100644
--- a/app/sites/[id]/cdn/page.tsx
+++ b/app/sites/[id]/cdn/page.tsx
@@ -177,10 +177,10 @@ export default function CDNPage() {
-
+
Connect BunnyCDN
-
+
Monitor your CDN performance including bandwidth usage, cache hit rates, request volumes, and geographic distribution.
-
+
CDN Analytics
-
+
BunnyCDN performance, bandwidth, and cache metrics
@@ -281,7 +281,7 @@ export default function CDNPage() {
{/* Bandwidth chart */}
- Bandwidth
+ Bandwidth
{daily.length > 0 ? (
@@ -317,8 +317,8 @@ export default function CDNPage() {
if (!active || !payload?.length) return null
return (
- {formatDateShort(label)}
-
+ {formatDateShort(label)}
+
Total: {formatBytes(payload[0]?.value as number)}
{payload[1] && (
@@ -359,7 +359,7 @@ export default function CDNPage() {
{/* Requests chart */}
- Requests
+ Requests
{daily.length > 0 ? (
@@ -385,8 +385,8 @@ export default function CDNPage() {
if (!active || !payload?.length) return null
return (
- {formatDateShort(label)}
-
+ {formatDateShort(label)}
+
{formatNumber(payload[0]?.value as number)} requests
@@ -405,7 +405,7 @@ export default function CDNPage() {
{/* Errors chart */}
- Errors
+ Errors
{daily.length > 0 ? (
- {formatDateShort(label)}
+ {formatDateShort(label)}
{payload.map((entry) => (
{entry.name}: {formatNumber(entry.value as number)}
@@ -464,7 +464,7 @@ export default function CDNPage() {
{/* Traffic Distribution */}
- Traffic Distribution
+ Traffic Distribution
{countries.length > 0 ? (
<>
@@ -480,9 +480,9 @@ export default function CDNPage() {
{cc && getFlagIcon(cc)}
- {city}
+ {city}
-
+
{formatBytes(row.bandwidth)}
@@ -530,13 +530,13 @@ function OverviewCard({
return (
- {label}
- {value}
+ {label}
+ {value}
{changeLabel && (
{changeLabel} vs previous period
diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx
index d8fe3c8..0ce4fe1 100644
--- a/app/sites/[id]/funnels/[funnelId]/page.tsx
+++ b/app/sites/[id]/funnels/[funnelId]/page.tsx
@@ -25,7 +25,7 @@ export default function FunnelReportPage() {
const [funnel, setFunnel] = useState (null)
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
- const [dateRange, setDateRange] = useState(getDateRange(30))
+ const [dateRange, setDateRange] = useState(() => getDateRange(30))
const [datePreset, setDatePreset] = useState<'7' | '30' | 'custom'>('30')
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [loadError, setLoadError] = useState<'not_found' | 'forbidden' | 'error' | null>(null)
@@ -154,7 +154,7 @@ export default function FunnelReportPage() {
-
+
{funnel.name}
{funnel.description && (
@@ -236,7 +236,7 @@ export default function FunnelReportPage() {
{trends && trends.dates.length > 1 && (
-
+
Conversion Trends
@@ -322,10 +322,10 @@ export default function FunnelReportPage() {
- | Step |
- Visitors |
- Drop-off |
- Conversion |
+ Step |
+ Visitors |
+ Drop-off |
+ Conversion |
@@ -338,13 +338,13 @@ export default function FunnelReportPage() {
{i + 1}
- {step.step.name}
- {step.step.value}
+ {step.step.name}
+ {step.step.value}
-
+
{step.visitors.toLocaleString()}
|
diff --git a/app/sites/[id]/funnels/page.tsx b/app/sites/[id]/funnels/page.tsx
index 2b40d16..5638bf0 100644
--- a/app/sites/[id]/funnels/page.tsx
+++ b/app/sites/[id]/funnels/page.tsx
@@ -6,6 +6,7 @@ import { useFunnels } from '@/lib/swr/dashboard'
import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
import { FunnelsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import Link from 'next/link'
+import Image from 'next/image'
export default function FunnelsPage() {
const params = useParams()
@@ -39,7 +40,7 @@ export default function FunnelsPage() {
-
+
Funnels
@@ -55,11 +56,16 @@ export default function FunnelsPage() {
{funnels.length === 0 ? (
-
-
-
+
+
+
No funnels yet
@@ -83,7 +89,7 @@ export default function FunnelsPage() {
-
+
{funnel.name}
{funnel.description && (
diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx
index 10d66e6..94fab56 100644
--- a/app/sites/[id]/journeys/page.tsx
+++ b/app/sites/[id]/journeys/page.tsx
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { motion } from 'framer-motion'
-import { getDateRange, formatDate } from '@ciphera-net/ui'
+import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
import { Select, DatePicker } from '@ciphera-net/ui'
import ColumnJourney from '@/components/journeys/ColumnJourney'
import SankeyJourney from '@/components/journeys/SankeyJourney'
@@ -18,20 +18,6 @@ import {
const DEFAULT_DEPTH = 4
-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) }
-}
-
export default function JourneysPage() {
const params = useParams()
const siteId = params.id as string
@@ -91,10 +77,10 @@ export default function JourneysPage() {
{/* Header */}
-
+
Journeys
-
+
How visitors navigate through your site
@@ -143,7 +129,7 @@ export default function JourneysPage() {
{/* Depth slider */}
-
+
2 steps
{depth} steps deep
@@ -196,7 +182,7 @@ export default function JourneysPage() {
aria-selected={viewMode === mode}
className={`relative px-3 py-1 text-xs font-medium transition-colors capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${
viewMode === mode
- ? 'text-neutral-900 dark:text-white'
+ ? 'text-white'
: 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
@@ -232,7 +218,7 @@ export default function JourneysPage() {
{/* Footer */}
{totalSessions > 0 && (
-
+
{totalSessions.toLocaleString()} sessions tracked
)}
diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx
index 5ad39da..892f135 100644
--- a/app/sites/[id]/page.tsx
+++ b/app/sites/[id]/page.tsx
@@ -17,7 +17,7 @@ import {
type Stats,
type DailyStat,
} from '@/lib/api/stats'
-import { getDateRange, formatDate } from '@ciphera-net/ui'
+import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
import { toast } from '@ciphera-net/ui'
import { Button } from '@ciphera-net/ui'
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
@@ -63,19 +63,6 @@ function loadSavedSettings(): {
}
}
-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) }
-}
function getInitialDateRange(): { start: string; end: string } {
const settings = loadSavedSettings()
@@ -442,7 +429,7 @@ export default function SiteDashboardPage() {
-
+
{site.name}
diff --git a/app/sites/[id]/pagespeed/error.tsx b/app/sites/[id]/pagespeed/error.tsx
new file mode 100644
index 0000000..04362bb
--- /dev/null
+++ b/app/sites/[id]/pagespeed/error.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import ErrorDisplay from '@/components/ErrorDisplay'
+
+export default function PageSpeedError({ reset }: { error: Error; reset: () => void }) {
+ return (
+
+ )
+}
diff --git a/app/sites/[id]/pagespeed/page.tsx b/app/sites/[id]/pagespeed/page.tsx
new file mode 100644
index 0000000..4eb5f27
--- /dev/null
+++ b/app/sites/[id]/pagespeed/page.tsx
@@ -0,0 +1,902 @@
+'use client'
+
+import { useAuth } from '@/lib/auth/context'
+import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
+import { useParams } from 'next/navigation'
+import { useSite, usePageSpeedConfig, usePageSpeedLatest, usePageSpeedHistory } from '@/lib/swr/dashboard'
+import { updatePageSpeedConfig, triggerPageSpeedCheck, getPageSpeedLatest, getPageSpeedCheck, type PageSpeedCheck, type AuditSummary } from '@/lib/api/pagespeed'
+import { toast, Button } from '@ciphera-net/ui'
+import { motion } from 'framer-motion'
+import ScoreGauge from '@/components/pagespeed/ScoreGauge'
+import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip } from '@/components/ui/area-chart'
+import { useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
+
+// * Metric status thresholds (Google's Core Web Vitals thresholds)
+function getMetricStatus(metric: string, value: number | null): { label: string; color: string } {
+ if (value === null) return { label: '--', color: 'text-neutral-400' }
+ const thresholds: Record = {
+ lcp: [2500, 4000],
+ cls: [0.1, 0.25],
+ tbt: [200, 600],
+ fcp: [1800, 3000],
+ si: [3400, 5800],
+ tti: [3800, 7300],
+ }
+ const [good, poor] = thresholds[metric] ?? [0, 0]
+ if (value <= good) return { label: 'Good', color: 'text-emerald-600 dark:text-emerald-400' }
+ if (value <= poor) return { label: 'Needs Improvement', color: 'text-amber-600 dark:text-amber-400' }
+ return { label: 'Poor', color: 'text-red-600 dark:text-red-400' }
+}
+
+// * Format metric values for display
+function formatMetricValue(metric: string, value: number | null): string {
+ if (value === null) return '--'
+ if (metric === 'cls') return value.toFixed(3)
+ if (value < 1000) return `${value}ms`
+ return `${(value / 1000).toFixed(1)}s`
+}
+
+// * Format time ago for last checked display
+function formatTimeAgo(dateString: string | null): string {
+ if (!dateString) return 'Never'
+ const date = new Date(dateString)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffSec = Math.floor(diffMs / 1000)
+
+ if (diffSec < 60) return 'just now'
+ if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
+ if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
+ return `${Math.floor(diffSec / 86400)}d ago`
+}
+
+// * Get dot color for audit items based on score
+function getAuditDotColor(score: number | null): string {
+ if (score === null) return 'bg-neutral-400'
+ if (score >= 0.9) return 'bg-emerald-500'
+ if (score >= 0.5) return 'bg-amber-500'
+ return 'bg-red-500'
+}
+
+// * Main PageSpeed page
+export default function PageSpeedPage() {
+ const { user } = useAuth()
+ const canEdit = user?.role === 'owner' || user?.role === 'admin'
+ const params = useParams()
+ const siteId = params.id as string
+
+ const { data: site } = useSite(siteId)
+ const { data: config, mutate: mutateConfig } = usePageSpeedConfig(siteId)
+ const { data: latestChecks, isLoading, mutate: mutateLatest } = usePageSpeedLatest(siteId)
+
+ const [strategy, setStrategy] = useState<'mobile' | 'desktop'>('mobile')
+ const [running, setRunning] = useState(false)
+ const [toggling, setToggling] = useState(false)
+ const [frequency, setFrequency] = useState('weekly')
+
+ const { data: historyChecks } = usePageSpeedHistory(siteId, strategy)
+
+ // * Check history navigation — build unique check timestamps from history data
+ const [selectedCheckId, setSelectedCheckId] = useState(null)
+ const [selectedCheckData, setSelectedCheckData] = useState(null)
+ const [loadingCheck, setLoadingCheck] = useState(false)
+
+ // * Build unique check timestamps (each check has mobile+desktop at the same time)
+ const checkTimestamps = useMemo(() => {
+ if (!historyChecks?.length) return []
+ const seen = new Set()
+ const timestamps: { id: string; checked_at: string }[] = []
+ // * History is sorted ASC by checked_at, reverse for newest first
+ for (let i = historyChecks.length - 1; i >= 0; i--) {
+ const c = historyChecks[i]
+ // * Group by minute to deduplicate mobile+desktop pairs
+ const key = c.checked_at.slice(0, 16)
+ if (!seen.has(key)) {
+ seen.add(key)
+ timestamps.push({ id: c.id, checked_at: c.checked_at })
+ }
+ }
+ return timestamps
+ }, [historyChecks])
+
+ const selectedIndex = selectedCheckId
+ ? checkTimestamps.findIndex(t => t.id === selectedCheckId)
+ : 0 // * 0 = latest
+
+ const canGoPrev = selectedIndex < checkTimestamps.length - 1
+ const canGoNext = selectedIndex > 0
+
+ const handlePrevCheck = () => {
+ if (!canGoPrev) return
+ const next = checkTimestamps[selectedIndex + 1]
+ setSelectedCheckId(next.id)
+ }
+
+ const handleNextCheck = () => {
+ if (selectedIndex <= 1) {
+ // * Going back to latest
+ setSelectedCheckId(null)
+ setSelectedCheckData(null)
+ return
+ }
+ const next = checkTimestamps[selectedIndex - 1]
+ setSelectedCheckId(next.id)
+ }
+
+ // * Fetch full check data when navigating to a historical check
+ useEffect(() => {
+ if (!selectedCheckId || !siteId) {
+ setSelectedCheckData(null)
+ return
+ }
+ let cancelled = false
+ setLoadingCheck(true)
+ getPageSpeedCheck(siteId, selectedCheckId).then(data => {
+ if (!cancelled) {
+ setSelectedCheckData(data)
+ setLoadingCheck(false)
+ }
+ }).catch(() => {
+ if (!cancelled) setLoadingCheck(false)
+ })
+ return () => { cancelled = true }
+ }, [selectedCheckId, siteId])
+
+ // * Determine which check to display — selected historical or latest
+ const displayCheck = selectedCheckId && selectedCheckData
+ ? selectedCheckData
+ : latestChecks?.find(c => c.strategy === strategy) ?? null
+
+ // * When viewing a historical check, we need both strategies — fetch the other one too
+ // * For simplicity, historical view shows the selected strategy's check
+ const currentCheck = displayCheck
+
+ // * Set document title
+ useEffect(() => {
+ if (site?.domain) document.title = `PageSpeed · ${site.domain} | Pulse`
+ }, [site?.domain])
+
+ // * Sync frequency from config when loaded
+ useEffect(() => {
+ if (config?.frequency) setFrequency(config.frequency)
+ }, [config?.frequency])
+
+ // * Toggle PageSpeed monitoring on/off
+ const handleToggle = async (enabled: boolean) => {
+ setToggling(true)
+ try {
+ await updatePageSpeedConfig(siteId, { enabled, frequency })
+ mutateConfig()
+ mutateLatest()
+ toast.success(enabled ? 'PageSpeed monitoring enabled' : 'PageSpeed monitoring disabled')
+ } catch {
+ toast.error('Failed to update PageSpeed monitoring')
+ } finally {
+ setToggling(false)
+ }
+ }
+
+ // * Trigger a manual PageSpeed check
+ const pollRef = useRef | null>(null)
+ const stopPolling = useCallback(() => {
+ if (pollRef.current) {
+ clearInterval(pollRef.current)
+ pollRef.current = null
+ }
+ }, [])
+
+ useEffect(() => () => stopPolling(), [stopPolling])
+
+ const handleRunCheck = async () => {
+ setRunning(true)
+ try {
+ await triggerPageSpeedCheck(siteId)
+ toast.success('PageSpeed check started — results will appear in 30-60 seconds')
+
+ // * Poll silently without triggering SWR re-renders.
+ // * Fetch latest directly and only update SWR cache once when new data arrives.
+ const initialCheckedAt = latestChecks?.[0]?.checked_at
+ const startedAt = Date.now()
+
+ stopPolling()
+ pollRef.current = setInterval(async () => {
+ if (Date.now() - startedAt > 120_000) {
+ stopPolling()
+ setRunning(false)
+ toast.error('Check is taking longer than expected. Results will appear when ready.')
+ return
+ }
+ try {
+ const fresh = await getPageSpeedLatest(siteId)
+ if (fresh?.[0]?.checked_at && fresh[0].checked_at !== initialCheckedAt) {
+ stopPolling()
+ setRunning(false)
+ mutateLatest() // * Single SWR revalidation when new data is ready
+ toast.success('PageSpeed check complete')
+ }
+ } catch {
+ // * Silent — keep polling
+ }
+ }, 5000)
+ } catch (err: any) {
+ toast.error(err?.message || 'Failed to start check')
+ setRunning(false)
+ }
+ }
+
+ // * Loading state with minimum display time (consistent with other pages)
+ const showSkeleton = useMinimumLoading(isLoading && !latestChecks)
+ const fadeClass = useSkeletonFade(showSkeleton)
+ if (showSkeleton) return
+ if (!site) return Site not found
+
+ const enabled = config?.enabled ?? false
+
+ // * Disabled state — show empty state with enable toggle
+ if (!enabled) {
+ return (
+
+ {/* Header */}
+
+
+ PageSpeed
+
+
+ Monitor your site's performance and Core Web Vitals
+
+
+
+ {/* Empty state */}
+
+
+
+ PageSpeed monitoring is disabled
+
+
+ Enable PageSpeed monitoring to track your site's performance scores, Core Web Vitals, and get actionable improvement suggestions.
+
+
+ {/* Frequency selector */}
+
+
+
+
+
+ {canEdit && (
+
+ )}
+
+
+ )
+ }
+
+ // * Prepare chart data from history (visx needs Date objects for x-axis)
+ const chartData = (historyChecks ?? []).map(c => ({
+ dateObj: new Date(c.checked_at),
+ score: c.performance_score ?? 0,
+ }))
+
+ // * Parse audits into groups by Lighthouse category
+ const audits = currentCheck?.audits ?? []
+ const passed = audits.filter(a => a.category === 'passed')
+
+ const categoryGroups = [
+ { key: 'performance', label: 'Performance' },
+ { key: 'accessibility', label: 'Accessibility' },
+ { key: 'best-practices', label: 'Best Practices' },
+ { key: 'seo', label: 'SEO' },
+ ]
+
+ // * Build per-category failing audits, sorted by impact
+ const auditsByGroup: Record = {}
+ const manualByGroup: Record = {}
+ for (const group of categoryGroups) {
+ auditsByGroup[group.key] = audits
+ .filter(a => a.category !== 'passed' && a.category !== 'manual' && a.group === group.key)
+ .sort((a, b) => {
+ if (a.category === 'opportunity' && b.category !== 'opportunity') return -1
+ if (a.category !== 'opportunity' && b.category === 'opportunity') return 1
+ if (a.category === 'opportunity' && b.category === 'opportunity') {
+ return (b.savings_ms ?? 0) - (a.savings_ms ?? 0)
+ }
+ return 0
+ })
+ manualByGroup[group.key] = audits.filter(a => a.category === 'manual' && a.group === group.key)
+ }
+
+ // * Core Web Vitals metrics
+ const metrics = [
+ { key: 'fcp', label: 'First Contentful Paint', value: currentCheck?.fcp_ms ?? null },
+ { key: 'lcp', label: 'Largest Contentful Paint', value: currentCheck?.lcp_ms ?? null },
+ { key: 'tbt', label: 'Total Blocking Time', value: currentCheck?.tbt_ms ?? null },
+ { key: 'cls', label: 'Cumulative Layout Shift', value: currentCheck?.cls ?? null },
+ { key: 'si', label: 'Speed Index', value: currentCheck?.si_ms ?? null },
+ { key: 'tti', label: 'Time to Interactive', value: currentCheck?.tti_ms ?? null },
+ ]
+
+ // * All 4 category scores for the hero row
+ const allScores = [
+ { key: 'performance', label: 'Performance', score: currentCheck?.performance_score ?? null },
+ { key: 'accessibility', label: 'Accessibility', score: currentCheck?.accessibility_score ?? null },
+ { key: 'best-practices', label: 'Best Practices', score: currentCheck?.best_practices_score ?? null },
+ { key: 'seo', label: 'SEO', score: currentCheck?.seo_score ?? null },
+ ]
+
+ // * Map category key to score for diagnostics section
+ const scoreByGroup: Record = {
+ 'performance': currentCheck?.performance_score ?? null,
+ 'accessibility': currentCheck?.accessibility_score ?? null,
+ 'best-practices': currentCheck?.best_practices_score ?? null,
+ 'seo': currentCheck?.seo_score ?? null,
+ }
+
+ function getMetricDotColor(metric: string, value: number | null): string {
+ if (value === null) return 'bg-neutral-400'
+ const status = getMetricStatus(metric, value)
+ if (status.label === 'Good') return 'bg-emerald-500'
+ if (status.label === 'Needs Improvement') return 'bg-amber-500'
+ return 'bg-red-500'
+ }
+
+ // * Enabled state — show full PageSpeed dashboard
+ return (
+
+ {/* Header */}
+
+
+
+ PageSpeed
+
+
+ Performance scores and Core Web Vitals for {site.domain}
+
+
+
+ {/* Mobile / Desktop toggle */}
+
+ {(['mobile', 'desktop'] as const).map(tab => (
+
+ ))}
+
+
+ {canEdit && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {/* Section 1 — Score Overview: 4 equal gauges + screenshot */}
+
+
+ {/* 4 equal gauges — click to scroll to diagnostics */}
+
+ {allScores.map(({ key, label, score }) => (
+
+ ))}
+
+
+ {/* Screenshot */}
+ {currentCheck?.screenshot && (
+
+ 
+
+ )}
+
+
+ {/* Check navigator + frequency + legend */}
+
+
+ {/* Prev/Next arrows */}
+ {checkTimestamps.length > 1 && (
+
+ )}
+ {currentCheck?.checked_at && (
+
+ {selectedCheckId
+ ? new Date(currentCheck.checked_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
+ : `Last checked ${formatTimeAgo(currentCheck.checked_at)}`
+ }
+
+ )}
+ {checkTimestamps.length > 1 && (
+
+ )}
+ {config?.frequency && (
+
+ {config.frequency}
+
+ )}
+ {loadingCheck && (
+ Loading...
+ )}
+
+
+ 0–49
+ 50–89
+ 90–100
+
+
+
+
+ {/* Filmstrip — page load progression */}
+ {currentCheck?.filmstrip && currentCheck.filmstrip.length > 0 && (
+
+
+ Page Load Timeline
+
+
+ {currentCheck.filmstrip.map((frame, idx) => (
+
+ 
+
+ {frame.timing < 1000 ? `${frame.timing}ms` : `${(frame.timing / 1000).toFixed(1)}s`}
+
+
+ ))}
+
+ {/* Fade indicator for horizontal scroll */}
+
+
+ )}
+
+ {/* Section 2 — Metrics Card */}
+
+
+ Metrics
+
+
+ {metrics.map(({ key, label, value }) => (
+
+
+
+
+ {label}
+
+
+ {formatMetricValue(key, value)}
+
+
+
+ ))}
+
+
+
+ {/* Section 3 — Score Trend Chart (visx) */}
+ {chartData.length >= 2 && (
+
+
+ Performance Score Trend
+
+
+ []}
+ xDataKey="dateObj"
+ aspectRatio="4 / 1"
+ margin={{ top: 10, right: 10, bottom: 30, left: 40 }}
+ >
+
+
+ d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
+ />
+ String(Math.round(v))}
+ />
+ ) => [{
+ label: 'Score',
+ value: String(Math.round(point.score as number)),
+ color: 'var(--chart-line-primary)',
+ }]}
+ />
+
+
+
+ )}
+
+ {/* Section 4 — Diagnostics by Category */}
+ {audits.length > 0 && (
+
+ {categoryGroups.map(group => {
+ const groupAudits = auditsByGroup[group.key] ?? []
+ const groupPassed = passed.filter(a => a.group === group.key)
+ const groupManual = manualByGroup[group.key] ?? []
+ if (groupAudits.length === 0 && groupPassed.length === 0 && groupManual.length === 0) return null
+ return (
+
+ {/* Category header with gauge */}
+
+
+
+
+ {group.label}
+
+
+ {(() => {
+ const realIssues = groupAudits.filter(a => a.score !== null && a.score !== undefined).length
+ return realIssues === 0 ? 'No issues found' : `${realIssues} issue${realIssues !== 1 ? 's' : ''} found`
+ })()}
+
+
+
+
+ {groupAudits.length > 0 && (
+
+ )}
+
+ {groupManual.length > 0 && (
+
+
+ Additional items to manually check ({groupManual.length})
+
+
+ {groupManual.map(audit => )}
+
+
+ )}
+
+ {groupPassed.length > 0 && (
+
+
+ {groupPassed.length} passed audit{groupPassed.length !== 1 ? 's' : ''}
+
+
+ {groupPassed.map(audit => )}
+
+
+ )}
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
+// * Sort audits by severity: red (< 0.5) → orange (0.5-0.89) → empty (null) → green (>= 0.9)
+function sortBySeverity(audits: AuditSummary[]): AuditSummary[] {
+ return [...audits].sort((a, b) => {
+ const rank = (s: number | null | undefined) => {
+ if (s === null || s === undefined) return 2 // empty circle
+ if (s < 0.5) return 0 // red
+ if (s < 0.9) return 1 // orange
+ return 3 // green
+ }
+ return rank(a.score) - rank(b.score)
+ })
+}
+
+// * Known sub-group ordering: insights-type groups come before diagnostics-type groups
+const subGroupPriority: Record = {
+ // * Performance
+ 'budgets': 0, 'load-opportunities': 0, 'diagnostics': 1,
+ // * Accessibility
+ 'a11y-names-labels': 0, 'a11y-contrast': 1, 'a11y-best-practices': 2,
+ 'a11y-color-contrast': 1, 'a11y-aria': 3, 'a11y-navigation': 4,
+ 'a11y-language': 5, 'a11y-audio-video': 6, 'a11y-tables-lists': 7,
+ // * SEO
+ 'seo-mobile': 0, 'seo-content': 1, 'seo-crawl': 2,
+}
+
+// * Group audits by sub-group within a category (e.g., "Names and Labels", "Contrast")
+function AuditsBySubGroup({ audits }: { audits: AuditSummary[] }) {
+ // * Collect unique sub-groups
+ const bySubGroup: Record = {}
+
+ for (const audit of audits) {
+ const key = audit.sub_group || '__none__'
+ if (!bySubGroup[key]) {
+ bySubGroup[key] = []
+ }
+ bySubGroup[key].push(audit)
+ }
+
+ const subGroupOrder = Object.keys(bySubGroup).sort((a, b) => {
+ const pa = subGroupPriority[a] ?? 0
+ const pb = subGroupPriority[b] ?? 0
+ return pa - pb
+ })
+
+ // * If no sub-groups exist, render flat list sorted by severity
+ if (subGroupOrder.length === 1 && subGroupOrder[0] === '__none__') {
+ return (
+
+ {sortBySeverity(audits).map(audit => )}
+
+ )
+ }
+
+ return (
+
+ {subGroupOrder.map(key => {
+ const items = sortBySeverity(bySubGroup[key])
+ const title = items[0]?.sub_group_title
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {items.map(audit => )}
+
+
+ )
+ })}
+
+ )
+}
+
+// * Severity indicator based on audit score (pagespeed.web.dev style)
+function AuditSeverityIcon({ score }: { score: number | null }) {
+ if (score === null) {
+ return
+ }
+ if (score < 0.5) {
+ return
+ }
+ if (score < 0.9) {
+ return
+ }
+ return
+}
+
+// * Expandable audit row with description and detail items
+function AuditRow({ audit }: { audit: AuditSummary }) {
+ return (
+
+
+
+ {audit.title}
+ {audit.display_value && (
+ {audit.display_value}
+ )}
+ {audit.savings_ms != null && audit.savings_ms > 0 && !audit.display_value && (
+
+ {audit.savings_ms < 1000 ? `${Math.round(audit.savings_ms)}ms` : `${(audit.savings_ms / 1000).toFixed(1)}s`}
+
+ )}
+
+
+
+ {/* Description with parsed markdown links */}
+ {audit.description && (
+
+
+
+ )}
+ {/* Items list */}
+ {audit.details && Array.isArray(audit.details) && audit.details.length > 0 && (
+
+ {audit.details.slice(0, 10).map((item: Record , idx: number) => (
+
+ ))}
+ {audit.details.length > 10 && (
+ + {audit.details.length - 10} more items
+ )}
+
+ )}
+
+
+ )
+}
+
+// * Parse markdown-style links [text](url) into clickable tags
+function AuditDescription({ text }: { text: string }) {
+ const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g)
+ return (
+ <>
+ {parts.map((part, i) => {
+ const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
+ if (match) {
+ return (
+
+ {match[1]}
+
+ )
+ }
+ return {part}
+ })}
+ >
+ )
+}
+
+// * Render a single audit detail item — handles various field types from the PSI API
+function AuditItem({ item }: { item: Record }) {
+ // * Determine the primary label
+ const label = item.node?.nodeLabel || item.label || item.groupLabel || item.source?.url || null
+ // * URL can be in item.url or item.href
+ const url = item.url || item.href || null
+ // * Text content (used by SEO audits like "link text")
+ const text = item.text || item.linkText || null
+
+ return (
+
+ {/* Element screenshot */}
+ {item.node?.screenshot?.data && (
+ 
+ )}
+ {/* Content */}
+
+ {label && (
+
+ {label}
+
+ )}
+ {url && (
+ {url}
+ )}
+ {text && (
+ {text}
+ )}
+ {item.node?.snippet && (
+ {item.node.snippet}
+ )}
+ {/* Fallback for items with only string values we haven't handled */}
+ {!label && !url && !text && !item.node && item.statistic && (
+ {item.statistic}
+ )}
+
+ {/* Metrics on the right */}
+
+ {item.wastedBytes != null && (
+
+ {item.wastedBytes < 1024 ? `${item.wastedBytes} B` : `${(item.wastedBytes / 1024).toFixed(1)} KiB`}
+
+ )}
+ {item.totalBytes != null && !item.wastedBytes && (
+
+ {item.totalBytes < 1024 ? `${item.totalBytes} B` : `${(item.totalBytes / 1024).toFixed(1)} KiB`}
+
+ )}
+ {item.wastedMs != null && (
+
+ {item.wastedMs < 1000 ? `${Math.round(item.wastedMs)}ms` : `${(item.wastedMs / 1000).toFixed(1)}s`}
+
+ )}
+
+
+ )
+}
+
+// * Skeleton loading state
+function PageSpeedSkeleton() {
+ return (
+
+
+ {/* Hero skeleton */}
+
+ {/* Metrics skeleton */}
+
+
+
+ {[...Array(6)].map((_, i) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/app/sites/[id]/search/error.tsx b/app/sites/[id]/search/error.tsx
new file mode 100644
index 0000000..65d8929
--- /dev/null
+++ b/app/sites/[id]/search/error.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import ErrorDisplay from '@/components/ErrorDisplay'
+
+export default function SearchError({ reset }: { error: Error; reset: () => void }) {
+ return (
+
+ )
+}
diff --git a/app/sites/[id]/search/page.tsx b/app/sites/[id]/search/page.tsx
index 050298c..81f9bbb 100644
--- a/app/sites/[id]/search/page.tsx
+++ b/app/sites/[id]/search/page.tsx
@@ -3,7 +3,8 @@
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 { Select, DatePicker } from '@ciphera-net/ui'
+import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges'
import { CaretDown, CaretUp, MagnifyingGlass, ArrowSquareOut } from '@phosphor-icons/react'
import { useDashboard, useGSCStatus, useGSCOverview, useGSCTopQueries, useGSCTopPages, useGSCNewQueries } from '@/lib/swr/dashboard'
import { getGSCQueryPages, getGSCPageQueries } from '@/lib/api/gsc'
@@ -13,20 +14,6 @@ import ClicksImpressionsChart from '@/components/search/ClicksImpressionsChart'
// ─── 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) + '%'
@@ -179,10 +166,10 @@ export default function SearchConsolePage() {
-
+
Connect Google Search Console
-
+
See how your site performs in Google Search. View top queries, pages, click-through rates, and average position data.
-
+
Search Console
-
+
Google Search performance, queries, and page rankings
@@ -296,9 +283,9 @@ export default function SearchConsolePage() {
{topQueries.queries.slice(0, 5).map((q) => (
- {q.query}
+ {q.query}
- {q.position.toFixed(1)}
+ {q.position.toFixed(1)}
pos
{q.clicks} {q.clicks === 1 ? 'click' : 'clicks'}
@@ -322,8 +309,8 @@ export default function SearchConsolePage() {
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'
+ ? 'bg-white dark:bg-neutral-700 text-white shadow-sm'
+ : 'text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
Top Queries
@@ -332,8 +319,8 @@ export default function SearchConsolePage() {
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'
+ ? 'bg-white dark:bg-neutral-700 text-white shadow-sm'
+ : 'text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
Top Pages
@@ -347,12 +334,12 @@ export default function SearchConsolePage() {
- |
- Query |
- Clicks |
- Impressions |
- CTR |
- Position |
+ |
+ Query |
+ Clicks |
+ Impressions |
+ CTR |
+ Position |
@@ -369,7 +356,7 @@ export default function SearchConsolePage() {
))
) : queries.length === 0 ? (
- |
+ |
No query data available for this period.
|
@@ -391,7 +378,7 @@ export default function SearchConsolePage() {
{/* Pagination */}
{queriesTotal > PAGE_SIZE && (
-
+
Showing {queryPage * PAGE_SIZE + 1}-{Math.min((queryPage + 1) * PAGE_SIZE, queriesTotal)} of {queriesTotal.toLocaleString()}
@@ -421,12 +408,12 @@ export default function SearchConsolePage() {
- |
- Page |
- Clicks |
- Impressions |
- CTR |
- Position |
+ |
+ Page |
+ Clicks |
+ Impressions |
+ CTR |
+ Position |
@@ -443,7 +430,7 @@ export default function SearchConsolePage() {
))
) : pages.length === 0 ? (
- |
+ |
No page data available for this period.
|
@@ -465,7 +452,7 @@ export default function SearchConsolePage() {
{/* Pagination */}
{pagesTotal > PAGE_SIZE && (
-
+
Showing {pagePage * PAGE_SIZE + 1}-{Math.min((pagePage + 1) * PAGE_SIZE, pagesTotal)} of {pagesTotal.toLocaleString()}
@@ -522,13 +509,13 @@ function OverviewCard({
return (
- {label}
- {value}
+ {label}
+ {value}
{change && (
{change.label} vs previous period
@@ -560,7 +547,7 @@ function QueryRow({
|
- {row.query} |
+ {row.query} |
{row.clicks.toLocaleString()} |
{row.impressions.toLocaleString()} |
{formatCTR(row.ctr)} |
@@ -576,7 +563,7 @@ function QueryRow({
))}
) : expandedData.length === 0 ? (
- No pages found for this query.
+ No pages found for this query.
) : (
@@ -631,7 +618,7 @@ function PageRow({
|
|
- {row.page} |
+ {row.page} |
{row.clicks.toLocaleString()} |
{row.impressions.toLocaleString()} |
{formatCTR(row.ctr)} |
@@ -647,7 +634,7 @@ function PageRow({
))}
) : expandedData.length === 0 ? (
- No queries found for this page.
+ No queries found for this page.
) : (
diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx
index 6e1520f..a907e2b 100644
--- a/app/sites/[id]/settings/page.tsx
+++ b/app/sites/[id]/settings/page.tsx
@@ -21,7 +21,8 @@ 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, useAlertSchedules, useSubscription, useGSCStatus, useBunnyStatus, useSessions, useBotFilterStats } from '@/lib/swr/dashboard'
+import { useSite, useGoals, useReportSchedules, useAlertSchedules, useSubscription, useGSCStatus, useBunnyStatus, useSessions, useBotFilterStats, usePageSpeedConfig } from '@/lib/swr/dashboard'
+import { updatePageSpeedConfig } from '@/lib/api/pagespeed'
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '@/lib/auth/context'
@@ -130,6 +131,7 @@ export default function SiteSettingsPage() {
const [gscConnecting, setGscConnecting] = useState(false)
const [gscDisconnecting, setGscDisconnecting] = useState(false)
const { data: bunnyStatus, mutate: mutateBunnyStatus } = useBunnyStatus(siteId)
+ const { data: psiConfig, mutate: mutatePSIConfig } = usePageSpeedConfig(siteId)
const [bunnyApiKey, setBunnyApiKey] = useState('')
const [bunnyPullZones, setBunnyPullZones] = useState([])
const [bunnySelectedZone, setBunnySelectedZone] = useState(null)
@@ -736,9 +738,9 @@ export default function SiteSettingsPage() {
- Site Settings
+ Site Settings
- Manage settings for {site.domain}
+ Manage settings for {site.domain}
@@ -858,8 +860,8 @@ export default function SiteSettingsPage() {
- Tracking Script
-
+ Tracking Script
+
Add this script to your website to start tracking visitors. Choose your framework for setup instructions.
: }
{site.is_verified ? 'Verified' : 'Verify Installation'}
-
+
{site.is_verified ? 'Your site is sending data correctly.' : 'Check if your site is sending data correctly.'}
@@ -962,7 +964,7 @@ export default function SiteSettingsPage() {
Danger Zone
- Irreversible actions for your site.
+ Irreversible actions for your site.
@@ -1001,8 +1003,8 @@ export default function SiteSettingsPage() {
|