Merge pull request #46 from ciphera-net/staging

Chart UX improvements, behavior page polish, and SEO setup
This commit is contained in:
Usman
2026-03-13 14:42:01 +01:00
committed by GitHub
8 changed files with 225 additions and 20 deletions

View File

@@ -9,8 +9,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Improved
- **Cleaner page paths in your reports.** Pages like `/products?_t=123456` or `/about?session=abc` now correctly show as `/products` and `/about`. Only marketing attribution parameters (like UTM tags) are preserved for traffic source tracking — all other junk parameters are automatically removed, so your Top Pages and Journeys stay clean without us having to chase down every new parameter format.
- **Refreshed chart background.** The dashboard chart now has subtle horizontal lines instead of the old dotted background, giving the chart area a cleaner look with soft faded edges.
- **Smoother loading transitions.** When your data finishes loading, the page now fades in smoothly instead of appearing all at once. This applies across Dashboard, Journeys, Funnels, Uptime, Settings, Notifications, and shared dashboards. If your data was already cached from a previous visit, it still loads instantly with no animation — the fade only kicks in when you're actually waiting for fresh data.
- **Easier to hover country dots on the map.** The orange location markers on the world map are now much easier to interact with — you no longer need pixel-perfect aim to see the tooltip.
- **Smoother chart curves and filled area.** The dashboard chart line now flows with natural curves instead of sharp flat tops at peaks. The area beneath the line is filled with a soft transparent orange gradient that fades toward the bottom, making trends easier to read at a glance.
- **Refreshed chart background.** The dashboard chart now has subtle horizontal lines instead of the old dotted background, giving the chart area a cleaner look.
- **Smoother loading transitions.** When your data finishes loading, the page now fades in smoothly instead of appearing all at once. This applies across Dashboard, Journeys, Funnels, Behavior, Uptime, Settings, Notifications, and shared dashboards. If your data was already cached from a previous visit, it still loads instantly with no animation — the fade only kicks in when you're actually waiting for fresh data.
- **Faster tab switching across the board.** Switching between Settings, Funnels, Uptime, and other tabs now shows your data instantly instead of flashing a loading skeleton every time. Previously visited tabs remember their data and show it right away, while quietly refreshing in the background so you always see the latest numbers without the wait.
- **Smoother loading on the Journeys page.** The Journeys tab now shows a polished skeleton placeholder while data loads, matching the loading experience on other tabs.
- **Consistent chart colors.** All dashboard charts — Unique Visitors, Total Pageviews, Bounce Rate, and Visit Duration — now use the same brand orange color for a cleaner, more cohesive look.

50
app/robots.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: [
'/',
'/about',
'/features',
'/pricing',
'/faq',
'/changelog',
'/installation',
'/integrations',
],
disallow: [
'/api/',
'/admin/',
'/sites/',
'/notifications/',
'/onboarding/',
'/org-settings/',
'/welcome/',
'/auth/',
'/actions/',
'/share/',
],
},
{
userAgent: 'GPTBot',
disallow: ['/'],
},
{
userAgent: 'ChatGPT-User',
disallow: ['/'],
},
{
userAgent: 'Google-Extended',
disallow: ['/'],
},
{
userAgent: 'CCBot',
disallow: ['/'],
},
],
sitemap: 'https://pulse.ciphera.net/sitemap.xml',
}
}

38
app/sitemap.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { MetadataRoute } from 'next'
const BASE_URL = 'https://pulse.ciphera.net'
export default function sitemap(): MetadataRoute.Sitemap {
const integrationSlugs = [
'nextjs',
'react',
'vue',
'wordpress',
]
const publicRoutes = [
{ url: '', priority: 1.0, changeFrequency: 'weekly' as const },
{ url: '/about', priority: 0.8, changeFrequency: 'monthly' as const },
{ url: '/features', priority: 0.9, changeFrequency: 'monthly' as const },
{ url: '/pricing', priority: 0.9, changeFrequency: 'monthly' as const },
{ url: '/faq', priority: 0.7, changeFrequency: 'monthly' as const },
{ url: '/changelog', priority: 0.6, changeFrequency: 'weekly' as const },
{ url: '/installation', priority: 0.8, changeFrequency: 'monthly' as const },
{ url: '/integrations', priority: 0.8, changeFrequency: 'monthly' as const },
]
const integrationRoutes = integrationSlugs.map((slug) => ({
url: `/integrations/${slug}`,
priority: 0.7,
changeFrequency: 'monthly' as const,
}))
const allRoutes = [...publicRoutes, ...integrationRoutes]
return allRoutes.map((route) => ({
url: `${BASE_URL}${route.url}`,
lastModified: new Date(),
changeFrequency: route.changeFrequency,
priority: route.priority,
}))
}

