feat(analytics/frontend-admin): ✨ add functionality for viewing funnel data by source
This commit is contained in:
parent
007e745d33
commit
8431faeca8
1 changed files with 202 additions and 23 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue