27
CHANGELOG.md
27
CHANGELOG.md
@@ -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**.
|
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
|
## [0.14.0-alpha] - 2026-03-12
|
||||||
|
|
||||||
|
|||||||
168
app/sites/[id]/behavior/page.tsx
Normal file
168
app/sites/[id]/behavior/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
app/sites/[id]/journeys/error.tsx
Normal file
13
app/sites/[id]/journeys/error.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
app/sites/[id]/journeys/layout.tsx
Normal file
9
app/sites/[id]/journeys/layout.tsx
Normal 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
|
||||||
|
}
|
||||||
179
app/sites/[id]/journeys/page.tsx
Normal file
179
app/sites/[id]/journeys/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,7 +34,6 @@ import TechSpecs from '@/components/dashboard/TechSpecs'
|
|||||||
|
|
||||||
const PerformanceStats = dynamic(() => import('@/components/dashboard/PerformanceStats'))
|
const PerformanceStats = dynamic(() => import('@/components/dashboard/PerformanceStats'))
|
||||||
const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats'))
|
const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats'))
|
||||||
const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth'))
|
|
||||||
const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns'))
|
const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns'))
|
||||||
const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours'))
|
const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours'))
|
||||||
const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties'))
|
const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties'))
|
||||||
@@ -452,9 +451,8 @@ export default function SiteDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Realtime Indicator */}
|
{/* Realtime Indicator */}
|
||||||
<button
|
<div
|
||||||
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<span className="relative flex h-2 w-2">
|
<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>
|
<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">
|
<span className="text-sm font-medium text-green-700 dark:text-green-400">
|
||||||
{realtime} current visitors
|
{realtime} current visitors
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -618,12 +616,11 @@ export default function SiteDashboardPage() {
|
|||||||
<PeakHours siteId={siteId} dateRange={dateRange} />
|
<PeakHours siteId={siteId} dateRange={dateRange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
<div className="mb-8">
|
||||||
<GoalStats
|
<GoalStats
|
||||||
goalCounts={(dashboard?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
|
goalCounts={(dashboard?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
|
||||||
onSelectEvent={setSelectedEvent}
|
onSelectEvent={setSelectedEvent}
|
||||||
/>
|
/>
|
||||||
<ScrollDepth goalCounts={dashboard?.goal_counts ?? []} totalPageviews={stats.pageviews} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event Properties Breakdown */}
|
{/* Event Properties Breakdown */}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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">
|
|
||||||
← 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)
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react'
|
|||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
|
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 { 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 { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
AlertTriangleIcon,
|
AlertTriangleIcon,
|
||||||
ZapIcon,
|
ZapIcon,
|
||||||
} from '@ciphera-net/ui'
|
} from '@ciphera-net/ui'
|
||||||
|
import { PaperPlaneTilt, Envelope, WebhooksLogo, SpinnerGap, Trash, PencilSimple, Play } from '@phosphor-icons/react'
|
||||||
|
|
||||||
const TIMEZONES = [
|
const TIMEZONES = [
|
||||||
'UTC',
|
'UTC',
|
||||||
@@ -54,7 +56,7 @@ export default function SiteSettingsPage() {
|
|||||||
const [site, setSite] = useState<Site | null>(null)
|
const [site, setSite] = useState<Site | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
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({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -91,6 +93,24 @@ export default function SiteSettingsPage() {
|
|||||||
const [goalSaving, setGoalSaving] = useState(false)
|
const [goalSaving, setGoalSaving] = useState(false)
|
||||||
const initialFormRef = useRef<string>('')
|
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(() => {
|
useEffect(() => {
|
||||||
loadSite()
|
loadSite()
|
||||||
loadSubscription()
|
loadSubscription()
|
||||||
@@ -102,6 +122,12 @@ export default function SiteSettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [activeTab, siteId])
|
}, [activeTab, siteId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'reports' && siteId) {
|
||||||
|
loadReportSchedules()
|
||||||
|
}
|
||||||
|
}, [activeTab, siteId])
|
||||||
|
|
||||||
const loadSubscription = async () => {
|
const loadSubscription = async () => {
|
||||||
try {
|
try {
|
||||||
setSubscriptionLoadFailed(false)
|
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 = () => {
|
const openAddGoal = () => {
|
||||||
setEditingGoal(null)
|
setEditingGoal(null)
|
||||||
setGoalForm({ name: '', event_name: '' })
|
setGoalForm({ name: '', event_name: '' })
|
||||||
@@ -389,7 +593,7 @@ export default function SiteSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col md:flex-row gap-8">
|
<div className="flex flex-col md:flex-row gap-8">
|
||||||
<nav className="w-full md:w-64 flex-shrink-0 space-y-1">
|
<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" />
|
<div key={i} className="h-12 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -476,6 +680,19 @@ export default function SiteSettingsPage() {
|
|||||||
<ZapIcon className="w-5 h-5" />
|
<ZapIcon className="w-5 h-5" />
|
||||||
Goals & Events
|
Goals & Events
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
@@ -1124,6 +1341,135 @@ export default function SiteSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1177,6 +1523,165 @@ export default function SiteSettingsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</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
|
<VerificationModal
|
||||||
isOpen={showVerificationModal}
|
isOpen={showVerificationModal}
|
||||||
onClose={() => setShowVerificationModal(false)}
|
onClose={() => setShowVerificationModal(false)}
|
||||||
|
|||||||
114
components/behavior/FrustrationByPageTable.tsx
Normal file
114
components/behavior/FrustrationByPageTable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
components/behavior/FrustrationSummaryCards.tsx
Normal file
124
components/behavior/FrustrationSummaryCards.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
220
components/behavior/FrustrationTable.tsx
Normal file
220
components/behavior/FrustrationTable.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
components/behavior/FrustrationTrend.tsx
Normal file
166
components/behavior/FrustrationTrend.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -174,7 +174,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
!hideIndicator && (
|
!hideIndicator && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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 === 'dot' && 'h-2.5 w-2.5 rounded-full',
|
||||||
indicator === 'line' && 'w-1',
|
indicator === 'line' && 'w-1',
|
||||||
indicator === 'dashed' &&
|
indicator === 'dashed' &&
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
|
|
||||||
interface RealtimeVisitorsProps {
|
interface RealtimeVisitorsProps {
|
||||||
count: number
|
count: number
|
||||||
siteId?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RealtimeVisitors({ count, siteId }: RealtimeVisitorsProps) {
|
export default function RealtimeVisitors({ count }: RealtimeVisitorsProps) {
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
||||||
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="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
|||||||
@@ -18,14 +18,16 @@ export default function SiteNav({ siteId }: SiteNavProps) {
|
|||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ label: 'Dashboard', href: `/sites/${siteId}` },
|
{ label: 'Dashboard', href: `/sites/${siteId}` },
|
||||||
{ label: 'Uptime', href: `/sites/${siteId}/uptime` },
|
{ label: 'Journeys', href: `/sites/${siteId}/journeys` },
|
||||||
{ label: 'Funnels', href: `/sites/${siteId}/funnels` },
|
{ 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` }] : []),
|
...(canEdit ? [{ label: 'Settings', href: `/sites/${siteId}/settings` }] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
const isActive = (href: string) => {
|
||||||
if (href === `/sites/${siteId}`) {
|
if (href === `/sites/${siteId}`) {
|
||||||
return pathname === href || pathname === `${href}/realtime`
|
return pathname === href
|
||||||
}
|
}
|
||||||
return pathname.startsWith(href)
|
return pathname.startsWith(href)
|
||||||
}
|
}
|
||||||
|
|||||||
457
components/journeys/SankeyDiagram.tsx
Normal file
457
components/journeys/SankeyDiagram.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
components/journeys/TopPathsTable.tsx
Normal file
87
components/journeys/TopPathsTable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -166,74 +166,31 @@ export function DashboardSkeleton() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Realtime page skeleton ──────────────────────────────────
|
// ─── Journeys page skeleton ─────────────────────────────────
|
||||||
|
|
||||||
export function RealtimeSkeleton() {
|
export function JourneysSkeleton() {
|
||||||
return (
|
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="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
|
||||||
<div className="mb-6">
|
{/* Header */}
|
||||||
<SkeletonLine className="h-4 w-32 mb-2" />
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<SkeletonLine className="h-8 w-64" />
|
<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>
|
||||||
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
|
{/* Controls */}
|
||||||
{/* Visitors list */}
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<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">
|
<SkeletonLine className="h-9 w-48 rounded-lg" />
|
||||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
|
<SkeletonLine className="h-9 w-48 rounded-lg" />
|
||||||
<SkeletonLine className="h-6 w-32" />
|
</div>
|
||||||
</div>
|
{/* Sankey area */}
|
||||||
<div className="p-2 space-y-1">
|
<SkeletonCard className="h-[400px] mb-6" />
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{/* Top paths table */}
|
||||||
<div key={i} className="p-4 space-y-2">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
|
||||||
<div className="flex justify-between">
|
<SkeletonLine className="h-6 w-24 mb-4" />
|
||||||
<SkeletonLine className="h-4 w-32" />
|
<TableSkeleton rows={5} cols={4} />
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
93
lib/api/journeys.ts
Normal file
93
lib/api/journeys.ts
Normal 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 ?? [])
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
80
lib/api/report-schedules.ts
Normal file
80
lib/api/report-schedules.ts
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -103,6 +103,34 @@ export interface AuthParams {
|
|||||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
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 ────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function appendAuthParams(params: URLSearchParams, auth?: AuthParams) {
|
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 })}`)
|
return apiRequest<{ values: EventPropertyValue[] }>(`/sites/${siteId}/goals/${encodeURIComponent(eventName)}/properties/${encodeURIComponent(propName)}${buildQuery({ startDate, endDate, limit })}`)
|
||||||
.then(r => r?.values || [])
|
.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 ?? [])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,16 @@ import {
|
|||||||
getRealtime,
|
getRealtime,
|
||||||
getStats,
|
getStats,
|
||||||
getDailyStats,
|
getDailyStats,
|
||||||
|
getBehavior,
|
||||||
} from '@/lib/api/stats'
|
} 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 { listAnnotations } from '@/lib/api/annotations'
|
||||||
import type { Annotation } from '@/lib/api/annotations'
|
import type { Annotation } from '@/lib/api/annotations'
|
||||||
import { getSite } from '@/lib/api/sites'
|
import { getSite } from '@/lib/api/sites'
|
||||||
@@ -32,6 +41,7 @@ import type {
|
|||||||
DashboardReferrersData,
|
DashboardReferrersData,
|
||||||
DashboardPerformanceData,
|
DashboardPerformanceData,
|
||||||
DashboardGoalsData,
|
DashboardGoalsData,
|
||||||
|
BehaviorData,
|
||||||
} from '@/lib/api/stats'
|
} from '@/lib/api/stats'
|
||||||
|
|
||||||
// * SWR fetcher functions
|
// * SWR fetcher functions
|
||||||
@@ -52,6 +62,13 @@ const fetchers = {
|
|||||||
campaigns: (siteId: string, start: string, end: string, limit: number) =>
|
campaigns: (siteId: string, start: string, end: string, limit: number) =>
|
||||||
getCampaigns(siteId, start, end, limit),
|
getCampaigns(siteId, start, end, limit),
|
||||||
annotations: (siteId: string, start: string, end: string) => listAnnotations(siteId, start, end),
|
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
|
// * 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
|
// * Re-export for convenience
|
||||||
export { fetchers }
|
export { fetchers }
|
||||||
|
|||||||
123
package-lock.json
generated
123
package-lock.json
generated
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-frontend",
|
"name": "pulse-frontend",
|
||||||
"version": "0.13.0-alpha",
|
"version": "0.14.0-alpha",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-frontend",
|
"name": "pulse-frontend",
|
||||||
"version": "0.13.0-alpha",
|
"version": "0.14.0-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.2.4",
|
"@ciphera-net/ui": "^0.2.5",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cobe": "^0.6.5",
|
"cobe": "^0.6.5",
|
||||||
"country-flag-icons": "^1.6.4",
|
"country-flag-icons": "^1.6.4",
|
||||||
|
"d3-sankey": "^0.12.3",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/d3-sankey": "^0.12.5",
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/node": "^20.14.12",
|
"@types/node": "^20.14.12",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
@@ -1667,9 +1669,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ciphera-net/ui": {
|
"node_modules/@ciphera-net/ui": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.4/e2e049be27e465b91cb347295c3854c1cd9927d7",
|
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.5/01371025a6706621b7a2c353cb1b07a239961fc3",
|
||||||
"integrity": "sha512-qqVYM4umgEQf/rQ/F2SdEpEi8D99eyMgO54As8l4O8B0z5kL+9CRtB5MT2ZcrhjsvGLj5tzL18gEzbaqrlXMmA==",
|
"integrity": "sha512-Ybd3zZLqpdv/dktNylT/jOm9OTMVST35+19QY+DTvDeluF3B4bN2YA7S85V7PpXGmZBmnPQX3U8qP4t2HwyyMw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -5600,9 +5602,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-color": {
|
"node_modules/@types/d3-color": {
|
||||||
"version": "2.0.6",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
"integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==",
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-ease": {
|
"node_modules/@types/d3-ease": {
|
||||||
@@ -5611,12 +5613,48 @@
|
|||||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/d3-path": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/d3-scale": {
|
||||||
"version": "4.0.9",
|
"version": "4.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
@@ -7879,6 +7917,46 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-scale": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
@@ -7931,6 +8009,15 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -14804,15 +14891,6 @@
|
|||||||
"d3-timer": "^3.0.1"
|
"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": {
|
"node_modules/victory-vendor/node_modules/d3-ease": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
@@ -14822,15 +14900,6 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-frontend",
|
"name": "pulse-frontend",
|
||||||
"version": "0.14.0-alpha",
|
"version": "0.15.0-alpha",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.2.4",
|
"@ciphera-net/ui": "^0.2.5",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cobe": "^0.6.5",
|
"cobe": "^0.6.5",
|
||||||
"country-flag-icons": "^1.6.4",
|
"country-flag-icons": "^1.6.4",
|
||||||
|
"d3-sankey": "^0.12.3",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/d3-sankey": "^0.12.5",
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/node": "^20.14.12",
|
"@types/node": "^20.14.12",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
245
public/script.js
245
public/script.js
@@ -424,6 +424,251 @@
|
|||||||
}, { passive: true });
|
}, { 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)
|
// * 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
|
// * Opt-out: add data-no-outbound or data-no-downloads to the script tag
|
||||||
var trackOutbound = !script.hasAttribute('data-no-outbound');
|
var trackOutbound = !script.hasAttribute('data-no-outbound');
|
||||||
|
|||||||
Reference in New Issue
Block a user