diff --git a/app/admin/filtered-traffic/page.tsx b/app/admin/filtered-traffic/page.tsx index 488f829..348a58b 100644 --- a/app/admin/filtered-traffic/page.tsx +++ b/app/admin/filtered-traffic/page.tsx @@ -28,8 +28,8 @@ export default function FilteredTrafficPage() {
-

Filtered Traffic

-

+

Filtered Traffic

+

{totalBlocked.toLocaleString()} spam referrers blocked in the last {days} days

@@ -52,22 +52,22 @@ export default function FilteredTrafficPage() {
{referrers.length === 0 ? ( -
+
No filtered referrers in this period
) : ( - - - + + + {referrers.map((r) => ( - + - diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index a777657..0eb6fa3 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -37,7 +37,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) return (
-

Pulse Admin

+

Pulse Admin

{children}
diff --git a/app/admin/orgs/[id]/page.tsx b/app/admin/orgs/[id]/page.tsx index 3559ff9..74e1a55 100644 --- a/app/admin/orgs/[id]/page.tsx +++ b/app/admin/orgs/[id]/page.tsx @@ -107,7 +107,7 @@ export default function AdminOrgDetailPage() { return (
-

+

{org.business_name || 'Unnamed Organization'}

{org.organization_id} @@ -116,7 +116,7 @@ export default function AdminOrgDetailPage() {
{/* Current Status */}
-

Current Status

+

Current Status

Plan: {org.plan_id} @@ -135,17 +135,17 @@ export default function AdminOrgDetailPage() { {org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '-'} - Stripe Cust: - {org.stripe_customer_id || '-'} - - Stripe Sub: - {org.stripe_subscription_id || '-'} + Customer ID: + {org.billing_customer_id || '-'} + + Subscription ID: + {org.billing_subscription_id || '-'}
{/* Sites */}
-

Sites ({org.sites.length})

+

Sites ({org.sites.length})

    {org.sites.map((site) => (
  • @@ -160,7 +160,7 @@ export default function AdminOrgDetailPage() { {/* Grant Plan Form */}
    -

    Grant Plan (Manual Override)

    +

    Grant Plan (Manual Override)

    @@ -196,7 +196,7 @@ export default function AdminOrgDetailPage() { type="datetime-local" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} - className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2" + className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2" required />
    diff --git a/app/admin/orgs/page.tsx b/app/admin/orgs/page.tsx index 59747ec..e1a2f3d 100644 --- a/app/admin/orgs/page.tsx +++ b/app/admin/orgs/page.tsx @@ -43,28 +43,28 @@ export default function AdminOrgsPage() { return (
    -

    Organizations

    +

    Organizations

    -

    All Organizations

    +

    All Organizations

DomainReasonBlockedDomainReasonBlocked
{r.domain}{r.domain} + {r.count.toLocaleString()}
- - - - - - - + + + + + + + {orgs.map((org) => ( -
NameOrg IDPlanStatusLimitUpdatedActionsNameOrg IDPlanStatusLimitUpdatedActions
+ {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

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 && ( -
-
- -
+
+ Set up your first site

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
-

+

{site.name} 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() { - - - - + + + + @@ -338,13 +338,13 @@ export default function FunnelReportPage() { {i + 1}
-

{step.step.name}

-

{step.step.value}

+

{step.step.name}

+

{step.step.value}

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 && ( +
+ {`${strategy} +
+ )} +
+ + {/* 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}ms`} + + {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() {

StepVisitorsDrop-offConversionStepVisitorsDrop-offConversion
- + {step.visitors.toLocaleString()}
- - - - - + + + + + @@ -369,7 +356,7 @@ export default function SearchConsolePage() { )) ) : queries.length === 0 ? ( - @@ -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() {
- QueryClicksImpressionsCTRPosition + QueryClicksImpressionsCTRPosition
+ No query data available for this period.
- - - - - + + + + + @@ -443,7 +430,7 @@ export default function SearchConsolePage() { )) ) : pages.length === 0 ? ( - @@ -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({
- + @@ -576,7 +563,7 @@ function QueryRow({ ))} ) : expandedData.length === 0 ? ( -

No pages found for this query.

+

No pages found for this query.

) : (
- PageClicksImpressionsCTRPosition + PageClicksImpressionsCTRPosition
+ No page data available for this period.
{row.query}{row.query} {row.clicks.toLocaleString()} {row.impressions.toLocaleString()} {formatCTR(row.ctr)}
@@ -631,7 +618,7 @@ function PageRow({ - + @@ -647,7 +634,7 @@ function PageRow({ ))} ) : expandedData.length === 0 ? ( -

No queries found for this page.

+

No queries found for this page.

) : (
{row.page}{row.page} {row.clicks.toLocaleString()} {row.impressions.toLocaleString()} {formatCTR(row.ctr)}
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() {
-

General Configuration

-

Update your site details and tracking script.

+

General Configuration

+

Update your site details and tracking script.

@@ -905,17 +907,17 @@ export default function SiteSettingsPage() { type="text" value={site.domain} disabled - className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-100 dark:bg-neutral-800/50 text-neutral-500 dark:text-neutral-400 cursor-not-allowed" + className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-100 dark:bg-neutral-800/50 text-neutral-400 cursor-not-allowed" /> -

+

Domain cannot be changed after creation

-

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() {
-

Visibility Settings

-

Manage who can view your dashboard.

+

Visibility Settings

+

Manage who can view your dashboard.

@@ -1012,8 +1014,8 @@ export default function SiteSettingsPage() {
-

Public Dashboard

-

+

Public Dashboard

+

Allow anyone with the link to view this dashboard

@@ -1039,7 +1041,7 @@ export default function SiteSettingsPage() { className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800 overflow-hidden space-y-6" >
-
@@ -1152,8 +1154,8 @@ export default function SiteSettingsPage() {
-

Data & Privacy

-

Control what visitor data is collected. Less data = more privacy.

+

Data & Privacy

+

Control what visitor data is collected. Less data = more privacy.

{/* Data Collection Controls */} @@ -1164,8 +1166,8 @@ export default function SiteSettingsPage() {
-

Page Paths

-

+

Page Paths

+

Track which pages visitors view

@@ -1185,8 +1187,8 @@ export default function SiteSettingsPage() {
-

Referrers

-

+

Referrers

+

Track where visitors come from

@@ -1206,8 +1208,8 @@ export default function SiteSettingsPage() {
-

Device Info

-

+

Device Info

+

Track browser, OS, and device type

@@ -1227,8 +1229,8 @@ export default function SiteSettingsPage() {
-

Geographic Data

-

+

Geographic Data

+

Control location tracking granularity

@@ -1251,8 +1253,8 @@ export default function SiteSettingsPage() {
-

Screen Resolution

-

+

Screen Resolution

+

Track visitor screen sizes

@@ -1275,8 +1277,8 @@ export default function SiteSettingsPage() {
-

Hide unknown locations

-

+

Hide unknown locations

+

Exclude entries where geographic data could not be resolved from location stats

@@ -1313,8 +1315,8 @@ export default function SiteSettingsPage() {
-

Keep raw event data for

-

+

Keep raw event data for

+

Events older than this are automatically deleted. Aggregated daily stats are kept permanently.

@@ -1345,6 +1347,47 @@ export default function SiteSettingsPage() {
+ {/* PageSpeed Monitoring */} +
+

PageSpeed Monitoring

+
+
+
+

Check frequency

+

+ How often PageSpeed Insights runs automated checks on your site. +

+
+ {psiConfig?.enabled ? ( +
-
-

+

Choose your organization

-

+

Continue with an existing one or create a new organization.

@@ -415,7 +413,7 @@ function WelcomeContent() { > {initial}
- + {org.organization_name || 'Organization'} {isCurrent && ( @@ -440,10 +438,12 @@ function WelcomeContent() { ) : (
-
- -
-

+ Welcome to Pulse +

Welcome to Pulse

@@ -475,7 +475,7 @@ function WelcomeContent() {

@@ -546,7 +546,7 @@ function WelcomeContent() {
-
- -
-

+ Add your first site +

Add your first site

@@ -723,10 +725,12 @@ function WelcomeContent() { className={cardClass} >

-
- -
-

+ All set +

You're all set

@@ -754,7 +758,7 @@ function WelcomeContent() { > Verify installation -

+

Check if your site is sending data correctly.

diff --git a/components/ErrorDisplay.tsx b/components/ErrorDisplay.tsx index 6bed625..a1d6d84 100644 --- a/components/ErrorDisplay.tsx +++ b/components/ErrorDisplay.tsx @@ -30,13 +30,13 @@ export default function ErrorDisplay({
-
- - - -
+ Something went wrong -

+

{title}

diff --git a/components/Footer.tsx b/components/Footer.tsx index 7a0ff24..ee8fd54 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -48,7 +48,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate

-
+
© 2024-{year} Ciphera. All rights reserved.
@@ -88,7 +88,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate loading="lazy" className="w-9 h-9 object-contain group-hover:scale-105 transition-transform duration-300" /> - + Pulse @@ -125,7 +125,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate {/* * Products */}
-

Products

+

Products

    {footerLinks.products.map((link) => (
  • @@ -153,7 +153,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate {/* * Company */}
    -

    Company

    +

    Company

      {footerLinks.company.map((link) => (
    • @@ -181,7 +181,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate {/* * Resources */}
      -

      Resources

      +

      Resources

        {footerLinks.resources.map((link) => (
      • @@ -209,7 +209,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate {/* * Legal */}
        -

        Legal

        +

        Legal

          {footerLinks.legal.map((link) => (
        • @@ -232,10 +232,10 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate {/* * Bottom bar */}
          -

          +

          © 2024-{year} Ciphera. All rights reserved.

          -

          +

          Where Privacy Still Exists

          diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx index 51f5ef8..abe8c1d 100644 --- a/components/PricingSection.tsx +++ b/components/PricingSection.tsx @@ -109,7 +109,7 @@ export default function PricingSection() { const [loadingPlan, setLoadingPlan] = useState(null) const { user } = useAuth() - // * Show toast when redirected from Stripe Checkout with canceled=true + // * Show toast when redirected from Polar Checkout with canceled=true useEffect(() => { if (searchParams.get('canceled') === 'true') { toast.info('Checkout was canceled. You can try again whenever you’re ready.') @@ -196,7 +196,7 @@ export default function PricingSection() { limit, }) - // 3. Redirect to Stripe Checkout + // 3. Redirect to Polar Checkout if (url) { window.location.href = url } else { diff --git a/components/behavior/FrustrationByPageTable.tsx b/components/behavior/FrustrationByPageTable.tsx index fa1169b..2331228 100644 --- a/components/behavior/FrustrationByPageTable.tsx +++ b/components/behavior/FrustrationByPageTable.tsx @@ -34,11 +34,11 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy return (
          -

          +

          Frustration by Page

          -

          +

          Pages with the most frustration signals

          @@ -72,7 +72,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy style={{ width: `${barWidth}%` }} /> {page.page_path} @@ -84,7 +84,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy {formatNumber(page.dead_clicks)} - + {formatNumber(page.total)} @@ -99,14 +99,17 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy ) : (
          - +
          -

          +

          No frustration signals detected

          -

          +

          Page-level frustration data will appear here once rage clicks or dead clicks are detected on your site.

          + + View setup guide +
          )}
          diff --git a/components/behavior/FrustrationSummaryCards.tsx b/components/behavior/FrustrationSummaryCards.tsx index 304c974..6f7cf48 100644 --- a/components/behavior/FrustrationSummaryCards.tsx +++ b/components/behavior/FrustrationSummaryCards.tsx @@ -31,7 +31,7 @@ function ChangeIndicator({ change }: { change: ReturnType }) { ? 'text-red-600 dark:text-red-400' : isDown ? 'text-green-600 dark:text-green-400' - : 'text-neutral-500 dark:text-neutral-400' + : 'text-neutral-400' }`} > {isUp ? '+' : ''}{change.value}% @@ -71,11 +71,11 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu
          {/* Rage Clicks */}
          -

          +

          Rage Clicks

          - + {data.rage_clicks.toLocaleString()} @@ -87,11 +87,11 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu {/* Dead Clicks */}
          -

          +

          Dead Clicks

          - + {data.dead_clicks.toLocaleString()} @@ -103,10 +103,10 @@ export default function FrustrationSummaryCards({ data, loading }: FrustrationSu {/* Total Frustration Signals */}
          -

          +

          Total Signals

          - + {totalSignals.toLocaleString()} {topPage ? ( diff --git a/components/behavior/FrustrationTable.tsx b/components/behavior/FrustrationTable.tsx index 916b749..acbdf9a 100644 --- a/components/behavior/FrustrationTable.tsx +++ b/components/behavior/FrustrationTable.tsx @@ -53,7 +53,7 @@ function SelectorCell({ selector }: { selector: string }) { className="flex items-center gap-1 min-w-0 group/copy cursor-pointer" title={selector} > - + {selector} @@ -145,7 +145,7 @@ export default function FrustrationTable({
          -

          +

          {title}

          {showViewAll && ( @@ -159,7 +159,7 @@ export default function FrustrationTable({ )}
          -

          +

          {description}

          @@ -177,18 +177,23 @@ export default function FrustrationTable({ ) : (
          -
          - -
          -

          + No frustration signals +

          No {title.toLowerCase()} detected

          -

          +

          Frustration tracking requires the add-on script. Add it after your core Pulse script:

          {''} + + View setup guide +
          )}
          @@ -212,7 +217,7 @@ export default function FrustrationTable({ ))}
          ) : ( -

          +

          No data available

          )} diff --git a/components/behavior/FrustrationTrend.tsx b/components/behavior/FrustrationTrend.tsx index 9247113..18fb8f6 100644 --- a/components/behavior/FrustrationTrend.tsx +++ b/components/behavior/FrustrationTrend.tsx @@ -59,7 +59,7 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array< className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: item.fill }} /> - + {LABELS[item.type] ?? item.type} @@ -93,21 +93,21 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP return (
          -

          +

          Frustration Trend

          -

          +

          Rage vs. dead click breakdown

          - +
          -

          +

          No trend data yet

          -

          +

          Frustration trend data will appear here once rage clicks or dead clicks are detected on your site.

          @@ -118,11 +118,11 @@ export default function FrustrationTrend({ summary, loading }: FrustrationTrendP return (
          -

          +

          Frustration Trend

          -

          +

          {hasPrevious ? 'Rage and dead clicks split across current and previous period' : 'Rage vs. dead click breakdown'} diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 4b47fc0..5a41b30 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -322,7 +322,7 @@ export default function Chart({ >

          {m.label}
          - + {m.change !== null && ( {m.isPositive ? : } @@ -357,7 +357,7 @@ export default function Chart({ {/* Toolbar */}
          - + {METRIC_CONFIGS.find((m) => m.key === metric)?.label}
          @@ -414,7 +414,12 @@ export default function Chart({
          {!hasData || !hasAnyNonZero ? ( -
          +
          + No data available

          {!hasData ? 'No data for this period' : `No ${METRIC_CONFIGS.find((m) => m.key === metric)?.label.toLowerCase()} recorded`}

          @@ -521,7 +526,7 @@ export default function Chart({ {ANNOTATION_LABELS[a.category] || 'Note'} · {formatEU(a.date)}{a.time ? ` at ${a.time}` : ''} -

          {a.text}

          +

          {a.text}

          ))} @@ -588,16 +593,16 @@ export default function Chart({ {annotationForm.visible && (
          -

          +

          {annotationForm.editingId ? 'Edit annotation' : 'Add annotation'}

          - +
          -
- - + ))} @@ -1300,7 +1255,7 @@ export default function OrganizationSettings() { {/* Pagination */} {auditTotal > auditPageSize && (
- + {auditPage * auditPageSize + 1}–{Math.min((auditPage + 1) * auditPageSize, auditTotal)} of {auditTotal}
@@ -1406,7 +1361,7 @@ export default function OrganizationSettings() { value={deleteConfirm} onChange={(e) => setDeleteConfirm(e.target.value)} autoComplete="off" - className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400" + className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400" placeholder="DELETE" />
@@ -1455,7 +1410,7 @@ export default function OrganizationSettings() { className="bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-md w-full p-6 border border-neutral-200 dark:border-neutral-800" >
-

Cancel subscription?

+

Cancel subscription?

{formatDateTime(new Date(entry.occurred_at))} + {entry.actor_email || entry.actor_id || 'System'} {entry.action}{entry.action} {entry.resource_type}