diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx
index 2dfd50d..1026cec 100644
--- a/app/sites/[id]/funnels/[funnelId]/page.tsx
+++ b/app/sites/[id]/funnels/[funnelId]/page.tsx
@@ -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() {
Funnel Visualization
-
-
-
-
-
-
- {
- if (active && payload && payload.length) {
- const data = payload[0].payload;
- return (
-
-
{label}
-
- {data.visitors.toLocaleString()} visitors
+
+
+
+
+
+ {
+ if (active && payload && payload.length) {
+ const data = payload[0].payload;
+ return (
+
+
{label}
+
+ {data.visitors.toLocaleString()} visitors
+
+ {data.dropoff > 0 && (
+
+ {Math.round(data.dropoff)}% drop-off
- {data.dropoff > 0 && (
-
- {Math.round(data.dropoff)}% drop-off
-
- )}
- {data.conversion > 0 && (
-
- {Math.round(data.conversion)}% conversion (overall)
-
- )}
-
- );
- }
- return null;
- }}
- />
-
- {chartData.map((entry, index) => (
- |
- ))}
-
-
-
-
+ )}
+ {data.conversion > 0 && (
+
+ {Math.round(data.conversion)}% conversion (overall)
+
+ )}
+
+ );
+ }
+ return null;
+ }}
+ />
+
+ {chartData.map((entry, index) => (
+ |
+ ))}
+
+
+
{/* Detailed Stats Table */}
diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx
index 09ac33c..cfb9876 100644
--- a/app/sites/[id]/uptime/page.tsx
+++ b/app/sites/[id]/uptime/page.tsx
@@ -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) => {
- if (!active || !payload?.length) return null
- return (
-
-
{label}
-
- {payload[0].value}ms
-
-
- )
- }
-
return (
Response Time
-
-
-
-
-
-
-
-
-
-
-
- `${v}ms`}
- />
- } />
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ `${v}ms`}
+ />
+ {value}ms}
+ />
+ }
+ />
+
+
+
)
}
diff --git a/components/charts/chart.tsx b/components/charts/chart.tsx
new file mode 100644
index 0000000..a9c5398
--- /dev/null
+++ b/components/charts/chart.tsx
@@ -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(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+ if (!context) {
+ throw new Error('useChart must be used within a ')
+ }
+ return context
+}
+
+// ─── ChartContainer ────────────────────────────────────────────────
+
+const ChartContainer = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'> & {
+ config: ChartConfig
+ children: React.ComponentProps['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 = {}
+ for (const [key, value] of Object.entries(config)) {
+ if (value.color) {
+ vars[`--color-${key}`] = value.color
+ }
+ }
+ return vars
+ }, [config])
+
+ return (
+
+
+
+ {children}
+
+
+
+ )
+})
+ChartContainer.displayName = 'ChartContainer'
+
+// ─── ChartTooltip ──────────────────────────────────────────────────
+
+const ChartTooltip = Tooltip
+
+// ─── ChartTooltipContent ───────────────────────────────────────────
+
+const ChartTooltipContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps &
+ React.ComponentProps<'div'> & {
+ hideLabel?: boolean
+ hideIndicator?: boolean
+ indicator?: 'line' | 'dot' | 'dashed'
+ nameKey?: string
+ labelKey?: string
+ labelFormatter?: (value: string, payload: Record[]) => 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[],
+ )
+ }
+
+ return value
+ }, [label, labelFormatter, payload, hideLabel, config, labelKey])
+
+ if (!active || !payload?.length) return null
+
+ const nestLabel = payload.length === 1 && indicator !== 'dot'
+
+ return (
+
+ {!nestLabel ? tooltipLabel ? (
+
+ {tooltipLabel}
+
+ ) : null : null}
+
+ {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 (
+
svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
+ indicator === 'dot' && 'items-center',
+ )}
+ >
+ {itemConfig?.icon ? (
+
+ ) : (
+ !hideIndicator && (
+
+ )
+ )}
+
+
+ {nestLabel ? tooltipLabel : null}
+
+ {itemConfig?.label || item.name}
+
+
+ {item.value != null && (
+
+ {typeof item.value === 'number'
+ ? item.value.toLocaleString()
+ : item.value}
+
+ )}
+
+
+ )
+ })}
+
+
+ )
+ },
+)
+ChartTooltipContent.displayName = 'ChartTooltipContent'
+
+// ─── ChartLegend ───────────────────────────────────────────────────
+
+const ChartLegend = Legend
+
+const ChartLegendContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'> &
+ Pick, 'payload' | 'verticalAlign'> & {
+ hideIcon?: boolean
+ nameKey?: string
+ }
+>(
+ (
+ { className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
+ ref,
+ ) => {
+ const { config } = useChart()
+
+ if (!payload?.length) return null
+
+ return (
+
+ {payload.map((item) => {
+ const key = `${nameKey || item.dataKey || 'value'}`
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
+
+ return (
+
+ {itemConfig?.icon && !hideIcon ? (
+
+ ) : (
+
+ )}
+
+ {itemConfig?.label}
+
+
+ )
+ })}
+
+ )
+ },
+)
+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).payload === 'object' &&
+ (payload as Record).payload !== null
+ ? ((payload as Record).payload as Record)
+ : 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,
+}
diff --git a/components/charts/index.ts b/components/charts/index.ts
new file mode 100644
index 0000000..cb253fe
--- /dev/null
+++ b/components/charts/index.ts
@@ -0,0 +1,8 @@
+export {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ ChartLegend,
+ ChartLegendContent,
+ type ChartConfig,
+} from './chart'
diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx
index 3df1850..6c60c7d 100644
--- a/components/dashboard/Chart.tsx
+++ b/components/dashboard/Chart.tsx
@@ -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 = {
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; 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 (
-
-
+
+
{label}
-
+
{formatValue(value)}
-
+
{metricLabel}
{hasPrev && (
-
+
vs {formatValue(prev)} {prevPeriodLabel ? `(${prevPeriodLabel})` : ''}
{delta !== null && (
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
-
+
Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
@@ -587,8 +566,8 @@ export default function Chart({
) : (
-
-
+
+
@@ -598,11 +577,12 @@ export default function Chart({
- ) => (
- ; value: number; dataKey?: string }>}
- label={p.label as string}
+
- )}
- cursor={{ stroke: colors.axis, strokeOpacity: 0.3, strokeWidth: 1 }}
+ }
+ cursor={{ stroke: 'var(--chart-axis)', strokeOpacity: 0.3, strokeWidth: 1 }}
/>
-
{hasPrev && (
-
+
)}
diff --git a/styles/globals.css b/styles/globals.css
index 12feb64..0cfd53e 100644
--- a/styles/globals.css
+++ b/styles/globals.css
@@ -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 {