From 4c7ed858f71e17936c41449b7f4169d7e13780be Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 14:34:07 +0100 Subject: [PATCH] feat(funnels): add step-level breakdown drawer with dimension tabs --- app/sites/[id]/funnels/[funnelId]/page.tsx | 17 +++- components/funnels/BreakdownDrawer.tsx | 111 +++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 components/funnels/BreakdownDrawer.tsx diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 7050a1d..9fd7a5c 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -13,6 +13,7 @@ import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/comp import Link from 'next/link' import { FunnelChart } from '@/components/ui/funnel-chart' import { getDateRange } from '@ciphera-net/ui' +import BreakdownDrawer from '@/components/funnels/BreakdownDrawer' import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts' export default function FunnelReportPage() { @@ -32,6 +33,7 @@ export default function FunnelReportPage() { const [expandedExitStep, setExpandedExitStep] = useState(null) const [trends, setTrends] = useState(null) const [visibleSteps, setVisibleSteps] = useState>(new Set()) + const [breakdownStep, setBreakdownStep] = useState(null) const loadData = useCallback(async () => { setLoadError(null) @@ -326,7 +328,7 @@ export default function FunnelReportPage() { {stats.steps.map((step, i) => ( - + setBreakdownStep(i)}>
@@ -398,6 +400,19 @@ export default function FunnelReportPage() {
+ {breakdownStep !== null && stats && ( + setBreakdownStep(null)} + /> + )} + setIsDatePickerOpen(false)} diff --git a/components/funnels/BreakdownDrawer.tsx b/components/funnels/BreakdownDrawer.tsx new file mode 100644 index 0000000..fc605ab --- /dev/null +++ b/components/funnels/BreakdownDrawer.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { getFunnelBreakdown, type FunnelBreakdown } from '@/lib/api/funnels' +import { DIMENSION_LABELS } from '@/lib/filters' + +const BREAKDOWN_DIMENSIONS = [ + 'device', 'country', 'browser', 'os', + 'utm_source', 'utm_medium', 'utm_campaign' +] + +interface BreakdownDrawerProps { + siteId: string + funnelId: string + stepIndex: number + stepName: string + startDate: string + endDate: string + filters?: string + onClose: () => void +} + +export default function BreakdownDrawer({ siteId, funnelId, stepIndex, stepName, startDate, endDate, filters, onClose }: BreakdownDrawerProps) { + const [activeDimension, setActiveDimension] = useState('device') + const [breakdown, setBreakdown] = useState(null) + const [loading, setLoading] = useState(true) + + const loadBreakdown = useCallback(async () => { + setLoading(true) + try { + const data = await getFunnelBreakdown(siteId, funnelId, stepIndex, activeDimension, startDate, endDate, filters) + setBreakdown(data) + } catch { + setBreakdown(null) + } finally { + setLoading(false) + } + }, [siteId, funnelId, stepIndex, activeDimension, startDate, endDate, filters]) + + useEffect(() => { + loadBreakdown() + }, [loadBreakdown]) + + return ( + <> + {/* Backdrop */} +
+ +
+ {/* Header */} +
+
+

Step Breakdown

+

{stepName}

+
+ +
+ + {/* Dimension tabs */} +
+ {BREAKDOWN_DIMENSIONS.map(dim => ( + + ))} +
+ + {/* Content */} +
+ {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : !breakdown || breakdown.entries.length === 0 ? ( +

No data for this dimension

+ ) : ( +
+ {breakdown.entries.map(entry => ( +
+ + {entry.value || '(unknown)'} + +
+ {entry.visitors} + + {Math.round(entry.conversion)}% + +
+
+ ))} +
+ )} +
+
+ + ) +}