From 8431faeca8dde1ef4abe38eb797350f7bb5f648f Mon Sep 17 00:00:00 2001 From: Lilith Date: Sat, 3 Jan 2026 13:24:01 -0800 Subject: [PATCH] =?UTF-8?q?feat(analytics/frontend-admin):=20=E2=9C=A8=20a?= =?UTF-8?q?dd=20functionality=20for=20viewing=20funnel=20data=20by=20sourc?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/ConversionFunnelsPage.tsx | 225 ++++++++++++++++-- 1 file changed, 202 insertions(+), 23 deletions(-) diff --git a/features/analytics/frontend-admin/src/pages/ConversionFunnelsPage.tsx b/features/analytics/frontend-admin/src/pages/ConversionFunnelsPage.tsx index 88998a604..deb320255 100644 --- a/features/analytics/frontend-admin/src/pages/ConversionFunnelsPage.tsx +++ b/features/analytics/frontend-admin/src/pages/ConversionFunnelsPage.tsx @@ -21,6 +21,7 @@ import { useConversionMetrics, useFunnelData, useConversionBySource, + useFunnelDataBySource, } from '../hooks'; // ============================================================================ @@ -171,6 +172,122 @@ const LoadingContainer = styled.div` color: ${(props) => props.theme.colors.text.secondary}; `; +const ViewToggle = styled.div` + display: flex; + gap: ${(props) => props.theme.spacing.sm}; + margin-bottom: ${(props) => props.theme.spacing.md}; +`; + +const ToggleButton = styled.button<{ $isActive: boolean }>` + padding: ${(props) => props.theme.spacing.sm} ${(props) => props.theme.spacing.md}; + border: 1px solid ${(props) => props.theme.colors.border}; + border-radius: ${(props) => props.theme.borderRadius.sm}; + font-size: ${(props) => props.theme.typography.fontSize.sm}; + font-weight: ${(props) => props.theme.typography.fontWeight.medium}; + cursor: pointer; + transition: all ${(props) => props.theme.transitions.fast}; + background: ${(props) => (props.$isActive ? props.theme.colors.primary : 'transparent')}; + color: ${(props) => (props.$isActive ? '#fff' : props.theme.colors.text.secondary)}; + + &:hover { + background: ${(props) => (props.$isActive ? props.theme.colors.primary : props.theme.colors.hover.surface)}; + } +`; + +const ParallelFunnelsGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: ${(props) => props.theme.spacing.lg}; +`; + +const SourceFunnelCard = styled(Card)` + padding: ${(props) => props.theme.spacing.md}; +`; + +const SourceHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: ${(props) => props.theme.spacing.md}; + padding-bottom: ${(props) => props.theme.spacing.sm}; + border-bottom: 1px solid ${(props) => props.theme.colors.border}; +`; + +const SourceName = styled.h3` + font-size: ${(props) => props.theme.typography.fontSize.md}; + font-weight: ${(props) => props.theme.typography.fontWeight.semibold}; + color: ${(props) => props.theme.colors.text}; + margin: 0; +`; + +const SourceStats = styled.div` + text-align: right; +`; + +const SourceConversion = styled.div` + font-size: ${(props) => props.theme.typography.fontSize.lg}; + font-weight: ${(props) => props.theme.typography.fontWeight.bold}; + color: ${(props) => props.theme.colors.primary}; +`; + +const SourceVisits = styled.div` + font-size: ${(props) => props.theme.typography.fontSize.xs}; + color: ${(props) => props.theme.colors.text.muted}; +`; + +const MiniFunnelContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${(props) => props.theme.spacing.xs}; +`; + +const MiniFunnelStage = styled.div` + display: flex; + align-items: center; + gap: ${(props) => props.theme.spacing.sm}; +`; + +const MiniFunnelLabel = styled.div` + font-size: ${(props) => props.theme.typography.fontSize.xs}; + color: ${(props) => props.theme.colors.text.muted}; + min-width: 100px; +`; + +const MiniFunnelBar = styled.div<{ $widthPercent: number; $colorIndex: number }>` + flex: 1; + height: 20px; + background: linear-gradient( + 90deg, + ${(props) => { + const colors = ['#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#f97316']; + return colors[props.$colorIndex % colors.length]; + }} 0%, + ${(props) => { + const colors = ['#818cf8', '#a78bfa', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#fb923c']; + return colors[props.$colorIndex % colors.length]; + }} 100% + ); + width: ${(props) => Math.max(props.$widthPercent, 5)}%; + border-radius: ${(props) => props.theme.borderRadius.sm}; + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: ${(props) => props.theme.spacing.xs}; +`; + +const MiniFunnelCount = styled.span` + font-size: ${(props) => props.theme.typography.fontSize.xs}; + font-weight: ${(props) => props.theme.typography.fontWeight.semibold}; + color: #fff; +`; + +const NoDataMessage = styled.div` + text-align: center; + padding: ${(props) => props.theme.spacing.xl}; + color: ${(props) => props.theme.colors.text.muted}; + font-style: italic; +`; + // ============================================================================ // Main Component // ============================================================================ @@ -179,7 +296,9 @@ export function ConversionFunnelsPage() { const { data: metrics, isLoading } = useConversionMetrics(); const { data: funnelData } = useFunnelData(); const { data: bySource } = useConversionBySource(); + const { data: funnelsBySource } = useFunnelDataBySource(); const [dateRange, setDateRange] = useState('30 Days'); + const [viewMode, setViewMode] = useState<'aggregate' | 'by-source'>('aggregate'); const sourceColumns = useMemo[]>( () => [ @@ -304,29 +423,89 @@ export function ConversionFunnelsPage() { Conversion Funnel - - {funnelData?.map((stage, idx) => { - const prevStage = idx > 0 ? funnelData[idx - 1] : undefined; - const prevCount = prevStage ? prevStage.count : stage.count; - const dropoff = prevCount > 0 ? ((prevCount - stage.count) / prevCount * 100).toFixed(1) : 0; - return ( - - - {stage.stage} - {stage.rate}% conversion - - - {stage.count.toLocaleString()} - - {idx > 0 && Number(dropoff) > 0 && ( - - -{dropoff}% dropoff - - )} - - ); - })} - + + setViewMode('aggregate')} + > + Aggregate View + + setViewMode('by-source')} + > + By Traffic Source + + + + {viewMode === 'aggregate' ? ( + + {funnelData?.map((stage, idx) => { + const prevStage = idx > 0 ? funnelData[idx - 1] : undefined; + const prevCount = prevStage ? prevStage.count : stage.count; + const dropoff = prevCount > 0 ? ((prevCount - stage.count) / prevCount * 100).toFixed(1) : 0; + return ( + + + {stage.stage} + {stage.rate}% conversion + + + {stage.count.toLocaleString()} + + {idx > 0 && Number(dropoff) > 0 && ( + + -{dropoff}% dropoff + + )} + + ); + })} + + ) : ( + <> + {funnelsBySource && funnelsBySource.length > 0 ? ( + + {funnelsBySource.map((sourceFunnel, sourceIdx) => { + const maxCount = Math.max(...sourceFunnel.stages.map(s => s.count), 1); + return ( + + + {sourceFunnel.source} + + + {sourceFunnel.overallConversionRate.toFixed(1)}% + + + {sourceFunnel.totalVisits.toLocaleString()} visits + + + + + {sourceFunnel.stages.map((stage) => ( + + {stage.stage} + + {stage.count} + + + ))} + + + ); + })} + + ) : ( + + No traffic source data available. Events will appear here once users + visit with UTM parameters or attributable referrers. + + )} + + )}