feat(analytics/frontend-admin): add functionality for viewing funnel data by source

This commit is contained in:
Lilith 2026-01-03 13:24:01 -08:00
parent 007e745d33
commit 8431faeca8

View file

@ -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<Column<ConversionBySourceItem>[]>(
() => [
@ -304,29 +423,89 @@ export function ConversionFunnelsPage() {
<DashboardWidget>
<SectionCard>
<SectionTitle>Conversion Funnel</SectionTitle>
<FunnelContainer>
{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 (
<FunnelStage key={idx} $widthPercent={(stage.count / maxFunnelCount) * 100}>
<FunnelLabel>
<FunnelStageName>{stage.stage}</FunnelStageName>
<FunnelRate>{stage.rate}% conversion</FunnelRate>
</FunnelLabel>
<FunnelBar $widthPercent={(stage.count / maxFunnelCount) * 100}>
<FunnelCount>{stage.count.toLocaleString()}</FunnelCount>
</FunnelBar>
{idx > 0 && Number(dropoff) > 0 && (
<FunnelDropoff>
-{dropoff}% dropoff
</FunnelDropoff>
)}
</FunnelStage>
);
})}
</FunnelContainer>
<ViewToggle>
<ToggleButton
$isActive={viewMode === 'aggregate'}
onClick={() => setViewMode('aggregate')}
>
Aggregate View
</ToggleButton>
<ToggleButton
$isActive={viewMode === 'by-source'}
onClick={() => setViewMode('by-source')}
>
By Traffic Source
</ToggleButton>
</ViewToggle>
{viewMode === 'aggregate' ? (
<FunnelContainer>
{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 (
<FunnelStage key={idx} $widthPercent={(stage.count / maxFunnelCount) * 100}>
<FunnelLabel>
<FunnelStageName>{stage.stage}</FunnelStageName>
<FunnelRate>{stage.rate}% conversion</FunnelRate>
</FunnelLabel>
<FunnelBar $widthPercent={(stage.count / maxFunnelCount) * 100}>
<FunnelCount>{stage.count.toLocaleString()}</FunnelCount>
</FunnelBar>
{idx > 0 && Number(dropoff) > 0 && (
<FunnelDropoff>
-{dropoff}% dropoff
</FunnelDropoff>
)}
</FunnelStage>
);
})}
</FunnelContainer>
) : (
<>
{funnelsBySource && funnelsBySource.length > 0 ? (
<ParallelFunnelsGrid>
{funnelsBySource.map((sourceFunnel, sourceIdx) => {
const maxCount = Math.max(...sourceFunnel.stages.map(s => s.count), 1);
return (
<SourceFunnelCard key={sourceFunnel.source}>
<SourceHeader>
<SourceName>{sourceFunnel.source}</SourceName>
<SourceStats>
<SourceConversion>
{sourceFunnel.overallConversionRate.toFixed(1)}%
</SourceConversion>
<SourceVisits>
{sourceFunnel.totalVisits.toLocaleString()} visits
</SourceVisits>
</SourceStats>
</SourceHeader>
<MiniFunnelContainer>
{sourceFunnel.stages.map((stage) => (
<MiniFunnelStage key={stage.stage}>
<MiniFunnelLabel>{stage.stage}</MiniFunnelLabel>
<MiniFunnelBar
$widthPercent={(stage.count / maxCount) * 100}
$colorIndex={sourceIdx}
>
<MiniFunnelCount>{stage.count}</MiniFunnelCount>
</MiniFunnelBar>
</MiniFunnelStage>
))}
</MiniFunnelContainer>
</SourceFunnelCard>
);
})}
</ParallelFunnelsGrid>
) : (
<NoDataMessage>
No traffic source data available. Events will appear here once users
visit with UTM parameters or attributable referrers.
</NoDataMessage>
)}
</>
)}
</SectionCard>
</DashboardWidget>
</DashboardLayout>