501 lines
16 KiB
TypeScript
501 lines
16 KiB
TypeScript
import { ColorSquare } from '@/components/color-square';
|
|
import { Button } from '@/components/ui/button';
|
|
import { pushModal } from '@/modals';
|
|
import type { RouterOutputs } from '@/trpc/client';
|
|
import { cn } from '@/utils/cn';
|
|
import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
|
|
|
|
import { alphabetIds } from '@openpanel/constants';
|
|
|
|
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
|
import { BarShapeBlue, BarShapeProps } from '@/components/charts/common-bar';
|
|
import { Tooltiper } from '@/components/ui/tooltip';
|
|
import { WidgetTable } from '@/components/widget-table';
|
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
|
import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
|
|
import { getPreviousMetric } from '@openpanel/common';
|
|
import {
|
|
Bar,
|
|
BarChart,
|
|
CartesianGrid,
|
|
Cell,
|
|
ResponsiveContainer,
|
|
XAxis,
|
|
YAxis,
|
|
} from 'recharts';
|
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
|
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
|
import { useReportChartContext } from '../context';
|
|
|
|
type Props = {
|
|
data: {
|
|
current: RouterOutputs['chart']['funnel']['current'][number];
|
|
previous: RouterOutputs['chart']['funnel']['current'][number] | null;
|
|
};
|
|
};
|
|
|
|
export const Metric = ({
|
|
label,
|
|
value,
|
|
enhancer,
|
|
className,
|
|
}: {
|
|
label: string;
|
|
value: React.ReactNode;
|
|
enhancer?: React.ReactNode;
|
|
className?: string;
|
|
}) => (
|
|
<div className={cn('gap-1 justify-between flex-1 col', className)}>
|
|
<div className="text-sm text-muted-foreground">{label}</div>
|
|
<div className="row items-center gap-2 justify-between">
|
|
<div className="font-mono font-semibold">{value}</div>
|
|
{enhancer && <div>{enhancer}</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
export function Summary({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
|
const number = useNumber();
|
|
const highestConversion = data.current
|
|
.slice(0)
|
|
.sort((a, b) => b.lastStep.percent - a.lastStep.percent)[0];
|
|
const highestCount = data.current
|
|
.slice(0)
|
|
.sort((a, b) => b.lastStep.count - a.lastStep.count)[0];
|
|
return (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{highestConversion && (
|
|
<div className="card row items-center p-4 py-3">
|
|
<Metric
|
|
label="Highest conversion rate"
|
|
value={
|
|
<ChartName breakdowns={highestConversion.breakdowns ?? []} />
|
|
}
|
|
/>
|
|
<span className="text-xl font-semibold font-mono">
|
|
{number.formatWithUnit(
|
|
highestConversion.lastStep.percent / 100,
|
|
'%',
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{highestCount && (
|
|
<div className="card row items-center p-4 py-3">
|
|
<Metric
|
|
label="Most conversions"
|
|
value={<ChartName breakdowns={highestCount.breakdowns ?? []} />}
|
|
/>
|
|
<span className="text-xl font-semibold font-mono">
|
|
{number.format(highestCount.lastStep.count)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChartName({
|
|
breakdowns,
|
|
className,
|
|
}: { breakdowns: string[]; className?: string }) {
|
|
return (
|
|
<div className={cn('flex items-center gap-2 font-medium', className)}>
|
|
{breakdowns.map((name, index) => {
|
|
return (
|
|
<>
|
|
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
|
<span key={name}>{name}</span>
|
|
</>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Tables({
|
|
data: {
|
|
current: { steps, mostDropoffsStep, lastStep, breakdowns },
|
|
previous: previousData,
|
|
},
|
|
}: Props) {
|
|
const number = useNumber();
|
|
const hasHeader = breakdowns.length > 0;
|
|
const {
|
|
report: {
|
|
projectId,
|
|
startDate,
|
|
endDate,
|
|
range,
|
|
interval,
|
|
series: reportSeries,
|
|
breakdowns: reportBreakdowns,
|
|
previous,
|
|
options,
|
|
},
|
|
} = useReportChartContext();
|
|
|
|
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
|
const funnelWindow = funnelOptions?.funnelWindow;
|
|
const funnelGroup = funnelOptions?.funnelGroup;
|
|
|
|
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
|
if (!projectId || !step.event.id) return;
|
|
|
|
// For funnels, we need to pass the step index so the modal can query
|
|
// users who completed at least that step in the funnel sequence
|
|
pushModal('ViewChartUsers', {
|
|
type: 'funnel',
|
|
report: {
|
|
projectId,
|
|
series: reportSeries,
|
|
breakdowns: reportBreakdowns || [],
|
|
interval: interval || 'day',
|
|
startDate,
|
|
endDate,
|
|
range,
|
|
previous,
|
|
chartType: 'funnel',
|
|
metric: 'sum',
|
|
options: funnelOptions,
|
|
},
|
|
stepIndex, // Pass the step index for funnel queries
|
|
});
|
|
};
|
|
return (
|
|
<div className={cn('col @container divide-y divide-border card')}>
|
|
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
|
|
<div className={cn('bg-def-100', !hasHeader && 'rounded-t-md')}>
|
|
<div className="col max-md:divide-y md:row md:items-center md:divide-x divide-border">
|
|
<Metric
|
|
className="p-4 py-3"
|
|
label="Conversion"
|
|
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
|
|
enhancer={
|
|
previousData && (
|
|
<PreviousDiffIndicatorPure
|
|
{...getPreviousMetric(
|
|
lastStep?.percent,
|
|
previousData.lastStep?.percent,
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
<Metric
|
|
className="p-4 py-3"
|
|
label="Completed"
|
|
value={number.format(lastStep?.count)}
|
|
enhancer={
|
|
previousData && (
|
|
<PreviousDiffIndicatorPure
|
|
{...getPreviousMetric(
|
|
lastStep?.count,
|
|
previousData.lastStep?.count,
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
{!!mostDropoffsStep && (
|
|
<Metric
|
|
className="p-4 py-3"
|
|
label="Most dropoffs after"
|
|
value={mostDropoffsStep?.event?.displayName}
|
|
enhancer={
|
|
<Tooltiper
|
|
tooltipClassName="max-w-xs"
|
|
content={
|
|
<span>
|
|
<span className="font-semibold">
|
|
{mostDropoffsStep?.dropoffCount}
|
|
</span>{' '}
|
|
dropped after this event. Improve this step and your
|
|
conversion rate will likely increase.
|
|
</span>
|
|
}
|
|
>
|
|
<InfoIcon className="size-3" />
|
|
</Tooltiper>
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="col divide-y divide-def-200">
|
|
<WidgetTable
|
|
data={steps}
|
|
keyExtractor={(item) => item.event.id!}
|
|
className={'text-sm @container'}
|
|
columnClassName="px-2 group/row items-center"
|
|
eachRow={(item, index) => {
|
|
return (
|
|
<div className="absolute inset-px !p-0">
|
|
<div
|
|
className={cn(
|
|
'h-full bg-def-300 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors relative',
|
|
item.isHighestDropoff && [
|
|
'bg-red-500/20',
|
|
'group-hover/row:bg-red-500/70',
|
|
],
|
|
index === steps.length - 1 && 'rounded-bl-sm',
|
|
)}
|
|
style={{
|
|
width: `${item.percent}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}}
|
|
columns={[
|
|
{
|
|
name: 'Event',
|
|
render: (item, index) => (
|
|
<div className="row items-center gap-2 min-w-0 relative">
|
|
<ColorSquare color={getChartColor(index)}>
|
|
{alphabetIds[index]}
|
|
</ColorSquare>
|
|
<span className="truncate">{item.event.displayName}</span>
|
|
</div>
|
|
),
|
|
width: 'w-full',
|
|
className: 'text-left font-mono font-semibold',
|
|
},
|
|
{
|
|
name: 'Completed',
|
|
render: (item) => number.format(item.count),
|
|
className: 'text-right font-mono hidden @xl:block',
|
|
width: '82px',
|
|
},
|
|
{
|
|
name: 'Dropped after',
|
|
render: (item) =>
|
|
item.dropoffCount !== null && item.dropoffPercent !== null
|
|
? number.format(item.dropoffCount)
|
|
: null,
|
|
className: 'text-right font-mono hidden @xl:block',
|
|
width: '110px',
|
|
},
|
|
{
|
|
name: 'Conversion',
|
|
render: (item) => number.formatWithUnit(item.percent / 100, '%'),
|
|
className: 'text-right font-mono font-semibold',
|
|
width: '90px',
|
|
},
|
|
{
|
|
name: '',
|
|
render: (item) => (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const stepIndex = steps.findIndex(
|
|
(s) => s.event.id === item.event.id,
|
|
);
|
|
handleInspectStep(item, stepIndex);
|
|
}}
|
|
title="View users who completed this step"
|
|
>
|
|
<UsersIcon size={16} />
|
|
</Button>
|
|
),
|
|
className: 'text-right',
|
|
width: '48px',
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type RechartData = {
|
|
name: string;
|
|
[key: `step:percent:${number}`]: number | null;
|
|
[key: `step:data:${number}`]:
|
|
| (RouterOutputs['chart']['funnel']['current'][number] & {
|
|
step: RouterOutputs['chart']['funnel']['current'][number]['steps'][number];
|
|
})
|
|
| null;
|
|
[key: `prev_step:percent:${number}`]: number | null;
|
|
[key: `prev_step:data:${number}`]:
|
|
| (RouterOutputs['chart']['funnel']['current'][number] & {
|
|
step: RouterOutputs['chart']['funnel']['current'][number]['steps'][number];
|
|
})
|
|
| null;
|
|
};
|
|
|
|
const useRechartData = ({
|
|
current,
|
|
previous,
|
|
}: RouterOutputs['chart']['funnel']): RechartData[] => {
|
|
const firstFunnel = current[0];
|
|
return (
|
|
firstFunnel?.steps.map((step, stepIndex) => {
|
|
return {
|
|
id: step?.event.id ?? '',
|
|
name: step?.event.displayName ?? '',
|
|
...current.reduce((acc, item, index) => {
|
|
const diff = previous?.[index];
|
|
return {
|
|
...acc,
|
|
[`step:percent:${index}`]: item.steps[stepIndex]?.percent ?? null,
|
|
[`step:data:${index}`]: {
|
|
...item,
|
|
step: item.steps[stepIndex],
|
|
},
|
|
[`prev_step:percent:${index}`]:
|
|
diff?.steps[stepIndex]?.percent ?? null,
|
|
[`prev_step:data:${index}`]: diff
|
|
? {
|
|
...diff,
|
|
step: diff?.steps?.[stepIndex],
|
|
}
|
|
: null,
|
|
};
|
|
}, {}),
|
|
};
|
|
}) ?? []
|
|
);
|
|
};
|
|
|
|
export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
|
const rechartData = useRechartData(data);
|
|
const xAxisProps = useXAxisProps();
|
|
const yAxisProps = useYAxisProps();
|
|
const hasBreakdowns = data.current.length > 1;
|
|
|
|
return (
|
|
<TooltipProvider data={data.current}>
|
|
<div className="aspect-video max-h-[250px] w-full p-4 card pb-1">
|
|
<ResponsiveContainer>
|
|
<BarChart data={rechartData}>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
horizontal={true}
|
|
vertical={true}
|
|
className="stroke-border"
|
|
/>
|
|
<XAxis
|
|
{...xAxisProps}
|
|
dataKey="id"
|
|
allowDuplicatedCategory={false}
|
|
type={'category'}
|
|
scale="auto"
|
|
domain={undefined}
|
|
interval="preserveStartEnd"
|
|
tickSize={0}
|
|
tickMargin={4}
|
|
tickFormatter={(id) =>
|
|
data.current[0].steps.find((step) => step.event.id === id)
|
|
?.event.displayName ?? ''
|
|
}
|
|
/>
|
|
<YAxis {...yAxisProps} />
|
|
{hasBreakdowns ? (
|
|
data.current.map((item, breakdownIndex) => (
|
|
<Bar
|
|
key={`step:percent:${item.id}`}
|
|
dataKey={`step:percent:${breakdownIndex}`}
|
|
shape={<BarShapeProps />}
|
|
>
|
|
{rechartData.map((item, stepIndex) => (
|
|
<Cell
|
|
key={`${item.name}-${breakdownIndex}`}
|
|
fill={getChartTranslucentColor(breakdownIndex)}
|
|
stroke={getChartColor(breakdownIndex)}
|
|
/>
|
|
))}
|
|
</Bar>
|
|
))
|
|
) : (
|
|
<Bar
|
|
data={rechartData}
|
|
dataKey="step:percent:0"
|
|
shape={<BarShapeProps />}
|
|
>
|
|
{rechartData.map((item, index) => (
|
|
<Cell
|
|
key={item.name}
|
|
fill={getChartTranslucentColor(index)}
|
|
stroke={getChartColor(index)}
|
|
/>
|
|
))}
|
|
</Bar>
|
|
)}
|
|
<Tooltip />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
const { Tooltip, TooltipProvider } = createChartTooltip<
|
|
RechartData,
|
|
{
|
|
data: RouterOutputs['chart']['funnel']['current'];
|
|
}
|
|
>(({ data: dataArray, context, ...props }) => {
|
|
const data = dataArray[0]!;
|
|
const number = useNumber();
|
|
const variants = Object.keys(data).filter((key) =>
|
|
key.startsWith('step:data:'),
|
|
) as `step:data:${number}`[];
|
|
|
|
const index = context.data[0].steps.findIndex(
|
|
(step) => step.event.id === (data as any).id,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="flex justify-between gap-8 text-muted-foreground">
|
|
<div>{data.name}</div>
|
|
</div>
|
|
{variants.map((key, breakdownIndex) => {
|
|
const variant = data[key];
|
|
const prevVariant = data[`prev_${key}`];
|
|
if (!variant?.step) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div className="row gap-2" key={key}>
|
|
<div
|
|
className="w-[3px] rounded-full"
|
|
style={{
|
|
background: getChartColor(
|
|
variants.length > 1 ? breakdownIndex : index,
|
|
),
|
|
}}
|
|
/>
|
|
<div className="col flex-1 gap-1">
|
|
<div className="flex items-center gap-1">
|
|
<ChartName breakdowns={variant.breakdowns ?? []} />
|
|
</div>
|
|
<div className="flex justify-between gap-8 font-mono font-medium">
|
|
<div className="col gap-1">
|
|
<span>
|
|
{number.formatWithUnit(variant.step.percent / 100, '%')}
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
({number.format(variant.step.count)})
|
|
</span>
|
|
</div>
|
|
|
|
<PreviousDiffIndicatorPure
|
|
{...getPreviousMetric(
|
|
variant.step.percent,
|
|
prevVariant?.step.percent,
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
});
|