Merge pull request #43 from ciphera-net/staging

Release 0.15.0-alpha
This commit is contained in:
Usman
2026-03-13 00:13:41 +01:00
committed by GitHub
29 changed files with 2763 additions and 474 deletions

View File

@@ -4,7 +4,32 @@ All notable changes to Pulse (frontend and product) are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**.
## [Unreleased]
## [0.15.0-alpha] - 2026-03-13
### Added
- **User Journeys tab.** A new "Journeys" tab on your site dashboard visualizes how visitors navigate through your site. A Sankey flow diagram shows the most common paths users take — from landing page through to exit — so you can see where traffic flows and where it drops off. Filter by entry page, adjust the depth (2-10 steps), and click any page in the diagram to drill into paths through it. Below the diagram, a "Top Paths" table ranks the most common full navigation sequences with session counts and average duration.
### Removed
- **Realtime visitors detail page.** The page that showed individual active visitors and their page-by-page session journey has been removed. The live visitor count on your dashboard still works — it just no longer links to a separate page.
### Added
- **Rage click detection.** Pulse now detects when visitors rapidly click the same element 3 or more times — a strong signal of UI frustration. Rage clicks are tracked automatically (no setup required) and surfaced in the new Behavior tab with the element, page, click count, and number of affected sessions.
- **Dead click detection.** Clicks on buttons, links, and other interactive elements that produce no visible result (no navigation, no DOM change, no network request) are now detected and reported. This helps you find broken buttons, disabled links, and unresponsive UI elements your visitors are struggling with.
- **Behavior tab.** A new tab in your site dashboard — alongside Dashboard, Uptime, and Funnels — dedicated to user behavior signals. Houses rage clicks, dead clicks, a by-page frustration breakdown, and scroll depth (moved from the main dashboard for a cleaner layout).
- **Frustration summary cards.** The Behavior tab opens with three at-a-glance cards: total rage clicks, total dead clicks, and total frustration signals with the most affected page — each with a percentage change compared to the previous period.
- **Scheduled Reports.** You can now get your analytics delivered automatically — set up daily, weekly, or monthly reports sent straight to your email, Slack, Discord, or any webhook. Each report includes your key stats (visitors, pageviews, bounce rate), top pages, and traffic sources, all in a clean branded format. Set them up in your site settings under the new "Reports" tab, and hit "Test" to preview before going live. You can create up to 10 schedules per site.
- **Time-of-day report scheduling.** Choose when your reports arrive — pick the hour, day of week (for weekly), or day of month (for monthly). Schedule cards show a human-readable description like "Every Monday at 9:00 AM (UTC)."
### Changed
- **Scroll depth moved to Behavior tab.** The scroll depth radar chart has been relocated from the main dashboard to the new Behavior tab, where it fits more naturally alongside other user behavior metrics.
### Fixed
- **Region names now display correctly.** Some regions were showing as cryptic codes like "14" (Poland), "KKC" (Thailand), or "IDF" (France) instead of their actual names. The Locations panel now shows proper region names like "Masovian", "Khon Kaen", and "Île-de-France."
## [0.14.0-alpha] - 2026-03-12

View File

@@ -0,0 +1,168 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { getDateRange, formatDate } from '@ciphera-net/ui'
import { Select, DatePicker } from '@ciphera-net/ui'
import dynamic from 'next/dynamic'
import { getRageClicks, getDeadClicks } from '@/lib/api/stats'
import FrustrationSummaryCards from '@/components/behavior/FrustrationSummaryCards'
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'
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
const [period, setPeriod] = useState('30')
const [dateRange, setDateRange] = useState(() => getDateRange(30))
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
// Single request for all frustration data
const { data: behavior, isLoading: loading, error: behaviorError } = useBehavior(siteId, dateRange.start, dateRange.end)
// Fetch dashboard data for scroll depth (goal_counts + stats)
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
useEffect(() => {
const domain = dashboard?.site?.domain
document.title = domain ? `Behavior · ${domain} | Pulse` : 'Behavior | Pulse'
}, [dashboard?.site?.domain])
// On-demand fetchers for modal "view all"
const fetchAllRage = useCallback(
() => getRageClicks(siteId, dateRange.start, dateRange.end, 100),
[siteId, dateRange.start, dateRange.end]
)
const fetchAllDead = useCallback(
() => getDeadClicks(siteId, dateRange.start, dateRange.end, 100),
[siteId, dateRange.start, dateRange.end]
)
const summary = behavior?.summary ?? null
const rageClicks = behavior?.rage_clicks ?? { items: [], total: 0 }
const deadClicks = behavior?.dead_clicks ?? { items: [], total: 0 }
const byPage = behavior?.by_page ?? []
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>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
Behavior
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Frustration signals and user engagement patterns
</p>
</div>
<Select
variant="input"
className="min-w-[140px]"
value={period}
onChange={(value) => {
if (value === 'today') {
const today = formatDate(new Date())
setDateRange({ start: today, end: today })
setPeriod('today')
} else if (value === '7') {
setDateRange(getDateRange(7))
setPeriod('7')
} else if (value === 'week') {
setDateRange(getThisWeekRange())
setPeriod('week')
} else if (value === '30') {
setDateRange(getDateRange(30))
setPeriod('30')
} else if (value === 'month') {
setDateRange(getThisMonthRange())
setPeriod('month')
} else if (value === 'custom') {
setIsDatePickerOpen(true)
}
}}
options={[
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '30', label: 'Last 30 days' },
{ value: 'divider-1', label: '', divider: true },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: 'divider-2', label: '', divider: true },
{ value: 'custom', label: 'Custom' },
]}
/>
</div>
{/* Summary cards */}
<FrustrationSummaryCards data={summary} loading={loading} />
{/* Rage clicks + Dead clicks side by side */}
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<FrustrationTable
title="Rage Clicks"
description="Elements users clicked repeatedly in frustration"
items={rageClicks.items}
total={rageClicks.total}
totalSignals={summary?.rage_clicks ?? 0}
showAvgClicks
loading={loading}
fetchAll={fetchAllRage}
/>
<FrustrationTable
title="Dead Clicks"
description="Elements users clicked that produced no response"
items={deadClicks.items}
total={deadClicks.total}
totalSignals={summary?.dead_clicks ?? 0}
loading={loading}
fetchAll={fetchAllDead}
/>
</div>
{/* By page breakdown */}
<FrustrationByPageTable pages={byPage} loading={loading} />
{/* Scroll depth + Frustration trend — hide when data failed to load */}
{!behaviorError && (
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<ScrollDepth
goalCounts={dashboard?.goal_counts ?? []}
totalPageviews={dashboard?.stats?.pageviews ?? 0}
/>
<FrustrationTrend summary={summary} loading={loading} />
</div>
)}
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
onApply={(range) => {
setDateRange(range)
setPeriod('custom')
setIsDatePickerOpen(false)
}}
initialRange={dateRange}
/>
</div>
)
}

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function JourneysError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Journeys failed to load"
message="We couldn't load the journey data. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,9 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Journeys | Pulse',
}
export default function JourneysLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { getDateRange, formatDate } from '@ciphera-net/ui'
import { Select, DatePicker } from '@ciphera-net/ui'
import SankeyDiagram from '@/components/journeys/SankeyDiagram'
import TopPathsTable from '@/components/journeys/TopPathsTable'
import { SkeletonCard } from '@/components/skeletons'
import {
useDashboard,
useJourneyTransitions,
useJourneyTopPaths,
useJourneyEntryPoints,
} from '@/lib/swr/dashboard'
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
const [period, setPeriod] = useState('30')
const [dateRange, setDateRange] = useState(() => getDateRange(30))
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [depth, setDepth] = useState(3)
const [entryPath, setEntryPath] = useState('')
const { data: transitionsData, isLoading: transitionsLoading } = useJourneyTransitions(
siteId, dateRange.start, dateRange.end, depth, 2, entryPath || undefined
)
const { data: topPaths, isLoading: topPathsLoading } = useJourneyTopPaths(
siteId, dateRange.start, dateRange.end, 20, 2, entryPath || undefined
)
const { data: entryPoints } = useJourneyEntryPoints(siteId, dateRange.start, dateRange.end)
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
useEffect(() => {
const domain = dashboard?.site?.domain
document.title = domain ? `Journeys \u00b7 ${domain} | Pulse` : 'Journeys | Pulse'
}, [dashboard?.site?.domain])
const entryPointOptions = [
{ value: '', label: 'All entry points' },
...(entryPoints ?? []).map((ep) => ({
value: ep.path,
label: `${ep.path} (${ep.session_count.toLocaleString()})`,
})),
]
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>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">
Journeys
</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
How visitors navigate through your site
</p>
</div>
<Select
variant="input"
className="min-w-[140px]"
value={period}
onChange={(value) => {
if (value === 'today') {
const today = formatDate(new Date())
setDateRange({ start: today, end: today })
setPeriod('today')
} else if (value === '7') {
setDateRange(getDateRange(7))
setPeriod('7')
} else if (value === 'week') {
setDateRange(getThisWeekRange())
setPeriod('week')
} else if (value === '30') {
setDateRange(getDateRange(30))
setPeriod('30')
} else if (value === 'month') {
setDateRange(getThisMonthRange())
setPeriod('month')
} else if (value === 'custom') {
setIsDatePickerOpen(true)
}
}}
options={[
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '30', label: 'Last 30 days' },
{ value: 'divider-1', label: '', divider: true },
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: 'divider-2', label: '', divider: true },
{ value: 'custom', label: 'Custom' },
]}
/>
</div>
{/* Controls */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex items-center gap-3">
<label className="text-sm text-neutral-500 dark:text-neutral-400">Depth</label>
<input
type="range"
min={2}
max={5}
step={1}
value={depth}
onChange={(e) => setDepth(Number(e.target.value))}
className="w-32 accent-brand-orange"
/>
<span className="text-sm font-medium text-neutral-900 dark:text-white w-4">{depth}</span>
</div>
<Select
variant="input"
className="min-w-[180px]"
value={entryPath}
onChange={(value) => setEntryPath(value)}
options={entryPointOptions}
/>
{(depth !== 3 || entryPath) && (
<button
onClick={() => { setDepth(3); setEntryPath('') }}
className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
>
Reset
</button>
)}
</div>
{/* Sankey Diagram */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
{transitionsLoading ? (
<div className="h-[400px] flex items-center justify-center">
<SkeletonCard className="w-full h-full" />
</div>
) : (
<SankeyDiagram
transitions={transitionsData?.transitions ?? []}
totalSessions={transitionsData?.total_sessions ?? 0}
depth={depth}
onNodeClick={(path) => setEntryPath(path)}
/>
)}
</div>
{/* Top Paths */}
<TopPathsTable paths={topPaths ?? []} loading={topPathsLoading} />
{/* Date Picker Modal */}
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
onApply={(range) => {
setDateRange(range)
setPeriod('custom')
setIsDatePickerOpen(false)
}}
initialRange={dateRange}
/>
</div>
)
}

View File

