fix: improve how previous state is shown for funnels
This commit is contained in:
@@ -3,7 +3,7 @@ import { type VariantProps, cva } from 'class-variance-authority';
|
|||||||
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
|
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
|
||||||
|
|
||||||
const deltaChipVariants = cva(
|
const deltaChipVariants = cva(
|
||||||
'flex items-center gap-1 rounded-full px-2 py-1 text-sm font-semibold',
|
'flex items-center justify-center gap-1 rounded-full font-semibold',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -12,9 +12,10 @@ const deltaChipVariants = cva(
|
|||||||
default: 'bg-muted text-muted-foreground',
|
default: 'bg-muted text-muted-foreground',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
sm: 'text-xs',
|
xs: 'px-1.5 py-0 leading-none text-[10px]',
|
||||||
md: 'text-sm',
|
sm: 'px-2 py-1 text-xs',
|
||||||
lg: 'text-base',
|
md: 'px-2 py-1 text-sm',
|
||||||
|
lg: 'px-2 py-1 text-base',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -30,6 +31,7 @@ type DeltaChipProps = VariantProps<typeof deltaChipVariants> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const iconVariants: Record<NonNullable<DeltaChipProps['size']>, number> = {
|
const iconVariants: Record<NonNullable<DeltaChipProps['size']>, number> = {
|
||||||
|
xs: 8,
|
||||||
sm: 12,
|
sm: 12,
|
||||||
md: 16,
|
md: 16,
|
||||||
lg: 20,
|
lg: 20,
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ interface PreviousDiffIndicatorPureProps {
|
|||||||
diff?: number | null | undefined;
|
diff?: number | null | undefined;
|
||||||
state?: string | null | undefined;
|
state?: string | null | undefined;
|
||||||
inverted?: boolean;
|
inverted?: boolean;
|
||||||
size?: 'sm' | 'lg' | 'md';
|
size?: 'xs' | 'sm' | 'lg' | 'md';
|
||||||
className?: string;
|
className?: string;
|
||||||
showPrevious?: boolean;
|
showPrevious?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { useNumber } from '@/hooks/use-numer-formatter';
|
|||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
import { getPreviousMetric } from '@openpanel/common';
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||||
import { Tables } from './chart';
|
import { Tables } from './chart';
|
||||||
|
|
||||||
interface BreakdownListProps {
|
interface BreakdownListProps {
|
||||||
@@ -48,10 +50,9 @@ export function BreakdownList({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get the color index for a breakdown based on its position in the
|
// Get the stable color index for a breakdown (position in full list, matches chart)
|
||||||
// visible series list (so colors match the chart bars)
|
const getStableColorIndex = (id: string) => {
|
||||||
const getVisibleIndex = (id: string) => {
|
return allBreakdowns.findIndex((b) => b.id === id);
|
||||||
return visibleSeriesIds.indexOf(id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (allBreakdowns.length === 0) {
|
if (allBreakdowns.length === 0) {
|
||||||
@@ -81,14 +82,12 @@ export function BreakdownList({
|
|||||||
{allBreakdowns.map((item, index) => {
|
{allBreakdowns.map((item, index) => {
|
||||||
const isExpanded = expandedIds.has(item.id);
|
const isExpanded = expandedIds.has(item.id);
|
||||||
const isVisible = visibleSeriesIds.includes(item.id);
|
const isVisible = visibleSeriesIds.includes(item.id);
|
||||||
const visibleIndex = getVisibleIndex(item.id);
|
const stableColorIndex = getStableColorIndex(item.id);
|
||||||
const previousItem = previousData[index] ?? null;
|
const previousItem = previousData[index] ?? null;
|
||||||
const hasBreakdownName =
|
const hasBreakdownName =
|
||||||
item.breakdowns && item.breakdowns.length > 0;
|
item.breakdowns && item.breakdowns.length > 0;
|
||||||
const color =
|
const color =
|
||||||
isVisible && visibleIndex !== -1
|
stableColorIndex >= 0 ? getChartColor(stableColorIndex) : undefined;
|
||||||
? getChartColor(visibleIndex)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="col">
|
<div key={item.id} className="col">
|
||||||
@@ -107,7 +106,7 @@ export function BreakdownList({
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
style={{
|
style={{
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
backgroundColor: isVisible ? color : 'transparent',
|
backgroundColor: isVisible && color ? color : 'transparent',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -141,6 +140,14 @@ export function BreakdownList({
|
|||||||
'%',
|
'%',
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{previousItem && (
|
||||||
|
<PreviousDiffIndicatorPure
|
||||||
|
{...getPreviousMetric(
|
||||||
|
item.lastStep.percent,
|
||||||
|
previousItem.lastStep.percent,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right row gap-2 items-center">
|
<div className="text-right row gap-2 items-center">
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground text-sm">
|
||||||
@@ -149,6 +156,14 @@ export function BreakdownList({
|
|||||||
<div className="font-mono font-semibold text-sm">
|
<div className="font-mono font-semibold text-sm">
|
||||||
{number.format(item.lastStep.count)}
|
{number.format(item.lastStep.count)}
|
||||||
</div>
|
</div>
|
||||||
|
{previousItem && (
|
||||||
|
<PreviousDiffIndicatorPure
|
||||||
|
{...getPreviousMetric(
|
||||||
|
item.lastStep.count,
|
||||||
|
previousItem.lastStep.count,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,6 +175,7 @@ export function BreakdownList({
|
|||||||
current: item,
|
current: item,
|
||||||
previous: previousItem,
|
previous: previousItem,
|
||||||
}}
|
}}
|
||||||
|
noTopBorderRadius
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,6 @@
|
|||||||
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 type { IVisibleFunnelBreakdowns } from '@/hooks/use-visible-funnel-breakdowns';
|
|
||||||
import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
|
|
||||||
import { getPreviousMetric } from '@openpanel/common';
|
import { getPreviousMetric } from '@openpanel/common';
|
||||||
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
|
import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
@@ -31,12 +17,24 @@ import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
|||||||
import { SerieIcon } from '../common/serie-icon';
|
import { SerieIcon } from '../common/serie-icon';
|
||||||
import { SerieName } from '../common/serie-name';
|
import { SerieName } from '../common/serie-name';
|
||||||
import { useReportChartContext } from '../context';
|
import { useReportChartContext } from '../context';
|
||||||
|
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
||||||
|
import { BarShapeProps } from '@/components/charts/common-bar';
|
||||||
|
import { ColorSquare } from '@/components/color-square';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltiper } from '@/components/ui/tooltip';
|
||||||
|
import { WidgetTable } from '@/components/widget-table';
|
||||||
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: {
|
data: {
|
||||||
current: RouterOutputs['chart']['funnel']['current'][number];
|
current: RouterOutputs['chart']['funnel']['current'][number];
|
||||||
previous: RouterOutputs['chart']['funnel']['current'][number] | null;
|
previous: RouterOutputs['chart']['funnel']['current'][number] | null;
|
||||||
};
|
};
|
||||||
|
noTopBorderRadius?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Metric = ({
|
export const Metric = ({
|
||||||
@@ -50,20 +48,16 @@ export const Metric = ({
|
|||||||
enhancer?: React.ReactNode;
|
enhancer?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) => (
|
}) => (
|
||||||
<div className={cn('gap-1 justify-between flex-1 col', className)}>
|
<div className={cn('col flex-1 justify-between gap-1', className)}>
|
||||||
<div className="text-sm text-muted-foreground">{label}</div>
|
<div className="text-muted-foreground text-sm">{label}</div>
|
||||||
<div className="row items-center gap-2 justify-between">
|
<div className="row items-center justify-between gap-2">
|
||||||
<div className="font-mono font-semibold">{value}</div>
|
<div className="font-mono font-semibold">{value}</div>
|
||||||
{enhancer && <div>{enhancer}</div>}
|
{enhancer && <div>{enhancer}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export function Summary({
|
export function Summary({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
data: RouterOutputs['chart']['funnel'];
|
|
||||||
}) {
|
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const highestConversion = data.current
|
const highestConversion = data.current
|
||||||
.slice(0)
|
.slice(0)
|
||||||
@@ -81,10 +75,10 @@ export function Summary({
|
|||||||
<ChartName breakdowns={highestConversion.breakdowns ?? []} />
|
<ChartName breakdowns={highestConversion.breakdowns ?? []} />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="text-xl font-semibold font-mono">
|
<span className="font-mono font-semibold text-xl">
|
||||||
{number.formatWithUnit(
|
{number.formatWithUnit(
|
||||||
highestConversion.lastStep.percent / 100,
|
highestConversion.lastStep.percent / 100,
|
||||||
'%',
|
'%'
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +89,7 @@ export function Summary({
|
|||||||
label="Most conversions"
|
label="Most conversions"
|
||||||
value={<ChartName breakdowns={highestCount.breakdowns ?? []} />}
|
value={<ChartName breakdowns={highestCount.breakdowns ?? []} />}
|
||||||
/>
|
/>
|
||||||
<span className="text-xl font-semibold font-mono">
|
<span className="font-mono font-semibold text-xl">
|
||||||
{number.format(highestCount.lastStep.count)}
|
{number.format(highestCount.lastStep.count)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +101,10 @@ export function Summary({
|
|||||||
function ChartName({
|
function ChartName({
|
||||||
breakdowns,
|
breakdowns,
|
||||||
className,
|
className,
|
||||||
}: { breakdowns: string[]; className?: string }) {
|
}: {
|
||||||
|
breakdowns: string[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center gap-2 font-medium', className)}>
|
<div className={cn('flex items-center gap-2 font-medium', className)}>
|
||||||
{breakdowns.map((name, index) => {
|
{breakdowns.map((name, index) => {
|
||||||
@@ -127,6 +124,7 @@ export function Tables({
|
|||||||
current: { steps, mostDropoffsStep, lastStep, breakdowns },
|
current: { steps, mostDropoffsStep, lastStep, breakdowns },
|
||||||
previous: previousData,
|
previous: previousData,
|
||||||
},
|
},
|
||||||
|
noTopBorderRadius,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const hasHeader = breakdowns.length > 0;
|
const hasHeader = breakdowns.length > 0;
|
||||||
@@ -145,11 +143,11 @@ export function Tables({
|
|||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
|
|
||||||
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||||
const funnelWindow = funnelOptions?.funnelWindow;
|
|
||||||
const funnelGroup = funnelOptions?.funnelGroup;
|
|
||||||
|
|
||||||
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
||||||
if (!projectId || !step.event.id) return;
|
if (!(projectId && step.event.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// For funnels, we need to pass the step index so the modal can query
|
// 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
|
// users who completed at least that step in the funnel sequence
|
||||||
@@ -172,48 +170,56 @@ export function Tables({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className={cn('col @container divide-y divide-border card')}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'col @container card divide-y divide-border',
|
||||||
|
noTopBorderRadius && 'rounded-t-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
|
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
|
||||||
<div className={cn('bg-def-100', !hasHeader && 'rounded-t-md')}>
|
<div
|
||||||
<div className="col max-md:divide-y md:row md:items-center md:divide-x divide-border">
|
className={cn(
|
||||||
|
'bg-def-100',
|
||||||
|
!hasHeader && 'rounded-t-md',
|
||||||
|
noTopBorderRadius && 'rounded-t-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="col md:row divide-border max-md:divide-y md:items-center md:divide-x">
|
||||||
<Metric
|
<Metric
|
||||||
className="p-4 py-3"
|
className="p-4 py-3"
|
||||||
label="Conversion"
|
|
||||||
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
|
|
||||||
enhancer={
|
enhancer={
|
||||||
previousData && (
|
previousData && (
|
||||||
<PreviousDiffIndicatorPure
|
<PreviousDiffIndicatorPure
|
||||||
{...getPreviousMetric(
|
{...getPreviousMetric(
|
||||||
lastStep?.percent,
|
lastStep?.percent,
|
||||||
previousData.lastStep?.percent,
|
previousData.lastStep?.percent
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
label="Conversion"
|
||||||
|
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
className="p-4 py-3"
|
className="p-4 py-3"
|
||||||
label="Completed"
|
|
||||||
value={number.format(lastStep?.count)}
|
|
||||||
enhancer={
|
enhancer={
|
||||||
previousData && (
|
previousData && (
|
||||||
<PreviousDiffIndicatorPure
|
<PreviousDiffIndicatorPure
|
||||||
{...getPreviousMetric(
|
{...getPreviousMetric(
|
||||||
lastStep?.count,
|
lastStep?.count,
|
||||||
previousData.lastStep?.count,
|
previousData.lastStep?.count
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
label="Completed"
|
||||||
|
value={number.format(lastStep?.count)}
|
||||||
/>
|
/>
|
||||||
{!!mostDropoffsStep && (
|
{!!mostDropoffsStep && (
|
||||||
<Metric
|
<Metric
|
||||||
className="p-4 py-3"
|
className="p-4 py-3"
|
||||||
label="Most dropoffs after"
|
|
||||||
value={mostDropoffsStep?.event?.displayName}
|
|
||||||
enhancer={
|
enhancer={
|
||||||
<Tooltiper
|
<Tooltiper
|
||||||
tooltipClassName="max-w-xs"
|
|
||||||
content={
|
content={
|
||||||
<span>
|
<span>
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
@@ -223,44 +229,26 @@ export function Tables({
|
|||||||
conversion rate will likely increase.
|
conversion rate will likely increase.
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
tooltipClassName="max-w-xs"
|
||||||
>
|
>
|
||||||
<InfoIcon className="size-3" />
|
<InfoIcon className="size-3" />
|
||||||
</Tooltiper>
|
</Tooltiper>
|
||||||
}
|
}
|
||||||
|
label="Most dropoffs after"
|
||||||
|
value={mostDropoffsStep?.event?.displayName}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col divide-y divide-def-200">
|
<div className="col divide-y divide-def-200">
|
||||||
<WidgetTable
|
<WidgetTable
|
||||||
data={steps}
|
className={'@container text-sm'}
|
||||||
keyExtractor={(item) => item.event.id!}
|
|
||||||
className={'text-sm @container'}
|
|
||||||
columnClassName="px-2 group/row items-center"
|
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={[
|
columns={[
|
||||||
{
|
{
|
||||||
name: 'Event',
|
name: 'Event',
|
||||||
render: (item, index) => (
|
render: (item, index) => (
|
||||||
<div className="row items-center gap-2 min-w-0 relative">
|
<div className="row relative min-w-0 items-center gap-2">
|
||||||
<ColorSquare color={getChartColor(index)}>
|
<ColorSquare color={getChartColor(index)}>
|
||||||
{alphabetIds[index]}
|
{alphabetIds[index]}
|
||||||
</ColorSquare>
|
</ColorSquare>
|
||||||
@@ -295,17 +283,17 @@ export function Tables({
|
|||||||
name: '',
|
name: '',
|
||||||
render: (item) => (
|
render: (item) => (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const stepIndex = steps.findIndex(
|
const stepIndex = steps.findIndex(
|
||||||
(s) => s.event.id === item.event.id,
|
(s) => s.event.id === item.event.id
|
||||||
);
|
);
|
||||||
handleInspectStep(item, stepIndex);
|
handleInspectStep(item, stepIndex);
|
||||||
}}
|
}}
|
||||||
|
size="sm"
|
||||||
title="View users who completed this step"
|
title="View users who completed this step"
|
||||||
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<UsersIcon size={16} />
|
<UsersIcon size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -314,6 +302,27 @@ export function Tables({
|
|||||||
width: '48px',
|
width: '48px',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
data={steps}
|
||||||
|
eachRow={(item, index) => {
|
||||||
|
return (
|
||||||
|
<div className="!p-0 absolute inset-px">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative h-full bg-def-300 transition-colors group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900',
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
keyExtractor={(item) => item.event.id!}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,9 +372,11 @@ const useRechartData = ({
|
|||||||
...visibleBreakdowns.reduce((acc, visibleItem, visibleIdx) => {
|
...visibleBreakdowns.reduce((acc, visibleItem, visibleIdx) => {
|
||||||
// Find the original index for this visible breakdown
|
// Find the original index for this visible breakdown
|
||||||
const originalIndex = current.findIndex(
|
const originalIndex = current.findIndex(
|
||||||
(item) => item.id === visibleItem.id,
|
(item) => item.id === visibleItem.id
|
||||||
);
|
);
|
||||||
if (originalIndex === -1) return acc;
|
if (originalIndex === -1) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
const diff = previous?.[originalIndex];
|
const diff = previous?.[originalIndex];
|
||||||
return {
|
return {
|
||||||
@@ -391,6 +402,47 @@ const useRechartData = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const StripedBarShape = (props: any) => {
|
||||||
|
const { x, y, width, height, fill, stroke, value } = props;
|
||||||
|
const patternId = `prev-stripes-${(fill || '').replace(/[^a-z0-9]/gi, '')}`;
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
height="6"
|
||||||
|
id={patternId}
|
||||||
|
patternTransform="rotate(-45)"
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
width="6"
|
||||||
|
>
|
||||||
|
<rect fill="transparent" height="6" width="6" />
|
||||||
|
<rect fill={fill} height="6" width="3" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
fill={`url(#${patternId})`}
|
||||||
|
height={height}
|
||||||
|
rx={3}
|
||||||
|
width={width}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
/>
|
||||||
|
{value > 0 && (
|
||||||
|
<rect
|
||||||
|
fill={stroke}
|
||||||
|
height={2}
|
||||||
|
opacity={0.6}
|
||||||
|
rx={2}
|
||||||
|
stroke="none"
|
||||||
|
width={width}
|
||||||
|
x={x}
|
||||||
|
y={y - 3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function Chart({
|
export function Chart({
|
||||||
data,
|
data,
|
||||||
visibleBreakdowns,
|
visibleBreakdowns,
|
||||||
@@ -403,96 +455,162 @@ export function Chart({
|
|||||||
const yAxisProps = useYAxisProps();
|
const yAxisProps = useYAxisProps();
|
||||||
const hasBreakdowns = data.current.length > 1;
|
const hasBreakdowns = data.current.length > 1;
|
||||||
const hasVisibleBreakdowns = visibleBreakdowns.length > 1;
|
const hasVisibleBreakdowns = visibleBreakdowns.length > 1;
|
||||||
|
const hasPrevious =
|
||||||
|
data.previous !== null &&
|
||||||
|
data.previous !== undefined &&
|
||||||
|
data.previous.length > 0;
|
||||||
|
const showPreviousBars = hasPrevious && !hasBreakdowns;
|
||||||
|
|
||||||
const CustomLegend = useCallback(() => {
|
const CustomLegend = useCallback(() => {
|
||||||
if (!hasVisibleBreakdowns) return null;
|
if (!hasVisibleBreakdowns) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs mt-4 -mb-2">
|
<div className="mt-4 -mb-2 flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs">
|
||||||
{visibleBreakdowns.map((breakdown, idx) => (
|
{visibleBreakdowns.map((breakdown, idx) => {
|
||||||
<div
|
const stableIndex = data.current.findIndex((b) => b.id === breakdown.id);
|
||||||
className="flex items-center gap-1"
|
const colorIndex = stableIndex >= 0 ? stableIndex : idx;
|
||||||
key={breakdown.id}
|
return (
|
||||||
style={{
|
<div
|
||||||
color: getChartColor(idx),
|
className="flex items-center gap-1.5 rounded px-2 py-1"
|
||||||
}}
|
key={breakdown.id}
|
||||||
>
|
style={{
|
||||||
<SerieIcon name={breakdown.breakdowns ?? []} />
|
color: getChartColor(colorIndex),
|
||||||
<SerieName
|
}}
|
||||||
name={
|
>
|
||||||
breakdown.breakdowns && breakdown.breakdowns.length > 0
|
<SerieIcon name={breakdown.breakdowns ?? []} />
|
||||||
? breakdown.breakdowns
|
<SerieName
|
||||||
: ['Funnel']
|
className="font-semibold"
|
||||||
}
|
name={
|
||||||
className="font-semibold"
|
breakdown.breakdowns && breakdown.breakdowns.length > 0
|
||||||
/>
|
? breakdown.breakdowns
|
||||||
</div>
|
: ['Funnel']
|
||||||
))}
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [visibleBreakdowns, hasVisibleBreakdowns]);
|
}, [visibleBreakdowns, hasVisibleBreakdowns]);
|
||||||
|
|
||||||
|
const PreviousLegend = useCallback(() => {
|
||||||
|
if (!showPreviousBars) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mt-4 -mb-2 flex flex-wrap justify-center gap-x-4 gap-y-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 rounded px-2 py-1">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(59, 121, 255, 0.3)',
|
||||||
|
borderTop: '2px solid rgba(59, 121, 255, 1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-muted-foreground">Current</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 rounded px-2 py-1">
|
||||||
|
<svg height="12" viewBox="0 0 12 12" width="12">
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
height="4"
|
||||||
|
id="legend-stripes"
|
||||||
|
patternTransform="rotate(-45)"
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
width="4"
|
||||||
|
>
|
||||||
|
<rect fill="transparent" height="4" width="4" />
|
||||||
|
<rect fill="rgba(59, 121, 255, 0.3)" height="4" width="2" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect fill="url(#legend-stripes)" height="12" rx="2" width="12" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium text-muted-foreground">Previous</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [showPreviousBars]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
data={data.current}
|
data={data.current}
|
||||||
|
hasBreakdowns={hasBreakdowns}
|
||||||
|
hasPrevious={hasPrevious}
|
||||||
visibleBreakdownIds={new Set(visibleBreakdowns.map((b) => b.id))}
|
visibleBreakdownIds={new Set(visibleBreakdowns.map((b) => b.id))}
|
||||||
>
|
>
|
||||||
<div className="aspect-video max-h-[250px] w-full p-4 card pb-1">
|
<div className="card aspect-video max-h-[250px] w-full p-4 pb-1">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<BarChart data={rechartData}>
|
<BarChart data={rechartData}>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
|
||||||
horizontal={true}
|
|
||||||
vertical={true}
|
|
||||||
className="stroke-border"
|
className="stroke-border"
|
||||||
|
horizontal={true}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={true}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
{...xAxisProps}
|
{...xAxisProps}
|
||||||
dataKey="id"
|
|
||||||
allowDuplicatedCategory={false}
|
allowDuplicatedCategory={false}
|
||||||
type={'category'}
|
dataKey="id"
|
||||||
scale="auto"
|
|
||||||
domain={undefined}
|
domain={undefined}
|
||||||
interval="preserveStartEnd"
|
interval="preserveStartEnd"
|
||||||
tickSize={0}
|
scale="auto"
|
||||||
tickMargin={4}
|
|
||||||
tickFormatter={(id) =>
|
tickFormatter={(id) =>
|
||||||
data.current[0].steps.find((step) => step.event.id === id)
|
data.current[0].steps.find((step) => step.event.id === id)
|
||||||
?.event.displayName ?? ''
|
?.event.displayName ?? ''
|
||||||
}
|
}
|
||||||
|
tickMargin={4}
|
||||||
|
tickSize={0}
|
||||||
|
type={'category'}
|
||||||
/>
|
/>
|
||||||
<YAxis {...yAxisProps} />
|
<YAxis {...yAxisProps} />
|
||||||
{hasBreakdowns ? (
|
{hasBreakdowns &&
|
||||||
visibleBreakdowns.map((item, breakdownIndex) => (
|
visibleBreakdowns.map((item, breakdownIndex) => {
|
||||||
<Bar
|
const stableIndex = data.current.findIndex(
|
||||||
key={`step:percent:${item.id}`}
|
(b) => b.id === item.id,
|
||||||
dataKey={`step:percent:${breakdownIndex}`}
|
);
|
||||||
shape={<BarShapeProps />}
|
const colorIndex =
|
||||||
>
|
stableIndex >= 0 ? stableIndex : breakdownIndex;
|
||||||
{rechartData.map((item, stepIndex) => (
|
return (
|
||||||
<Cell
|
<Bar
|
||||||
key={`${item.name}-${breakdownIndex}`}
|
dataKey={`step:percent:${breakdownIndex}`}
|
||||||
fill={getChartTranslucentColor(breakdownIndex)}
|
key={`step:percent:${item.id}`}
|
||||||
stroke={getChartColor(breakdownIndex)}
|
shape={<BarShapeProps />}
|
||||||
/>
|
>
|
||||||
))}
|
{rechartData.map((row, stepIndex) => (
|
||||||
</Bar>
|
<Cell
|
||||||
))
|
fill={getChartTranslucentColor(colorIndex)}
|
||||||
) : (
|
key={`${row.name}-${breakdownIndex}`}
|
||||||
<Bar
|
stroke={getChartColor(colorIndex)}
|
||||||
data={rechartData}
|
/>
|
||||||
dataKey="step:percent:0"
|
))}
|
||||||
shape={<BarShapeProps />}
|
</Bar>
|
||||||
>
|
);
|
||||||
|
})}
|
||||||
|
{!hasBreakdowns && (
|
||||||
|
<Bar dataKey="step:percent:0" shape={<BarShapeProps />}>
|
||||||
{rechartData.map((item, index) => (
|
{rechartData.map((item, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={item.name}
|
|
||||||
fill={getChartTranslucentColor(index)}
|
fill={getChartTranslucentColor(index)}
|
||||||
|
key={item.name}
|
||||||
|
stroke={getChartColor(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
)}
|
||||||
|
{showPreviousBars && (
|
||||||
|
<Bar dataKey="prev_step:percent:0" shape={<StripedBarShape />}>
|
||||||
|
{rechartData.map((item, index) => (
|
||||||
|
<Cell
|
||||||
|
fill={getChartTranslucentColor(index)}
|
||||||
|
key={`prev-${item.name}`}
|
||||||
stroke={getChartColor(index)}
|
stroke={getChartColor(index)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
)}
|
)}
|
||||||
{hasVisibleBreakdowns && <Legend content={<CustomLegend />} />}
|
{hasVisibleBreakdowns && <Legend content={<CustomLegend />} />}
|
||||||
|
{showPreviousBars && <Legend content={<PreviousLegend />} />}
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -506,27 +624,85 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
|||||||
{
|
{
|
||||||
data: RouterOutputs['chart']['funnel']['current'];
|
data: RouterOutputs['chart']['funnel']['current'];
|
||||||
visibleBreakdownIds: Set<string>;
|
visibleBreakdownIds: Set<string>;
|
||||||
|
hasPrevious: boolean;
|
||||||
|
hasBreakdowns: boolean;
|
||||||
}
|
}
|
||||||
>(({ data: dataArray, context, ...props }) => {
|
>(({ data: dataArray, context, ...props }) => {
|
||||||
const data = dataArray[0]!;
|
const data = dataArray[0];
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const variants = Object.keys(data).filter((key) =>
|
const variants = Object.keys(data).filter((key) =>
|
||||||
key.startsWith('step:data:'),
|
key.startsWith('step:data:')
|
||||||
) as `step:data:${number}`[];
|
) as `step:data:${number}`[];
|
||||||
|
|
||||||
const index = context.data[0].steps.findIndex(
|
const index = context.data[0].steps.findIndex(
|
||||||
(step) => step.event.id === (data as any).id,
|
(step) => step.event.id === (data as any).id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter variants to only show visible breakdowns
|
// Filter variants to only show visible breakdowns
|
||||||
// The variant object contains the full breakdown item, so we can check its ID directly
|
// The variant object contains the full breakdown item, so we can check its ID directly
|
||||||
const visibleVariants = variants.filter((key) => {
|
const visibleVariants = variants.filter((key) => {
|
||||||
const variant = data[key];
|
const variant = data[key];
|
||||||
if (!variant) return false;
|
if (!variant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// The variant is the breakdown item itself (with step added), so it has an id property
|
// The variant is the breakdown item itself (with step added), so it has an id property
|
||||||
return context.visibleBreakdownIds.has(variant.id);
|
return context.visibleBreakdownIds.has(variant.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!context.hasBreakdowns && context.hasPrevious) {
|
||||||
|
const currentVariant = data['step:data:0'];
|
||||||
|
const previousVariant = data['prev_step:data:0'];
|
||||||
|
|
||||||
|
if (!currentVariant?.step) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metric = getPreviousMetric(
|
||||||
|
currentVariant.step.percent,
|
||||||
|
previousVariant?.step.percent
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-muted-foreground">{data.name}</div>
|
||||||
|
<div className="col gap-1.5">
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||||
|
<span className="text-muted-foreground">Current</span>
|
||||||
|
<span>
|
||||||
|
{number.format(currentVariant.step.count)} (
|
||||||
|
{number.formatWithUnit(currentVariant.step.percent / 100, '%')})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{previousVariant?.step && (
|
||||||
|
<div className="flex justify-between gap-8 font-medium font-mono text-muted-foreground">
|
||||||
|
<span>Previous</span>
|
||||||
|
<span>
|
||||||
|
{number.format(previousVariant.step.count)} (
|
||||||
|
{number.formatWithUnit(previousVariant.step.percent / 100, '%')}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metric && metric.diff != null && (
|
||||||
|
<div className="mt-0.5 flex items-center justify-between gap-8 border-border border-t pt-1.5">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{metric.state === 'positive'
|
||||||
|
? 'Improvement'
|
||||||
|
: metric.state === 'negative'
|
||||||
|
? 'Decline'
|
||||||
|
: 'No change'}
|
||||||
|
</span>
|
||||||
|
<PreviousDiffIndicatorPure {...metric} size="xs" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||||
@@ -538,30 +714,33 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
|||||||
if (!variant?.step) {
|
if (!variant?.step) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Find the original breakdown index for color
|
// Find the original breakdown index for color (matches chart bar order)
|
||||||
const originalBreakdownIndex = context.data.findIndex(
|
const originalBreakdownIndex = context.data.findIndex(
|
||||||
(b) => b.id === variant.id,
|
(b) => b.id === variant.id
|
||||||
);
|
);
|
||||||
|
let colorIndex = index;
|
||||||
|
if (visibleVariants.length > 1) {
|
||||||
|
colorIndex =
|
||||||
|
originalBreakdownIndex >= 0 ? originalBreakdownIndex : visibleIndex;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2" key={key}>
|
<div className="row gap-2" key={key}>
|
||||||
<div
|
<div
|
||||||
className="w-[3px] rounded-full"
|
className="w-[3px] rounded-full shrink-0"
|
||||||
style={{
|
style={{
|
||||||
background: getChartColor(
|
background: getChartColor(colorIndex),
|
||||||
visibleVariants.length > 1 ? visibleIndex : index,
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="col flex-1 gap-1">
|
<div className="col flex-1 gap-1 min-w-0">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ChartName breakdowns={variant.breakdowns ?? []} />
|
<ChartName breakdowns={variant.breakdowns ?? []} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
<div className="flex items-center justify-between gap-4 font-mono font-medium">
|
||||||
<div className="col gap-1">
|
<div className="col gap-0.5">
|
||||||
<span>
|
<span>
|
||||||
{number.formatWithUnit(variant.step.percent / 100, '%')}
|
{number.formatWithUnit(variant.step.percent / 100, '%')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
({number.format(variant.step.count)})
|
({number.format(variant.step.count)})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -569,8 +748,9 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
|||||||
<PreviousDiffIndicatorPure
|
<PreviousDiffIndicatorPure
|
||||||
{...getPreviousMetric(
|
{...getPreviousMetric(
|
||||||
variant.step.percent,
|
variant.step.percent,
|
||||||
prevVariant?.step.percent,
|
prevVariant?.step.percent
|
||||||
)}
|
)}
|
||||||
|
size="xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ import {
|
|||||||
} from './chart.service';
|
} from './chart.service';
|
||||||
import { onlyReportEvents } from './reports.service';
|
import { onlyReportEvents } from './reports.service';
|
||||||
|
|
||||||
|
/** Display label for null/empty breakdown values (e.g. property not set). */
|
||||||
|
export const EMPTY_BREAKDOWN_LABEL = 'Not set';
|
||||||
|
|
||||||
|
function normalizeBreakdownValue(value: unknown): string {
|
||||||
|
if (value == null || value === '') {
|
||||||
|
return EMPTY_BREAKDOWN_LABEL;
|
||||||
|
}
|
||||||
|
const s = String(value).trim();
|
||||||
|
return s === '' ? EMPTY_BREAKDOWN_LABEL : s;
|
||||||
|
}
|
||||||
|
|
||||||
export class FunnelService {
|
export class FunnelService {
|
||||||
constructor(private client: typeof ch) {}
|
constructor(private client: typeof ch) {}
|
||||||
|
|
||||||
@@ -144,20 +155,24 @@ export class FunnelService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group by breakdown values
|
// Group by breakdown values (normalize empty/null to "Not set")
|
||||||
const series = funnel.reduce(
|
const series = funnel.reduce(
|
||||||
(acc, f) => {
|
(acc, f) => {
|
||||||
if (limit && Object.keys(acc).length >= limit) {
|
if (limit && Object.keys(acc).length >= limit) {
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = breakdowns.map((b, index) => f[`b_${index}`]).join('|');
|
const key = breakdowns
|
||||||
|
.map((b, index) => normalizeBreakdownValue(f[`b_${index}`]))
|
||||||
|
.join('|');
|
||||||
if (!acc[key]) {
|
if (!acc[key]) {
|
||||||
acc[key] = [];
|
acc[key] = [];
|
||||||
}
|
}
|
||||||
acc[key]!.push({
|
acc[key]!.push({
|
||||||
id: key,
|
id: key,
|
||||||
breakdowns: breakdowns.map((b, index) => f[`b_${index}`]),
|
breakdowns: breakdowns.map((b, index) =>
|
||||||
|
normalizeBreakdownValue(f[`b_${index}`]),
|
||||||
|
),
|
||||||
level: f.level,
|
level: f.level,
|
||||||
count: f.count,
|
count: f.count,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user