View File

@@ -11,6 +11,7 @@ import FrustrationTable from '@/components/behavior/FrustrationTable'
import FrustrationByPageTable from '@/components/behavior/FrustrationByPageTable'
import FrustrationTrend from '@/components/behavior/FrustrationTrend'
import { useDashboard, useBehavior } from '@/lib/swr/dashboard'
import { BehaviorSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth'))
@@ -42,6 +43,9 @@ export default function BehaviorPage() {
// Fetch dashboard data for scroll depth (goal_counts + stats)
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
const showSkeleton = useMinimumLoading(loading && !behavior)
const fadeClass = useSkeletonFade(showSkeleton)
useEffect(() => {
const domain = dashboard?.site?.domain
document.title = domain ? `Behavior · ${domain} | Pulse` : 'Behavior | Pulse'
@@ -63,8 +67,10 @@ export default function BehaviorPage() {
const deadClicks = behavior?.dead_clicks ?? { items: [], total: 0 }
const byPage = behavior?.by_page ?? []
if (showSkeleton) return <BehaviorSkeleton />
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>

View File

@@ -2,7 +2,7 @@
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
import { useTheme } from '@ciphera-net/ui'
import { CartesianGrid, Line, LineChart, XAxis, YAxis, ReferenceLine } from 'recharts'
import { Area, CartesianGrid, ComposedChart, Line, XAxis, YAxis, ReferenceLine } from 'recharts'
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
@@ -456,12 +456,16 @@ export default function Chart({
config={chartConfig}
className="h-96 w-full overflow-visible [&_.recharts-curve.recharts-tooltip-cursor]:stroke-[initial]"
>
<LineChart
<ComposedChart
data={chartData}
margin={{ top: 20, right: 20, left: 5, bottom: 20 }}
style={{ overflow: 'visible' }}
>
<defs>
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={chartConfig[metric]?.color} stopOpacity={0.15} />
<stop offset="100%" stopColor={chartConfig[metric]?.color} stopOpacity={0.01} />
</linearGradient>
<filter id="lineShadow" x="-100%" y="-100%" width="300%" height="300%">
<feDropShadow
dx="4"
@@ -522,8 +526,14 @@ export default function Chart({
)
})}
<Area
type="bump"
dataKey={metric}
fill="url(#areaFill)"
stroke="none"
/>
<Line
type="monotone"
type="bump"
dataKey={metric}
stroke={chartConfig[metric]?.color}
strokeWidth={2}
@@ -537,7 +547,7 @@ export default function Chart({
filter: 'url(#dotShadow)',
}}
/>
</LineChart>
</ComposedChart>
</ChartContainer>
</div>
)}

View File

@@ -118,30 +118,30 @@ export default function DottedMap({ data, className }: DottedMapProps) {
const rowIndex = _stagger.yToRowIndex.get(marker.y) ?? 0
const offsetX = rowIndex % 2 === 1 ? _stagger.xStep / 2 : 0
const info = markerData[index]
const cx = marker.x + offsetX
const cy = marker.y
return (
<circle
cx={marker.x + offsetX}
cy={marker.y}
r={marker.size ?? DOT_RADIUS}
fill="#FD5E0F"
filter="url(#marker-glow)"
className="cursor-pointer"
<g
key={`marker-${marker.x}-${marker.y}-${index}`}
className="cursor-pointer"
onMouseEnter={(e) => {
if (info) {
const rect = (e.target as SVGCircleElement).closest('svg')!.getBoundingClientRect()
const svgX = marker.x + offsetX
const svgY = marker.y
const rect = (e.target as SVGElement).closest('svg')!.getBoundingClientRect()
setTooltip({
x: rect.left + (svgX / MAP_WIDTH) * rect.width,
y: rect.top + (svgY / MAP_HEIGHT) * rect.height,
x: rect.left + (cx / MAP_WIDTH) * rect.width,
y: rect.top + (cy / MAP_HEIGHT) * rect.height,
country: info.country,
pageviews: info.pageviews,
})
}
}}
onMouseLeave={() => setTooltip(null)}
/>
>
{/* Invisible larger hitbox */}
<circle cx={cx} cy={cy} r={2.5} fill="transparent" />
{/* Visible dot */}
<circle cx={cx} cy={cy} r={marker.size ?? DOT_RADIUS} fill="#FD5E0F" filter="url(#marker-glow)" />
</g>
)
})}
</svg>

View File

@@ -370,6 +370,55 @@ export function PricingCardsSkeleton() {
)
}
// ─── Behavior page skeleton ─────────────────────────────────
export function BehaviorSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<SkeletonLine className="h-8 w-32 mb-2" />
<SkeletonLine className="h-4 w-64" />
</div>
<SkeletonLine className="h-9 w-36 rounded-lg" />
</div>
{/* Summary cards (3 cols) */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 space-y-3">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-8 w-16" />
<SkeletonLine className="h-3 w-32" />
</div>
))}
</div>
{/* Rage clicks + Dead clicks side by side */}
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<WidgetSkeleton />
<WidgetSkeleton />
</div>
{/* By-page table */}
<div className="mb-8">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-40 mb-2" />
<SkeletonLine className="h-4 w-64 mb-4" />
<TableSkeleton rows={5} cols={4} />
</div>
</div>
{/* Scroll depth + Frustration trend */}
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<WidgetSkeleton />
<WidgetSkeleton />
</div>
</div>
)
}
// ─── Organization settings skeleton (members, billing, etc) ─
export function MembersListSkeleton() {

50
public/llms.txt Normal file
View File

@@ -0,0 +1,50 @@
# Pulse by Ciphera
> Privacy-first web analytics. No cookies, no fingerprinting, no personal data collection. GDPR compliant by architecture.
Pulse is a lightweight, privacy-focused website analytics platform built by Ciphera. It provides meaningful traffic insights without compromising visitor privacy. The tracking script is under 2KB and requires no cookie banners.
## Key Features
- No cookies, no fingerprinting, no personal data collection
- GDPR, CCPA, and PECR compliant by design — no consent banners needed
- Lightweight tracking script (under 2KB, no impact on page speed)
- Real-time dashboard with pageviews, visitors, referrers, and geographic data
- UTM campaign tracking and custom event tracking
- Public/shared dashboard support
- Organization and team management
- Scheduled email reports
## Integrations
- [Next.js](https://pulse.ciphera.net/integrations/nextjs)
- [React](https://pulse.ciphera.net/integrations/react)
- [Vue](https://pulse.ciphera.net/integrations/vue)
- [WordPress](https://pulse.ciphera.net/integrations/wordpress)
- Works with any website via a single script tag
## Installation
Add one script tag to your site:
```html
<script defer src="https://pulse.ciphera.net/script.js" data-site-id="YOUR_SITE_ID"></script>
```
## Pages
- [Home](https://pulse.ciphera.net): Product overview and dashboard
- [Features](https://pulse.ciphera.net/features): Full feature list
- [Pricing](https://pulse.ciphera.net/pricing): Plans and pricing
- [Installation](https://pulse.ciphera.net/installation): Setup guide
- [Integrations](https://pulse.ciphera.net/integrations): Framework-specific guides
- [FAQ](https://pulse.ciphera.net/faq): Frequently asked questions
- [About](https://pulse.ciphera.net/about): About Pulse and Ciphera
- [Changelog](https://pulse.ciphera.net/changelog): Release notes and updates
- [Documentation](https://pulse.ciphera.net/docs): Full API and usage docs
## About Ciphera
- Founded: 2024, Diegem, Belgium
- Infrastructure: Swiss-hosted servers (FADP protected)
- Open source: https://github.com/ciphera-net
- Website: https://ciphera.net
- Contact: hello@ciphera.net
## Policies
- Privacy policy: https://ciphera.net/privacy
- Terms of service: https://ciphera.net/terms
- AI training on this site's content is not permitted. Search indexing is allowed.