diff --git a/src/app/(main)/reports/utm/UTMView.module.css b/src/app/(main)/reports/utm/UTMView.module.css index e595503e..9792e0f3 100644 --- a/src/app/(main)/reports/utm/UTMView.module.css +++ b/src/app/(main)/reports/utm/UTMView.module.css @@ -1,24 +1,14 @@ .title { font-size: 18px; + line-height: 36px; font-weight: 700; } -.params { - display: grid; - gap: 10px; - padding: 20px 0; -} - .row { - display: flex; + display: grid; + grid-template-columns: 50% 50%; gap: 20px; -} - -.label { - min-width: 200px; -} - -.value { - min-width: 50px; - text-align: right; + border-bottom: 1px solid var(--base300); + padding-bottom: 30px; + margin-bottom: 30px; } diff --git a/src/app/(main)/reports/utm/UTMView.tsx b/src/app/(main)/reports/utm/UTMView.tsx index 16b27588..48fb6497 100644 --- a/src/app/(main)/reports/utm/UTMView.tsx +++ b/src/app/(main)/reports/utm/UTMView.tsx @@ -1,8 +1,11 @@ import { useContext } from 'react'; import { firstBy } from 'thenby'; import { ReportContext } from '../[reportId]/Report'; +import { CHART_COLORS, UTM_PARAMS } from 'lib/constants'; +import PieChart from 'components/charts/PieChart'; +import ListTable from 'components/metrics/ListTable'; import styles from './UTMView.module.css'; -import { UTM_PARAMS } from 'lib/constants'; +import { useMessages } from 'components/hooks'; function toArray(data: { [key: string]: number }) { return Object.keys(data) @@ -13,6 +16,7 @@ function toArray(data: { [key: string]: number }) { } export default function UTMView() { + const { formatMessage, labels } = useMessages(); const { report } = useContext(ReportContext); const { data } = report || {}; @@ -23,18 +27,35 @@ export default function UTMView() { return (
{UTM_PARAMS.map(key => { + const items = toArray(data[key]); + const chartData = { + labels: items.map(({ name }) => name), + datasets: [ + { + data: items.map(({ value }) => value), + backgroundColor: CHART_COLORS, + }, + ], + }; + const total = items.reduce((sum, { value }) => { + return +sum + +value; + }, 0); + return ( -
-
{key}
-
- {toArray(data[key]).map(({ name, value }) => { - return ( -
-
{name}
-
{value}
-
- ); - })} +
+
+
{key}
+ ({ + x: name, + y: value, + z: (value / total) * 100, + }))} + /> +
+
+
); diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx new file mode 100644 index 00000000..09ff5154 --- /dev/null +++ b/src/components/charts/BarChart.tsx @@ -0,0 +1,85 @@ +import { useTheme } from 'components/hooks'; +import Chart, { ChartProps } from 'components/charts/Chart'; +import { renderNumberLabels } from 'lib/charts'; +import { useState } from 'react'; +import BarChartTooltip from 'components/charts/BarChartTooltip'; + +export interface BarChartProps extends ChartProps { + unit: string; + stacked?: boolean; + renderXLabel?: (label: string, index: number, values: any[]) => string; + renderYLabel?: (label: string, index: number, values: any[]) => string; + XAxisType?: string; + YAxisType?: string; +} + +export function BarChart(props: BarChartProps) { + const [tooltip, setTooltip] = useState(null); + const { colors } = useTheme(); + const { + renderXLabel, + renderYLabel, + unit, + XAxisType = 'time', + YAxisType = 'linear', + stacked = false, + } = props; + + const options = { + scales: { + x: { + type: XAxisType, + stacked: true, + time: { + unit, + }, + grid: { + display: false, + }, + border: { + color: colors.chart.line, + }, + ticks: { + color: colors.chart.text, + autoSkip: false, + maxRotation: 0, + callback: renderXLabel, + }, + }, + y: { + type: YAxisType, + min: 0, + beginAtZero: true, + stacked, + grid: { + color: colors.chart.line, + }, + border: { + color: colors.chart.line, + }, + ticks: { + color: colors.chart.text, + callback: renderYLabel || renderNumberLabels, + }, + }, + }, + }; + + const handleTooltip = ({ tooltip }: { tooltip: any }) => { + const { opacity } = tooltip; + + setTooltip(opacity ? : null); + }; + + return ( + + ); +} + +export default BarChart; diff --git a/src/components/charts/BarChartTooltip.tsx b/src/components/charts/BarChartTooltip.tsx new file mode 100644 index 00000000..b81d55fe --- /dev/null +++ b/src/components/charts/BarChartTooltip.tsx @@ -0,0 +1,32 @@ +import { formatDate } from 'lib/date'; +import { Flexbox, StatusLight } from 'react-basics'; +import { formatLongNumber } from 'lib/format'; +import { useLocale } from 'components/hooks'; + +const formats = { + millisecond: 'T', + second: 'pp', + minute: 'p', + hour: 'h:mm aaa - PP', + day: 'PPPP', + week: 'PPPP', + month: 'LLLL yyyy', + quarter: 'qqq', + year: 'yyyy', +}; + +export default function BarChartTooltip({ tooltip, unit }) { + const { locale } = useLocale(); + const { labelColors, dataPoints } = tooltip; + + return ( + +
{formatDate(new Date(dataPoints[0].raw.x), formats[unit], locale)}
+
+ + {formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label} + +
+
+ ); +} diff --git a/src/components/metrics/BarChart.module.css b/src/components/charts/Chart.module.css similarity index 100% rename from src/components/metrics/BarChart.module.css rename to src/components/charts/Chart.module.css diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx new file mode 100644 index 00000000..abaa6eb8 --- /dev/null +++ b/src/components/charts/Chart.tsx @@ -0,0 +1,141 @@ +import { useState, useRef, useEffect, ReactNode } from 'react'; +import { Loading } from 'react-basics'; +import classNames from 'classnames'; +import ChartJS, { LegendItem } from 'chart.js/auto'; +import HoverTooltip from 'components/common/HoverTooltip'; +import Legend from 'components/metrics/Legend'; +import { DEFAULT_ANIMATION_DURATION } from 'lib/constants'; +import styles from './Chart.module.css'; + +export interface ChartProps { + type?: 'bar' | 'bubble' | 'doughnut' | 'pie' | 'line' | 'polarArea' | 'radar' | 'scatter'; + data?: object; + isLoading?: boolean; + animationDuration?: number; + updateMode?: string; + onCreate?: (chart: any) => void; + onUpdate?: (chart: any) => void; + onTooltip?: (model: any) => void; + className?: string; + chartOptions?: { [key: string]: any }; + tooltip?: ReactNode; +} + +export function Chart({ + type, + data, + isLoading = false, + animationDuration = DEFAULT_ANIMATION_DURATION, + tooltip, + updateMode, + onCreate, + onUpdate, + onTooltip, + className, + chartOptions, +}: ChartProps) { + const canvas = useRef(); + const chart = useRef(null); + const [legendItems, setLegendItems] = useState([]); + + const options = { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: animationDuration, + resize: { + duration: 0, + }, + active: { + duration: 0, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + external: onTooltip, + }, + }, + ...chartOptions, + }; + + const createChart = (data: any) => { + ChartJS.defaults.font.family = 'Inter'; + + chart.current = new ChartJS(canvas.current, { + type, + data, + options, + }); + + onCreate?.(chart.current); + + setLegendItems(chart.current.legend.legendItems); + }; + + const updateChart = (data: any) => { + chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => { + dataset.data = data?.datasets[index]?.data; + }); + + chart.current.options = options; + + // Allow config changes before update + onUpdate?.(chart.current); + + chart.current.update(updateMode); + + setLegendItems(chart.current.legend.legendItems); + }; + + useEffect(() => { + if (data) { + if (!chart.current) { + createChart(data); + } else { + updateChart(data); + } + } + }, [data]); + + const handleLegendClick = (item: LegendItem) => { + if (type === 'bar') { + const { datasetIndex } = item; + const meta = chart.current.getDatasetMeta(datasetIndex); + + meta.hidden = + meta.hidden === null ? !chart.current.data.datasets[datasetIndex]?.hidden : null; + } else { + const { index } = item; + const meta = chart.current.getDatasetMeta(0); + const hidden = !!meta.data[index].hidden; + + meta.data[index].hidden = !hidden; + chart.current.legend.legendItems[index].hidden = !hidden; + } + + chart.current.update(updateMode); + + setLegendItems(chart.current.legend.legendItems); + }; + + return ( + <> +
+ {isLoading && } + +
+ + {tooltip && ( + +
{tooltip}
+
+ )} + + ); +} + +export default Chart; diff --git a/src/components/charts/PieChart.tsx b/src/components/charts/PieChart.tsx new file mode 100644 index 00000000..11ad125c --- /dev/null +++ b/src/components/charts/PieChart.tsx @@ -0,0 +1,27 @@ +import { Chart, ChartProps } from 'components/charts/Chart'; +import { useState } from 'react'; +import { StatusLight } from 'react-basics'; +import { formatLongNumber } from 'lib/format'; + +export interface PieChartProps extends ChartProps { + type?: 'doughnut' | 'pie'; +} + +export default function PieChart(props: PieChartProps) { + const [tooltip, setTooltip] = useState(null); + const { type } = props; + + const handleTooltip = ({ tooltip }) => { + const { labelColors, dataPoints } = tooltip; + + setTooltip( + tooltip.opacity ? ( + + {formatLongNumber(dataPoints?.[0]?.raw)} {dataPoints?.[0]?.label} + + ) : null, + ); + }; + + return ; +} diff --git a/src/components/common/Breadcrumb.tsx b/src/components/common/Breadcrumb.tsx index fa7bde15..ebdce497 100644 --- a/src/components/common/Breadcrumb.tsx +++ b/src/components/common/Breadcrumb.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { Flexbox, Icon, Icons, Text } from 'react-basics'; import styles from './Breadcrumb.module.css'; +import { Fragment } from 'react'; export interface BreadcrumbProps { data: { @@ -14,7 +15,7 @@ export function Breadcrumb({ data }: BreadcrumbProps) { {data.map((a, i) => { return ( - <> + {a.url ? ( {a.label} @@ -27,7 +28,7 @@ export function Breadcrumb({ data }: BreadcrumbProps) { ) : null} - + ); })} diff --git a/src/components/metrics/BarChart.tsx b/src/components/metrics/BarChart.tsx deleted file mode 100644 index 257b9334..00000000 --- a/src/components/metrics/BarChart.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; -import { Loading } from 'react-basics'; -import classNames from 'classnames'; -import Chart from 'chart.js/auto'; -import HoverTooltip from 'components/common/HoverTooltip'; -import Legend from 'components/metrics/Legend'; -import { useLocale } from 'components/hooks'; -import { useTheme } from 'components/hooks'; -import { DEFAULT_ANIMATION_DURATION } from 'lib/constants'; -import { renderNumberLabels } from 'lib/charts'; -import styles from './BarChart.module.css'; - -export interface BarChartProps { - datasets?: any[]; - unit?: string; - animationDuration?: number; - stacked?: boolean; - isLoading?: boolean; - renderXLabel?: (label: string, index: number, values: any[]) => string; - renderYLabel?: (label: string, index: number, values: any[]) => string; - XAxisType?: string; - YAxisType?: string; - renderTooltipPopup?: (setTooltipPopup: (data: any) => void, model: any) => void; - updateMode?: string; - onCreate?: (chart: any) => void; - onUpdate?: (chart: any) => void; - className?: string; -} - -export function BarChart({ - datasets = [], - unit, - animationDuration = DEFAULT_ANIMATION_DURATION, - stacked = false, - isLoading = false, - renderXLabel, - renderYLabel, - XAxisType = 'time', - YAxisType = 'linear', - renderTooltipPopup, - updateMode, - onCreate, - onUpdate, - className, -}: BarChartProps) { - const canvas = useRef(); - const chart = useRef(null); - const [tooltip, setTooltipPopup] = useState(null); - const { locale } = useLocale(); - const { theme, colors } = useTheme(); - const [legendItems, setLegendItems] = useState([]); - - const getOptions = useCallback(() => { - return { - responsive: true, - maintainAspectRatio: false, - animation: { - duration: animationDuration, - resize: { - duration: 0, - }, - active: { - duration: 0, - }, - }, - plugins: { - legend: { - display: false, - }, - tooltip: { - enabled: false, - external: renderTooltipPopup ? renderTooltipPopup.bind(null, setTooltipPopup) : undefined, - }, - }, - scales: { - x: { - type: XAxisType, - stacked: true, - time: { - unit, - }, - grid: { - display: false, - }, - border: { - color: colors.chart.line, - }, - ticks: { - color: colors.chart.text, - autoSkip: false, - maxRotation: 0, - callback: renderXLabel, - }, - }, - y: { - type: YAxisType, - min: 0, - beginAtZero: true, - stacked, - grid: { - color: colors.chart.line, - }, - border: { - color: colors.chart.line, - }, - ticks: { - color: colors.chart.text, - callback: renderYLabel || renderNumberLabels, - }, - }, - }, - }; - }, [ - animationDuration, - renderTooltipPopup, - renderXLabel, - XAxisType, - YAxisType, - stacked, - colors, - unit, - locale, - ]); - - const createChart = (datasets: any[]) => { - Chart.defaults.font.family = 'Inter'; - - chart.current = new Chart(canvas.current, { - type: 'bar', - data: { - datasets, - }, - options: getOptions() as any, - }); - - onCreate?.(chart.current); - - setLegendItems(chart.current.legend.legendItems); - }; - - const updateChart = (datasets: any[]) => { - setTooltipPopup(null); - - chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => { - dataset.data = datasets[index]?.data; - }); - - chart.current.options = getOptions(); - - // Allow config changes before update - onUpdate?.(chart.current); - - chart.current.update(updateMode); - - setLegendItems(chart.current.legend.legendItems); - }; - - useEffect(() => { - if (datasets) { - if (!chart.current) { - createChart(datasets); - } else { - updateChart(datasets); - } - } - }, [datasets, unit, theme, animationDuration, locale]); - - const handleLegendClick = (index: number) => { - const meta = chart.current.getDatasetMeta(index); - - meta.hidden = meta.hidden === null ? !chart.current.data.datasets[index].hidden : null; - - chart.current.update(); - }; - - return ( - <> -
- {isLoading && } - -
- - {tooltip && ( - -
{tooltip}
-
- )} - - ); -} - -export default BarChart; diff --git a/src/components/metrics/EventsChart.tsx b/src/components/metrics/EventsChart.tsx index b1c581fc..87e65276 100644 --- a/src/components/metrics/EventsChart.tsx +++ b/src/components/metrics/EventsChart.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { Loading } from 'react-basics'; import { colord } from 'colord'; -import BarChart from './BarChart'; +import BarChart from 'components/charts/BarChart'; import { getDateArray } from 'lib/date'; import { useLocale, @@ -10,8 +10,8 @@ import { useNavigation, useWebsiteEvents, } from 'components/hooks'; -import { EVENT_COLORS } from 'lib/constants'; -import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts'; +import { CHART_COLORS } from 'lib/constants'; +import { renderDateLabels } from 'lib/charts'; export interface EventsChartProps { websiteId: string; @@ -26,7 +26,6 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) { const { query: { url, event }, } = useNavigation(); - const { data, isLoading } = useWebsiteEvents(websiteId, { startAt: +startDate, endAt: +endDate, @@ -38,9 +37,8 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) { offset, }); - const datasets = useMemo(() => { + const chartData = useMemo(() => { if (!data) return []; - if (isLoading) return data; const map = (data as any[]).reduce((obj, { x, t, y }) => { if (!obj[x]) { @@ -56,18 +54,20 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) { map[key] = getDateArray(map[key], startDate, endDate, unit); }); - return Object.keys(map).map((key, index) => { - const color = colord(EVENT_COLORS[index % EVENT_COLORS.length]); - return { - label: key, - data: map[key], - lineTension: 0, - backgroundColor: color.alpha(0.6).toRgbString(), - borderColor: color.alpha(0.7).toRgbString(), - borderWidth: 1, - }; - }); - }, [data, isLoading, startDate, endDate, unit]); + return { + datasets: Object.keys(map).map((key, index) => { + const color = colord(CHART_COLORS[index % CHART_COLORS.length]); + return { + label: key, + data: map[key], + lineTension: 0, + backgroundColor: color.alpha(0.6).toRgbString(), + borderColor: color.alpha(0.7).toRgbString(), + borderWidth: 1, + }; + }), + }; + }, [data, startDate, endDate, unit]); if (isLoading) { return ; @@ -76,11 +76,10 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) { return ( ); diff --git a/src/components/metrics/Legend.tsx b/src/components/metrics/Legend.tsx index c2bed639..5fbee827 100644 --- a/src/components/metrics/Legend.tsx +++ b/src/components/metrics/Legend.tsx @@ -1,9 +1,8 @@ -import { useEffect } from 'react'; import { StatusLight } from 'react-basics'; import { colord } from 'colord'; import classNames from 'classnames'; +import { LegendItem } from 'chart.js/auto'; import { useLocale } from 'components/hooks'; -import { useForceUpdate } from 'components/hooks'; import styles from './Legend.module.css'; export function Legend({ @@ -11,14 +10,9 @@ export function Legend({ onClick, }: { items: any[]; - onClick: (index: number) => void; + onClick: (index: LegendItem) => void; }) { const { locale } = useLocale(); - const forceUpdate = useForceUpdate(); - - useEffect(() => { - forceUpdate(); - }, [locale, forceUpdate]); if (!items.find(({ text }) => text)) { return null; @@ -26,14 +20,15 @@ export function Legend({ return (
- {items.map(({ text, fillStyle, datasetIndex, hidden }) => { + {items.map(item => { + const { text, fillStyle, hidden } = item; const color = colord(fillStyle); return (