From 2f01be1c67b657941e21671b3c7402d637b1f377 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Mar 2026 18:03:22 +0100 Subject: [PATCH] feat: polish behavior page UI with 8 improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add column headers to rage/dead click tables - Rich empty states with icons matching dashboard pattern - Add frustration trend comparison chart (current vs previous period) - Show "New" badge instead of misleading "+100%" when previous period is 0 - Click-to-copy on CSS selectors with toast feedback - Normalize min-height to 270px for consistent card sizing - Fix page title to include site domain (Behavior · domain | Pulse) - Add "last seen" column with relative timestamps --- app/sites/[id]/behavior/page.tsx | 9 +- .../behavior/FrustrationByPageTable.tsx | 13 +- .../behavior/FrustrationSummaryCards.tsx | 21 ++- components/behavior/FrustrationTable.tsx | 84 ++++++++-- components/behavior/FrustrationTrend.tsx | 158 ++++++++++++++++++ 5 files changed, 256 insertions(+), 29 deletions(-) create mode 100644 components/behavior/FrustrationTrend.tsx diff --git a/app/sites/[id]/behavior/page.tsx b/app/sites/[id]/behavior/page.tsx index f96b3cd..6de0d44 100644 --- a/app/sites/[id]/behavior/page.tsx +++ b/app/sites/[id]/behavior/page.tsx @@ -18,6 +18,7 @@ import { 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 } from '@/lib/swr/dashboard' const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth')) @@ -91,8 +92,9 @@ export default function BehaviorPage() { }, [fetchData]) useEffect(() => { - document.title = 'Behavior | Pulse' - }, []) + const domain = dashboard?.site?.domain + document.title = domain ? `Behavior · ${domain} | Pulse` : 'Behavior | Pulse' + }, [dashboard?.site?.domain]) const fetchAllRage = useCallback( () => getRageClicks(siteId, dateRange.start, dateRange.end, 100), @@ -181,12 +183,13 @@ export default function BehaviorPage() { {/* By page breakdown */} - {/* Scroll depth */} + {/* Scroll depth + Frustration trend */}
+
) : ( -
-

- No frustration signals detected in this period +

+
+ +
+

+ No frustration signals detected +

+

+ Page-level frustration data will appear here once rage clicks or dead clicks are detected on your site.

)} diff --git a/components/behavior/FrustrationSummaryCards.tsx b/components/behavior/FrustrationSummaryCards.tsx index a271326..304c974 100644 --- a/components/behavior/FrustrationSummaryCards.tsx +++ b/components/behavior/FrustrationSummaryCards.tsx @@ -7,16 +7,23 @@ interface FrustrationSummaryCardsProps { loading: boolean } -function pctChange(current: number, previous: number): number | null { +function pctChange(current: number, previous: number): { type: 'pct'; value: number } | { type: 'new' } | null { if (previous === 0 && current === 0) return null - if (previous === 0) return 100 - return Math.round(((current - previous) / previous) * 100) + if (previous === 0) return { type: 'new' } + return { type: 'pct', value: Math.round(((current - previous) / previous) * 100) } } -function ChangeIndicator({ change }: { change: number | null }) { +function ChangeIndicator({ change }: { change: ReturnType }) { if (change === null) return null - const isUp = change > 0 - const isDown = change < 0 + if (change.type === 'new') { + return ( + + New + + ) + } + const isUp = change.value > 0 + const isDown = change.value < 0 return ( - {isUp ? '+' : ''}{change}% + {isUp ? '+' : ''}{change.value}% ) } diff --git a/components/behavior/FrustrationTable.tsx b/components/behavior/FrustrationTable.tsx index 13e1276..cf1041c 100644 --- a/components/behavior/FrustrationTable.tsx +++ b/components/behavior/FrustrationTable.tsx @@ -2,8 +2,10 @@ import { useState, useEffect } from 'react' import { formatNumber, Modal } from '@ciphera-net/ui' -import { FrameCornersIcon } from '@phosphor-icons/react' +import { FrameCornersIcon, Copy, Check, CursorClick } from '@phosphor-icons/react' +import { toast } from '@ciphera-net/ui' import type { FrustrationElement } from '@/lib/api/stats' +import { formatRelativeTime } from '@/lib/utils/formatDate' import { ListSkeleton } from '@/components/skeletons' interface FrustrationTableProps { @@ -32,6 +34,37 @@ function SkeletonRows() { ) } +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 ( + + ) +} + function Row({ item, showAvgClicks, @@ -42,14 +75,9 @@ function Row({ return (
+ - {item.selector} - - {item.page_path} @@ -64,6 +92,9 @@ function Row({ {item.sessions} {item.sessions === 1 ? 'session' : 'sessions'} + + {formatRelativeTime(item.last_seen)} + {formatNumber(item.count)} @@ -129,19 +160,40 @@ export default function FrustrationTable({ {description}

-
+
{loading ? ( ) : hasData ? ( -
- {items.map((item, i) => ( - - ))} +
+ {/* Column headers */} +
+
+ Selector + Page +
+
+ {showAvgClicks && Avg} + Sessions + Last Seen + Count +
+
+
+ {items.map((item, i) => ( + + ))} +
) : ( -
-

- No {title.toLowerCase()} detected in this period +

+
+ +
+

+ No {title.toLowerCase()} detected +

+

+ {description}. Data will appear here once frustration signals are detected on your site.

)} diff --git a/components/behavior/FrustrationTrend.tsx b/components/behavior/FrustrationTrend.tsx new file mode 100644 index 0000000..330b0a6 --- /dev/null +++ b/components/behavior/FrustrationTrend.tsx @@ -0,0 +1,158 @@ +'use client' + +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts' +import { TrendUp } from '@phosphor-icons/react' +import type { FrustrationSummary } from '@/lib/api/stats' + +interface FrustrationTrendProps { + summary: FrustrationSummary | null + loading: boolean +} + +function SkeletonCard() { + return ( +
+
+
+
+
+
+ {[120, 80, 140, 100].map((h, i) => ( +
+ ))} +
+
+ ) +} + +export default function FrustrationTrend({ summary, loading }: FrustrationTrendProps) { + if (loading || !summary) return + + const hasData = summary.rage_clicks > 0 || summary.dead_clicks > 0 || + summary.prev_rage_clicks > 0 || summary.prev_dead_clicks > 0 + + const chartData = [ + { + label: 'Rage', + current: summary.rage_clicks, + previous: summary.prev_rage_clicks, + }, + { + label: 'Dead', + current: summary.dead_clicks, + previous: summary.prev_dead_clicks, + }, + ] + + 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 + + return ( +
+
+

+ Frustration Trend +

+
+

+ Current vs. previous period comparison +

+ + {hasData ? ( +
+ {/* Summary line */} +
+ + {totalCurrent.toLocaleString()} + + + total signals + + {totalChange !== null && ( + 0 + ? 'text-red-600 dark:text-red-400' + : totalChange < 0 + ? 'text-green-600 dark:text-green-400' + : 'text-neutral-500 dark:text-neutral-400' + }`}> + {totalChange > 0 ? '+' : ''}{totalChange}% + + )} + {totalChange === null && totalCurrent > 0 && ( + + New + + )} +
+ + {/* Chart */} +
+ + + + + [ + value.toLocaleString(), + name === 'current' ? 'Current' : 'Previous', + ]} + /> + + {chartData.map((_, i) => ( + + ))} + + + {chartData.map((_, i) => ( + + ))} + + + +
+ + {/* Legend */} +
+
+
+ Current period +
+
+
+ Previous period +
+
+
+ ) : ( +
+
+ +
+

+ No trend data yet +

+

+ Frustration trend data will appear here once rage clicks or dead clicks are detected across periods. +

+
+ )} +
+ ) +}