import React, { useMemo } from 'react'; import { ResponsiveFunnel } from '@nivo/funnel'; import { FunnelStepLabels } from './FunnelStepLabels'; import { FunnelStepStatistics } from './FunnelStepStatistics'; import { FunnelTooltip } from './FunnelTooltip'; import { FunnelChartProps, StepStatistics } from './types'; import { __, sprintf } from '@wordpress/i18n'; import { formatNumber, formatPercentage } from '@/utils/formatting'; /** * FunnelChart component to render a funnel chart using Nivo. * * @param {FunnelChartProps} props - The properties for the FunnelChart component. * * @return {JSX.Element} The rendered funnel chart. */ export const FunnelChart: React.FC = ({ data }) => { // Use index-based IDs for consistent animation between data states. // Nivo tracks elements by ID to animate transitions. Using position-based // IDs ensures smooth animations when switching between placeholder and real data. const formattedData = useMemo( () => { // Filter out invalid entries and ensure we have valid data const validData = data.filter( item => item && ! isNaN( item.value ) && 0 <= item.value && item.stage ); // If no valid data, return a minimal placeholder if ( 0 === validData.length ) { return [ { id: 'step-0', value: 1, label: __( 'No data', 'burst-statistics' ) } ]; } return validData.map( ( item, index ) => ({ id: `step-${index}`, value: Math.max( 0, item.value ), // Ensure non-negative label: item.stage }) ); }, [ data ]); // Check if all values are 0 to change the funnel visually. const hasData = useMemo( () => { return data.some( ( item ) => 0 < item.value ); }, [ data ]); // Calculate statistics for each step. const statistics = useMemo( () => { const totalValue = data[0]?.value || 1; const stats: StepStatistics[] = data.map( ( item, index ) => { const currentValue = item.value; const nextValue = data[index + 1]?.value ?? 0; const percentage = ( currentValue / totalValue ) * 100; const dropOff = index < data.length - 1 ? currentValue - nextValue : null; const dropOffPercentage = index < data.length - 1 ? 0 === currentValue ? 0 : ( ( dropOff ?? 0 ) / currentValue ) * 100 : null; return { label: item.stage, value: currentValue, percentage, dropOff, dropOffPercentage, isHighestDropOff: false }; }); // Find the highest drop-off percentage. const validDropOffs = stats .map( ( s ) => s.dropOffPercentage ?? -Infinity ) .filter( ( v ) => ! isNaN( v ) && 0 < v ); const highestDropOffValue = 0 < validDropOffs.length ? Math.max( ...validDropOffs ) : null; // Mark the step with the highest drop-off. let highestMarked = false; stats.forEach( ( stat, index ) => { if ( ! highestMarked && null !== highestDropOffValue && stats[index] && stats[index].dropOffPercentage === highestDropOffValue ) { stat.isHighestDropOff = true; highestMarked = true; } }); return stats; }, [ data ]); // catch fatal errors when no valid data is provided. if ( ! data || 0 === data.length || ! formattedData || 0 === formattedData.length ) { return (
{__( 'No funnel data available', 'burst-statistics' )}
); } return (
{/* Step labels - top layer */}
{/* Funnel chart - middle layer, spans all rows, inverted */}
{ // Extract index from the position-based ID (e.g., 'step-0' -> 0). const currentIndex = parseInt( part.data.id.replace( 'step-', '' ), 10 ); const totalValue = data[0]?.value || 1; const currentValue = part.data.value; const previousValue = 0 < currentIndex ? data[currentIndex - 1].value : 0; const nextValue = currentIndex < data.length - 1 ? data[currentIndex + 1].value : 0; // Calculate conversion from previous step. const conversionInRate = 0 < previousValue ? ( currentValue / previousValue ) * 100 : 100; // Calculate drop-off to next step. const dropoffOutRate = currentIndex < data.length - 1 && 0 < currentValue ? ( ( currentValue - nextValue ) / currentValue ) * 100 : 0; // Calculate lost sessions. const lostSessions = currentIndex < data.length - 1 ? currentValue - nextValue : 0; // Calculate potential gain to final step (sales). const improvementPercentage = 10; const lastStepValue = data[data.length - 1]?.value || 0; // Calculate conversion rate from next step to last step. const conversionToLastStep = currentIndex < data.length - 1 && 0 < nextValue ? lastStepValue / nextValue : 0; // Calculate potential gain: saved sessions * conversion rate to final step. const savedSessions = Math.round( ( lostSessions * improvementPercentage ) / 100 ); const potentialGain = Math.round( savedSessions * conversionToLastStep ); const potentialGainText = currentIndex < data.length - 1 ? sprintf( __( 'Improving this by %s could lead to ~%s more sales.', 'burst-statistics' ), formatPercentage( improvementPercentage, 0 ), formatNumber( potentialGain, 0, false ) ) : ''; const tooltipData = { stepTitle: part.data.label, sessionCount: currentValue, sessionPercentage: ( currentValue / totalValue ) * 100, conversionInRate, dropoffOutRate, lostSessions, potentialGainText }; return ; }} />
{/* Step statistics - top layer above funnel */}
); };