refactor: update references from Ciphera Analytics to Ciphera Pulse across the application for consistent branding and messaging

This commit is contained in:
Usman Baig
2026-01-19 16:49:42 +01:00
parent d0a13adf36
commit 9dbe74fd9f
19 changed files with 198 additions and 55 deletions

View File

@@ -4,7 +4,7 @@
[![Built with Next.js](https://img.shields.io/badge/Built%20with-Next.js-blue.svg?logo=next.js&logoColor=white)](https://nextjs.org/)
[![Hosted on Railway](https://img.shields.io/badge/Hosted%20on-Railway-orange.svg?logo=railway&logoColor=white)](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

View File

@@ -2,12 +2,12 @@ export default function AboutPage() {
return (
<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">
About Ciphera Analytics
About Ciphera Pulse
</h1>
<div className="prose prose-neutral dark:prose-invert max-w-none">
<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.
</p>

View File

@@ -1,19 +1,19 @@
export default function FAQPage() {
const faqs = [
{
question: "Is Ciphera Analytics 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."
question: "Is Ciphera Pulse GDPR compliant?",
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?",
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."
},
{
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."
},
{

View File

@@ -16,7 +16,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
appName={
<span className="flex items-center">
<span className="font-bold">Ciphera</span>
<span className="font-light">Analytics</span>
<span className="font-light">Pulse</span>
</span> as any
}
/>
@@ -25,7 +25,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
</main>
<Footer
LinkComponent={Link}
appName="Ciphera Analytics"
appName="Ciphera Pulse"
/>
</>
)

View File

@@ -13,7 +13,7 @@ const plusJakartaSans = Plus_Jakarta_Sans({
})
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.',
keywords: ['analytics', 'privacy', 'web analytics', 'ciphera', 'GDPR'],
authors: [{ name: 'Ciphera' }],

View File

@@ -12,7 +12,7 @@ export default function HomePage() {
const { user, loading } = useAuth()
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) {
@@ -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="relative inline-flex rounded-full h-2 w-2 bg-brand-orange"></span>
</span>
Privacy-First Analytics
Privacy-First Pulse
</div>
<h1 className="text-5xl md:text-7xl font-bold tracking-tight text-neutral-900 dark:text-white">

View File

@@ -10,7 +10,7 @@ export default function SecurityPage() {
Data Protection
</h2>
<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>
<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>
@@ -24,7 +24,7 @@ export default function SecurityPage() {
Compliance
</h2>
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
Ciphera Analytics is compliant with:
Ciphera Pulse is compliant with:
</p>
<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>

View File

@@ -1,7 +1,7 @@
import ProfileSettings from '@/components/settings/ProfileSettings'
export const metadata = {
title: 'Settings - Ciphera Analytics',
title: 'Settings - Ciphera Pulse',
description: 'Manage your account settings',
}

View File

@@ -96,7 +96,7 @@ export default function PublicDashboardPage() {
}
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) {
@@ -140,7 +140,7 @@ export default function PublicDashboardPage() {
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
const safeDailyStats = daily_stats || []
@@ -274,7 +274,7 @@ export default function PublicDashboardPage() {
{/* Performance Stats - Only show if enabled */}
{performance && data.site?.enable_performance_insights && (
<div className="mb-8">
<PerformanceStats stats={performance} />
<PerformanceStats stats={performance} performanceByPage={performance_by_page} />
</div>
)}

View File

@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
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 { toast } from 'sonner'
import LoadingOverlay from '@/components/LoadingOverlay'
@@ -40,6 +40,7 @@ export default function SiteDashboardPage() {
const [devices, setDevices] = useState<any[]>([])
const [screenResolutions, setScreenResolutions] = useState<any[]>([])
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 [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
@@ -113,6 +114,7 @@ export default function SiteDashboardPage() {
setDevices(Array.isArray(data.devices) ? data.devices : [])
setScreenResolutions(Array.isArray(data.screen_resolutions) ? data.screen_resolutions : [])
setPerformance(data.performance || { lcp: 0, cls: 0, inp: 0 })
setPerformanceByPage(data.performance_by_page ?? null)
} catch (error: any) {
toast.error('Failed to load data: ' + (error.message || 'Unknown error'))
} finally {
@@ -130,7 +132,7 @@ export default function SiteDashboardPage() {
}
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) {
@@ -231,7 +233,14 @@ export default function SiteDashboardPage() {
{/* Performance Stats - Only show if enabled */}
{site.enable_performance_insights && (
<div className="mb-8">
<PerformanceStats stats={performance} />
<PerformanceStats
stats={performance}
performanceByPage={performanceByPage}
siteId={siteId}
startDate={dateRange.start}
endDate={dateRange.end}
getPerformanceByPage={getPerformanceByPage}
/>
</div>
)}

View File

@@ -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>
return (

View File

@@ -230,7 +230,7 @@ export default function SiteSettingsPage() {
}
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) {
@@ -785,7 +785,7 @@ export default function SiteSettingsPage() {
For your privacy policy
</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Copy the text below into your site&apos;s Privacy Policy to describe your use of Ciphera Analytics.
Copy the text below into your site&apos;s Privacy Policy to describe your use of Ciphera Pulse.
It updates automatically based on your saved settings above.
</p>
<p className="text-xs text-amber-600 dark:text-amber-500">

View File

@@ -10,7 +10,7 @@ interface LoadingOverlayProps {
export default function LoadingOverlay({
logoSrc = "/ciphera_icon_no_margins.png",
title = "Ciphera Analytics"
title = "Ciphera Pulse"
}: LoadingOverlayProps) {
const [mounted, setMounted] = useState(false)
@@ -27,11 +27,11 @@ export default function LoadingOverlay({
<div className="flex items-center gap-3">
<img
src={logoSrc}
alt={typeof title === 'string' ? title : "Ciphera Analytics"}
alt={typeof title === 'string' ? title : "Ciphera Pulse"}
className="h-12 w-auto object-contain"
/>
<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>
</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" />

View File

@@ -1,9 +1,16 @@
'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 {
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' }) {
@@ -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)
const getScore = (metric: 'lcp' | 'cls' | 'inp', value: number) => {
if (metric === 'lcp') return value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor'
@@ -33,9 +40,47 @@ export default function PerformanceStats({ stats }: Props) {
return 'good'
}
// * If no data, don't show or show placeholder?
// * Showing placeholder with 0s might be confusing if they actually have 0ms latency (impossible)
// * But we handle empty stats in parent or pass 0 here.
const [sortBy, setSortBy] = useState<'lcp' | 'cls' | 'inp'>('lcp')
const [overrideRows, setOverrideRows] = useState<PerformanceByPageStat[] | null>(null)
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 (
<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
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard
label="Largest Contentful Paint (LCP)"
value={Math.round(stats.lcp)}
unit="ms"
score={getScore('lcp', stats.lcp)}
<MetricCard
label="Largest Contentful Paint (LCP)"
value={Math.round(stats.lcp)}
unit="ms"
score={getScore('lcp', stats.lcp)}
/>
<MetricCard
label="Cumulative Layout Shift (CLS)"
value={Number(stats.cls.toFixed(3))}
unit=""
score={getScore('cls', stats.cls)}
<MetricCard
label="Cumulative Layout Shift (CLS)"
value={Number(stats.cls.toFixed(3))}
unit=""
score={getScore('cls', stats.cls)}
/>
<MetricCard
label="Interaction to Next Paint (INP)"
value={Math.round(stats.inp)}
unit="ms"
score={getScore('inp', stats.inp)}
<MetricCard
label="Interaction to Next Paint (INP)"
value={Math.round(stats.inp)}
unit="ms"
score={getScore('inp', stats.inp)}
/>
</div>
<div className="mt-4 text-xs text-neutral-500">
* Averages calculated from real user sessions. Lower is better.
</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>
)
}

View File

@@ -43,7 +43,7 @@ export default function SiteList() {
}
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) {

View File

@@ -25,6 +25,14 @@ export interface PerformanceStats {
inp: number
}
export interface PerformanceByPageStat {
path: string
samples: number
lcp: number | null
cls: number | null
inp: number | null
}
export interface TopReferrer {
referrer: string
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 || [])
}
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 {
site: Site
stats: Stats
@@ -197,6 +222,7 @@ export interface DashboardData {
devices: DeviceStat[]
screen_resolutions: ScreenResolutionStat[]
performance?: PerformanceStats
performance_by_page?: PerformanceByPageStat[]
}
export async function getDashboard(siteId: string, startDate?: string, endDate?: string, limit = 10, interval?: string): Promise<DashboardData> {

View File

@@ -99,7 +99,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return (
<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}
</AuthContext.Provider>
)

View File

@@ -3,10 +3,10 @@ import type { Site } from '@/lib/api/sites'
const DOCS_URL =
(typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_APP_URL)
? `${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
* 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.
@@ -40,13 +40,13 @@ export function generatePrivacySnippet(site: Site): string {
: 'minimal anonymous data about site usage (e.g. that a page was viewed)'
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}. `
if (filterBots) {
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}`
}

View File

@@ -1,5 +1,5 @@
/**
* Ciphera Analytics - Privacy-First Tracking Script
* Ciphera Pulse - Privacy-First Tracking Script
* Lightweight, no cookies, GDPR compliant
* Includes optional session replay with privacy controls
*/