feature(dashboard): add conversion rate graph
This commit is contained in:
@@ -9,7 +9,7 @@ export function createChartTooltip<
|
||||
Tooltip: React.ComponentType<
|
||||
{
|
||||
context: PropsFromContext;
|
||||
data: PropsFromTooltip;
|
||||
data: PropsFromTooltip[];
|
||||
} & TooltipProps<number, string>
|
||||
>,
|
||||
) {
|
||||
@@ -24,7 +24,7 @@ export function createChartTooltip<
|
||||
|
||||
const InnerTooltip = (tooltip: TooltipProps<number, string>) => {
|
||||
const context = useContext();
|
||||
const data = tooltip.payload?.[0]?.payload;
|
||||
const data = tooltip.payload?.map((p) => p.payload) ?? [];
|
||||
|
||||
if (!data || !tooltip.active) {
|
||||
return null;
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ReportTable({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stats className="my-4">
|
||||
<Stats className="my-4 grid grid-cols-1 @xl:grid-cols-3 @4xl:grid-cols-6">
|
||||
<StatsCard title="Total" value={number.format(data.metrics.sum)} />
|
||||
<StatsCard
|
||||
title="Average"
|
||||
|
||||
183
apps/dashboard/src/components/report-chart/conversion/chart.tsx
Normal file
183
apps/dashboard/src/components/report-chart/conversion/chart.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { api } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { getPreviousMetric } from '@openpanel/common';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import { Fragment } from 'react';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
interface Props {
|
||||
data: RouterOutputs['chart']['conversion'];
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const {
|
||||
report: {
|
||||
previous,
|
||||
interval,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
lineType,
|
||||
events,
|
||||
},
|
||||
isEditMode,
|
||||
options: { hideXAxis, hideYAxis, maxDomain },
|
||||
} = useReportChartContext();
|
||||
const dataLength = data.current.length || 0;
|
||||
const references = api.reference.getChartReferences.useQuery(
|
||||
{
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
},
|
||||
{
|
||||
staleTime: 1000 * 60 * 10,
|
||||
},
|
||||
);
|
||||
|
||||
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
||||
const yAxisProps = useYAxisProps({
|
||||
hide: hideYAxis,
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider conversion={data} interval={interval}>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'#94a3b8'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
<YAxis {...yAxisProps} domain={[0, 100]} />
|
||||
<XAxis {...xAxisProps} allowDuplicatedCategory={false} />
|
||||
<Tooltip />
|
||||
{data.current.map((serie, index) => {
|
||||
const color = getChartColor(index);
|
||||
return (
|
||||
<Fragment key={serie.id}>
|
||||
<Line
|
||||
data={serie.data}
|
||||
dot={false}
|
||||
name={`rate_${index}`}
|
||||
dataKey="rate"
|
||||
stroke={color}
|
||||
type={lineType}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Line
|
||||
data={serie.data}
|
||||
dot={false}
|
||||
name={`prev_rate_${index}`}
|
||||
dataKey="previousRate"
|
||||
stroke={color}
|
||||
type={lineType}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
NonNullable<
|
||||
RouterOutputs['chart']['conversion']['current'][number]
|
||||
>['data'][number],
|
||||
{
|
||||
conversion: RouterOutputs['chart']['conversion'];
|
||||
interval: IInterval;
|
||||
}
|
||||
>(({ data, context }) => {
|
||||
const { date } = data[0]!;
|
||||
const formatDate = useFormatDateInterval(context.interval);
|
||||
const number = useNumber();
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>{formatDate(date)}</div>
|
||||
</div>
|
||||
{context.conversion.current.map((serie, index) => {
|
||||
const item = data[index];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
const prevItem =
|
||||
context.conversion?.previous?.[item.serieIndex]?.data[item.index];
|
||||
|
||||
const title =
|
||||
serie.breakdowns.length > 0
|
||||
? (serie.breakdowns.join(',') ?? 'Not set')
|
||||
: 'Conversion';
|
||||
return (
|
||||
<div className="row gap-2" key={serie.id}>
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: getChartColor(index) }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">{title}</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="col gap-1">
|
||||
<span>{number.formatWithUnit(item.rate / 100, '%')}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({number.format(item.total)})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(item.rate, prevItem?.rate)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { api } from '@/trpc/client';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
import { Summary } from './summary';
|
||||
|
||||
export function ReportConversionChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
|
||||
const res = api.chart.conversion.useQuery(report, {
|
||||
keepPreviousData: true,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
});
|
||||
|
||||
if (
|
||||
isLazyLoading ||
|
||||
res.isLoading ||
|
||||
(res.isFetching && !res.data?.current.length)
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (res.data.current.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Summary data={res.data} />
|
||||
<AspectContainer>
|
||||
<Chart data={res.data} />
|
||||
</AspectContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { Stats, StatsCard } from '@/components/stats';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import { average, getPreviousMetric, sum } from '@openpanel/common';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
interface Props {
|
||||
data: RouterOutputs['chart']['conversion'];
|
||||
}
|
||||
|
||||
export function Summary({ data }: Props) {
|
||||
const number = useNumber();
|
||||
const { report } = useReportChartContext();
|
||||
|
||||
const bestConversionRateMatch = useMemo(() => {
|
||||
return data.current.reduce(
|
||||
(acc, serie, serieIndex) => {
|
||||
const serieMax = serie.data.reduce(
|
||||
(maxInSerie, item, dataIndex) => {
|
||||
if (item.rate > maxInSerie.rate) {
|
||||
return { rate: item.rate, serieIndex, dataIndex };
|
||||
}
|
||||
return maxInSerie;
|
||||
},
|
||||
{ rate: 0, serieIndex, dataIndex: 0 },
|
||||
);
|
||||
|
||||
return serieMax.rate > acc.rate ? serieMax : acc;
|
||||
},
|
||||
{
|
||||
rate: 0,
|
||||
serieIndex: 0,
|
||||
dataIndex: 0,
|
||||
},
|
||||
);
|
||||
}, [data.current]);
|
||||
|
||||
const worstConversionRateMatch = useMemo(() => {
|
||||
return data.current.reduce(
|
||||
(acc, serie, serieIndex) => {
|
||||
const serieMin = serie.data.reduce(
|
||||
(minInSerie, item, dataIndex) => {
|
||||
if (item.rate < minInSerie.rate) {
|
||||
return { rate: item.rate, serieIndex, dataIndex };
|
||||
}
|
||||
return minInSerie;
|
||||
},
|
||||
{ rate: 100, serieIndex, dataIndex: 0 },
|
||||
);
|
||||
|
||||
return serieMin.rate < acc.rate ? serieMin : acc;
|
||||
},
|
||||
{
|
||||
rate: 100,
|
||||
serieIndex: 0,
|
||||
dataIndex: 0,
|
||||
},
|
||||
);
|
||||
}, [data.current]);
|
||||
const bestConversionRate =
|
||||
data.current[bestConversionRateMatch.serieIndex]?.data[
|
||||
bestConversionRateMatch.dataIndex
|
||||
];
|
||||
const worstConversionRate =
|
||||
data.current[worstConversionRateMatch.serieIndex]?.data[
|
||||
worstConversionRateMatch.dataIndex
|
||||
];
|
||||
|
||||
const bestAverageConversionRateMatch = data.current.reduce(
|
||||
(acc, serie) => {
|
||||
const averageRate = average(serie.data.map((item) => item.rate));
|
||||
return averageRate > acc.averageRate ? { serie, averageRate } : acc;
|
||||
},
|
||||
{ serie: data.current[0], averageRate: 0 },
|
||||
);
|
||||
const worstAverageConversionRateMatch = data.current.reduce(
|
||||
(acc, serie) => {
|
||||
const averageRate = average(serie.data.map((item) => item.rate));
|
||||
return averageRate < acc.averageRate ? { serie, averageRate } : acc;
|
||||
},
|
||||
{ serie: data.current[0], averageRate: 100 },
|
||||
);
|
||||
|
||||
const averageConversionRate = average(
|
||||
data.current.map((serie) => {
|
||||
return average(serie.data.map((item) => item.rate));
|
||||
}, 0),
|
||||
);
|
||||
|
||||
const averageConversionRatePrevious =
|
||||
average(
|
||||
data.previous?.map((serie) => {
|
||||
return average(serie.data.map((item) => item.rate));
|
||||
}) ?? [],
|
||||
) ?? 0;
|
||||
|
||||
const sumConversions = data.current.reduce((acc, serie) => {
|
||||
return acc + sum(serie.data.map((item) => item.conversions));
|
||||
}, 0);
|
||||
const sumConversionsPrevious = data.previous?.reduce((acc, serie) => {
|
||||
return acc + sum(serie.data.map((item) => item.conversions));
|
||||
}, 0);
|
||||
|
||||
const hasManySeries = data.current.length > 1;
|
||||
|
||||
const getConversionRateNode = (
|
||||
item: RouterOutputs['chart']['conversion']['current'][0]['data'][0],
|
||||
) => {
|
||||
const breakdowns = item.serie.breakdowns.join(', ');
|
||||
if (breakdowns) {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
On{' '}
|
||||
<span className="text-foreground">
|
||||
{item.serie.breakdowns.join(', ')}
|
||||
</span>{' '}
|
||||
with{' '}
|
||||
<span className="text-foreground">
|
||||
{number.formatWithUnit(item.rate / 100, '%')}
|
||||
</span>{' '}
|
||||
at {formatDate(new Date(item.date))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-foreground">
|
||||
{number.formatWithUnit(item.rate / 100, '%')}
|
||||
</span>{' '}
|
||||
at {formatDate(new Date(item.date))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stats className="my-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Flow"
|
||||
value={
|
||||
<div className="row flex-wrap gap-1">
|
||||
{report.events.map((event, index) => {
|
||||
return (
|
||||
<div key={event.id} className="row items-center gap-2">
|
||||
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
||||
<span>{event.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{bestAverageConversionRateMatch && hasManySeries && (
|
||||
<StatsCard
|
||||
title="Best breakdown (avg)"
|
||||
value={
|
||||
<span>
|
||||
{bestAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '}
|
||||
<span className="text-muted-foreground">with</span>{' '}
|
||||
{number.formatWithUnit(
|
||||
bestAverageConversionRateMatch.averageRate / 100,
|
||||
'%',
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{worstAverageConversionRateMatch && hasManySeries && (
|
||||
<StatsCard
|
||||
title="Worst breakdown (avg)"
|
||||
value={
|
||||
<span>
|
||||
{worstAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '}
|
||||
<span className="text-muted-foreground">with</span>{' '}
|
||||
{number.formatWithUnit(
|
||||
worstAverageConversionRateMatch.averageRate / 100,
|
||||
'%',
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<StatsCard
|
||||
title="Average conversion rate"
|
||||
value={number.formatWithUnit(averageConversionRate / 100, '%')}
|
||||
enhancer={
|
||||
data.previous && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
averageConversionRate,
|
||||
averageConversionRatePrevious,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Total conversions"
|
||||
value={number.format(sumConversions)}
|
||||
enhancer={
|
||||
data.previous && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(sumConversions, sumConversionsPrevious)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{bestConversionRate && (
|
||||
<StatsCard
|
||||
title="Best conversion rate"
|
||||
value={getConversionRateNode(bestConversionRate)}
|
||||
/>
|
||||
)}
|
||||
{worstConversionRate && (
|
||||
<StatsCard
|
||||
title="Worst conversion rate"
|
||||
value={getConversionRateNode(worstConversionRate)}
|
||||
/>
|
||||
)}
|
||||
</Stats>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
const Metric = ({
|
||||
export const Metric = ({
|
||||
label,
|
||||
value,
|
||||
enhancer,
|
||||
@@ -218,7 +218,7 @@ export function Tables({
|
||||
{
|
||||
name: 'Completed',
|
||||
render: (item) => number.format(item.count),
|
||||
className: 'text-right font-mono',
|
||||
className: 'text-right font-mono hidden @xl:block',
|
||||
width: '82px',
|
||||
},
|
||||
{
|
||||
@@ -227,7 +227,7 @@ export function Tables({
|
||||
item.dropoffCount !== null && item.dropoffPercent !== null
|
||||
? number.format(item.dropoffCount)
|
||||
: null,
|
||||
className: 'text-right font-mono',
|
||||
className: 'text-right font-mono hidden @xl:block',
|
||||
width: '110px',
|
||||
},
|
||||
{
|
||||
@@ -341,7 +341,8 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
RechartData,
|
||||
Record<string, unknown>
|
||||
>(({ data }) => {
|
||||
>(({ data: dataArray }) => {
|
||||
const data = dataArray[0]!;
|
||||
const number = useNumber();
|
||||
const variants = Object.keys(data).filter((key) =>
|
||||
key.startsWith('step:data:'),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ReportAreaChart } from './area';
|
||||
import { ReportBarChart } from './bar';
|
||||
import type { ReportChartProps } from './context';
|
||||
import { ReportChartProvider } from './context';
|
||||
import { ReportConversionChart } from './conversion';
|
||||
import { ReportFunnelChart } from './funnel';
|
||||
import { ReportHistogramChart } from './histogram';
|
||||
import { ReportLineChart } from './line';
|
||||
@@ -51,6 +52,8 @@ export function ReportChart(props: ReportChartProps) {
|
||||
return <ReportFunnelChart />;
|
||||
case 'retention':
|
||||
return <ReportRetentionChart />;
|
||||
case 'conversion':
|
||||
return <ReportConversionChart />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LineChartIcon,
|
||||
type LucideIcon,
|
||||
PieChartIcon,
|
||||
TrendingUpIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -52,6 +53,7 @@ export function ReportChartType({ className }: ReportChartTypeProps) {
|
||||
metric: GaugeIcon,
|
||||
retention: UsersIcon,
|
||||
map: Globe2Icon,
|
||||
conversion: TrendingUpIcon,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -76,10 +78,11 @@ export function ReportChartType({ className }: ReportChartTypeProps) {
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
onClick={() => dispatch(changeChartType(item.value))}
|
||||
className="group"
|
||||
>
|
||||
{item.label}
|
||||
<DropdownMenuShortcut>
|
||||
<Icon className="size-4" />
|
||||
<Icon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,8 @@ export function ReportInterval({ className }: ReportIntervalProps) {
|
||||
chartType !== 'histogram' &&
|
||||
chartType !== 'area' &&
|
||||
chartType !== 'metric' &&
|
||||
chartType !== 'retention'
|
||||
chartType !== 'retention' &&
|
||||
chartType !== 'conversion'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -15,9 +15,12 @@ export function ReportLineType({ className }: ReportLineTypeProps) {
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const type = useSelector((state) => state.report.lineType);
|
||||
|
||||
if (chartType !== 'linear' && chartType !== 'area') {
|
||||
if (
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'linear' &&
|
||||
chartType !== 'area'
|
||||
)
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
|
||||
@@ -33,7 +33,8 @@ export function ReportEvents() {
|
||||
const showAddFilter = !['retention'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention'].includes(chartType);
|
||||
const isAddEventDisabled =
|
||||
chartType === 'retention' && selectedEvents.length >= 2;
|
||||
(chartType === 'retention' || chartType === 'conversion') &&
|
||||
selectedEvents.length >= 2;
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
|
||||
@@ -9,7 +9,10 @@ import { ReportSettings } from './ReportSettings';
|
||||
|
||||
export function ReportSidebar() {
|
||||
const { chartType } = useSelector((state) => state.report);
|
||||
const showFormula = chartType !== 'funnel' && chartType !== 'retention';
|
||||
const showFormula =
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'funnel' &&
|
||||
chartType !== 'retention';
|
||||
const showBreakdown = chartType !== 'retention';
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -10,21 +10,25 @@ export function Stats({
|
||||
return (
|
||||
<div className="@container">
|
||||
<div
|
||||
className={cn(
|
||||
'grid overflow-hidden rounded border bg-background @xl:grid-cols-3 @4xl:grid-cols-6',
|
||||
className,
|
||||
)}
|
||||
className={cn('overflow-hidden rounded border bg-card', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsCard({ title, value }: { title: string; value: string }) {
|
||||
export function StatsCard({
|
||||
title,
|
||||
value,
|
||||
enhancer,
|
||||
}: { title: string; value: React.ReactNode; enhancer?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
|
||||
<div className="text-muted-foreground">{title}</div>
|
||||
<div className="truncate font-mono text-2xl font-bold">{value}</div>
|
||||
<div className="text-muted-foreground text-sm">{title}</div>
|
||||
<div className="row justify-between gap-4">
|
||||
<div className="font-mono text-lg font-bold leading-snug">{value}</div>
|
||||
<div>{enhancer}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function WidgetTable<T>({
|
||||
<div
|
||||
key={keyExtractor(item)}
|
||||
className={cn(
|
||||
'group/row relative border-b border-border last:border-0 h-8',
|
||||
'group/row relative border-b border-border last:border-0 h-8 overflow-hidden',
|
||||
columnClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user