@@ -34,7 +34,6 @@ import TechSpecs from '@/components/dashboard/TechSpecs'
const PerformanceStats = dynamic(() => import('@/components/dashboard/PerformanceStats'))
const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats'))
const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth'))
const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns'))
const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours'))
const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties'))
@@ -452,9 +451,8 @@ export default function SiteDashboardPage() {
</div>
{/* Realtime Indicator */}
<button
onClick={() => router.push(`/sites/${siteId}/realtime`)}
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 hover:bg-green-500/20 transition-colors cursor-pointer"
<div
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
@@ -463,7 +461,7 @@ export default function SiteDashboardPage() {
<span className="text-sm font-medium text-green-700 dark:text-green-400">
{realtime} current visitors
</span>
</button>
</div>
</div>
<div className="flex items-center gap-2">
@@ -618,12 +616,11 @@ export default function SiteDashboardPage() {
<PeakHours siteId={siteId} dateRange={dateRange} />
</div>
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<div className="mb-8">
<GoalStats
goalCounts={(dashboard?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
onSelectEvent={setSelectedEvent}
/>
<ScrollDepth goalCounts={dashboard?.goal_counts ?? []} totalPageviews={stats.pageviews} />
</div>
{/* Event Properties Breakdown */}

View File

@@ -1,13 +0,0 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function RealtimeError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Realtime view failed to load"
message="We couldn't connect to the realtime data stream. Please try again."
onRetry={reset}
/>
)
}

View File

@@ -1,15 +0,0 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Realtime | Pulse',
description: 'See who is on your site right now.',
robots: { index: false, follow: false },
}
export default function RealtimeLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -1,234 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getSite, type Site } from '@/lib/api/sites'
import { getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime'
import { useRealtimeSSE } from '@/lib/hooks/useRealtimeSSE'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { UserIcon } from '@ciphera-net/ui'
import { RealtimeSkeleton, SessionEventsSkeleton, useMinimumLoading } from '@/components/skeletons'
import { motion, AnimatePresence } from 'framer-motion'
function formatTimeAgo(dateString: string) {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) return 'just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
return `${Math.floor(diffInSeconds / 86400)}d ago`
}
export default function RealtimePage() {
const params = useParams()
const router = useRouter()
const siteId = params.id as string
const [site, setSite] = useState<Site | null>(null)
const { visitors } = useRealtimeSSE(siteId)
const [selectedVisitor, setSelectedVisitor] = useState<Visitor | null>(null)
const [sessionEvents, setSessionEvents] = useState<SessionEvent[]>([])
const [loading, setLoading] = useState(true)
const [loadingEvents, setLoadingEvents] = useState(false)
// Load site info
useEffect(() => {
const init = async () => {
try {
const siteData = await getSite(siteId)
setSite(siteData)
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load site')
} finally {
setLoading(false)
}
}
init()
}, [siteId])
// Auto-select the first visitor when the list populates and nothing is selected
useEffect(() => {
if (visitors.length > 0 && !selectedVisitor) {
handleSelectVisitor(visitors[0])
}
}, [visitors]) // eslint-disable-line react-hooks/exhaustive-deps
const handleSelectVisitor = async (visitor: Visitor) => {
setSelectedVisitor(visitor)
setLoadingEvents(true)
try {
const events = await getSessionDetails(siteId, visitor.session_id)
setSessionEvents(events || [])
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load session events')
} finally {
setLoadingEvents(false)
}
}
useEffect(() => {
if (site?.domain) document.title = `Realtime · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return <RealtimeSkeleton />
if (!site) return <div className="p-8">Site not found</div>
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 h-[calc(100vh-64px)] flex flex-col">
<div className="mb-6 flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<button onClick={() => router.push(`/sites/${siteId}`)} className="text-sm text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:rounded">
&larr; Back to Dashboard
</button>
</div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
Realtime Visitors
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
<span className="text-lg font-normal text-neutral-500" aria-live="polite" aria-atomic="true">
{visitors.length} active now
</span>
</h1>
</div>
</div>
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
{/* Visitors List */}
<div className="w-full md:w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Active Sessions</h2>
</div>
<div className="overflow-y-auto flex-1">
{visitors.length === 0 ? (
<div className="p-8 flex flex-col items-center justify-center text-center gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-3">
<UserIcon className="w-6 h-6 text-neutral-500 dark:text-neutral-400" />
</div>
<p className="text-sm font-medium text-neutral-900 dark:text-white">
No active visitors right now
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
New visitors will appear here in real-time
</p>
</div>
) : (
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
<AnimatePresence mode="popLayout">
{visitors.map((visitor) => (
<motion.button
key={visitor.session_id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2 }}
onClick={() => handleSelectVisitor(visitor)}
className={`w-full text-left p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-inset ${
selectedVisitor?.session_id === visitor.session_id ? 'bg-neutral-50 dark:bg-neutral-800/50 ring-1 ring-inset ring-neutral-200 dark:ring-neutral-700' : ''
}`}
>
<div className="flex justify-between items-start mb-1">
<div className="font-medium text-neutral-900 dark:text-white truncate pr-2">
{visitor.country ? `${getFlagEmoji(visitor.country)} ${visitor.city || 'Unknown City'}` : 'Unknown Location'}
</div>
<span className="text-xs text-neutral-500 whitespace-nowrap">
{formatTimeAgo(visitor.last_seen)}
</span>
</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400 truncate mb-1" title={visitor.current_path}>
{visitor.current_path}
</div>
<div className="flex items-center gap-2 text-xs text-neutral-400">
<span>{visitor.device_type}</span>
<span></span>
<span>{visitor.browser}</span>
<span></span>
<span>{visitor.os}</span>
<span className="ml-auto bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded text-neutral-600 dark:text-neutral-400">
{visitor.pageviews} views
</span>
</div>
</motion.button>
))}
</AnimatePresence>
</div>
)}
</div>
</div>
{/* Session Details */}
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50 flex justify-between items-center">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">
{selectedVisitor ? 'Session Journey' : 'Select a visitor'}
</h2>
{selectedVisitor && (
<span className="text-xs font-mono text-neutral-400">
ID: {selectedVisitor.session_id.substring(0, 8)}...
</span>
)}
</div>
<div className="flex-1 overflow-y-auto p-6">
{!selectedVisitor ? (
<div className="h-full flex items-center justify-center text-neutral-500">
Select a visitor on the left to see their activity.
</div>
) : loadingEvents ? (
<SessionEventsSkeleton />
) : (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{sessionEvents.map((event, idx) => (
<div key={event.id} className="relative">
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900 ${
idx === 0 ? 'bg-green-500 ring-4 ring-green-100 dark:ring-green-900/30' : 'bg-neutral-300 dark:bg-neutral-700'
}`}></span>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-900 dark:text-white">
Visited {event.path}
</span>
<span className="text-xs text-neutral-500">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
</div>
{event.referrer && (
<div className="text-xs text-neutral-500">
Referrer: <span className="text-neutral-700 dark:text-neutral-300">{event.referrer}</span>
</div>
)}
</div>
</div>
))}
<div className="relative">
<span className="absolute -left-[29px] top-1 h-3 w-3 rounded-full border-2 border-white dark:border-neutral-900 bg-neutral-300 dark:bg-neutral-700"></span>
<div className="text-sm text-neutral-500">
Session started {formatTimeAgo(sessionEvents[sessionEvents.length - 1]?.timestamp || new Date().toISOString())}
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}
function getFlagEmoji(countryCode: string) {
if (!countryCode || countryCode.length !== 2) return '🌍'
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0))
return String.fromCodePoint(...codePoints)
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
import { listReportSchedules, createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
@@ -25,6 +26,7 @@ import {
AlertTriangleIcon,
ZapIcon,
} from '@ciphera-net/ui'
import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play } from '@phosphor-icons/react'
const TIMEZONES = [
'UTC',
@@ -54,7 +56,7 @@ export default function SiteSettingsPage() {
const [site, setSite] = useState<Site | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals'>('general')
const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports'>('general')
const [formData, setFormData] = useState({
name: '',
@@ -91,6 +93,24 @@ export default function SiteSettingsPage() {
const [goalSaving, setGoalSaving] = useState(false)
const initialFormRef = useRef<string>('')
// Report schedules state
const [reportSchedules, setReportSchedules] = useState<ReportSchedule[]>([])
const [reportLoading, setReportLoading] = useState(false)
const [reportModalOpen, setReportModalOpen] = useState(false)
const [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null)
const [reportSaving, setReportSaving] = useState(false)
const [reportTesting, setReportTesting] = useState<string | null>(null)
const [reportForm, setReportForm] = useState({
channel: 'email' as string,
recipients: '',
webhookUrl: '',
frequency: 'weekly' as string,
reportType: 'summary' as string,
timezone: '',
sendHour: 9,
sendDay: 1,
})
useEffect(() => {
loadSite()
loadSubscription()
@@ -102,6 +122,12 @@ export default function SiteSettingsPage() {
}
}, [activeTab, siteId])
useEffect(() => {
if (activeTab === 'reports' && siteId) {
loadReportSchedules()
}
}, [activeTab, siteId])
const loadSubscription = async () => {
try {
setSubscriptionLoadFailed(false)
@@ -191,6 +217,184 @@ export default function SiteSettingsPage() {
}
}
const loadReportSchedules = async () => {
try {
setReportLoading(true)
const data = await listReportSchedules(siteId)
setReportSchedules(data)
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load report schedules')
} finally {
setReportLoading(false)
}
}
const resetReportForm = () => {
setReportForm({
channel: 'email',
recipients: '',
webhookUrl: '',
frequency: 'weekly',
reportType: 'summary',
timezone: site?.timezone || '',
sendHour: 9,
sendDay: 1,
})
}
const openEditSchedule = (schedule: ReportSchedule) => {
setEditingSchedule(schedule)
const isEmail = schedule.channel === 'email'
setReportForm({
channel: schedule.channel,
recipients: isEmail ? (schedule.channel_config as EmailConfig).recipients.join(', ') : '',
webhookUrl: !isEmail ? (schedule.channel_config as WebhookConfig).url : '',
frequency: schedule.frequency,
reportType: schedule.report_type,
timezone: schedule.timezone || site?.timezone || '',
sendHour: schedule.send_hour ?? 9,
sendDay: schedule.send_day ?? (schedule.frequency === 'monthly' ? 1 : 0),
})
setReportModalOpen(true)
}
const handleReportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
let channelConfig: EmailConfig | WebhookConfig
if (reportForm.channel === 'email') {
const recipients = reportForm.recipients.split(',').map(r => r.trim()).filter(r => r.length > 0)
if (recipients.length === 0) {
toast.error('At least one recipient email is required')
return
}
channelConfig = { recipients }
} else {
if (!reportForm.webhookUrl.trim()) {
toast.error('Webhook URL is required')
return
}
channelConfig = { url: reportForm.webhookUrl.trim() }
}
const payload: CreateReportScheduleRequest = {
channel: reportForm.channel,
channel_config: channelConfig,
frequency: reportForm.frequency,
timezone: reportForm.timezone || undefined,
report_type: reportForm.reportType,
send_hour: reportForm.sendHour,
...(reportForm.frequency !== 'daily' ? { send_day: reportForm.sendDay } : {}),
}
setReportSaving(true)
try {
if (editingSchedule) {
await updateReportSchedule(siteId, editingSchedule.id, payload)
toast.success('Report schedule updated')
} else {
await createReportSchedule(siteId, payload)
toast.success('Report schedule created')
}
setReportModalOpen(false)
loadReportSchedules()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to save report schedule')
} finally {
setReportSaving(false)
}
}
const handleReportDelete = async (schedule: ReportSchedule) => {
if (!confirm('Delete this report schedule?')) return
try {
await deleteReportSchedule(siteId, schedule.id)
toast.success('Report schedule deleted')
loadReportSchedules()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to delete report schedule')
}
}
const handleReportToggle = async (schedule: ReportSchedule) => {
try {
await updateReportSchedule(siteId, schedule.id, { enabled: !schedule.enabled })
toast.success(schedule.enabled ? 'Report paused' : 'Report enabled')
loadReportSchedules()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to update report schedule')
}
}
const handleReportTest = async (schedule: ReportSchedule) => {
setReportTesting(schedule.id)
try {
await testReportSchedule(siteId, schedule.id)
toast.success('Test report sent successfully')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to send test report')
} finally {
setReportTesting(null)
}
}
const getChannelLabel = (channel: string) => {
switch (channel) {
case 'email': return 'Email'
case 'slack': return 'Slack'
case 'discord': return 'Discord'
case 'webhook': return 'Webhook'
default: return channel
}
}
const getFrequencyLabel = (frequency: string) => {
switch (frequency) {
case 'daily': return 'Daily'
case 'weekly': return 'Weekly'
case 'monthly': return 'Monthly'
default: return frequency
}
}
const WEEKDAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const formatHour = (hour: number) => {
if (hour === 0) return '12:00 AM'
if (hour === 12) return '12:00 PM'
return hour < 12 ? `${hour}:00 AM` : `${hour - 12}:00 PM`
}
const getScheduleDescription = (schedule: ReportSchedule) => {
const hour = formatHour(schedule.send_hour ?? 9)
const tz = schedule.timezone || 'UTC'
switch (schedule.frequency) {
case 'daily':
return `Every day at ${hour} (${tz})`
case 'weekly': {
const day = WEEKDAY_NAMES[schedule.send_day ?? 0] || 'Monday'
return `Every ${day} at ${hour} (${tz})`
}
case 'monthly': {
const d = schedule.send_day ?? 1
const suffix = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th'
return `${d}${suffix} of each month at ${hour} (${tz})`
}
default:
return schedule.frequency
}
}
const getReportTypeLabel = (type: string) => {
switch (type) {
case 'summary': return 'Summary'
case 'pages': return 'Pages'
case 'sources': return 'Sources'
case 'goals': return 'Goals'
default: return type
}
}
const openAddGoal = () => {
setEditingGoal(null)
setGoalForm({ name: '', event_name: '' })
@@ -389,7 +593,7 @@ export default function SiteSettingsPage() {
</div>
<div className="flex flex-col md:flex-row gap-8">
<nav className="w-full md:w-64 flex-shrink-0 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
))}
</nav>
@@ -476,6 +680,19 @@ export default function SiteSettingsPage() {
<ZapIcon className="w-5 h-5" />
Goals & Events
</button>
<button
onClick={() => setActiveTab('reports')}
role="tab"
aria-selected={activeTab === 'reports'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 ${
activeTab === 'reports'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
<PaperPlaneTilt className="w-5 h-5" />
Reports
</button>
</nav>
{/* Content Area */}
@@ -1124,6 +1341,135 @@ export default function SiteSettingsPage() {
)}
</div>
)}
{activeTab === 'reports' && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Scheduled Reports</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Automatically deliver analytics reports via email or webhooks.</p>
</div>
{canEdit && (
<Button onClick={() => { setEditingSchedule(null); resetReportForm(); setReportModalOpen(true) }}>
Add Report
</Button>
)}
</div>
{reportLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-20 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
))}
</div>
) : reportSchedules.length === 0 ? (
<div className="p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 text-center text-neutral-500 dark:text-neutral-400 text-sm">
No scheduled reports yet. Add a report to automatically receive analytics summaries.
</div>
) : (
<div className="space-y-3">
{reportSchedules.map((schedule) => (
<div
key={schedule.id}
className={`rounded-xl border p-4 transition-colors ${
schedule.enabled
? 'border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900/50'
: 'border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/30 opacity-60'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<div className="p-2 bg-neutral-100 dark:bg-neutral-800 rounded-lg mt-0.5">
{schedule.channel === 'email' ? (
<Envelope className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
) : (
<WebhooksLogo className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-neutral-900 dark:text-white">
{getChannelLabel(schedule.channel)}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-brand-orange/10 text-brand-orange">
{getFrequencyLabel(schedule.frequency)}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400">
{getReportTypeLabel(schedule.report_type)}
</span>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate">
{schedule.channel === 'email'
? (schedule.channel_config as EmailConfig).recipients.join(', ')
: (schedule.channel_config as WebhookConfig).url}
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{getScheduleDescription(schedule)}
</p>
<div className="flex items-center gap-3 mt-1 text-xs text-neutral-400 dark:text-neutral-500">
<span>
Last sent: {schedule.last_sent_at
? new Date(schedule.last_sent_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
: 'Never'}
</span>
</div>
{schedule.last_error && (
<p className="text-xs text-red-500 dark:text-red-400 mt-1">
Error: {schedule.last_error}
</p>
)}
</div>
</div>
{canEdit && (
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={() => handleReportTest(schedule)}
disabled={reportTesting === schedule.id}
className="p-2 text-neutral-500 hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors disabled:opacity-50"
title="Send test report"
>
{reportTesting === schedule.id ? (
<SpinnerGap className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={() => openEditSchedule(schedule)}
className="p-2 text-neutral-500 hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
title="Edit schedule"
>
<PencilSimple className="w-4 h-4" />
</button>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={schedule.enabled}
onChange={() => handleReportToggle(schedule)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-orange/20 dark:peer-focus:ring-brand-orange/20 rounded-full peer dark:bg-neutral-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-neutral-600 peer-checked:bg-brand-orange"></div>
</label>
<button
type="button"
onClick={() => handleReportDelete(schedule)}
className="p-2 text-neutral-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
title="Delete schedule"
>
<Trash className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
</motion.div>
</div>
</div>
@@ -1177,6 +1523,165 @@ export default function SiteSettingsPage() {
</form>
</Modal>
<Modal
isOpen={reportModalOpen}
onClose={() => setReportModalOpen(false)}
title={editingSchedule ? 'Edit report schedule' : 'Add report schedule'}
>
<form onSubmit={handleReportSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Channel</label>
<div className="grid grid-cols-2 gap-2">
{(['email', 'slack', 'discord', 'webhook'] as const).map((ch) => (
<button
key={ch}
type="button"
onClick={() => setReportForm({ ...reportForm, channel: ch })}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
reportForm.channel === ch
? 'border-brand-orange bg-brand-orange/10 text-brand-orange'
: 'border-neutral-200 dark:border-neutral-700 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
{ch === 'email' ? <Envelope className="w-4 h-4" /> : <WebhooksLogo className="w-4 h-4" />}
{getChannelLabel(ch)}
</button>
))}
</div>
</div>
{reportForm.channel === 'email' ? (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Recipients</label>
<input
type="text"
value={reportForm.recipients}
onChange={(e) => setReportForm({ ...reportForm, recipients: e.target.value })}
placeholder="email1@example.com, email2@example.com"
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"
required
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Comma-separated email addresses.</p>
</div>
) : (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{reportForm.channel === 'slack' ? 'Slack Webhook URL' : reportForm.channel === 'discord' ? 'Discord Webhook URL' : 'Webhook URL'}
</label>
<input
type="url"
value={reportForm.webhookUrl}
onChange={(e) => setReportForm({ ...reportForm, webhookUrl: e.target.value })}
placeholder="https://hooks.example.com/..."
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"
required
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Frequency</label>
<Select
value={reportForm.frequency}
onChange={(v) => setReportForm({ ...reportForm, frequency: v })}
options={[
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
]}
variant="input"
fullWidth
align="left"
/>
</div>
{reportForm.frequency === 'weekly' && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Day of week</label>
<Select
value={String(reportForm.sendDay)}
onChange={(v) => setReportForm({ ...reportForm, sendDay: parseInt(v) })}
options={WEEKDAY_NAMES.map((name, i) => ({ value: String(i), label: name }))}
variant="input"
fullWidth
align="left"
/>
</div>
)}
{reportForm.frequency === 'monthly' && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Day of month</label>
<Select
value={String(reportForm.sendDay)}
onChange={(v) => setReportForm({ ...reportForm, sendDay: parseInt(v) })}
options={Array.from({ length: 28 }, (_, i) => {
const d = i + 1
const suffix = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th'
return { value: String(d), label: `${d}${suffix}` }
})}
variant="input"
fullWidth
align="left"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Time</label>
<Select
value={String(reportForm.sendHour)}
onChange={(v) => setReportForm({ ...reportForm, sendHour: parseInt(v) })}
options={Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: formatHour(i),
}))}
variant="input"
fullWidth
align="left"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Timezone</label>
<Select
value={reportForm.timezone || 'UTC'}
onChange={(v) => setReportForm({ ...reportForm, timezone: v })}
options={TIMEZONES.map((tz) => ({ value: tz, label: tz }))}
variant="input"
fullWidth
align="left"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Report Type</label>
<Select
value={reportForm.reportType}
onChange={(v) => setReportForm({ ...reportForm, reportType: v })}
options={[
{ value: 'summary', label: 'Summary' },
{ value: 'pages', label: 'Pages' },
{ value: 'sources', label: 'Sources' },
{ value: 'goals', label: 'Goals' },
]}
variant="input"
fullWidth
align="left"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={() => setReportModalOpen(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={reportSaving}>
{reportSaving ? 'Saving...' : editingSchedule ? 'Update' : 'Create'}
</Button>
</div>
</form>
</Modal>
<VerificationModal
isOpen={showVerificationModal}
onClose={() => setShowVerificationModal(false)}

View File

@@ -0,0 +1,114 @@
'use client'
import { formatNumber } from '@ciphera-net/ui'
import { Files } from '@phosphor-icons/react'
import type { FrustrationByPage } from '@/lib/api/stats'
interface FrustrationByPageTableProps {
pages: FrustrationByPage[]
loading: boolean
}
function SkeletonRows() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="animate-pulse flex items-center justify-between h-9 px-2">
<div className="h-4 w-40 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="flex gap-6">
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
</div>
))}
</div>
)
}
export default function FrustrationByPageTable({ pages, loading }: FrustrationByPageTableProps) {
const hasData = pages.length > 0
const maxTotal = Math.max(...pages.map(p => p.total), 1)
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-8">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Frustration by Page
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
Pages with the most frustration signals
</p>
{loading ? (
<SkeletonRows />
) : hasData ? (
<div>
{/* Header */}
<div className="flex items-center justify-between px-2 -mx-2 mb-2 text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
<span>Page</span>
<div className="flex items-center gap-6">
<span className="w-12 text-right">Rage</span>
<span className="w-12 text-right">Dead</span>
<span className="w-12 text-right">Total</span>
<span className="w-16 text-right">Elements</span>
</div>
</div>
{/* Rows */}
<div className="space-y-0.5">
{pages.map((page) => {
const barWidth = (page.total / maxTotal) * 100
return (
<div
key={page.page_path}
className="relative flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors"
>
{/* Background bar */}
<div
className="absolute inset-y-0 left-0 bg-brand-orange/5 dark:bg-brand-orange/10 rounded-lg transition-all"
style={{ width: `${barWidth}%` }}
/>
<span
className="relative text-sm text-neutral-900 dark:text-white truncate max-w-[300px]"
title={page.page_path}
>
{page.page_path}
</span>
<div className="relative flex items-center gap-6">
<span className="w-12 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{formatNumber(page.rage_clicks)}
</span>
<span className="w-12 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{formatNumber(page.dead_clicks)}
</span>
<span className="w-12 text-right text-sm font-semibold tabular-nums text-neutral-900 dark:text-white">
{formatNumber(page.total)}
</span>
<span className="w-16 text-right text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{page.unique_elements}
</span>
</div>
</div>
)
})}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-4 min-h-[200px]">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<Files className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No frustration signals detected
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
Page-level frustration data will appear here once rage clicks or dead clicks are detected on your site.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,124 @@
'use client'
import type { FrustrationSummary } from '@/lib/api/stats'
interface FrustrationSummaryCardsProps {
data: FrustrationSummary | null
loading: boolean
}
function pctChange(current: number, previous: number): { type: 'pct'; value: number } | { type: 'new' } | null {
if (previous === 0 && current === 0) return null
if (previous === 0) return { type: 'new' }
return { type: 'pct', value: Math.round(((current - previous) / previous) * 100) }
}
function ChangeIndicator({ change }: { change: ReturnType<typeof pctChange> }) {
if (change === null) return null
if (change.type === 'new') {
return (
<span className="text-xs font-medium bg-brand-orange/10 text-brand-orange px-1.5 py-0.5 rounded">
New
</span>
)
}
const isUp = change.value > 0
const isDown = change.value < 0
return (
<span
className={`text-xs font-medium ${
isUp
? 'text-red-600 dark:text-red-400'
: isDown
? 'text-green-600 dark:text-green-400'
: 'text-neutral-500 dark:text-neutral-400'
}`}
>
{isUp ? '+' : ''}{change.value}%
</span>
)
}
function SkeletonCard() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="animate-pulse space-y-3">
<div className="h-4 w-24 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-8 w-16 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-3 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
</div>
)
}
export default function FrustrationSummaryCards({ data, loading }: FrustrationSummaryCardsProps) {
if (loading || !data) {
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
)
}
const rageChange = pctChange(data.rage_clicks, data.prev_rage_clicks)
const deadChange = pctChange(data.dead_clicks, data.prev_dead_clicks)
const topPage = data.rage_top_page || data.dead_top_page
const totalSignals = data.rage_clicks + data.dead_clicks
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
{/* Rage Clicks */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Rage Clicks
</p>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums">
{data.rage_clicks.toLocaleString()}
</span>
<ChangeIndicator change={rageChange} />
</div>
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{data.rage_unique_elements} unique elements
</p>
</div>
{/* Dead Clicks */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Dead Clicks
</p>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums">
{data.dead_clicks.toLocaleString()}
</span>
<ChangeIndicator change={deadChange} />
</div>
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{data.dead_unique_elements} unique elements
</p>
</div>
{/* Total Frustration Signals */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Total Signals
</p>
<span className="text-2xl font-bold text-neutral-900 dark:text-white tabular-nums">
{totalSignals.toLocaleString()}
</span>
{topPage ? (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
Top page: {topPage}
</p>
) : (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
No data in this period
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,220 @@
'use client'
import { useState, useEffect } from 'react'
import { formatNumber, Modal } from '@ciphera-net/ui'
import { FrameCornersIcon, Copy, Check, CursorClick } from '@phosphor-icons/react'
import { toast } from '@ciphera-net/ui'
import type { FrustrationElement } from '@/lib/api/stats'
import { ListSkeleton } from '@/components/skeletons'
const DISPLAY_LIMIT = 7
interface FrustrationTableProps {
title: string
description: string
items: FrustrationElement[]
total: number
totalSignals: number
showAvgClicks?: boolean
loading: boolean
fetchAll?: () => Promise<{ items: FrustrationElement[]; total: number }>
}
function SkeletonRows() {
return (
<div className="space-y-2">
{Array.from({ length: DISPLAY_LIMIT }).map((_, i) => (
<div key={i} className="animate-pulse flex items-center justify-between h-9 px-2">
<div className="flex items-center gap-3 flex-1">
<div className="h-4 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-3 w-20 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
))}
</div>
)
}
function SelectorCell({ selector }: { selector: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard.writeText(selector)
setCopied(true)
toast.success('Selector copied')
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
className="flex items-center gap-1 min-w-0 group/copy cursor-pointer"
title={selector}
>
<span className="text-sm font-mono text-neutral-900 dark:text-white truncate">
{selector}
</span>
<span className="opacity-0 group-hover/copy:opacity-100 transition-opacity shrink-0">
{copied ? (
<Check className="w-3 h-3 text-green-500" />
) : (
<Copy className="w-3 h-3 text-neutral-400" />
)}
</span>
</button>
)
}
function Row({
item,
showAvgClicks,
totalSignals,
}: {
item: FrustrationElement
showAvgClicks?: boolean
totalSignals: number
}) {
const pct = totalSignals > 0 ? `${Math.round((item.count / totalSignals) * 100)}%` : ''
return (
<div className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<SelectorCell selector={item.selector} />
<span
className="text-xs text-neutral-400 dark:text-neutral-500 truncate shrink-0"
title={item.page_path}
>
{item.page_path}
</span>
</div>
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
{/* Percentage badge: slides in on hover */}
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200 tabular-nums">
{pct}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 tabular-nums">
{formatNumber(item.count)}
</span>
</div>
</div>
)
}
export default function FrustrationTable({
title,
description,
items,
total,
totalSignals,
showAvgClicks,
loading,
fetchAll,
}: FrustrationTableProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<FrustrationElement[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
const hasData = items.length > 0
const showViewAll = hasData && total > items.length
const emptySlots = Math.max(0, DISPLAY_LIMIT - items.length)
useEffect(() => {
if (isModalOpen && fetchAll) {
const load = async () => {
setIsLoadingFull(true)
try {
const result = await fetchAll()
setFullData(result.items)
} catch {
// silent
} finally {
setIsLoadingFull(false)
}
}
load()
} else {
setFullData([])
}
}, [isModalOpen, fetchAll])
return (
<>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
{title}
</h3>
{showViewAll && (
<button
onClick={() => setIsModalOpen(true)}
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg"
aria-label={`View all ${title.toLowerCase()}`}
>
<FrameCornersIcon className="w-4 h-4" weight="bold" />
</button>
)}
</div>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
{description}
</p>
<div className="flex-1 min-h-[270px]">
{loading ? (
<SkeletonRows />
) : hasData ? (
<>
{items.map((item, i) => (
<Row key={`${item.selector}-${item.page_path}-${i}`} item={item} showAvgClicks={showAvgClicks} totalSignals={totalSignals} />
))}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
))}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<CursorClick className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No {title.toLowerCase()} detected
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
{description}. Data will appear here once frustration signals are detected on your site.
</p>
</div>
)}
</div>
</div>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={title}
className="max-w-2xl"
>
<div className="max-h-[80vh] overflow-y-auto">
{isLoadingFull ? (
<div className="py-4">
<ListSkeleton rows={10} />
</div>
) : fullData.length > 0 ? (
<div className="space-y-0.5">
{fullData.map((item, i) => (
<Row key={`${item.selector}-${item.page_path}-${i}`} item={item} showAvgClicks={showAvgClicks} totalSignals={totalSignals} />
))}
</div>
) : (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-8 text-center">
No data available
</p>
)}
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,166 @@
'use client'
import { TrendUp } from '@phosphor-icons/react'
import { Pie, PieChart, Tooltip } from 'recharts'
import {
ChartContainer,
type ChartConfig,
} from '@/components/charts'
import type { FrustrationSummary } from '@/lib/api/stats'
interface FrustrationTrendProps {
summary: FrustrationSummary | null
loading: boolean
}
function SkeletonCard() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="animate-pulse space-y-3 mb-4">
<div className="h-5 w-36 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-48 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
<div className="flex-1 min-h-[270px] animate-pulse flex items-center justify-center">
<div className="w-[200px] h-[200px] rounded-full bg-neutral-200 dark:bg-neutral-700" />
</div>
</div>
)
}
const LABELS: Record<string, string> = {
rage_clicks: 'Rage Clicks',
dead_clicks: 'Dead Clicks',
prev_rage_clicks: 'Prev Rage Clicks',
prev_dead_clicks: 'Prev Dead Clicks',
}
const COLORS = {
rage_clicks: 'rgba(253, 94, 15, 0.7)',
dead_clicks: 'rgba(180, 83, 9, 0.7)',
prev_rage_clicks: 'rgba(253, 94, 15, 0.35)',
prev_dead_clicks: 'rgba(180, 83, 9, 0.35)',
} as const
const chartConfig = {
count: { label: 'Count' },
rage_clicks: { label: 'Rage Clicks', color: COLORS.rage_clicks },
dead_clicks: { label: 'Dead Clicks', color: COLORS.dead_clicks },
prev_rage_clicks: { label: 'Prev Rage Clicks', color: COLORS.prev_rage_clicks },
prev_dead_clicks: { label: 'Prev Dead Clicks', color: COLORS.prev_dead_clicks },
} satisfies ChartConfig
function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { type: string; count: number; fill: string } }> }) {
if (!active || !payload?.length) return null
const item = payload[0].payload
return (
<div className="flex items-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-2.5 py-1.5 text-xs shadow-xl">
<div
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: item.fill }}
/>
<span className="text-neutral-500 dark:text-neutral-400">
{LABELS[item.type] ?? item.type}
</span>
<span className="font-mono font-medium tabular-nums text-neutral-900 dark:text-neutral-50">
{item.count.toLocaleString()}
</span>
</div>
)
}
export default function FrustrationTrend({ summary, loading }: FrustrationTrendProps) {
if (loading || !summary) return <SkeletonCard />
const hasData = summary.rage_clicks > 0 || summary.dead_clicks > 0 ||
summary.prev_rage_clicks > 0 || summary.prev_dead_clicks > 0
const totalCurrent = summary.rage_clicks + summary.dead_clicks
const totalPrevious = summary.prev_rage_clicks + summary.prev_dead_clicks
const totalChange = totalPrevious > 0
? Math.round(((totalCurrent - totalPrevious) / totalPrevious) * 100)
: null
const hasPrevious = totalPrevious > 0
const chartData = [
{ type: 'rage_clicks', count: summary.rage_clicks, fill: COLORS.rage_clicks },
{ type: 'dead_clicks', count: summary.dead_clicks, fill: COLORS.dead_clicks },
{ type: 'prev_rage_clicks', count: summary.prev_rage_clicks, fill: COLORS.prev_rage_clicks },
{ type: 'prev_dead_clicks', count: summary.prev_dead_clicks, fill: COLORS.prev_dead_clicks },
].filter(d => d.count > 0)
if (!hasData) {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Frustration Trend
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
Rage vs. dead click breakdown
</p>
<div className="flex-1 min-h-[270px] flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<TrendUp className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No trend data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-md">
Frustration trend data will appear here once rage clicks or dead clicks are detected on your site.
</p>
</div>
</div>
)
}
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Frustration Trend
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
{hasPrevious
? 'Rage and dead clicks split across current and previous period'
: 'Rage vs. dead click breakdown'}
</p>
<div className="flex-1">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<PieChart>
<Tooltip
cursor={false}
content={<CustomTooltip />}
/>
<Pie
data={chartData}
dataKey="count"
nameKey="type"
stroke="0"
/>
</PieChart>
</ChartContainer>
</div>
<div className="flex items-center justify-center gap-2 text-sm font-medium pt-2">
{totalChange !== null ? (
<>
{totalChange > 0 ? 'Up' : totalChange < 0 ? 'Down' : 'No change'} by {Math.abs(totalChange)}% vs previous period <TrendUp className="h-4 w-4" />
</>
) : totalCurrent > 0 ? (
<>
{totalCurrent.toLocaleString()} new signals this period <TrendUp className="h-4 w-4" />
</>
) : (
'No frustration signals detected'
)}
</div>
</div>
)
}

View File

@@ -174,7 +174,7 @@ const ChartTooltipContent = React.forwardRef<
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
'shrink-0 rounded-[2px] border-[var(--color-border)] bg-[var(--color-bg)]',
indicator === 'dot' && 'h-2.5 w-2.5 rounded-full',
indicator === 'line' && 'w-1',
indicator === 'dashed' &&

View File

@@ -1,19 +1,11 @@
'use client'
import { useRouter } from 'next/navigation'
interface RealtimeVisitorsProps {
count: number
siteId?: string
}
export default function RealtimeVisitors({ count, siteId }: RealtimeVisitorsProps) {
const router = useRouter()
export default function RealtimeVisitors({ count }: RealtimeVisitorsProps) {
return (
<div
onClick={() => siteId && router.push(`/sites/${siteId}/realtime`)}
className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 ${siteId ? 'cursor-pointer hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors' : ''}`}
<div
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"
>
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-neutral-600 dark:text-neutral-400">

View File

@@ -18,14 +18,16 @@ export default function SiteNav({ siteId }: SiteNavProps) {
const tabs = [
{ label: 'Dashboard', href: `/sites/${siteId}` },
{ label: 'Uptime', href: `/sites/${siteId}/uptime` },
{ label: 'Journeys', href: `/sites/${siteId}/journeys` },
{ label: 'Funnels', href: `/sites/${siteId}/funnels` },
{ label: 'Behavior', href: `/sites/${siteId}/behavior` },
{ label: 'Uptime', href: `/sites/${siteId}/uptime` },
...(canEdit ? [{ label: 'Settings', href: `/sites/${siteId}/settings` }] : []),
]
const isActive = (href: string) => {
if (href === `/sites/${siteId}`) {
return pathname === href || pathname === `${href}/realtime`
return pathname === href
}
return pathname.startsWith(href)
}

View File

@@ -0,0 +1,457 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTheme } from '@ciphera-net/ui'
import { TreeStructure } from '@phosphor-icons/react'
import { sankey, sankeyJustify } from 'd3-sankey'
import type {
SankeyNode as D3SankeyNode,
SankeyLink as D3SankeyLink,
SankeyExtraProperties,
} from 'd3-sankey'
import type { PathTransition } from '@/lib/api/journeys'
// ─── Types ──────────────────────────────────────────────────────────
interface SankeyDiagramProps {
transitions: PathTransition[]
totalSessions: number
depth: number
onNodeClick?: (path: string) => void
}
interface NodeExtra extends SankeyExtraProperties {
id: string
label: string
color: string
}
interface LinkExtra extends SankeyExtraProperties {
value: number
}
type LayoutNode = D3SankeyNode<NodeExtra, LinkExtra>
type LayoutLink = D3SankeyLink<NodeExtra, LinkExtra>
// ─── Constants ──────────────────────────────────────────────────────
const COLUMN_COLORS = [
'#FD5E0F', // brand orange (entry)
'#3B82F6', // blue
'#10B981', // emerald
'#F59E0B', // amber
'#8B5CF6', // violet
'#EC4899', // pink
'#06B6D4', // cyan
'#EF4444', // red
'#84CC16', // lime
'#F97316', // orange again
'#6366F1', // indigo
]
const EXIT_GREY = '#52525b'
const SVG_W = 1100
const MARGIN = { top: 24, right: 140, bottom: 24, left: 10 }
const MAX_NODES_PER_COLUMN = 5
function colorForColumn(col: number): string {
return COLUMN_COLORS[col % COLUMN_COLORS.length]
}
// ─── Smart label: show last meaningful path segment ─────────────────
function smartLabel(path: string): string {
if (path === '/' || path === '(exit)') return path
// Remove trailing slash, split, take last 2 segments
const segments = path.replace(/\/$/, '').split('/')
if (segments.length <= 2) return path
// Show /last-segment for short paths, or …/last-segment for deep ones
const last = segments[segments.length - 1]
return `…/${last}`
}
function truncateLabel(s: string, max: number) {
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s
}
function estimateTextWidth(s: string) {
return s.length * 7
}
// ─── Data transformation ────────────────────────────────────────────
function buildSankeyData(transitions: PathTransition[], depth: number) {
const numCols = depth + 1
const nodeMap = new Map<string, NodeExtra>()
const links: Array<{ source: string; target: string; value: number }> = []
const flowOut = new Map<string, number>()
const flowIn = new Map<string, number>()
for (const t of transitions) {
if (t.step_index >= numCols || t.step_index + 1 >= numCols) continue
const fromId = `${t.step_index}:${t.from_path}`
const toId = `${t.step_index + 1}:${t.to_path}`
if (!nodeMap.has(fromId)) {
nodeMap.set(fromId, { id: fromId, label: t.from_path, color: colorForColumn(t.step_index) })
}
if (!nodeMap.has(toId)) {
nodeMap.set(toId, { id: toId, label: t.to_path, color: colorForColumn(t.step_index + 1) })
}
links.push({ source: fromId, target: toId, value: t.session_count })
flowOut.set(fromId, (flowOut.get(fromId) ?? 0) + t.session_count)
flowIn.set(toId, (flowIn.get(toId) ?? 0) + t.session_count)
}
// ─── Cap nodes per column: keep top N by flow, merge rest into (other) ──
const columns = new Map<number, string[]>()
for (const [nodeId] of nodeMap) {
if (nodeId === 'exit') continue
const col = parseInt(nodeId.split(':')[0], 10)
if (!columns.has(col)) columns.set(col, [])
columns.get(col)!.push(nodeId)
}
for (const [col, nodeIds] of columns) {
if (nodeIds.length <= MAX_NODES_PER_COLUMN) continue
// Sort by total flow (max of in/out) descending
nodeIds.sort((a, b) => {
const flowA = Math.max(flowIn.get(a) ?? 0, flowOut.get(a) ?? 0)
const flowB = Math.max(flowIn.get(b) ?? 0, flowOut.get(b) ?? 0)
return flowB - flowA
})
const keep = new Set(nodeIds.slice(0, MAX_NODES_PER_COLUMN))
const otherId = `${col}:(other)`
nodeMap.set(otherId, { id: otherId, label: '(other)', color: colorForColumn(col) })
// Redirect links from/to pruned nodes to (other)
for (let i = 0; i < links.length; i++) {
const l = links[i]
if (!keep.has(l.source) && nodeIds.includes(l.source)) {
links[i] = { ...l, source: otherId }
}
if (!keep.has(l.target) && nodeIds.includes(l.target)) {
links[i] = { ...l, target: otherId }
}
}
// Remove pruned nodes
for (const id of nodeIds) {
if (!keep.has(id)) nodeMap.delete(id)
}
}
// Deduplicate links after merging (same source→target pairs)
const linkMap = new Map<string, { source: string; target: string; value: number }>()
for (const l of links) {
const key = `${l.source}->${l.target}`
const existing = linkMap.get(key)
if (existing) {
existing.value += l.value
} else {
linkMap.set(key, { ...l })
}
}
// Recalculate flowOut/flowIn after merge
flowOut.clear()
flowIn.clear()
for (const l of linkMap.values()) {
flowOut.set(l.source, (flowOut.get(l.source) ?? 0) + l.value)
flowIn.set(l.target, (flowIn.get(l.target) ?? 0) + l.value)
}
// Add exit nodes for flows that don't continue
for (const [nodeId] of nodeMap) {
if (nodeId === 'exit') continue
const col = parseInt(nodeId.split(':')[0], 10)
if (col >= numCols - 1) continue
const totalIn = flowIn.get(nodeId) ?? 0
const totalOut = flowOut.get(nodeId) ?? 0
const flow = Math.max(totalIn, totalOut)
const exitCount = flow - totalOut
if (exitCount > 0) {
const exitId = 'exit'
if (!nodeMap.has(exitId)) {
nodeMap.set(exitId, { id: exitId, label: '(exit)', color: EXIT_GREY })
}
const key = `${nodeId}->exit`
const existing = linkMap.get(key)
if (existing) {
existing.value += exitCount
} else {
linkMap.set(key, { source: nodeId, target: exitId, value: exitCount })
}
}
}
return {
nodes: Array.from(nodeMap.values()),
links: Array.from(linkMap.values()),
}
}
// ─── SVG path for a link ribbon ─────────────────────────────────────
function ribbonPath(link: LayoutLink): string {
const src = link.source as LayoutNode
const tgt = link.target as LayoutNode
const sx = src.x1!
const tx = tgt.x0!
const w = link.width!
// d3-sankey y0/y1 are the CENTER of the link band, not the top
const sy = link.y0! - w / 2
const ty = link.y1! - w / 2
const mx = (sx + tx) / 2
return [
`M${sx},${sy}`,
`C${mx},${sy} ${mx},${ty} ${tx},${ty}`,
`L${tx},${ty + w}`,
`C${mx},${ty + w} ${mx},${sy + w} ${sx},${sy + w}`,
'Z',
].join(' ')
}
// ─── Component ──────────────────────────────────────────────────────
export default function SankeyDiagram({
transitions,
totalSessions,
depth,
onNodeClick,
}: SankeyDiagramProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [hovered, setHovered] = useState<{ type: 'link' | 'node'; id: string } | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const data = useMemo(
() => buildSankeyData(transitions, depth),
[transitions, depth],
)
// Dynamic SVG height based on max nodes in any column
const svgH = useMemo(() => {
const columns = new Map<number, number>()
for (const node of data.nodes) {
if (node.id === 'exit') continue
const col = parseInt(node.id.split(':')[0], 10)
columns.set(col, (columns.get(col) ?? 0) + 1)
}
const maxNodes = Math.max(1, ...columns.values())
// Base 400 + 50px per node beyond 4
return Math.max(400, Math.min(800, 400 + Math.max(0, maxNodes - 4) * 50))
}, [data])
const layout = useMemo(() => {
if (!data.links.length) return null
const generator = sankey<NodeExtra, LinkExtra>()
.nodeId((d) => d.id)
.nodeWidth(18)
.nodePadding(16)
.nodeAlign(sankeyJustify)
.extent([
[MARGIN.left, MARGIN.top],
[SVG_W - MARGIN.right, svgH - MARGIN.bottom],
])
return generator({
nodes: data.nodes.map((d) => ({ ...d })),
links: data.links.map((d) => ({ ...d })),
})
}, [data, svgH])
// Single event handler on SVG — reads data-* attrs from e.target
const handleMouseOver = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
const target = e.target as SVGElement
const el = target.closest('[data-node-id], [data-link-id]') as SVGElement | null
if (!el) return
const nodeId = el.getAttribute('data-node-id')
const linkId = el.getAttribute('data-link-id')
if (nodeId) {
setHovered((prev) => (prev?.type === 'node' && prev.id === nodeId) ? prev : { type: 'node', id: nodeId })
} else if (linkId) {
setHovered((prev) => (prev?.type === 'link' && prev.id === linkId) ? prev : { type: 'link', id: linkId })
}
}, [])
const handleMouseLeave = useCallback(() => {
setHovered(null)
}, [])
// ─── Empty state ────────────────────────────────────────────────
if (!transitions.length || !layout) {
return (
<div className="h-[400px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<TreeStructure className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No journey data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Navigation flows will appear here as visitors browse through your site.
</p>
</div>
)
}
// ─── Colors ─────────────────────────────────────────────────────
const labelColor = isDark ? '#e5e5e5' : '#404040'
const labelBg = isDark ? 'rgba(23, 23, 23, 0.9)' : 'rgba(255, 255, 255, 0.9)'
const nodeStroke = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'
return (
<svg
ref={svgRef}
viewBox={`0 0 ${SVG_W} ${svgH}`}
preserveAspectRatio="xMidYMid meet"
className="w-full"
role="img"
aria-label="User journey Sankey diagram"
onMouseMove={handleMouseOver}
onMouseLeave={handleMouseLeave}
>
{/* Links */}
<g>
{layout.links.map((link, i) => {
const src = link.source as LayoutNode
const tgt = link.target as LayoutNode
const srcId = String(src.id)
const tgtId = String(tgt.id)
const linkId = `${srcId}->${tgtId}`
let isHighlighted = false
if (hovered?.type === 'link') {
isHighlighted = hovered.id === linkId
} else if (hovered?.type === 'node') {
isHighlighted = srcId === hovered.id || tgtId === hovered.id
}
let opacity = isDark ? 0.45 : 0.5
if (hovered) {
opacity = isHighlighted ? 0.75 : 0.08
}
return (
<path
key={i}
d={ribbonPath(link)}
fill={src.color}
opacity={opacity}
style={{ transition: 'opacity 0.15s ease' }}
data-link-id={linkId}
>
<title>
{src.label} {tgt.label}:{' '}
{(link.value as number).toLocaleString()} sessions
</title>
</path>
)
})}
</g>
{/* Nodes */}
<g>
{layout.nodes.map((node) => {
const nodeId = String(node.id)
const isExit = nodeId === 'exit'
const w = isExit ? 8 : (node.x1 ?? 0) - (node.x0 ?? 0)
const h = (node.y1 ?? 0) - (node.y0 ?? 0)
const x = isExit ? (node.x0 ?? 0) + 5 : (node.x0 ?? 0)
return (
<rect
key={nodeId}
x={x}
y={node.y0}
width={w}
height={h}
fill={node.color}
stroke={nodeStroke}
strokeWidth={1}
rx={2}
className={
onNodeClick && !isExit ? 'cursor-pointer' : 'cursor-default'
}
data-node-id={nodeId}
onClick={() => {
if (onNodeClick && !isExit) onNodeClick(node.label)
}}
>
<title>
{node.label} {(node.value ?? 0).toLocaleString()} sessions
</title>
</rect>
)
})}
</g>
{/* Labels — only for nodes tall enough to avoid overlap */}
<g>
{layout.nodes.map((node) => {
const x0 = node.x0 ?? 0
const x1 = node.x1 ?? 0
const y0 = node.y0 ?? 0
const y1 = node.y1 ?? 0
const nodeH = y1 - y0
if (nodeH < 36) return null // hide labels for small nodes — hover for details
const rawLabel = smartLabel(node.label)
const label = truncateLabel(rawLabel, 24)
const textW = estimateTextWidth(label)
const padX = 6
const rectW = textW + padX * 2
const rectH = 20
const isRight = x1 > SVG_W - MARGIN.right - 60
const textX = isRight ? x0 - 6 : x1 + 6
const textY = y0 + nodeH / 2
const anchor = isRight ? 'end' : 'start'
const bgX = isRight ? textX - textW - padX : textX - padX
const bgY = textY - rectH / 2
const nodeId = String(node.id)
const isExit = nodeId === 'exit'
return (
<g key={`label-${nodeId}`} data-node-id={nodeId}>
<rect
x={bgX}
y={bgY}
width={rectW}
height={rectH}
rx={3}
fill={labelBg}
/>
<text
x={textX}
y={textY}
dy="0.35em"
textAnchor={anchor}
fill={labelColor}
fontSize={12}
fontFamily="system-ui, -apple-system, sans-serif"
className={
onNodeClick && !isExit ? 'cursor-pointer' : 'cursor-default'
}
onClick={() => {
if (onNodeClick && !isExit) onNodeClick(node.label)
}}
>
{label}
</text>
</g>
)
})}
</g>
</svg>
)
}

View File

@@ -0,0 +1,87 @@
'use client'
import type { TopPath } from '@/lib/api/journeys'
import { TableSkeleton } from '@/components/skeletons'
import { Path } from '@phosphor-icons/react'
interface TopPathsTableProps {
paths: TopPath[]
loading: boolean
}
function formatDuration(seconds: number): string {
if (seconds <= 0) return '0s'
const m = Math.floor(seconds / 60)
const s = Math.round(seconds % 60)
if (m === 0) return `${s}s`
return `${m}m ${s}s`
}
export default function TopPathsTable({ paths, loading }: TopPathsTableProps) {
const hasData = paths.length > 0
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Top Paths
</h3>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-4">
Most common navigation paths across sessions
</p>
{loading ? (
<TableSkeleton rows={7} cols={4} />
) : hasData ? (
<div>
{/* Header */}
<div className="flex items-center px-2 -mx-2 mb-2 text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
<span className="w-8 text-right shrink-0">#</span>
<span className="flex-1 ml-3">Path</span>
<span className="w-20 text-right shrink-0">Sessions</span>
<span className="w-16 text-right shrink-0">Dur.</span>
</div>
{/* Rows */}
<div className="space-y-0.5">
{paths.map((path, i) => (
<div
key={i}
className="flex items-center h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors"
>
<span className="w-8 text-right shrink-0 text-sm tabular-nums text-neutral-400">
{i + 1}
</span>
<span
className="flex-1 ml-3 text-sm text-neutral-900 dark:text-white truncate"
title={path.page_sequence.join(' → ')}
>
{path.page_sequence.join(' → ')}
</span>
<span className="w-20 text-right shrink-0 text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{path.session_count.toLocaleString()}
</span>
<span className="w-16 text-right shrink-0 text-sm tabular-nums text-neutral-600 dark:text-neutral-400">
{formatDuration(path.avg_duration)}
</span>
</div>
))}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<Path className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h4 className="font-semibold text-neutral-900 dark:text-white">
No path data yet
</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
Common navigation paths will appear here as visitors browse your site.
</p>
</div>
)}
</div>
)
}

View File

@@ -166,74 +166,31 @@ export function DashboardSkeleton() {
)
}
// ─── Realtime page skeleton ─────────────────────────────────
// ─── Journeys page skeleton ─────────────────────────────────
export function RealtimeSkeleton() {
export function JourneysSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 h-[calc(100vh-64px)] flex flex-col">
<div className="mb-6">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-64" />
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<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>
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
{/* Visitors list */}
<div className="w-full md:w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-32" />
</div>
<div className="p-2 space-y-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="p-4 space-y-2">
<div className="flex justify-between">
<SkeletonLine className="h-4 w-32" />
<SkeletonLine className="h-4 w-16" />
</div>
<SkeletonLine className="h-3 w-48" />
<div className="flex gap-2">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
</div>
</div>
))}
</div>
</div>
{/* Session details */}
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-40" />
</div>
<div className="p-6 space-y-6">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 pl-6">
<SkeletonCircle className="h-3 w-3 shrink-0 mt-1" />
<div className="space-y-1 flex-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-4 mb-6">
<SkeletonLine className="h-9 w-48 rounded-lg" />
<SkeletonLine className="h-9 w-48 rounded-lg" />
</div>
{/* Sankey area */}
<SkeletonCard className="h-[400px] mb-6" />
{/* Top paths table */}
<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-24 mb-4" />
<TableSkeleton rows={5} cols={4} />
</div>
</div>
)
}
// ─── Session events skeleton (for loading events panel) ──────
export function SessionEventsSkeleton() {
return (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="relative">
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full ${SK}`} />
<div className="space-y-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
)
}

93
lib/api/journeys.ts Normal file
View File

@@ -0,0 +1,93 @@
import apiRequest from './client'
// ─── Types ──────────────────────────────────────────────────────────
export interface PathTransition {
from_path: string
to_path: string
step_index: number
session_count: number
}
export interface TransitionsResponse {
transitions: PathTransition[]
total_sessions: number
}
export interface TopPath {
page_sequence: string[]
session_count: number
avg_duration: number
}
export interface EntryPoint {
path: string
session_count: number
}
// ─── Helpers ────────────────────────────────────────────────────────
function buildQuery(opts: {
startDate?: string
endDate?: string
depth?: number
limit?: number
min_sessions?: number
entry_path?: string
}): string {
const params = new URLSearchParams()
if (opts.startDate) params.append('start_date', opts.startDate)
if (opts.endDate) params.append('end_date', opts.endDate)
if (opts.depth != null) params.append('depth', opts.depth.toString())
if (opts.limit != null) params.append('limit', opts.limit.toString())
if (opts.min_sessions != null) params.append('min_sessions', opts.min_sessions.toString())
if (opts.entry_path) params.append('entry_path', opts.entry_path)
const query = params.toString()
return query ? `?${query}` : ''
}
// ─── API Functions ──────────────────────────────────────────────────
export function getJourneyTransitions(
siteId: string,
startDate?: string,
endDate?: string,
opts?: { depth?: number; minSessions?: number; entryPath?: string }
): Promise<TransitionsResponse> {
return apiRequest<TransitionsResponse>(
`/sites/${siteId}/journeys/transitions${buildQuery({
startDate,
endDate,
depth: opts?.depth,
min_sessions: opts?.minSessions,
entry_path: opts?.entryPath,
})}`
).then(r => r ?? { transitions: [], total_sessions: 0 })
}
export function getJourneyTopPaths(
siteId: string,
startDate?: string,
endDate?: string,
opts?: { limit?: number; minSessions?: number; entryPath?: string }
): Promise<TopPath[]> {
return apiRequest<{ paths: TopPath[] }>(
`/sites/${siteId}/journeys/top-paths${buildQuery({
startDate,
endDate,
limit: opts?.limit,
min_sessions: opts?.minSessions,
entry_path: opts?.entryPath,
})}`
).then(r => r?.paths ?? [])
}
export function getJourneyEntryPoints(
siteId: string,
startDate?: string,
endDate?: string
): Promise<EntryPoint[]> {
return apiRequest<{ entry_points: EntryPoint[] }>(
`/sites/${siteId}/journeys/entry-points${buildQuery({ startDate, endDate })}`
).then(r => r?.entry_points ?? [])
}

View File

@@ -1,42 +0,0 @@
import apiRequest from './client'
export interface Visitor {
session_id: string
first_seen: string
last_seen: string
pageviews: number
current_path: string
browser: string
os: string
device_type: string
country: string
city: string
}
export interface SessionEvent {
id: string
site_id: string
session_id: string
path: string
referrer: string | null
user_agent: string
country: string | null
city: string | null
region: string | null
device_type: string
screen_resolution: string | null
browser: string | null
os: string | null
timestamp: string
created_at: string
}
export async function getRealtimeVisitors(siteId: string): Promise<Visitor[]> {
const data = await apiRequest<{ visitors: Visitor[] }>(`/sites/${siteId}/realtime/visitors`)
return data.visitors
}
export async function getSessionDetails(siteId: string, sessionId: string): Promise<SessionEvent[]> {
const data = await apiRequest<{ events: SessionEvent[] }>(`/sites/${siteId}/sessions/${sessionId}`)
return data.events
}

View File

@@ -0,0 +1,80 @@
import apiRequest from './client'
export interface ReportSchedule {
id: string
site_id: string
organization_id: string
channel: 'email' | 'slack' | 'discord' | 'webhook'
channel_config: EmailConfig | WebhookConfig
frequency: 'daily' | 'weekly' | 'monthly'
timezone: string
enabled: boolean
report_type: 'summary' | 'pages' | 'sources' | 'goals'
send_hour: number
send_day: number | null
next_send_at: string | null
last_sent_at: string | null
last_error: string | null
created_at: string
updated_at: string
}
export interface EmailConfig {
recipients: string[]
}
export interface WebhookConfig {
url: string
}
export interface CreateReportScheduleRequest {
channel: string
channel_config: EmailConfig | WebhookConfig
frequency: string
timezone?: string
report_type?: string
send_hour?: number
send_day?: number
}
export interface UpdateReportScheduleRequest {
channel?: string
channel_config?: EmailConfig | WebhookConfig
frequency?: string
timezone?: string
report_type?: string
enabled?: boolean
send_hour?: number
send_day?: number
}
export async function listReportSchedules(siteId: string): Promise<ReportSchedule[]> {
const res = await apiRequest<{ report_schedules: ReportSchedule[] }>(`/sites/${siteId}/report-schedules`)
return res?.report_schedules ?? []
}
export async function createReportSchedule(siteId: string, data: CreateReportScheduleRequest): Promise<ReportSchedule> {
return apiRequest<ReportSchedule>(`/sites/${siteId}/report-schedules`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateReportSchedule(siteId: string, scheduleId: string, data: UpdateReportScheduleRequest): Promise<ReportSchedule> {
return apiRequest<ReportSchedule>(`/sites/${siteId}/report-schedules/${scheduleId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteReportSchedule(siteId: string, scheduleId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}`, {
method: 'DELETE',
})
}
export async function testReportSchedule(siteId: string, scheduleId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/report-schedules/${scheduleId}/test`, {
method: 'POST',
})
}

View File

@@ -103,6 +103,34 @@ export interface AuthParams {
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
}
export interface FrustrationSummary {
rage_clicks: number
rage_unique_elements: number
rage_top_page: string
dead_clicks: number
dead_unique_elements: number
dead_top_page: string
prev_rage_clicks: number
prev_dead_clicks: number
}
export interface FrustrationElement {
selector: string
page_path: string
count: number
avg_click_count?: number
sessions: number
last_seen: string
}
export interface FrustrationByPage {
page_path: string
rage_clicks: number
dead_clicks: number
total: number
unique_elements: number
}
// ─── Helpers ────────────────────────────────────────────────────────
function appendAuthParams(params: URLSearchParams, auth?: AuthParams) {
@@ -402,3 +430,48 @@ export function getEventPropertyValues(siteId: string, eventName: string, propNa
return apiRequest<{ values: EventPropertyValue[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties/${encodeURIComponent(propName)}${buildQuery({ startDate, endDate, limit })}`)
.then(r => r?.values || [])
}
// ─── Frustration Signals ────────────────────────────────────────────
export interface BehaviorData {
summary: FrustrationSummary
rage_clicks: { items: FrustrationElement[]; total: number }
dead_clicks: { items: FrustrationElement[]; total: number }
by_page: FrustrationByPage[]
}
const emptyBehavior: BehaviorData = {
summary: { rage_clicks: 0, rage_unique_elements: 0, rage_top_page: '', dead_clicks: 0, dead_unique_elements: 0, dead_top_page: '', prev_rage_clicks: 0, prev_dead_clicks: 0 },
rage_clicks: { items: [], total: 0 },
dead_clicks: { items: [], total: 0 },
by_page: [],
}
export function getBehavior(siteId: string, startDate?: string, endDate?: string, limit = 7): Promise<BehaviorData> {
return apiRequest<BehaviorData>(`/sites/${siteId}/behavior${buildQuery({ startDate, endDate, limit })}`)
.then(r => r ?? emptyBehavior)
}
export function getFrustrationSummary(siteId: string, startDate?: string, endDate?: string): Promise<FrustrationSummary> {
return apiRequest<FrustrationSummary>(`/sites/${siteId}/frustration/summary${buildQuery({ startDate, endDate })}`)
.then(r => r ?? { rage_clicks: 0, rage_unique_elements: 0, rage_top_page: '', dead_clicks: 0, dead_unique_elements: 0, dead_top_page: '', prev_rage_clicks: 0, prev_dead_clicks: 0 })
}
export function getRageClicks(siteId: string, startDate?: string, endDate?: string, limit = 10, pagePath?: string): Promise<{ items: FrustrationElement[], total: number }> {
const params = buildQuery({ startDate, endDate, limit })
const pageFilter = pagePath ? `&page_path=${encodeURIComponent(pagePath)}` : ''
return apiRequest<{ items: FrustrationElement[], total: number }>(`/sites/${siteId}/frustration/rage-clicks${params}${pageFilter}`)
.then(r => r ?? { items: [], total: 0 })
}
export function getDeadClicks(siteId: string, startDate?: string, endDate?: string, limit = 10, pagePath?: string): Promise<{ items: FrustrationElement[], total: number }> {
const params = buildQuery({ startDate, endDate, limit })
const pageFilter = pagePath ? `&page_path=${encodeURIComponent(pagePath)}` : ''
return apiRequest<{ items: FrustrationElement[], total: number }>(`/sites/${siteId}/frustration/dead-clicks${params}${pageFilter}`)
.then(r => r ?? { items: [], total: 0 })
}
export function getFrustrationByPage(siteId: string, startDate?: string, endDate?: string, limit = 20): Promise<FrustrationByPage[]> {
return apiRequest<{ pages: FrustrationByPage[] }>(`/sites/${siteId}/frustration/by-page${buildQuery({ startDate, endDate, limit })}`)
.then(r => r?.pages ?? [])
}

View File

@@ -1,53 +0,0 @@
// * SSE hook for real-time visitor streaming.
// * Replaces 5-second polling with a persistent EventSource connection.
// * The backend broadcasts one DB query per site to all connected clients,
// * so 1,000 users on the same site share a single query instead of each
// * triggering their own.
import { useEffect, useRef, useState, useCallback } from 'react'
import { API_URL } from '@/lib/api/client'
import type { Visitor } from '@/lib/api/realtime'
interface UseRealtimeSSEReturn {
visitors: Visitor[]
connected: boolean
}
export function useRealtimeSSE(siteId: string): UseRealtimeSSEReturn {
const [visitors, setVisitors] = useState<Visitor[]>([])
const [connected, setConnected] = useState(false)
const esRef = useRef<EventSource | null>(null)
// Stable callback so we don't recreate EventSource on every render
const handleMessage = useCallback((event: MessageEvent) => {
try {
const data = JSON.parse(event.data)
setVisitors(data.visitors || [])
} catch {
// Ignore malformed messages
}
}, [])
useEffect(() => {
if (!siteId) return
const url = `${API_URL}/api/v1/sites/${siteId}/realtime/stream`
const es = new EventSource(url, { withCredentials: true })
esRef.current = es
es.onopen = () => setConnected(true)
es.onmessage = handleMessage
es.onerror = () => {
setConnected(false)
// EventSource auto-reconnects with exponential backoff
}
return () => {
es.close()
esRef.current = null
setConnected(false)
}
}, [siteId, handleMessage])
return { visitors, connected }
}

View File

@@ -15,7 +15,16 @@ import {
getRealtime,
getStats,
getDailyStats,
getBehavior,
} from '@/lib/api/stats'
import {
getJourneyTransitions,
getJourneyTopPaths,
getJourneyEntryPoints,
type TransitionsResponse,
type TopPath as JourneyTopPath,
type EntryPoint,
} from '@/lib/api/journeys'
import { listAnnotations } from '@/lib/api/annotations'
import type { Annotation } from '@/lib/api/annotations'
import { getSite } from '@/lib/api/sites'
@@ -32,6 +41,7 @@ import type {
DashboardReferrersData,
DashboardPerformanceData,
DashboardGoalsData,
BehaviorData,
} from '@/lib/api/stats'
// * SWR fetcher functions
@@ -52,6 +62,13 @@ const fetchers = {
campaigns: (siteId: string, start: string, end: string, limit: number) =>
getCampaigns(siteId, start, end, limit),
annotations: (siteId: string, start: string, end: string) => listAnnotations(siteId, start, end),
behavior: (siteId: string, start: string, end: string) => getBehavior(siteId, start, end),
journeyTransitions: (siteId: string, start: string, end: string, depth?: number, minSessions?: number, entryPath?: string) =>
getJourneyTransitions(siteId, start, end, { depth, minSessions, entryPath }),
journeyTopPaths: (siteId: string, start: string, end: string, limit?: number, minSessions?: number, entryPath?: string) =>
getJourneyTopPaths(siteId, start, end, { limit, minSessions, entryPath }),
journeyEntryPoints: (siteId: string, start: string, end: string) =>
getJourneyEntryPoints(siteId, start, end),
}
// * Standard SWR config for dashboard data
@@ -265,5 +282,57 @@ export function useAnnotations(siteId: string, startDate: string, endDate: strin
)
}
// * Hook for bundled behavior data (all frustration signals in one request)
export function useBehavior(siteId: string, start: string, end: string) {
return useSWR<BehaviorData>(
siteId && start && end ? ['behavior', siteId, start, end] : null,
() => fetchers.behavior(siteId, start, end),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 10 * 1000,
}
)
}
// * Hook for journey flow transitions (Sankey diagram data)
export function useJourneyTransitions(siteId: string, start: string, end: string, depth?: number, minSessions?: number, entryPath?: string) {
return useSWR<TransitionsResponse>(
siteId && start && end ? ['journeyTransitions', siteId, start, end, depth, minSessions, entryPath] : null,
() => fetchers.journeyTransitions(siteId, start, end, depth, minSessions, entryPath),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 10 * 1000,
}
)
}
// * Hook for top journey paths
export function useJourneyTopPaths(siteId: string, start: string, end: string, limit?: number, minSessions?: number, entryPath?: string) {
return useSWR<JourneyTopPath[]>(
siteId && start && end ? ['journeyTopPaths', siteId, start, end, limit, minSessions, entryPath] : null,
() => fetchers.journeyTopPaths(siteId, start, end, limit, minSessions, entryPath),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 10 * 1000,
}
)
}
// * Hook for journey entry points (refreshes less frequently)
export function useJourneyEntryPoints(siteId: string, start: string, end: string) {
return useSWR<EntryPoint[]>(
siteId && start && end ? ['journeyEntryPoints', siteId, start, end] : null,
() => fetchers.journeyEntryPoints(siteId, start, end),
{
...dashboardSWRConfig,
refreshInterval: 5 * 60 * 1000,
dedupingInterval: 30 * 1000,
}
)
}
// * Re-export for convenience
export { fetchers }

123
package-lock.json generated
View File

@@ -1,14 +1,14 @@
{
"name": "pulse-frontend",
"version": "0.13.0-alpha",
"version": "0.14.0-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-frontend",
"version": "0.13.0-alpha",
"version": "0.14.0-alpha",
"dependencies": {
"@ciphera-net/ui": "^0.2.4",
"@ciphera-net/ui": "^0.2.5",
"@ducanh2912/next-pwa": "^10.2.9",
"@phosphor-icons/react": "^2.1.10",
"@simplewebauthn/browser": "^13.2.2",
@@ -19,6 +19,7 @@
"class-variance-authority": "^0.7.1",
"cobe": "^0.6.5",
"country-flag-icons": "^1.6.4",
"d3-sankey": "^0.12.3",
"d3-scale": "^4.0.2",
"framer-motion": "^12.23.26",
"html-to-image": "^1.11.13",
@@ -41,6 +42,7 @@
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/d3-sankey": "^0.12.5",
"@types/d3-scale": "^4.0.9",
"@types/node": "^20.14.12",
"@types/react": "^19.2.14",
@@ -1667,9 +1669,9 @@
}
},
"node_modules/@ciphera-net/ui": {
"version": "0.2.4",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.4/e2e049be27e465b91cb347295c3854c1cd9927d7",
"integrity": "sha512-qqVYM4umgEQf/rQ/F2SdEpEi8D99eyMgO54As8l4O8B0z5kL+9CRtB5MT2ZcrhjsvGLj5tzL18gEzbaqrlXMmA==",
"version": "0.2.5",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.5/01371025a6706621b7a2c353cb1b07a239961fc3",
"integrity": "sha512-Ybd3zZLqpdv/dktNylT/jOm9OTMVST35+19QY+DTvDeluF3B4bN2YA7S85V7PpXGmZBmnPQX3U8qP4t2HwyyMw==",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"class-variance-authority": "^0.7.1",
@@ -5600,9 +5602,9 @@
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz",
"integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
@@ -5611,12 +5613,48 @@
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-sankey": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz",
"integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-shape": "^1"
}
},
"node_modules/@types/d3-sankey/node_modules/@types/d3-path": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz",
"integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-sankey/node_modules/@types/d3-shape": {
"version": "1.3.12",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz",
"integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-path": "^1"
}
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
@@ -7879,6 +7917,46 @@
"node": ">=12"
}
},
"node_modules/d3-sankey": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz",
"integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "1 - 2",
"d3-shape": "^1.2.0"
}
},
"node_modules/d3-sankey/node_modules/d3-array": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
"license": "BSD-3-Clause",
"dependencies": {
"internmap": "^1.0.0"
}
},
"node_modules/d3-sankey/node_modules/d3-path": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
"integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
"license": "BSD-3-Clause"
},
"node_modules/d3-sankey/node_modules/d3-shape": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
"integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-path": "1"
}
},
"node_modules/d3-sankey/node_modules/internmap": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
"license": "ISC"
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@@ -7931,6 +8009,15 @@
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -14804,15 +14891,6 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/victory-vendor/node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/victory-vendor/node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@@ -14822,15 +14900,6 @@
"node": ">=12"
}
},
"node_modules/victory-vendor/node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "pulse-frontend",
"version": "0.14.0-alpha",
"version": "0.15.0-alpha",
"private": true,
"scripts": {
"dev": "next dev",
@@ -12,7 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@ciphera-net/ui": "^0.2.4",
"@ciphera-net/ui": "^0.2.5",
"@ducanh2912/next-pwa": "^10.2.9",
"@phosphor-icons/react": "^2.1.10",
"@simplewebauthn/browser": "^13.2.2",
@@ -23,6 +23,7 @@
"class-variance-authority": "^0.7.1",
"cobe": "^0.6.5",
"country-flag-icons": "^1.6.4",
"d3-sankey": "^0.12.3",
"d3-scale": "^4.0.2",
"framer-motion": "^12.23.26",
"html-to-image": "^1.11.13",
@@ -51,6 +52,7 @@
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/d3-sankey": "^0.12.5",
"@types/d3-scale": "^4.0.9",
"@types/node": "^20.14.12",
"@types/react": "^19.2.14",

View File

@@ -424,6 +424,251 @@
}, { passive: true });
}
// * Strip HTML tags from a string (used for sanitizing attribute values)
function stripHtml(str) {
if (typeof str !== 'string') return '';
return str.replace(/<[^>]*>/g, '').trim();
}
// * Build a compact element identifier string for frustration tracking
// * Format: tag#id.class1.class2[href="/path"]
function getElementIdentifier(el) {
if (!el || !el.tagName) return '';
var result = el.tagName.toLowerCase();
// * Add #id if present
if (el.id) {
result += '#' + stripHtml(el.id);
}
// * Add classes (handle SVG elements where className is SVGAnimatedString)
var rawClassName = el.className;
if (rawClassName && typeof rawClassName !== 'string' && rawClassName.baseVal !== undefined) {
rawClassName = rawClassName.baseVal;
}
if (typeof rawClassName === 'string' && rawClassName.trim()) {
var classes = rawClassName.trim().split(/\s+/);
var filtered = [];
for (var ci = 0; ci < classes.length && filtered.length < 5; ci++) {
var cls = classes[ci];
if (cls.length > 50) continue;
if (/^(ng-|js-|is-|has-|animate)/.test(cls)) continue;
filtered.push(cls);
}
if (filtered.length > 0) {
result += '.' + filtered.join('.');
}
}
// * Add key attributes
var attrs = ['href', 'role', 'type', 'name', 'data-action'];
for (var ai = 0; ai < attrs.length; ai++) {
var attrName = attrs[ai];
var attrVal = el.getAttribute(attrName);
if (attrVal !== null && attrVal !== '') {
var sanitized = stripHtml(attrVal);
if (sanitized.length > 50) sanitized = sanitized.substring(0, 50);
result += '[' + attrName + '="' + sanitized + '"]';
}
}
// * Truncate to max 200 chars
if (result.length > 200) {
result = result.substring(0, 200);
}
return result;
}
// * Auto-track rage clicks (rapid repeated clicks on the same element)
// * Fires rage_click when same element is clicked 3+ times within 800ms
// * Opt-out: add data-no-rage to the script tag
if (!script.hasAttribute('data-no-rage')) {
var rageClickHistory = {}; // * selector -> { times: [timestamps], lastFired: 0 }
var RAGE_CLICK_THRESHOLD = 3;
var RAGE_CLICK_WINDOW = 800;
var RAGE_CLICK_DEBOUNCE = 5000;
var RAGE_CLEANUP_INTERVAL = 10000;
// * Cleanup stale rage click entries every 10 seconds
setInterval(function() {
var now = Date.now();
for (var key in rageClickHistory) {
if (!rageClickHistory.hasOwnProperty(key)) continue;
var entry = rageClickHistory[key];
// * Remove if last click was more than 10 seconds ago
if (entry.times.length === 0 || now - entry.times[entry.times.length - 1] > RAGE_CLEANUP_INTERVAL) {
delete rageClickHistory[key];
}
}
}, RAGE_CLEANUP_INTERVAL);
document.addEventListener('click', function(e) {
var el = e.target;
if (!el || !el.tagName) return;
var selector = getElementIdentifier(el);
if (!selector) return;
var now = Date.now();
var currentPath = window.location.pathname + window.location.search;
if (!rageClickHistory[selector]) {
rageClickHistory[selector] = { times: [], lastFired: 0 };
}
var entry = rageClickHistory[selector];
// * Add current click timestamp
entry.times.push(now);
// * Remove clicks outside the time window
while (entry.times.length > 0 && now - entry.times[0] > RAGE_CLICK_WINDOW) {
entry.times.shift();
}
// * Check if rage click threshold is met
if (entry.times.length >= RAGE_CLICK_THRESHOLD) {
// * Debounce: max one rage_click per element per 5 seconds
if (now - entry.lastFired >= RAGE_CLICK_DEBOUNCE) {
var clickCount = entry.times.length;
trackCustomEvent('rage_click', {
selector: selector,
click_count: String(clickCount),
page_path: currentPath,
x: String(Math.round(e.clientX)),
y: String(Math.round(e.clientY))
});
entry.lastFired = now;
}
// * Reset tracker after firing or debounce skip
entry.times = [];
}
}, true); // * Capture phase
}
// * Auto-track dead clicks (clicks on interactive elements that produce no effect)
// * Fires dead_click when an interactive element is clicked but no DOM change, navigation,
// * or network request occurs within 1 second
// * Opt-out: add data-no-dead to the script tag
if (!script.hasAttribute('data-no-dead')) {
var INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[onclick],[tabindex]';
var DEAD_CLICK_DEBOUNCE = 10000;
var DEAD_CLEANUP_INTERVAL = 30000;
var deadClickDebounce = {}; // * selector -> lastFiredTimestamp
// * Cleanup stale dead click debounce entries every 30 seconds
setInterval(function() {
var now = Date.now();
for (var key in deadClickDebounce) {
if (!deadClickDebounce.hasOwnProperty(key)) continue;
if (now - deadClickDebounce[key] > DEAD_CLEANUP_INTERVAL) {
delete deadClickDebounce[key];
}
}
}, DEAD_CLEANUP_INTERVAL);
// * Polyfill check for Element.matches
var matchesFn = (function() {
var ep = Element.prototype;
return ep.matches || ep.msMatchesSelector || ep.webkitMatchesSelector || null;
})();
// * Find the nearest interactive element by walking up max 3 levels
function findInteractiveElement(el) {
if (!matchesFn) return null;
var depth = 0;
var current = el;
while (current && depth <= 3) {
if (current.nodeType === 1 && matchesFn.call(current, INTERACTIVE_SELECTOR)) {
return current;
}
current = current.parentElement;
depth++;
}
return null;
}
document.addEventListener('click', function(e) {
var target = findInteractiveElement(e.target);
if (!target) return;
var selector = getElementIdentifier(target);
if (!selector) return;
var now = Date.now();
// * Debounce: max one dead_click per element per 10 seconds
if (deadClickDebounce[selector] && now - deadClickDebounce[selector] < DEAD_CLICK_DEBOUNCE) {
return;
}
var currentPath = window.location.pathname + window.location.search;
var clickX = String(Math.round(e.clientX));
var clickY = String(Math.round(e.clientY));
var effectDetected = false;
var hrefBefore = location.href;
var mutationObs = null;
var perfObs = null;
var cleanupTimer = null;
function cleanup() {
if (mutationObs) { try { mutationObs.disconnect(); } catch (ex) {} mutationObs = null; }
if (perfObs) { try { perfObs.disconnect(); } catch (ex) {} perfObs = null; }
if (cleanupTimer) { clearTimeout(cleanupTimer); cleanupTimer = null; }
}
function onEffect() {
effectDetected = true;
cleanup();
}
// * Set up MutationObserver to detect DOM changes on the element and its parent
if (typeof MutationObserver !== 'undefined') {
try {
mutationObs = new MutationObserver(function() {
onEffect();
});
var mutOpts = { childList: true, attributes: true, characterData: true, subtree: true };
mutationObs.observe(target, mutOpts);
var parent = target.parentElement;
if (parent && parent.tagName !== 'HTML' && parent.tagName !== 'BODY') {
mutationObs.observe(parent, { childList: true });
}
} catch (ex) {
mutationObs = null;
}
}
// * Set up PerformanceObserver to detect network requests
if (typeof PerformanceObserver !== 'undefined') {
try {
perfObs = new PerformanceObserver(function() {
onEffect();
});
perfObs.observe({ type: 'resource' });
} catch (ex) {
perfObs = null;
}
}
// * After 1 second, check if any effect was detected
cleanupTimer = setTimeout(function() {
cleanup();
// * Also check if navigation occurred
if (effectDetected || location.href !== hrefBefore) return;
deadClickDebounce[selector] = Date.now();
trackCustomEvent('dead_click', {
selector: selector,
page_path: currentPath,
x: clickX,
y: clickY
});
}, 1000);
}, true); // * Capture phase
}
// * Auto-track outbound link clicks and file downloads (on by default)
// * Opt-out: add data-no-outbound or data-no-downloads to the script tag
var trackOutbound = !script.hasAttribute('data-no-outbound');