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 {