refactor: update references from Ciphera Analytics to Ciphera Pulse across the application for consistent branding and messaging
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
[](https://railway.app/)
|
[](https://railway.app/)
|
||||||
|
|
||||||
Analytics Frontend is the dashboard interface for Ciphera Analytics. It provides a simple, intuitive interface for managing sites and viewing analytics data.
|
Analytics Frontend is the dashboard interface for Ciphera Pulse. It provides a simple, intuitive interface for managing sites and viewing analytics data.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ export default function AboutPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
<h1 className="text-3xl font-bold mb-6 text-neutral-900 dark:text-white">
|
<h1 className="text-3xl font-bold mb-6 text-neutral-900 dark:text-white">
|
||||||
About Ciphera Analytics
|
About Ciphera Pulse
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||||
<p className="text-lg text-neutral-600 dark:text-neutral-400 mb-4">
|
<p className="text-lg text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
Ciphera Analytics is a privacy-first web analytics platform that provides simple,
|
Ciphera Pulse is a privacy-first web analytics platform that provides simple,
|
||||||
intuitive insights without compromising your visitors' privacy.
|
intuitive insights without compromising your visitors' privacy.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
export default function FAQPage() {
|
export default function FAQPage() {
|
||||||
const faqs = [
|
const faqs = [
|
||||||
{
|
{
|
||||||
question: "Is Ciphera Analytics GDPR compliant?",
|
question: "Is Ciphera Pulse GDPR compliant?",
|
||||||
answer: "Yes, Ciphera Analytics is GDPR compliant by design. We don't use cookies, don't collect personal data, and process all data anonymously."
|
answer: "Yes, Ciphera Pulse is GDPR compliant by design. We don't use cookies, don't collect personal data, and process all data anonymously."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Do I need a cookie consent banner?",
|
question: "Do I need a cookie consent banner?",
|
||||||
answer: "No, you don't need a cookie consent banner. Ciphera Analytics doesn't use cookies, so it's exempt from cookie consent requirements under GDPR."
|
answer: "No, you don't need a cookie consent banner. Ciphera Pulse doesn't use cookies, so it's exempt from cookie consent requirements under GDPR."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "How does Ciphera Analytics track visitors?",
|
question: "How does Ciphera Pulse track visitors?",
|
||||||
answer: "We use a lightweight JavaScript snippet that sends anonymous pageview events. No cookies, no cross-session identifiers (we use sessionStorage only to group events within a single visit), and no cross-site tracking."
|
answer: "We use a lightweight JavaScript snippet that sends anonymous pageview events. No cookies, no cross-session identifiers (we use sessionStorage only to group events within a single visit), and no cross-site tracking."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "What data does Ciphera Analytics collect?",
|
question: "What data does Ciphera Pulse collect?",
|
||||||
answer: "We collect anonymous pageview data including page path, referrer, device type, browser, and country (derived from IP at request time; the IP itself is not stored). No personal information is collected. If you enable optional session replay, see 'What about session replay?' below."
|
answer: "We collect anonymous pageview data including page path, referrer, device type, browser, and country (derived from IP at request time; the IP itself is not stored). No personal information is collected. If you enable optional session replay, see 'What about session replay?' below."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
|||||||
appName={
|
appName={
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<span className="font-bold">Ciphera</span>
|
<span className="font-bold">Ciphera</span>
|
||||||
<span className="font-light">Analytics</span>
|
<span className="font-light">Pulse</span>
|
||||||
</span> as any
|
</span> as any
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -25,7 +25,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
|||||||
</main>
|
</main>
|
||||||
<Footer
|
<Footer
|
||||||
LinkComponent={Link}
|
LinkComponent={Link}
|
||||||
appName="Ciphera Analytics"
|
appName="Ciphera Pulse"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const plusJakartaSans = Plus_Jakarta_Sans({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Ciphera Analytics - Privacy-First Web Analytics',
|
title: 'Ciphera Pulse - Privacy-First Web Analytics',
|
||||||
description: 'Simple, privacy-focused web analytics. No cookies, no tracking. GDPR compliant.',
|
description: 'Simple, privacy-focused web analytics. No cookies, no tracking. GDPR compliant.',
|
||||||
keywords: ['analytics', 'privacy', 'web analytics', 'ciphera', 'GDPR'],
|
keywords: ['analytics', 'privacy', 'web analytics', 'ciphera', 'GDPR'],
|
||||||
authors: [{ name: 'Ciphera' }],
|
authors: [{ name: 'Ciphera' }],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function HomePage() {
|
|||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Analytics" />
|
return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Pulse" />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -25,7 +25,7 @@ export default function HomePage() {
|
|||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-orange opacity-75"></span>
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-orange opacity-75"></span>
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-brand-orange"></span>
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-brand-orange"></span>
|
||||||
</span>
|
</span>
|
||||||
Privacy-First Analytics
|
Privacy-First Pulse
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-5xl md:text-7xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
<h1 className="text-5xl md:text-7xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function SecurityPage() {
|
|||||||
Data Protection
|
Data Protection
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
|
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
Ciphera Analytics is built with security and privacy as core principles:
|
Ciphera Pulse is built with security and privacy as core principles:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-2 text-neutral-600 dark:text-neutral-400 mb-6">
|
<ul className="list-disc list-inside space-y-2 text-neutral-600 dark:text-neutral-400 mb-6">
|
||||||
<li>All data is encrypted in transit using TLS/SSL</li>
|
<li>All data is encrypted in transit using TLS/SSL</li>
|
||||||
@@ -24,7 +24,7 @@ export default function SecurityPage() {
|
|||||||
Compliance
|
Compliance
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
|
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
Ciphera Analytics is compliant with:
|
Ciphera Pulse is compliant with:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-2 text-neutral-600 dark:text-neutral-400 mb-6">
|
<ul className="list-disc list-inside space-y-2 text-neutral-600 dark:text-neutral-400 mb-6">
|
||||||
<li>GDPR (General Data Protection Regulation)</li>
|
<li>GDPR (General Data Protection Regulation)</li>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import ProfileSettings from '@/components/settings/ProfileSettings'
|
import ProfileSettings from '@/components/settings/ProfileSettings'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Settings - Ciphera Analytics',
|
title: 'Settings - Ciphera Pulse',
|
||||||
description: 'Manage your account settings',
|
description: 'Manage your account settings',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function PublicDashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading && !data && !isPasswordProtected) {
|
if (loading && !data && !isPasswordProtected) {
|
||||||
return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Analytics" />
|
return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Pulse" />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPasswordProtected && !data) {
|
if (isPasswordProtected && !data) {
|
||||||
@@ -140,7 +140,7 @@ export default function PublicDashboardPage() {
|
|||||||
|
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
|
|
||||||
const { site, stats, daily_stats, top_pages, entry_pages, exit_pages, top_referrers, countries, cities, regions, browsers, os, devices, screen_resolutions, performance, realtime_visitors } = data
|
const { site, stats, daily_stats, top_pages, entry_pages, exit_pages, top_referrers, countries, cities, regions, browsers, os, devices, screen_resolutions, performance, performance_by_page, realtime_visitors } = data
|
||||||
|
|
||||||
// Provide defaults for potentially undefined data
|
// Provide defaults for potentially undefined data
|
||||||
const safeDailyStats = daily_stats || []
|
const safeDailyStats = daily_stats || []
|
||||||
@@ -274,7 +274,7 @@ export default function PublicDashboardPage() {
|
|||||||
{/* Performance Stats - Only show if enabled */}
|
{/* Performance Stats - Only show if enabled */}
|
||||||
{performance && data.site?.enable_performance_insights && (
|
{performance && data.site?.enable_performance_insights && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<PerformanceStats stats={performance} />
|
<PerformanceStats stats={performance} performanceByPage={performance_by_page} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { getSite, type Site } from '@/lib/api/sites'
|
import { getSite, type Site } from '@/lib/api/sites'
|
||||||
import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, type Stats, type DailyStat } from '@/lib/api/stats'
|
import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
|
||||||
import { formatNumber, formatDuration, getDateRange } from '@/lib/utils/format'
|
import { formatNumber, formatDuration, getDateRange } from '@/lib/utils/format'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import LoadingOverlay from '@/components/LoadingOverlay'
|
import LoadingOverlay from '@/components/LoadingOverlay'
|
||||||
@@ -40,6 +40,7 @@ export default function SiteDashboardPage() {
|
|||||||
const [devices, setDevices] = useState<any[]>([])
|
const [devices, setDevices] = useState<any[]>([])
|
||||||
const [screenResolutions, setScreenResolutions] = useState<any[]>([])
|
const [screenResolutions, setScreenResolutions] = useState<any[]>([])
|
||||||
const [performance, setPerformance] = useState<{ lcp: number, cls: number, inp: number }>({ lcp: 0, cls: 0, inp: 0 })
|
const [performance, setPerformance] = useState<{ lcp: number, cls: number, inp: number }>({ lcp: 0, cls: 0, inp: 0 })
|
||||||
|
const [performanceByPage, setPerformanceByPage] = useState<PerformanceByPageStat[] | null>(null)
|
||||||
const [dateRange, setDateRange] = useState(getDateRange(30))
|
const [dateRange, setDateRange] = useState(getDateRange(30))
|
||||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||||
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
|
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
|
||||||
@@ -113,6 +114,7 @@ export default function SiteDashboardPage() {
|
|||||||
setDevices(Array.isArray(data.devices) ? data.devices : [])
|
setDevices(Array.isArray(data.devices) ? data.devices : [])
|
||||||
setScreenResolutions(Array.isArray(data.screen_resolutions) ? data.screen_resolutions : [])
|
setScreenResolutions(Array.isArray(data.screen_resolutions) ? data.screen_resolutions : [])
|
||||||
setPerformance(data.performance || { lcp: 0, cls: 0, inp: 0 })
|
setPerformance(data.performance || { lcp: 0, cls: 0, inp: 0 })
|
||||||
|
setPerformanceByPage(data.performance_by_page ?? null)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error('Failed to load data: ' + (error.message || 'Unknown error'))
|
toast.error('Failed to load data: ' + (error.message || 'Unknown error'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -130,7 +132,7 @@ export default function SiteDashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Analytics" />
|
return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Pulse" />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!site) {
|
if (!site) {
|
||||||
@@ -231,7 +233,14 @@ export default function SiteDashboardPage() {
|
|||||||
{/* Performance Stats - Only show if enabled */}
|
{/* Performance Stats - Only show if enabled */}
|
||||||
{site.enable_performance_insights && (
|
{site.enable_performance_insights && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<PerformanceStats stats={performance} />
|
<PerformanceStats
|
||||||
|
stats={performance}
|
||||||
|
performanceByPage={performanceByPage}
|
||||||
|
siteId={siteId}
|
||||||
|
startDate={dateRange.start}
|
||||||
|
endDate={dateRange.end}
|
||||||
|
getPerformanceByPage={getPerformanceByPage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function RealtimePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Realtime Analytics" />
|
if (loading) return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Realtime" />
|
||||||
if (!site) return <div className="p-8">Site not found</div>
|
if (!site) return <div className="p-8">Site not found</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export default function SiteSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Analytics" />
|
return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Pulse" />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!site) {
|
if (!site) {
|
||||||
@@ -785,7 +785,7 @@ export default function SiteSettingsPage() {
|
|||||||
For your privacy policy
|
For your privacy policy
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
Copy the text below into your site's Privacy Policy to describe your use of Ciphera Analytics.
|
Copy the text below into your site's Privacy Policy to describe your use of Ciphera Pulse.
|
||||||
It updates automatically based on your saved settings above.
|
It updates automatically based on your saved settings above.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface LoadingOverlayProps {
|
|||||||
|
|
||||||
export default function LoadingOverlay({
|
export default function LoadingOverlay({
|
||||||
logoSrc = "/ciphera_icon_no_margins.png",
|
logoSrc = "/ciphera_icon_no_margins.png",
|
||||||
title = "Ciphera Analytics"
|
title = "Ciphera Pulse"
|
||||||
}: LoadingOverlayProps) {
|
}: LoadingOverlayProps) {
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
@@ -27,11 +27,11 @@ export default function LoadingOverlay({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<img
|
<img
|
||||||
src={logoSrc}
|
src={logoSrc}
|
||||||
alt={typeof title === 'string' ? title : "Ciphera Analytics"}
|
alt={typeof title === 'string' ? title : "Ciphera Pulse"}
|
||||||
className="h-12 w-auto object-contain"
|
className="h-12 w-auto object-contain"
|
||||||
/>
|
/>
|
||||||
<span className="text-3xl tracking-tight text-neutral-900 dark:text-white">
|
<span className="text-3xl tracking-tight text-neutral-900 dark:text-white">
|
||||||
<span className="font-bold">Ciphera</span><span className="font-light">Analytics</span>
|
<span className="font-bold">Ciphera</span><span className="font-light">Pulse</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-brand-orange dark:border-neutral-800 dark:border-t-brand-orange" />
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-brand-orange dark:border-neutral-800 dark:border-t-brand-orange" />
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { PerformanceStats as Stats } from '@/lib/api/stats'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats'
|
||||||
|
import Select from '@/components/ui/Select'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stats: Stats
|
stats: Stats
|
||||||
|
performanceByPage?: PerformanceByPageStat[] | null
|
||||||
|
siteId?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
getPerformanceByPage?: typeof getPerformanceByPage
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard({ label, value, unit, score }: { label: string, value: number, unit: string, score: 'good' | 'needs-improvement' | 'poor' }) {
|
function MetricCard({ label, value, unit, score }: { label: string, value: number, unit: string, score: 'good' | 'needs-improvement' | 'poor' }) {
|
||||||
@@ -24,7 +31,7 @@ function MetricCard({ label, value, unit, score }: { label: string, value: numbe
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PerformanceStats({ stats }: Props) {
|
export default function PerformanceStats({ stats, performanceByPage, siteId, startDate, endDate, getPerformanceByPage }: Props) {
|
||||||
// * Scoring Logic (based on Google Web Vitals)
|
// * Scoring Logic (based on Google Web Vitals)
|
||||||
const getScore = (metric: 'lcp' | 'cls' | 'inp', value: number) => {
|
const getScore = (metric: 'lcp' | 'cls' | 'inp', value: number) => {
|
||||||
if (metric === 'lcp') return value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor'
|
if (metric === 'lcp') return value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor'
|
||||||
@@ -33,9 +40,47 @@ export default function PerformanceStats({ stats }: Props) {
|
|||||||
return 'good'
|
return 'good'
|
||||||
}
|
}
|
||||||
|
|
||||||
// * If no data, don't show or show placeholder?
|
const [sortBy, setSortBy] = useState<'lcp' | 'cls' | 'inp'>('lcp')
|
||||||
// * Showing placeholder with 0s might be confusing if they actually have 0ms latency (impossible)
|
const [overrideRows, setOverrideRows] = useState<PerformanceByPageStat[] | null>(null)
|
||||||
// * But we handle empty stats in parent or pass 0 here.
|
const [loadingTable, setLoadingTable] = useState(false)
|
||||||
|
|
||||||
|
// * When props.performanceByPage changes (e.g. date range), clear override so we use dashboard data
|
||||||
|
useEffect(() => {
|
||||||
|
setOverrideRows(null)
|
||||||
|
}, [performanceByPage])
|
||||||
|
|
||||||
|
const rows = overrideRows ?? performanceByPage ?? []
|
||||||
|
const canRefetch = Boolean(getPerformanceByPage && siteId && startDate && endDate)
|
||||||
|
|
||||||
|
const handleSortChange = (value: string) => {
|
||||||
|
const v = value as 'lcp' | 'cls' | 'inp'
|
||||||
|
setSortBy(v)
|
||||||
|
if (!getPerformanceByPage || !siteId || !startDate || !endDate) return
|
||||||
|
setLoadingTable(true)
|
||||||
|
getPerformanceByPage(siteId, startDate, endDate, { sort: v, limit: 20 })
|
||||||
|
.then(setOverrideRows)
|
||||||
|
.finally(() => setLoadingTable(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellScoreClass = (score: 'good' | 'needs-improvement' | 'poor') => {
|
||||||
|
const m: Record<string, string> = {
|
||||||
|
good: 'text-green-600 dark:text-green-400',
|
||||||
|
'needs-improvement': 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
poor: 'text-red-600 dark:text-red-400',
|
||||||
|
}
|
||||||
|
return m[score] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMetric = (metric: 'lcp' | 'cls' | 'inp', val: number | null) => {
|
||||||
|
if (val == null) return '—'
|
||||||
|
if (metric === 'cls') return val.toFixed(3)
|
||||||
|
return `${Math.round(val)} ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCellClass = (metric: 'lcp' | 'cls' | 'inp', val: number | null) => {
|
||||||
|
if (val == null) return 'text-neutral-400 dark:text-neutral-500'
|
||||||
|
return cellScoreClass(getScore(metric, val))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6">
|
||||||
@@ -43,28 +88,91 @@ export default function PerformanceStats({ stats }: Props) {
|
|||||||
Core Web Vitals
|
Core Web Vitals
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Largest Contentful Paint (LCP)"
|
label="Largest Contentful Paint (LCP)"
|
||||||
value={Math.round(stats.lcp)}
|
value={Math.round(stats.lcp)}
|
||||||
unit="ms"
|
unit="ms"
|
||||||
score={getScore('lcp', stats.lcp)}
|
score={getScore('lcp', stats.lcp)}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Cumulative Layout Shift (CLS)"
|
label="Cumulative Layout Shift (CLS)"
|
||||||
value={Number(stats.cls.toFixed(3))}
|
value={Number(stats.cls.toFixed(3))}
|
||||||
unit=""
|
unit=""
|
||||||
score={getScore('cls', stats.cls)}
|
score={getScore('cls', stats.cls)}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Interaction to Next Paint (INP)"
|
label="Interaction to Next Paint (INP)"
|
||||||
value={Math.round(stats.inp)}
|
value={Math.round(stats.inp)}
|
||||||
unit="ms"
|
unit="ms"
|
||||||
score={getScore('inp', stats.inp)}
|
score={getScore('inp', stats.inp)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-xs text-neutral-500">
|
<div className="mt-4 text-xs text-neutral-500">
|
||||||
* Averages calculated from real user sessions. Lower is better.
|
* Averages calculated from real user sessions. Lower is better.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* * Performance by page (worst first) */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800">
|
||||||
|
<div className="flex items-center justify-between gap-4 mb-3">
|
||||||
|
<h4 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
Worst pages by metric
|
||||||
|
</h4>
|
||||||
|
{canRefetch && (
|
||||||
|
<Select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={handleSortChange}
|
||||||
|
options={[
|
||||||
|
{ value: 'lcp', label: 'Sort by LCP (worst)' },
|
||||||
|
{ value: 'cls', label: 'Sort by CLS (worst)' },
|
||||||
|
{ value: 'inp', label: 'Sort by INP (worst)' },
|
||||||
|
]}
|
||||||
|
variant="input"
|
||||||
|
align="right"
|
||||||
|
className="min-w-[180px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loadingTable ? (
|
||||||
|
<div className="py-8 text-center text-neutral-500 text-sm">Loading…</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-neutral-500 text-sm">
|
||||||
|
No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto -mx-1">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200 dark:border-neutral-700">
|
||||||
|
<th className="text-left py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">Path</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">Samples</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">LCP</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">CLS</th>
|
||||||
|
<th className="text-right py-2 px-2 font-medium text-neutral-600 dark:text-neutral-400">INP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.path} className="border-b border-neutral-100 dark:border-neutral-800/80">
|
||||||
|
<td className="py-2 px-2 text-neutral-900 dark:text-white font-mono truncate max-w-[200px]" title={r.path}>
|
||||||
|
{r.path || '/'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-right text-neutral-600 dark:text-neutral-400">{r.samples}</td>
|
||||||
|
<td className={`py-2 px-2 text-right font-mono ${getCellClass('lcp', r.lcp)}`}>
|
||||||
|
{formatMetric('lcp', r.lcp)}
|
||||||
|
</td>
|
||||||
|
<td className={`py-2 px-2 text-right font-mono ${getCellClass('cls', r.cls)}`}>
|
||||||
|
{formatMetric('cls', r.cls)}
|
||||||
|
</td>
|
||||||
|
<td className={`py-2 px-2 text-right font-mono ${getCellClass('inp', r.inp)}`}>
|
||||||
|
{formatMetric('inp', r.inp)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function SiteList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Analytics" />
|
return <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Pulse" />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sites.length === 0) {
|
if (sites.length === 0) {
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export interface PerformanceStats {
|
|||||||
inp: number
|
inp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PerformanceByPageStat {
|
||||||
|
path: string
|
||||||
|
samples: number
|
||||||
|
lcp: number | null
|
||||||
|
cls: number | null
|
||||||
|
inp: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface TopReferrer {
|
export interface TopReferrer {
|
||||||
referrer: string
|
referrer: string
|
||||||
pageviews: number
|
pageviews: number
|
||||||
@@ -180,6 +188,23 @@ export async function getScreenResolutions(siteId: string, startDate?: string, e
|
|||||||
return apiRequest<{ screen_resolutions: ScreenResolutionStat[] }>(`/sites/${siteId}/screen-resolutions?${params.toString()}`).then(r => r?.screen_resolutions || [])
|
return apiRequest<{ screen_resolutions: ScreenResolutionStat[] }>(`/sites/${siteId}/screen-resolutions?${params.toString()}`).then(r => r?.screen_resolutions || [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPerformanceByPage(
|
||||||
|
siteId: string,
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string,
|
||||||
|
opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' }
|
||||||
|
): Promise<PerformanceByPageStat[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (startDate) params.append('start_date', startDate)
|
||||||
|
if (endDate) params.append('end_date', endDate)
|
||||||
|
if (opts?.limit != null) params.append('limit', String(opts.limit))
|
||||||
|
if (opts?.sort) params.append('sort', opts.sort)
|
||||||
|
const res = await apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
|
||||||
|
`/sites/${siteId}/performance-by-page?${params.toString()}`
|
||||||
|
)
|
||||||
|
return res?.performance_by_page ?? []
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
site: Site
|
site: Site
|
||||||
stats: Stats
|
stats: Stats
|
||||||
@@ -197,6 +222,7 @@ export interface DashboardData {
|
|||||||
devices: DeviceStat[]
|
devices: DeviceStat[]
|
||||||
screen_resolutions: ScreenResolutionStat[]
|
screen_resolutions: ScreenResolutionStat[]
|
||||||
performance?: PerformanceStats
|
performance?: PerformanceStats
|
||||||
|
performance_by_page?: PerformanceByPageStat[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise<DashboardData> {
|
export async function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise<DashboardData> {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}>
|
<AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}>
|
||||||
{isLoggingOut && <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Analytics" />}
|
{isLoggingOut && <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Pulse" />}
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import type { Site } from '@/lib/api/sites'
|
|||||||
const DOCS_URL =
|
const DOCS_URL =
|
||||||
(typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_APP_URL)
|
(typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_APP_URL)
|
||||||
? `${process.env.NEXT_PUBLIC_APP_URL}/faq`
|
? `${process.env.NEXT_PUBLIC_APP_URL}/faq`
|
||||||
: 'https://analytics.ciphera.net/faq'
|
: 'https://pulse.ciphera.net/faq'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a privacy-policy snippet for the site's use of Ciphera Analytics.
|
* Generates a privacy-policy snippet for the site's use of Ciphera Pulse.
|
||||||
* The text is derived from the site's data collection and filtering settings
|
* The text is derived from the site's data collection and filtering settings
|
||||||
* and is intended to be copied into the site owner's Privacy Policy page.
|
* and is intended to be copied into the site owner's Privacy Policy page.
|
||||||
* This is for transparency (GDPR Art. 13/14); it is not a cookie banner.
|
* This is for transparency (GDPR Art. 13/14); it is not a cookie banner.
|
||||||
@@ -40,13 +40,13 @@ export function generatePrivacySnippet(site: Site): string {
|
|||||||
: 'minimal anonymous data about site usage (e.g. that a page was viewed)'
|
: 'minimal anonymous data about site usage (e.g. that a page was viewed)'
|
||||||
|
|
||||||
const p1 =
|
const p1 =
|
||||||
'We use Ciphera Analytics to understand how visitors use our site. Ciphera does not use cookies or other persistent identifiers. A cookie consent banner is not required for Ciphera Analytics. We respect Do Not Track (DNT) browser settings.'
|
'We use Ciphera Pulse to understand how visitors use our site. Ciphera does not use cookies or other persistent identifiers. A cookie consent banner is not required for Ciphera Pulse. We respect Do Not Track (DNT) browser settings.'
|
||||||
|
|
||||||
let p2 = `We collect anonymous data: ${list}. `
|
let p2 = `We collect anonymous data: ${list}. `
|
||||||
if (filterBots) {
|
if (filterBots) {
|
||||||
p2 += 'Known bots and referrer spam are excluded from our analytics. '
|
p2 += 'Known bots and referrer spam are excluded from our analytics. '
|
||||||
}
|
}
|
||||||
p2 += `Data is processed in a privacy-preserving way and is not used to identify individuals. For more information, see Ciphera Analytics' documentation: ${DOCS_URL}`
|
p2 += `Data is processed in a privacy-preserving way and is not used to identify individuals. For more information, see Ciphera Pulse' documentation: ${DOCS_URL}`
|
||||||
|
|
||||||
return `${p1}\n\n${p2}`
|
return `${p1}\n\n${p2}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Ciphera Analytics - Privacy-First Tracking Script
|
* Ciphera Pulse - Privacy-First Tracking Script
|
||||||
* Lightweight, no cookies, GDPR compliant
|
* Lightweight, no cookies, GDPR compliant
|
||||||
* Includes optional session replay with privacy controls
|
* Includes optional session replay with privacy controls
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user