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>
|
||||
)
|
||||
}
|
||||
|
||||
325
components/charts/chart.tsx
Normal file
325
components/charts/chart.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import { cn } from '@ciphera-net/ui'
|
||||
|
||||
// ─── ChartConfig ────────────────────────────────────────────────────
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
color?: string
|
||||
theme?: { light: string; dark: string }
|
||||
}
|
||||
>
|
||||
|
||||
// ─── ChartContext ───────────────────────────────────────────────────
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ─── ChartContainer ────────────────────────────────────────────────
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<typeof ResponsiveContainer>['children']
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
|
||||
|
||||
// Build CSS variables from config
|
||||
const colorVars = React.useMemo(() => {
|
||||
const vars: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (value.color) {
|
||||
vars[`--color-${key}`] = value.color
|
||||
}
|
||||
}
|
||||
return vars
|
||||
}, [config])
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'[&_.recharts-cartesian-grid_line[stroke=\'#ccc\']]:stroke-border/50',
|
||||
'[&_.recharts-curve.recharts-tooltip-cursor]:stroke-border',
|
||||
'[&_.recharts-polar-grid_[stroke=\'#ccc\']]:stroke-border',
|
||||
'[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted',
|
||||
'[&_.recharts-reference-line_[stroke=\'#ccc\']]:stroke-border',
|
||||
'[&_.recharts-sector[stroke=\'#fff\']]:stroke-transparent',
|
||||
'[&_.recharts-sector]:outline-none',
|
||||
'[&_.recharts-surface]:outline-none',
|
||||
className,
|
||||
)}
|
||||
style={colorVars as React.CSSProperties}
|
||||
{...props}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{children}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
})
|
||||
ChartContainer.displayName = 'ChartContainer'
|
||||
|
||||
// ─── ChartTooltip ──────────────────────────────────────────────────
|
||||
|
||||
const ChartTooltip = Tooltip
|
||||
|
||||
// ─── ChartTooltipContent ───────────────────────────────────────────
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof Tooltip> &
|
||||
React.ComponentProps<'div'> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: 'line' | 'dot' | 'dashed'
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
labelFormatter?: (value: string, payload: Record<string, unknown>[]) => React.ReactNode
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelKey,
|
||||
nameKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) return null
|
||||
|
||||
const item = payload[0]
|
||||
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return labelFormatter(
|
||||
value as string,
|
||||
payload as Record<string, unknown>[],
|
||||
)
|
||||
}
|
||||
|
||||
return value
|
||||
}, [label, labelFormatter, payload, hideLabel, config, labelKey])
|
||||
|
||||
if (!active || !payload?.length) return null
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel ? (
|
||||
<div className="font-medium text-neutral-900 dark:text-neutral-50">
|
||||
{tooltipLabel}
|
||||
</div>
|
||||
) : null : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = item.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey || index}
|
||||
className={cn(
|
||||
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
|
||||
indicator === 'dot' && 'items-center',
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
|
||||
indicator === 'dot' && 'h-2.5 w-2.5 rounded-full',
|
||||
indicator === 'line' && 'w-1',
|
||||
indicator === 'dashed' &&
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent',
|
||||
nestLabel && indicator === 'dashed'
|
||||
? 'my-0.5'
|
||||
: 'my-0.5',
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium tabular-nums text-neutral-900 dark:text-neutral-50">
|
||||
{typeof item.value === 'number'
|
||||
? item.value.toLocaleString()
|
||||
: item.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
ChartTooltipContent.displayName = 'ChartTooltipContent'
|
||||
|
||||
// ─── ChartLegend ───────────────────────────────────────────────────
|
||||
|
||||
const ChartLegend = Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> &
|
||||
Pick<React.ComponentProps<typeof Legend>, 'payload' | 'verticalAlign'> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-4',
|
||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className="flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{itemConfig?.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
ChartLegendContent.displayName = 'ChartLegendContent'
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== 'object' || payload === null) return undefined
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload &&
|
||||
typeof (payload as Record<string, unknown>).payload === 'object' &&
|
||||
(payload as Record<string, unknown>).payload !== null
|
||||
? ((payload as Record<string, unknown>).payload as Record<string, unknown>)
|
||||
: undefined
|
||||
|
||||
let configLabelKey = key
|
||||
|
||||
if (
|
||||
key in config
|
||||
) {
|
||||
configLabelKey = key
|
||||
} else if (payloadPayload) {
|
||||
const payloadKey = Object.keys(payloadPayload).find(
|
||||
(k) => payloadPayload[k] === key && k in config,
|
||||
)
|
||||
if (payloadKey) configLabelKey = payloadKey
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartContext,
|
||||
useChart,
|
||||
}
|
||||
8
components/charts/index.ts
Normal file
8
components/charts/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
type ChartConfig,
|
||||
} from './chart'
|
||||
@@ -8,40 +8,29 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts'
|
||||
import type { TooltipProps } from 'recharts'
|
||||
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/charts'
|
||||
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
|
||||
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui'
|
||||
import { Checkbox } from '@ciphera-net/ui'
|
||||
|
||||
const COLORS = {
|
||||
brand: 'var(--color-brand-orange)',
|
||||
brand: 'var(--chart-1)',
|
||||
success: 'var(--color-success)',
|
||||
danger: 'var(--color-error)',
|
||||
}
|
||||
|
||||
const CHART_COLORS_LIGHT = {
|
||||
border: 'var(--color-neutral-200)',
|
||||
grid: 'var(--color-neutral-100)',
|
||||
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)',
|
||||
grid: 'var(--color-neutral-800)',
|
||||
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 dashboardChartConfig = {
|
||||
visitors: { label: 'Visitors', color: 'var(--chart-1)' },
|
||||
pageviews: { label: 'Pageviews', color: 'var(--chart-1)' },
|
||||
bounce_rate: { label: 'Bounce Rate', color: 'var(--chart-1)' },
|
||||
avg_duration: { label: 'Visit Duration', color: 'var(--chart-1)' },
|
||||
prevVisitors: { label: 'Previous', color: 'var(--chart-axis)' },
|
||||
prevPageviews: { label: 'Previous', color: 'var(--chart-axis)' },
|
||||
prevBounceRate: { label: 'Previous', color: 'var(--chart-axis)' },
|
||||
prevAvgDuration: { label: 'Previous', color: 'var(--chart-axis)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
const ANNOTATION_COLORS: Record<string, string> = {
|
||||
deploy: '#3b82f6',
|
||||
@@ -160,7 +149,7 @@ function getTrendContext(dateRange: { start: string; end: string }): string {
|
||||
|
||||
// ─── Tooltip ─────────────────────────────────────────────────────────
|
||||
|
||||
function ChartTooltip({
|
||||
function DashboardTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
@@ -169,7 +158,6 @@ function ChartTooltip({
|
||||
formatNumberFn,
|
||||
showComparison,
|
||||
prevPeriodLabel,
|
||||
colors,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: Array<{ payload: Record<string, number>; value: number; dataKey?: string }>
|
||||
@@ -179,7 +167,6 @@ function ChartTooltip({
|
||||
formatNumberFn: (n: number) => string
|
||||
showComparison: boolean
|
||||
prevPeriodLabel?: string
|
||||
colors: typeof CHART_COLORS_LIGHT
|
||||
}) {
|
||||
if (!active || !payload?.length || !label) return null
|
||||
|
||||
@@ -199,29 +186,26 @@ function ChartTooltip({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border px-3.5 py-2.5 shadow-lg"
|
||||
style={{ backgroundColor: colors.tooltipBg, borderColor: colors.tooltipBorder }}
|
||||
>
|
||||
<div className="text-[11px] font-medium mb-1" style={{ color: colors.textMuted }}>
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3.5 py-2.5 shadow-xl">
|
||||
<div className="text-[11px] font-medium mb-1 text-neutral-500 dark:text-neutral-400">
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-sm font-bold" style={{ color: colors.text }}>
|
||||
<span className="text-sm font-bold text-neutral-900 dark:text-neutral-50">
|
||||
{formatValue(value)}
|
||||
</span>
|
||||
<span className="text-[11px]" style={{ color: colors.textMuted }}>
|
||||
<span className="text-[11px] text-neutral-500 dark:text-neutral-400">
|
||||
{metricLabel}
|
||||
</span>
|
||||
</div>
|
||||
{hasPrev && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[11px]" style={{ color: colors.textMuted }}>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-neutral-500 dark:text-neutral-400">
|
||||
<span>vs {formatValue(prev)} {prevPeriodLabel ? `(${prevPeriodLabel})` : ''}</span>
|
||||
{delta !== null && (
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted,
|
||||
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : undefined,
|
||||
}}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}{delta}%
|
||||
@@ -297,11 +281,6 @@ export default function Chart({
|
||||
} catch { /* noop */ }
|
||||
}, [onExportChart, dateRange, resolvedTheme])
|
||||
|
||||
const colors = useMemo(
|
||||
() => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT),
|
||||
[resolvedTheme]
|
||||
)
|
||||
|
||||
// ─── Data ──────────────────────────────────────────────────────────
|
||||
|
||||
const chartData = data.map((item, i) => {
|
||||
@@ -515,7 +494,7 @@ export default function Chart({
|
||||
Current
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full border border-dashed" style={{ borderColor: colors.axis }} />
|
||||
<span className="h-1.5 w-1.5 rounded-full border border-dashed" style={{ borderColor: 'var(--chart-axis)' }} />
|
||||
Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
@@ -587,8 +566,8 @@ export default function Chart({
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[320px] w-full" onContextMenu={handleChartContextMenu}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 8 }}>
|
||||
<ChartContainer config={dashboardChartConfig} className="h-full w-full">
|
||||
<AreaChart accessibilityLayer data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 8 }}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${metric}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={COLORS.brand} stopOpacity={0.25} />
|
||||
@@ -598,11 +577,12 @@ export default function Chart({
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke={colors.grid}
|
||||
stroke="var(--chart-grid)"
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={colors.axis}
|
||||
stroke="var(--chart-axis)"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
@@ -611,7 +591,7 @@ export default function Chart({
|
||||
dy={8}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={colors.axis}
|
||||
stroke="var(--chart-axis)"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
@@ -624,29 +604,24 @@ export default function Chart({
|
||||
return formatAxisValue(val)
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(p: TooltipProps<number, string>) => (
|
||||
<ChartTooltip
|
||||
active={p.active}
|
||||
payload={p.payload as Array<{ payload: Record<string, number>; value: number; dataKey?: string }>}
|
||||
label={p.label as string}
|
||||
<ChartTooltip
|
||||
content={
|
||||
<DashboardTooltipContent
|
||||
metric={metric}
|
||||
metricLabel={metricLabel}
|
||||
formatNumberFn={formatNumber}
|
||||
showComparison={hasPrev}
|
||||
prevPeriodLabel={prevPeriodLabel}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
cursor={{ stroke: colors.axis, strokeOpacity: 0.3, strokeWidth: 1 }}
|
||||
}
|
||||
cursor={{ stroke: 'var(--chart-axis)', strokeOpacity: 0.3, strokeWidth: 1 }}
|
||||
/>
|
||||
|
||||
|
||||
{hasPrev && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={metric === 'visitors' ? 'prevVisitors' : metric === 'pageviews' ? 'prevPageviews' : metric === 'bounce_rate' ? 'prevBounceRate' : 'prevAvgDuration'}
|
||||
stroke={colors.axis}
|
||||
stroke="var(--chart-axis)"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
strokeLinecap="round"
|
||||
@@ -696,7 +671,7 @@ export default function Chart({
|
||||
)
|
||||
})}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,25 @@
|
||||
--color-success: #10B981;
|
||||
--color-warning: #F59E0B;
|
||||
--color-error: #EF4444;
|
||||
|
||||
/* * Chart colors */
|
||||
--chart-1: #FD5E0F;
|
||||
--chart-2: #3b82f6;
|
||||
--chart-3: #22c55e;
|
||||
--chart-4: #a855f7;
|
||||
--chart-5: #f59e0b;
|
||||
--chart-grid: #f5f5f5;
|
||||
--chart-axis: #a3a3a3;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--chart-1: #FD5E0F;
|
||||
--chart-2: #60a5fa;
|
||||
--chart-3: #4ade80;
|
||||
--chart-4: #c084fc;
|
||||
--chart-5: #fbbf24;
|
||||
--chart-grid: #262626;
|
||||
--chart-axis: #737373;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
Reference in New Issue
Block a user