feat: adopt ShadCN chart primitives
Add ChartContainer, ChartConfig, ChartTooltip, ChartTooltipContent primitives ported from ShadCN's chart pattern. Refactor all 3 chart locations (dashboard, funnels, uptime) to use CSS variable-driven theming instead of duplicated CHART_COLORS_LIGHT/DARK objects. - Add --chart-1 through --chart-5, --chart-grid, --chart-axis CSS vars - Remove duplicated color objects from 3 files (-223 lines) - Add accessibilityLayer to all charts - Rounded bar corners on funnel chart - Tooltips use Tailwind dark classes instead of imperative style props Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { ApiError } from '@/lib/api/client'
|
||||
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
|
||||
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
|
||||
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
@@ -13,27 +13,17 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell
|
||||
} from 'recharts'
|
||||
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/charts'
|
||||
import { getDateRange } from '@ciphera-net/ui'
|
||||
|
||||
const CHART_COLORS_LIGHT = {
|
||||
border: 'var(--color-neutral-200)',
|
||||
axis: 'var(--color-neutral-400)',
|
||||
tooltipBg: '#ffffff',
|
||||
tooltipBorder: 'var(--color-neutral-200)',
|
||||
}
|
||||
|
||||
const CHART_COLORS_DARK = {
|
||||
border: 'var(--color-neutral-700)',
|
||||
axis: 'var(--color-neutral-500)',
|
||||
tooltipBg: 'var(--color-neutral-800)',
|
||||
tooltipBorder: 'var(--color-neutral-700)',
|
||||
}
|
||||
|
||||
const BRAND_ORANGE = 'var(--color-brand-orange)'
|
||||
const chartConfig = {
|
||||
visitors: {
|
||||
label: 'Visitors',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export default function FunnelReportPage() {
|
||||
const params = useParams()
|
||||
@@ -74,12 +64,6 @@ export default function FunnelReportPage() {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const { resolvedTheme } = useTheme()
|
||||
const chartColors = useMemo(
|
||||
() => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT),
|
||||
[resolvedTheme]
|
||||
)
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure you want to delete this funnel?')) return
|
||||
|
||||
@@ -204,64 +188,56 @@ export default function FunnelReportPage() {
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
|
||||
Funnel Visualization
|
||||
</h3>
|
||||
<div className="h-[400px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartColors.border} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke={chartColors.axis}
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={chartColors.axis}
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'transparent' }}
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div
|
||||
className="p-3 rounded-xl shadow-lg border transition-shadow duration-300"
|
||||
style={{
|
||||
backgroundColor: chartColors.tooltipBg,
|
||||
borderColor: chartColors.tooltipBorder,
|
||||
}}
|
||||
>
|
||||
<p className="font-medium text-neutral-900 dark:text-white mb-1">{label}</p>
|
||||
<p className="text-brand-orange font-bold text-lg">
|
||||
{data.visitors.toLocaleString()} visitors
|
||||
<ChartContainer config={chartConfig} className="h-[400px] w-full">
|
||||
<BarChart accessibilityLayer data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" strokeOpacity={0.5} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="var(--chart-axis)"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--chart-axis)"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={{ fill: 'transparent' }}
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="p-3 rounded-lg shadow-xl border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800">
|
||||
<p className="font-medium text-neutral-900 dark:text-white mb-1">{label}</p>
|
||||
<p className="text-brand-orange font-bold text-lg">
|
||||
{data.visitors.toLocaleString()} visitors
|
||||
</p>
|
||||
{data.dropoff > 0 && (
|
||||
<p className="text-red-500 text-sm">
|
||||
{Math.round(data.dropoff)}% drop-off
|
||||
</p>
|
||||
{data.dropoff > 0 && (
|
||||
<p className="text-red-500 text-sm">
|
||||
{Math.round(data.dropoff)}% drop-off
|
||||
</p>
|
||||
)}
|
||||
{data.conversion > 0 && (
|
||||
<p className="text-green-500 text-sm">
|
||||
{Math.round(data.conversion)}% conversion (overall)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="visitors" radius={[4, 4, 0, 0]} barSize={60}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={BRAND_ORANGE} fillOpacity={Math.max(0.1, 1 - index * 0.15)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
{data.conversion > 0 && (
|
||||
<p className="text-green-500 text-sm">
|
||||
{Math.round(data.conversion)}% conversion (overall)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="visitors" radius={[6, 6, 0, 0]} barSize={60}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill="var(--color-visitors)" fillOpacity={Math.max(0.1, 1 - index * 0.15)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
{/* Detailed Stats Table */}
|
||||
|
||||
@@ -29,28 +29,15 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import type { TooltipProps } from 'recharts'
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/charts'
|
||||
|
||||
// * Chart theme colors (consistent with main Pulse chart)
|
||||
const CHART_COLORS_LIGHT = {
|
||||
border: 'var(--color-neutral-200)',
|
||||
text: 'var(--color-neutral-900)',
|
||||
textMuted: 'var(--color-neutral-500)',
|
||||
axis: 'var(--color-neutral-400)',
|
||||
tooltipBg: '#ffffff',
|
||||
tooltipBorder: 'var(--color-neutral-200)',
|
||||
}
|
||||
const CHART_COLORS_DARK = {
|
||||
border: 'var(--color-neutral-700)',
|
||||
text: 'var(--color-neutral-50)',
|
||||
textMuted: 'var(--color-neutral-400)',
|
||||
axis: 'var(--color-neutral-500)',
|
||||
tooltipBg: 'var(--color-neutral-800)',
|
||||
tooltipBorder: 'var(--color-neutral-700)',
|
||||
}
|
||||
const responseTimeChartConfig = {
|
||||
ms: {
|
||||
label: 'Response Time',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
// * Status color mapping
|
||||
function getStatusColor(status: string): string {
|
||||
@@ -285,9 +272,6 @@ function UptimeStatusBar({
|
||||
|
||||
// * Component: Response time chart (Recharts area chart)
|
||||
function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const colors = resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT
|
||||
|
||||
// * Prepare data in chronological order (oldest first)
|
||||
const data = [...checks]
|
||||
.reverse()
|
||||
@@ -303,71 +287,58 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
|
||||
|
||||
if (data.length < 2) return null
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl px-3 py-2 text-xs shadow-lg border transition-shadow duration-300"
|
||||
style={{
|
||||
background: colors.tooltipBg,
|
||||
borderColor: colors.tooltipBorder,
|
||||
color: colors.text,
|
||||
}}
|
||||
>
|
||||
<div className="font-medium mb-0.5">{label}</div>
|
||||
<div style={{ color: 'var(--color-brand-orange)' }} className="font-semibold">
|
||||
{payload[0].value}ms
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3">
|
||||
Response Time
|
||||
</h4>
|
||||
<div className="h-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="responseTimeGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-brand-orange)" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="var(--color-brand-orange)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke={colors.border}
|
||||
strokeOpacity={0.5}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 10, fill: colors.axis }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: colors.axis }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v: number) => `${v}ms`}
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ms"
|
||||
stroke="var(--color-brand-orange)"
|
||||
strokeWidth={2}
|
||||
fill="url(#responseTimeGradient)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: 'var(--color-brand-orange)', strokeWidth: 0 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ChartContainer config={responseTimeChartConfig} className="h-40">
|
||||
<AreaChart accessibilityLayer data={data} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="responseTimeGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-ms)" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="var(--color-ms)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--chart-grid)"
|
||||
strokeOpacity={0.5}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 10, fill: 'var(--chart-axis)' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: 'var(--chart-axis)' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v: number) => `${v}ms`}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="text-xs"
|
||||
labelKey="time"
|
||||
formatter={(value) => <span className="font-semibold">{value}ms</span>}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ms"
|
||||
stroke="var(--color-ms)"
|
||||
strokeWidth={2}
|
||||
fill="url(#responseTimeGradient)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: 'var(--color-ms)', strokeWidth: 0 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user